From 67333932278cbc93219f7a6cc5ce8cbf6283b063 Mon Sep 17 00:00:00 2001 From: arzafran Date: Thu, 9 Jul 2026 13:48:07 -0300 Subject: [PATCH 01/19] chore(terminal): delete disabled SurfacePool machinery SurfacePool was fully wired but permanently disabled behind programaSurfacePoolEnabled, a UserDefaults flag with no writer anywhere in the repo (verified via repo-wide grep including tests_v2/, CLI/, daemon/). The claim()/warmIfNeeded() calls always no-op, so this collapses each call site to the only path that ever ran: - TabManager.swift: drop the canUsePool/claim branch, call makeWorkspaceForCreation directly (the only reachable path). - AppDelegate.swift: drop the teardownAll() call at app termination. - GhosttyTerminalView.swift: drop the dead pre-warm DispatchQueue.main.async block after app init. Also removes the pbxproj entries for the deleted file. Refs #89. --- GhosttyTabs.xcodeproj/project.pbxproj | 4 - Sources/AppDelegate.swift | 1 - Sources/GhosttyTerminalView.swift | 6 - Sources/SurfacePool.swift | 264 -------------------------- Sources/TabManager.swift | 38 +--- 5 files changed, 8 insertions(+), 305 deletions(-) delete mode 100644 Sources/SurfacePool.swift diff --git a/GhosttyTabs.xcodeproj/project.pbxproj b/GhosttyTabs.xcodeproj/project.pbxproj index 5ba0f619fc7..e7a4fe5363d 100644 --- a/GhosttyTabs.xcodeproj/project.pbxproj +++ b/GhosttyTabs.xcodeproj/project.pbxproj @@ -87,7 +87,6 @@ A5001650 /* ProgramaConfig.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5001651 /* ProgramaConfig.swift */; }; A5001652 /* ProgramaConfigExecutor.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5001653 /* ProgramaConfigExecutor.swift */; }; A5001654 /* ProgramaDirectoryTrust.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5001655 /* ProgramaDirectoryTrust.swift */; }; - A5001660 /* SurfacePool.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5001661 /* SurfacePool.swift */; }; A5001100 /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = A5001101 /* Assets.xcassets */; }; A5001230 /* Sparkle in Frameworks */ = {isa = PBXBuildFile; productRef = A5001231 /* Sparkle */; }; B9000002A1B2C3D4E5F60719 /* programa.swift in Sources */ = {isa = PBXBuildFile; fileRef = B9000001A1B2C3D4E5F60719 /* programa.swift */; }; @@ -310,7 +309,6 @@ A5001651 /* ProgramaConfig.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ProgramaConfig.swift; sourceTree = ""; }; A5001653 /* ProgramaConfigExecutor.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ProgramaConfigExecutor.swift; sourceTree = ""; }; A5001655 /* ProgramaDirectoryTrust.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ProgramaDirectoryTrust.swift; sourceTree = ""; }; - A5001661 /* SurfacePool.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SurfacePool.swift; sourceTree = ""; }; A5001641 /* RemoteRelayZshBootstrap.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RemoteRelayZshBootstrap.swift; sourceTree = ""; }; 818DBCD4AB69EB72573E8138 /* SidebarResizeUITests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SidebarResizeUITests.swift; sourceTree = ""; }; B8F266256A1A3D9A45BD840F /* SidebarHelpMenuUITests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SidebarHelpMenuUITests.swift; sourceTree = ""; }; @@ -577,7 +575,6 @@ A5001651 /* ProgramaConfig.swift */, A5001653 /* ProgramaConfigExecutor.swift */, A5001655 /* ProgramaDirectoryTrust.swift */, - A5001661 /* SurfacePool.swift */, ); path = Sources; sourceTree = ""; @@ -906,7 +903,6 @@ A5001650 /* ProgramaConfig.swift in Sources */, A5001652 /* ProgramaConfigExecutor.swift in Sources */, A5001654 /* ProgramaDirectoryTrust.swift in Sources */, - A5001660 /* SurfacePool.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; }; diff --git a/Sources/AppDelegate.swift b/Sources/AppDelegate.swift index 72285c20bed..ae2cde747c7 100644 --- a/Sources/AppDelegate.swift +++ b/Sources/AppDelegate.swift @@ -3042,7 +3042,6 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent VSCodeServeWebController.shared.stop() BrowserProfileStore.shared.flushPendingSaves() notificationStore?.clearAll() - SurfacePool.shared.teardownAll() enableSuddenTerminationIfNeeded() } diff --git a/Sources/GhosttyTerminalView.swift b/Sources/GhosttyTerminalView.swift index f766dd1b47c..4aeee7b661c 100644 --- a/Sources/GhosttyTerminalView.swift +++ b/Sources/GhosttyTerminalView.swift @@ -1558,12 +1558,6 @@ class GhosttyApp { lastAppearanceColorScheme = GhosttyConfig.currentColorSchemePreference() NotificationCenter.default.post(name: .ghosttyConfigDidReload, object: nil) - // Pre-warm the surface pool so the first new tab opens instantly. - // Deferred to the next main-queue tick so the app is fully initialized. - DispatchQueue.main.async { - SurfacePool.shared.warmIfNeeded() - } - #if os(macOS) if let app { ghostty_app_set_focus(app, NSApp.isActive) diff --git a/Sources/SurfacePool.swift b/Sources/SurfacePool.swift deleted file mode 100644 index 3c29fadb4c6..00000000000 --- a/Sources/SurfacePool.swift +++ /dev/null @@ -1,264 +0,0 @@ -import AppKit -import Bonsplit -import Foundation - -/// Maintains a small pool of pre-warmed terminal surfaces so new tabs open instantly. -/// -/// Each pooled surface lives in a hidden offscreen `NSWindow` with a running shell process. -/// When `TabManager.addWorkspace()` needs a new terminal, it claims a pooled surface instead -/// of cold-starting one, eliminating the visible blank-screen delay. -@MainActor -final class SurfacePool { - static let shared = SurfacePool() - - // MARK: - Configuration - - private let maxPoolSize = 1 - - // MARK: - State - - enum EntryState { - case warming - case ready - } - - struct PoolEntry { - let surface: TerminalSurface - let offscreenWindow: NSWindow - var state: EntryState - let createdAt: Date - } - - /// Keyed by surface.id - private var entries: [UUID: PoolEntry] = [:] - - /// Whether the pool is enabled. Reads user default, defaulting to false. - /// Default-off: claiming races the pre-warmed shell — `patchClaimedSurface` types - /// into the pty before the shell reaches a prompt, and the trailing - /// `clear && printf '\e[3J'` can wipe the screen with no prompt redraw, - /// leaving new tabs blank. Re-enable only once claim waits for a shell-ready signal. - var isEnabled: Bool { - UserDefaults.standard.object(forKey: "programaSurfacePoolEnabled") as? Bool ?? false - } - - #if DEBUG - private(set) var claimCount = 0 - private(set) var missCount = 0 - #endif - - private init() {} - - // MARK: - Public API - - /// Pre-warm a surface if the pool isn't full. - func warmIfNeeded() { - guard isEnabled else { return } - guard GhosttyApp.shared.app != nil else { return } - guard entries.count < maxPoolSize else { return } - -#if DEBUG - dlog("pool.warm.start count=\(entries.count) max=\(maxPoolSize)") -#endif - - let entry = createPoolEntry() - entries[entry.surface.id] = entry - -#if DEBUG - dlog("pool.warm.done surface=\(entry.surface.id.uuidString.prefix(8)) count=\(entries.count)") -#endif - } - - /// Claim a pre-warmed surface for use in a real workspace. - /// - /// Returns `nil` if the pool is empty, not ready, or incompatible with the request. - /// The caller should fall back to the normal cold-start path. - func claim( - workspaceId: UUID, - portOrdinal: Int, - workingDirectory: String?, - configTemplate: ProgramaSurfaceConfigTemplate? - ) -> ClaimedSurface? { - guard isEnabled else { return nil } - - // Skip pool for non-default font sizes — the surface would need a resize - if let template = configTemplate, template.fontSize > 0 { - // fontSize 0 means "use default", which is what the pool uses. - // Skip pool if a non-default size was explicitly requested. -#if DEBUG - dlog("pool.claim.skip reason=fontSizeMismatch requested=\(template.fontSize)") - missCount += 1 -#endif - return nil - } - - guard let entry = entries.first(where: { $0.value.state == .ready }) else { -#if DEBUG - dlog("pool.claim.miss count=\(entries.count) warming=\(entries.values.filter { $0.state == .warming }.count)") - missCount += 1 -#endif - return nil - } - - let poolEntry = entry.value - entries.removeValue(forKey: entry.key) - -#if DEBUG - let warmMs = Date().timeIntervalSince(poolEntry.createdAt) * 1000 - dlog("pool.claim surface=\(poolEntry.surface.id.uuidString.prefix(8)) warmMs=\(String(format: "%.0f", warmMs))") - claimCount += 1 -#endif - - // Detach from offscreen window - poolEntry.surface.hostedView.removeFromSuperview() - poolEntry.offscreenWindow.contentView = nil - poolEntry.offscreenWindow.orderOut(nil) - - // Update workspace identity - poolEntry.surface.updateWorkspaceId(workspaceId) - - // Patch environment variables and working directory via pty - patchClaimedSurface( - poolEntry.surface, - workspaceId: workspaceId, - portOrdinal: portOrdinal, - workingDirectory: workingDirectory - ) - - // Replenish the pool on the next tick - DispatchQueue.main.async { [weak self] in - self?.warmIfNeeded() - } - - return ClaimedSurface( - surface: poolEntry.surface, - panel: TerminalPanel(workspaceId: workspaceId, surface: poolEntry.surface) - ) - } - - /// Tear down all pooled surfaces (app termination, memory pressure, etc.) - func teardownAll() { -#if DEBUG - dlog("pool.teardown count=\(entries.count)") -#endif - for (_, entry) in entries { - entry.surface.hostedView.removeFromSuperview() - entry.offscreenWindow.contentView = nil - entry.offscreenWindow.orderOut(nil) - entry.offscreenWindow.close() - } - entries.removeAll() - } - - // MARK: - Types - - struct ClaimedSurface { - let surface: TerminalSurface - let panel: TerminalPanel - } - - // MARK: - Private - - private func createPoolEntry() -> PoolEntry { - // Create surface with placeholder workspace ID — patched on claim - let placeholderWorkspaceId = UUID() - let surface = TerminalSurface( - tabId: placeholderWorkspaceId, - context: GHOSTTY_SURFACE_CONTEXT_TAB, - configTemplate: nil - ) - - // Create a hidden offscreen window to host the surface. - // ghostty_surface_new requires view.window != nil to trigger. - let offscreenWindow = NSWindow( - contentRect: NSRect(x: -10000, y: -10000, width: 800, height: 600), - styleMask: [.borderless], - backing: .buffered, - defer: false - ) - offscreenWindow.isReleasedWhenClosed = false - offscreenWindow.contentView = surface.hostedView - - // Moving the hosted view into the window triggers viewDidMoveToWindow - // on the GhosttyNSView, which calls attachToView → createSurface. - // However, the portal system's viewDidMoveToWindow also fires. We need - // the surface to be created but NOT registered with any portal. - // The hostedView is a GhosttySurfaceScrollView containing the GhosttyNSView. - // Setting it as contentView puts it in the window hierarchy, satisfying - // the view.window != nil guard in attachToView → createSurface. - - // Mark as ready once the surface exists (created synchronously in attachToView). - // If the surface wasn't created (e.g. Ghostty app not ready), stay in warming. - let state: EntryState = surface.surface != nil ? .ready : .warming - - if state == .warming { - // Surface creation was deferred. Try triggering it explicitly. - surface.requestBackgroundSurfaceStartIfNeeded() - - // Check again after a tick - DispatchQueue.main.async { [weak self] in - guard let self else { return } - guard var entry = self.entries[surface.id] else { return } - if surface.surface != nil && entry.state == .warming { - entry.state = .ready - self.entries[surface.id] = entry -#if DEBUG - let warmMs = Date().timeIntervalSince(entry.createdAt) * 1000 - dlog("pool.entry.ready surface=\(surface.id.uuidString.prefix(8)) warmMs=\(String(format: "%.0f", warmMs))") -#endif - } - } - } - -#if DEBUG - dlog("pool.entry.create surface=\(surface.id.uuidString.prefix(8)) state=\(state) hasSurface=\(surface.surface != nil)") -#endif - - return PoolEntry( - surface: surface, - offscreenWindow: offscreenWindow, - state: state, - createdAt: Date() - ) - } - - /// Inject updated environment variables and cd to the target directory via pty input. - private func patchClaimedSurface( - _ surface: TerminalSurface, - workspaceId: UUID, - portOrdinal: Int, - workingDirectory: String? - ) { - var commands: [String] = [] - - // Update per-surface/workspace env vars - commands.append("export PROGRAMA_SURFACE_ID=\(surface.id.uuidString)") - commands.append("export PROGRAMA_PANEL_ID=\(surface.id.uuidString)") - commands.append("export PROGRAMA_WORKSPACE_ID=\(workspaceId.uuidString)") - commands.append("export PROGRAMA_TAB_ID=\(workspaceId.uuidString)") - - // Update port range - let portBase = TerminalSurface.sessionPortBase - let portRange = TerminalSurface.sessionPortRangeSize - let startPort = portBase + portOrdinal * portRange - commands.append("export PROGRAMA_PORT=\(startPort)") - commands.append("export PROGRAMA_PORT_END=\(startPort + portRange - 1)") - - // cd to target directory if different from home - let homeDir = FileManager.default.homeDirectoryForCurrentUser.path - if let dir = workingDirectory, !dir.isEmpty, dir != homeDir { - commands.append("cd \(shellQuote(dir))") - } - - // Clear screen + scrollback to hide the pre-warm prompt/zshrc output - commands.append("clear && printf '\\e[3J'") - - let combined = commands.joined(separator: "; ") - surface.sendInput(combined + "\n") - } - - /// Shell-quote a string for safe interpolation into a shell command. - private func shellQuote(_ s: String) -> String { - "'" + s.replacingOccurrences(of: "'", with: "'\\''") + "'" - } -} - diff --git a/Sources/TabManager.swift b/Sources/TabManager.swift index cae341e10c8..38c71f09f93 100644 --- a/Sources/TabManager.swift +++ b/Sources/TabManager.swift @@ -1091,36 +1091,14 @@ class TabManager: ObservableObject { let ordinal = Self.nextPortOrdinal Self.nextPortOrdinal += 1 - // Try to claim a pre-warmed surface from the pool for instant tab creation. - // Fall back to normal cold-start when the pool is empty or the request is - // incompatible (custom command, custom env, etc.). - let canUsePool = initialTerminalCommand == nil - && initialTerminalEnvironment.isEmpty - let newWorkspace: Workspace - if canUsePool, - let claimed = SurfacePool.shared.claim( - workspaceId: UUID(), - portOrdinal: ordinal, - workingDirectory: workingDirectory, - configTemplate: inheritedConfig - ) { - newWorkspace = Workspace( - claimedPanel: claimed.panel, - title: title ?? "Terminal \(nextTabCount)", - workingDirectory: workingDirectory, - portOrdinal: ordinal, - configTemplate: inheritedConfig - ) - } else { - newWorkspace = makeWorkspaceForCreation( - title: title ?? "Terminal \(nextTabCount)", - workingDirectory: workingDirectory, - portOrdinal: ordinal, - configTemplate: inheritedConfig, - initialTerminalCommand: initialTerminalCommand, - initialTerminalEnvironment: initialTerminalEnvironment - ) - } + let newWorkspace = makeWorkspaceForCreation( + title: title ?? "Terminal \(nextTabCount)", + workingDirectory: workingDirectory, + portOrdinal: ordinal, + configTemplate: inheritedConfig, + initialTerminalCommand: initialTerminalCommand, + initialTerminalEnvironment: initialTerminalEnvironment + ) newWorkspace.owningTabManager = self if title != nil { newWorkspace.setCustomTitle(title) From b13bf098fc2af5f4cc58aa5294872da0f20ae1f8 Mon Sep 17 00:00:00 2001 From: arzafran Date: Thu, 9 Jul 2026 13:51:22 -0300 Subject: [PATCH 02/19] refactor(contentview): extract TabItemView.swift (mechanical move) Move TabItemView and its exclusive helper-view cluster (SidebarWorkspaceDescriptionText, SidebarMarkdownRenderer, SidebarMetadataRows/EntryRow/MarkdownBlocks/BlockRow) out of ContentView.swift into a new file, verbatim. Access-level widening: TabItemView was file-private (private struct); widened to internal (dropped the private modifier) because VerticalTabsSidebar, which stays in ContentView.swift, constructs it at its ForEach call site. No other behavior change. Equatable conformance, precomputed let parameters, and the .equatable() call site are untouched. Refs #94. --- GhosttyTabs.xcodeproj/project.pbxproj | 4 + Sources/ContentView.swift | 1950 ------------------------ Sources/TabItemView.swift | 1965 +++++++++++++++++++++++++ 3 files changed, 1969 insertions(+), 1950 deletions(-) create mode 100644 Sources/TabItemView.swift diff --git a/GhosttyTabs.xcodeproj/project.pbxproj b/GhosttyTabs.xcodeproj/project.pbxproj index 5ba0f619fc7..c973b5ea48c 100644 --- a/GhosttyTabs.xcodeproj/project.pbxproj +++ b/GhosttyTabs.xcodeproj/project.pbxproj @@ -11,6 +11,7 @@ A5FF0007 /* SettingDefinition.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5FF0017 /* SettingDefinition.swift */; }; A5001002 /* ContentView.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5001012 /* ContentView.swift */; }; A5FF0005 /* ContentView+CommandPalette.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5FF0015 /* ContentView+CommandPalette.swift */; }; + NRCV00000000000000000005 /* TabItemView.swift in Sources */ = {isa = PBXBuildFile; fileRef = NRCV00000000000000000006 /* TabItemView.swift */; }; E62155868BB29FEB5DAAAF25 /* SidebarSelectionState.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9AD52285508B1D6A9875E7B3 /* SidebarSelectionState.swift */; }; B9000018A1B2C3D4E5F60719 /* WindowDragHandleView.swift in Sources */ = {isa = PBXBuildFile; fileRef = B9000017A1B2C3D4E5F60719 /* WindowDragHandleView.swift */; }; A5001003 /* TabManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5001013 /* TabManager.swift */; }; @@ -235,6 +236,7 @@ A5FF0017 /* SettingDefinition.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SettingDefinition.swift; sourceTree = ""; }; A5001012 /* ContentView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ContentView.swift; sourceTree = ""; }; A5FF0015 /* ContentView+CommandPalette.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "ContentView+CommandPalette.swift"; sourceTree = ""; }; + NRCV00000000000000000006 /* TabItemView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TabItemView.swift; sourceTree = ""; }; 9AD52285508B1D6A9875E7B3 /* SidebarSelectionState.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SidebarSelectionState.swift; sourceTree = ""; }; B9000017A1B2C3D4E5F60719 /* WindowDragHandleView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WindowDragHandleView.swift; sourceTree = ""; }; A5001013 /* TabManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TabManager.swift; sourceTree = ""; }; @@ -504,6 +506,7 @@ A5FF0017 /* SettingDefinition.swift */, A5001012 /* ContentView.swift */, A5FF0015 /* ContentView+CommandPalette.swift */, + NRCV00000000000000000006 /* TabItemView.swift */, 9AD52285508B1D6A9875E7B3 /* SidebarSelectionState.swift */, B9000017A1B2C3D4E5F60719 /* WindowDragHandleView.swift */, A50012F0 /* Backport.swift */, @@ -834,6 +837,7 @@ A5FF0007 /* SettingDefinition.swift in Sources */, A5001002 /* ContentView.swift in Sources */, A5FF0005 /* ContentView+CommandPalette.swift in Sources */, + NRCV00000000000000000005 /* TabItemView.swift in Sources */, E62155868BB29FEB5DAAAF25 /* SidebarSelectionState.swift in Sources */, B9000018A1B2C3D4E5F60719 /* WindowDragHandleView.swift in Sources */, A50012F1 /* Backport.swift in Sources */, diff --git a/Sources/ContentView.swift b/Sources/ContentView.swift index e17214dc0e9..a49d2eec076 100644 --- a/Sources/ContentView.swift +++ b/Sources/ContentView.swift @@ -10255,1956 +10255,6 @@ enum SidebarTrailingAccessoryWidthPolicy { } } -// PERF: TabItemView is Equatable so SwiftUI skips body re-evaluation when -// the parent rebuilds with unchanged values. Without this, every TabManager -// or NotificationStore publish causes ALL tab items to re-evaluate (~18% of -// main thread during typing). If you add new properties, update == below. -// Reactive workspace state inside the row must not rely on parent diffs alone: -// `.equatable()` can otherwise leave sidebar badges/details stale until an -// unrelated parent change sneaks through. Keep the workspace reference plain -// and bridge only sidebar-visible workspace changes into local state. -// Do NOT add @EnvironmentObject or new @Binding without updating ==. -// Do NOT remove .equatable() from the ForEach call site in VerticalTabsSidebar. -private struct TabItemView: View, Equatable { - private static let workspaceObservationCoalesceInterval: RunLoop.SchedulerTimeType.Stride = .milliseconds(40) - - // Closures, Bindings, and object references are excluded from == - // because they're recreated every parent eval but don't affect rendering. - nonisolated static func == (lhs: TabItemView, rhs: TabItemView) -> Bool { - lhs.tab === rhs.tab && - lhs.index == rhs.index && - lhs.isActive == rhs.isActive && - lhs.workspaceShortcutDigit == rhs.workspaceShortcutDigit && - lhs.workspaceShortcutModifierSymbol == rhs.workspaceShortcutModifierSymbol && - lhs.canCloseWorkspace == rhs.canCloseWorkspace && - lhs.accessibilityWorkspaceCount == rhs.accessibilityWorkspaceCount && - lhs.unreadCount == rhs.unreadCount && - lhs.latestNotificationText == rhs.latestNotificationText && - lhs.rowSpacing == rhs.rowSpacing && - lhs.showsModifierShortcutHints == rhs.showsModifierShortcutHints && - lhs.contextMenuWorkspaceIds == rhs.contextMenuWorkspaceIds && - lhs.remoteContextMenuWorkspaceIds == rhs.remoteContextMenuWorkspaceIds && - lhs.allRemoteContextMenuTargetsConnecting == rhs.allRemoteContextMenuTargetsConnecting && - lhs.allRemoteContextMenuTargetsDisconnected == rhs.allRemoteContextMenuTargetsDisconnected && - lhs.settings == rhs.settings - } - - // Use plain references instead of @EnvironmentObject to avoid subscribing - // to ALL changes on these objects. Body reads use precomputed parameters; - // action handlers use the plain references without triggering re-evaluation. - let tabManager: TabManager - let notificationStore: TerminalNotificationStore - @Environment(\.colorScheme) private var colorScheme - let tab: Tab - let index: Int - let isActive: Bool - let workspaceShortcutDigit: Int? - let workspaceShortcutModifierSymbol: String - let canCloseWorkspace: Bool - let accessibilityWorkspaceCount: Int - let unreadCount: Int - let latestNotificationText: String? - let rowSpacing: CGFloat - let setSelectionToTabs: () -> Void - @Binding var selectedTabIds: Set - @Binding var lastSidebarSelectionIndex: Int? - let showsModifierShortcutHints: Bool - let dragAutoScrollController: SidebarDragAutoScrollController - @Binding var draggedTabId: UUID? - @Binding var dropIndicator: SidebarDropIndicator? - let contextMenuWorkspaceIds: [UUID] - let remoteContextMenuWorkspaceIds: [UUID] - let allRemoteContextMenuTargetsConnecting: Bool - let allRemoteContextMenuTargetsDisconnected: Bool - let settings: SidebarTabItemSettingsSnapshot - @State private var workspaceObservationGeneration: UInt64 = 0 - @State private var isHovering = false - @State private var rowHeight: CGFloat = 1 - // Cached results of the expensive bonsplit tree walk + branch/dir/PR snapshot. - // Updated only by the debounced publisher, onAppear, and settings changes — - // NOT by the immediate publisher (title keystrokes). This prevents the tree - // walk from running on every keystroke in single-panel workspaces. - @State private var cachedOrderedPanelIds: [UUID]? = nil - @State private var cachedBranchDirectoryLines: [VerticalBranchDirectoryLine] = [] - @State private var cachedPullRequestRows: [PullRequestDisplay] = [] - - var isMultiSelected: Bool { - selectedTabIds.contains(tab.id) - } - - private var isBeingDragged: Bool { - draggedTabId == tab.id - } - - private var sidebarShortcutHintXOffset: Double { - settings.sidebarShortcutHintXOffset - } - - private var sidebarShortcutHintYOffset: Double { - settings.sidebarShortcutHintYOffset - } - - private var alwaysShowShortcutHints: Bool { - settings.alwaysShowShortcutHints - } - - private var sidebarShowGitBranch: Bool { - settings.showsGitBranch - } - - private var sidebarBranchVerticalLayout: Bool { - settings.usesVerticalBranchLayout - } - - private var sidebarShowGitBranchIcon: Bool { - settings.showsGitBranchIcon - } - - private var sidebarShowSSH: Bool { - settings.showsSSH - } - - private var activeTabIndicatorStyle: SidebarActiveTabIndicatorStyle { - settings.activeTabIndicatorStyle - } - - private var sidebarSelectionColorHex: String? { - settings.selectionColorHex - } - - private var sidebarNotificationBadgeColorHex: String? { - settings.notificationBadgeColorHex - } - - private var openSidebarPullRequestLinksInProgramaBrowser: Bool { - settings.openPullRequestLinksInProgramaBrowser - } - - private var openSidebarPortLinksInProgramaBrowser: Bool { - settings.openPortLinksInProgramaBrowser - } - - private var titleFontWeight: Font.Weight { - .semibold - } - - private var showsLeadingRail: Bool { - explicitRailColor != nil - } - - private var activeBorderLineWidth: CGFloat { - switch activeTabIndicatorStyle { - case .leftRail: - return 0 - case .solidFill: - return isActive ? 1.5 : 0 - } - } - - private var activeBorderColor: Color { - guard isActive else { return .clear } - switch activeTabIndicatorStyle { - case .leftRail: - return .clear - case .solidFill: - return Color.primary.opacity(0.5) - } - } - - private var usesInvertedActiveForeground: Bool { - isActive - } - - private var activePrimaryTextColor: Color { - usesInvertedActiveForeground - ? Color(nsColor: sidebarSelectedWorkspaceForegroundNSColor(opacity: 1.0)) - : .primary - } - - private func activeSecondaryColor(_ opacity: Double = 0.75) -> Color { - usesInvertedActiveForeground - ? Color(nsColor: sidebarSelectedWorkspaceForegroundNSColor(opacity: CGFloat(opacity))) - : .secondary - } - - private var activeUnreadBadgeFillColor: Color { - if let hex = sidebarNotificationBadgeColorHex, let nsColor = NSColor(hex: hex) { - return Color(nsColor: nsColor) - } - return usesInvertedActiveForeground ? Color.white.opacity(0.25) : programaAccentColor() - } - - private var activeProgressTrackColor: Color { - usesInvertedActiveForeground ? Color.white.opacity(0.15) : Color.secondary.opacity(0.2) - } - - private var activeProgressFillColor: Color { - usesInvertedActiveForeground ? Color.white.opacity(0.8) : programaAccentColor() - } - - private var shortcutHintEmphasis: Double { - usesInvertedActiveForeground ? 1.0 : 0.9 - } - - private var showCloseButton: Bool { - isHovering && canCloseWorkspace && !(showsModifierShortcutHints || alwaysShowShortcutHints) - } - - private var workspaceShortcutLabel: String? { - guard let workspaceShortcutDigit else { return nil } - return "\(workspaceShortcutModifierSymbol)\(workspaceShortcutDigit)" - } - - private var showsWorkspaceShortcutHint: Bool { - (showsModifierShortcutHints || alwaysShowShortcutHints) && workspaceShortcutLabel != nil - } - - private var trailingAccessoryWidth: CGFloat { - SidebarTrailingAccessoryWidthPolicy.width( - canCloseWorkspace: canCloseWorkspace, - showsWorkspaceShortcutHint: showsWorkspaceShortcutHint, - workspaceShortcutLabel: workspaceShortcutLabel, - debugXOffset: sidebarShortcutHintXOffset - ) - } - - private var remoteWorkspaceSidebarText: String? { - guard tab.hasActiveRemoteTerminalSessions else { return nil } - let trimmedTarget = tab.remoteDisplayTarget?.trimmingCharacters(in: .whitespacesAndNewlines) - if let trimmedTarget, !trimmedTarget.isEmpty { - return trimmedTarget - } - return String(localized: "sidebar.remote.subtitleFallback", defaultValue: "SSH workspace") - } - - private var copyableSidebarSSHError: String? { - let fallbackTarget = tab.remoteDisplayTarget ?? String( - localized: "sidebar.remote.help.targetFallback", - defaultValue: "remote host" - ) - let trimmedDetail = tab.remoteConnectionDetail?.trimmingCharacters(in: .whitespacesAndNewlines) - if tab.remoteConnectionState == .error, let trimmedDetail, !trimmedDetail.isEmpty { - let entry = SidebarRemoteErrorCopyEntry( - workspaceTitle: tab.title, - target: fallbackTarget, - detail: trimmedDetail - ) - return SidebarRemoteErrorCopySupport.clipboardText(for: [entry]) - } - if let statusValue = tab.statusEntries["remote.error"]?.value - .trimmingCharacters(in: .whitespacesAndNewlines), - !statusValue.isEmpty { - let entry = SidebarRemoteErrorCopyEntry( - workspaceTitle: tab.title, - target: fallbackTarget, - detail: statusValue - ) - return SidebarRemoteErrorCopySupport.clipboardText(for: [entry]) - } - return nil - } - - private var remoteConnectionStatusText: String { - switch tab.remoteConnectionState { - case .connected: - return String(localized: "remote.status.connected", defaultValue: "Connected") - case .connecting: - return String(localized: "remote.status.connecting", defaultValue: "Connecting") - case .error: - return String(localized: "remote.status.error", defaultValue: "Error") - case .disconnected: - return String(localized: "remote.status.disconnected", defaultValue: "Disconnected") - } - } - - @ViewBuilder - private var remoteWorkspaceSection: some View { - if sidebarShowSSH, let remoteWorkspaceSidebarText { - VStack(alignment: .leading, spacing: 2) { - HStack(spacing: 6) { - Text(remoteWorkspaceSidebarText) - .font(.system(size: 10, design: .monospaced)) - .foregroundColor(activeSecondaryColor(0.8)) - .lineLimit(1) - .truncationMode(.middle) - - Spacer(minLength: 0) - - Text(remoteConnectionStatusText) - .font(.system(size: 9, weight: .medium)) - .foregroundColor(activeSecondaryColor(0.58)) - .lineLimit(1) - } - } - .padding(.top, latestNotificationText == nil ? 1 : 2) - .safeHelp(remoteStateHelpText) - } - } - - private func copyTextToPasteboard(_ text: String) { - let pasteboard = NSPasteboard.general - pasteboard.clearContents() - pasteboard.setString(text, forType: .string) - } - - private var visibleAuxiliaryDetails: SidebarWorkspaceAuxiliaryDetailVisibility { - settings.visibleAuxiliaryDetails - } - - var body: some View { - let _ = workspaceObservationGeneration - let closeWorkspaceTooltip = String(localized: "sidebar.closeWorkspace.tooltip", defaultValue: "Close Workspace") - let protectedWorkspaceTooltip = String( - localized: "sidebar.pinnedWorkspaceProtected.tooltip", - defaultValue: "Pinned workspace. Closing requires confirmation." - ) - let closeButtonTooltip = tab.isPinned - ? protectedWorkspaceTooltip - : KeyboardShortcutSettings.Action.closeWorkspace.tooltip(closeWorkspaceTooltip) - let accessibilityHintText = String(localized: "sidebar.workspace.accessibilityHint", defaultValue: "Activate to focus this workspace. Drag to reorder, or use Move Up and Move Down actions.") - let moveUpActionText = String(localized: "sidebar.workspace.moveUpAction", defaultValue: "Move Up") - let moveDownActionText = String(localized: "sidebar.workspace.moveDownAction", defaultValue: "Move Down") - let latestNotificationSubtitle = latestNotificationText - let effectiveSubtitle = latestNotificationSubtitle - let detailVisibility = visibleAuxiliaryDetails - // Read from cache — updated only by the debounced publisher, onAppear, - // and settings changes. Title-keystroke re-renders skip this tree walk. - let orderedPanelIds: [UUID]? = cachedOrderedPanelIds - let branchDirectoryLines: [VerticalBranchDirectoryLine] = cachedBranchDirectoryLines - let pullRequestRows: [PullRequestDisplay] = cachedPullRequestRows - let compactGitBranchSummaryText: String? = { - guard detailVisibility.showsBranchDirectory, - !sidebarBranchVerticalLayout, - sidebarShowGitBranch, - let orderedPanelIds else { - return nil - } - return gitBranchSummaryText(orderedPanelIds: orderedPanelIds) - }() - let compactDirectorySummaryText: String? = { - guard detailVisibility.showsBranchDirectory, - !sidebarBranchVerticalLayout, - let orderedPanelIds else { - return nil - } - return directorySummaryText(orderedPanelIds: orderedPanelIds) - }() - let compactBranchDirectoryRow = branchDirectoryRow( - gitSummary: compactGitBranchSummaryText, - directorySummary: compactDirectorySummaryText - ) - let branchLinesContainBranch = sidebarShowGitBranch && branchDirectoryLines.contains { $0.branch != nil } - - VStack(alignment: .leading, spacing: 4) { - HStack(spacing: 8) { - if unreadCount > 0 { - ZStack { - Circle() - .fill(activeUnreadBadgeFillColor) - Text("\(unreadCount)") - .font(.system(size: 9, weight: .semibold)) - .foregroundColor(.white) - } - .frame(width: 16, height: 16) - } - - if tab.isPinned { - Image(systemName: "pin.fill") - .font(.system(size: 9, weight: .semibold)) - .foregroundColor(activeSecondaryColor(0.8)) - .safeHelp(protectedWorkspaceTooltip) - } - - Text(tab.title) - .font(.system(size: 12.5, weight: titleFontWeight)) - .foregroundColor(activePrimaryTextColor) - .lineLimit(1) - .truncationMode(.tail) - .layoutPriority(1) - - Spacer(minLength: 0) - - ZStack(alignment: .trailing) { - Button(action: { - #if DEBUG - dlog("sidebar.close workspace=\(tab.id.uuidString.prefix(5)) method=button") - #endif - tabManager.closeWorkspaceWithConfirmation(tab) - }) { - Image(systemName: "xmark") - .font(.system(size: 9, weight: .medium)) - .foregroundColor(activeSecondaryColor(0.7)) - } - .buttonStyle(.plain) - .safeHelp(closeButtonTooltip) - .frame(width: SidebarTrailingAccessoryWidthPolicy.closeButtonWidth, height: 16, alignment: .center) - .opacity(showCloseButton && !showsWorkspaceShortcutHint ? 1 : 0) - .allowsHitTesting(showCloseButton && !showsWorkspaceShortcutHint) - - if showsWorkspaceShortcutHint, let workspaceShortcutLabel { - Text(workspaceShortcutLabel) - .lineLimit(1) - .fixedSize(horizontal: true, vertical: false) - .font(.system(size: 10, weight: .semibold, design: .rounded)) - .monospacedDigit() - .foregroundColor(activePrimaryTextColor) - .padding(.horizontal, 6) - .padding(.vertical, 2) - .background(ShortcutHintPillBackground(emphasis: shortcutHintEmphasis)) - .offset( - x: ShortcutHintDebugSettings.clamped(sidebarShortcutHintXOffset), - y: ShortcutHintDebugSettings.clamped(sidebarShortcutHintYOffset) - ) - .transition(.opacity) - } - } - .animation(.easeInOut(duration: 0.14), value: showsModifierShortcutHints || alwaysShowShortcutHints) - .frame(width: trailingAccessoryWidth, height: 16, alignment: .trailing) - } - - if let description = tab.customDescription { - SidebarWorkspaceDescriptionText( - markdown: description, - isActive: usesInvertedActiveForeground - ) - .id(description) - } - - if let subtitle = effectiveSubtitle { - Text(subtitle) - .font(.system(size: 10)) - .foregroundColor(activeSecondaryColor(0.8)) - .lineLimit(2) - .truncationMode(.tail) - .multilineTextAlignment(.leading) - } - - remoteWorkspaceSection - - if detailVisibility.showsMetadata { - let metadataEntries = tab.sidebarStatusEntriesInDisplayOrder() - let metadataBlocks = tab.sidebarMetadataBlocksInDisplayOrder() - if !metadataEntries.isEmpty { - SidebarMetadataRows( - entries: metadataEntries, - isActive: usesInvertedActiveForeground, - onFocus: { updateSelection() } - ) - .transition(.opacity.combined(with: .move(edge: .top))) - } - if !metadataBlocks.isEmpty { - SidebarMetadataMarkdownBlocks( - blocks: metadataBlocks, - isActive: usesInvertedActiveForeground, - onFocus: { updateSelection() } - ) - .transition(.opacity.combined(with: .move(edge: .top))) - } - } - - // Latest log entry - if detailVisibility.showsLog, let latestLog = tab.logEntries.last { - HStack(spacing: 4) { - Image(systemName: logLevelIcon(latestLog.level)) - .font(.system(size: 8)) - .foregroundColor(logLevelColor(latestLog.level, isActive: usesInvertedActiveForeground)) - Text(latestLog.message) - .font(.system(size: 10)) - .foregroundColor(activeSecondaryColor(0.8)) - .lineLimit(1) - .truncationMode(.tail) - } - .transition(.opacity.combined(with: .move(edge: .top))) - } - - // Progress bar - if detailVisibility.showsProgress, let progress = tab.progress { - VStack(alignment: .leading, spacing: 2) { - GeometryReader { geo in - ZStack(alignment: .leading) { - Capsule() - .fill(activeProgressTrackColor) - Capsule() - .fill(activeProgressFillColor) - .frame(width: max(0, geo.size.width * CGFloat(progress.value))) - } - } - .frame(height: 3) - - if let label = progress.label { - Text(label) - .font(.system(size: 9)) - .foregroundColor(activeSecondaryColor(0.6)) - .lineLimit(1) - } - } - .transition(.opacity.combined(with: .move(edge: .top))) - } - - // Branch + directory row - if detailVisibility.showsBranchDirectory { - if sidebarBranchVerticalLayout { - if !branchDirectoryLines.isEmpty { - HStack(alignment: .top, spacing: 3) { - if sidebarShowGitBranchIcon, branchLinesContainBranch { - Image(systemName: "arrow.triangle.branch") - .font(.system(size: 9)) - .foregroundColor(activeSecondaryColor(0.6)) - } - VStack(alignment: .leading, spacing: 1) { - ForEach(Array(branchDirectoryLines.enumerated()), id: \.offset) { _, line in - HStack(spacing: 3) { - if let branch = line.branch { - Text(branch) - .font(.system(size: 10, design: .monospaced)) - .foregroundColor(activeSecondaryColor(0.75)) - .lineLimit(1) - .truncationMode(.tail) - } - if line.branch != nil, line.directory != nil { - Image(systemName: "circle.fill") - .font(.system(size: 3)) - .foregroundColor(activeSecondaryColor(0.6)) - .padding(.horizontal, 1) - } - if let directory = line.directory { - Text(directory) - .font(.system(size: 10, design: .monospaced)) - .foregroundColor(activeSecondaryColor(0.75)) - .lineLimit(1) - .truncationMode(.tail) - } - } - } - } - } - } - } else if let dirRow = compactBranchDirectoryRow { - HStack(spacing: 3) { - if sidebarShowGitBranchIcon, compactGitBranchSummaryText != nil { - Image(systemName: "arrow.triangle.branch") - .font(.system(size: 9)) - .foregroundColor(activeSecondaryColor(0.6)) - } - Text(dirRow) - .font(.system(size: 10, design: .monospaced)) - .foregroundColor(activeSecondaryColor(0.75)) - .lineLimit(1) - .truncationMode(.tail) - } - } - } - - // Pull request rows - if detailVisibility.showsPullRequests, !pullRequestRows.isEmpty { - VStack(alignment: .leading, spacing: 1) { - ForEach(pullRequestRows) { pullRequest in - Button(action: { - openPullRequestLink(pullRequest.url) - }) { - HStack(spacing: 4) { - PullRequestStatusIcon( - status: pullRequest.status, - color: pullRequestForegroundColor - ) - Text("\(pullRequest.label) #\(pullRequest.number)") - .underline() - .lineLimit(1) - .truncationMode(.tail) - Text(pullRequestStatusLabel(pullRequest.status, checks: pullRequest.checks)) - .lineLimit(1) - Spacer(minLength: 0) - } - .font(.system(size: 10, weight: .semibold)) - .foregroundColor(pullRequestForegroundColor) - } - .buttonStyle(.plain) - .safeHelp(String(localized: "sidebar.pullRequest.openTooltip", defaultValue: "Open \(pullRequest.label) #\(pullRequest.number)")) - } - } - } - - // Ports row - if detailVisibility.showsPorts, !tab.listeningPorts.isEmpty { - HStack(spacing: 4) { - ForEach(tab.listeningPorts, id: \.self) { port in - Button(action: { - openPortLink(port) - }) { - Text(String(localized: "sidebar.port.label", defaultValue: ":\(port)")) - .underline() - } - .buttonStyle(.plain) - .safeHelp(String(localized: "sidebar.port.openTooltip", defaultValue: "Open localhost:\(port)")) - } - Spacer(minLength: 0) - } - .font(.system(size: 10, design: .monospaced)) - .foregroundColor(activeSecondaryColor(0.75)) - .lineLimit(1) - } - } - .animation(.easeInOut(duration: 0.2), value: tab.logEntries.count) - .animation(.easeInOut(duration: 0.2), value: tab.progress != nil) - .animation(.easeInOut(duration: 0.2), value: tab.metadataBlocks.count) - .padding(.horizontal, 10) - .padding(.vertical, 8) - .background( - RoundedRectangle(cornerRadius: 6) - .fill(backgroundColor) - .overlay { - RoundedRectangle(cornerRadius: 6) - .strokeBorder(activeBorderColor, lineWidth: activeBorderLineWidth) - } - .overlay(alignment: .leading) { - if showsLeadingRail { - Capsule(style: .continuous) - .fill(railColor) - .frame(width: 3) - .padding(.leading, 4) - .padding(.vertical, 5) - .offset(x: -1) - } - } - ) - .padding(.horizontal, 6) - .background { - GeometryReader { proxy in - Color.clear - .onAppear { - rowHeight = max(proxy.size.height, 1) - } - .onChange(of: proxy.size.height) { newHeight in - rowHeight = max(newHeight, 1) - } - } - } - .contentShape(Rectangle()) - .opacity(isBeingDragged ? 0.6 : 1) - .overlay { - MiddleClickCapture { - #if DEBUG - dlog("sidebar.close workspace=\(tab.id.uuidString.prefix(5)) method=middleClick") - #endif - tabManager.closeWorkspaceWithConfirmation(tab) - } - } - .overlay(alignment: .top) { - if showsCenteredTopDropIndicator { - Rectangle() - .fill(programaAccentColor()) - .frame(height: 2) - .padding(.horizontal, 8) - .offset(y: index == 0 ? 0 : -(rowSpacing / 2)) - } - } - .onReceive( - tab.sidebarImmediateObservationPublisher - .receive(on: RunLoop.main) - ) { _ in -#if DEBUG - let description = tab.customDescription ?? "" - dlog( - "sidebar.row.invalidate workspace=\(tab.id.uuidString.prefix(8)) " + - "source=immediate " + - "title=\"\(debugCommandPaletteTextPreview(tab.title))\" " + - "descLen=\((description as NSString).length) " + - "desc=\"\(debugCommandPaletteTextPreview(description))\"" - ) -#endif - workspaceObservationGeneration &+= 1 - } - .onReceive( - tab.sidebarObservationPublisher - .receive(on: RunLoop.main) - // Prompt-time sidebar telemetry can arrive as a short burst - // (pwd, branch, PR, shell state). Coalesce that burst so the - // row redraws once with the settled state instead of blinking. - .debounce(for: Self.workspaceObservationCoalesceInterval, scheduler: RunLoop.main) - ) { _ in -#if DEBUG - let description = tab.customDescription ?? "" - dlog( - "sidebar.row.invalidate workspace=\(tab.id.uuidString.prefix(8)) " + - "source=debounced " + - "title=\"\(debugCommandPaletteTextPreview(tab.title))\" " + - "descLen=\((description as NSString).length) " + - "desc=\"\(debugCommandPaletteTextPreview(description))\"" - ) -#endif - // Refresh expensive caches (tree walk, branch/dir/PR) before - // signalling a redraw. The immediate publisher intentionally does - // NOT call this so that title keystrokes skip the tree walk. - recomputeSidebarDetailCache() - workspaceObservationGeneration &+= 1 - } - .onDrag { - #if DEBUG - dlog("sidebar.onDrag tab=\(tab.id.uuidString.prefix(5))") - #endif - draggedTabId = tab.id - dropIndicator = nil - return SidebarTabDragPayload.provider(for: tab.id) - } - .internalOnlyTabDrag() - .onDrop(of: SidebarTabDragPayload.dropContentTypes, delegate: SidebarTabDropDelegate( - targetTabId: tab.id, - tabManager: tabManager, - draggedTabId: $draggedTabId, - selectedTabIds: $selectedTabIds, - lastSidebarSelectionIndex: $lastSidebarSelectionIndex, - targetRowHeight: rowHeight, - dragAutoScrollController: dragAutoScrollController, - dropIndicator: $dropIndicator - )) - .onDrop(of: BonsplitTabDragPayload.dropContentTypes, delegate: SidebarBonsplitTabDropDelegate( - targetWorkspaceId: tab.id, - tabManager: tabManager, - selectedTabIds: $selectedTabIds, - lastSidebarSelectionIndex: $lastSidebarSelectionIndex - )) - .onTapGesture { - updateSelection() - } - .onHover { hovering in - isHovering = hovering - } - .accessibilityElement(children: .combine) - .accessibilityLabel(Text(accessibilityTitle)) - .accessibilityHint(Text(accessibilityHintText)) - .accessibilityAction(named: Text(moveUpActionText)) { - moveBy(-1) - } - .accessibilityAction(named: Text(moveDownActionText)) { - moveBy(1) - } - .onAppear { - // Prime the cache so branch/dir/PR rows appear immediately, - // before the first debounced publisher fires. - recomputeSidebarDetailCache() - } - .onChange(of: visibleAuxiliaryDetails) { _ in - // Toggling branch/PR columns changes which data we need to cache. - recomputeSidebarDetailCache() - } - .contextMenu { workspaceContextMenu } - } - - private func contextMenuLabel(multi: String, single: String, isMulti: Bool) -> String { - isMulti ? multi : single - } - - private func remoteContextMenuWorkspaces() -> [Workspace] { - guard !remoteContextMenuWorkspaceIds.isEmpty else { return [] } - return remoteContextMenuWorkspaceIds.compactMap { workspaceId in - tabManager.tabs.first(where: { $0.id == workspaceId }) - } - } - - @ViewBuilder - private var workspaceContextMenu: some View { - let targetIds = contextMenuWorkspaceIds - let isMulti = targetIds.count > 1 - let tabColorPalette = WorkspaceTabColorSettings.palette() - let shouldPin = !tab.isPinned - let reconnectLabel = contextMenuLabel( - multi: String(localized: "contextMenu.reconnectWorkspaces", defaultValue: "Reconnect Workspaces"), - single: String(localized: "contextMenu.reconnectWorkspace", defaultValue: "Reconnect Workspace"), - isMulti: isMulti) - let disconnectLabel = contextMenuLabel( - multi: String(localized: "contextMenu.disconnectWorkspaces", defaultValue: "Disconnect Workspaces"), - single: String(localized: "contextMenu.disconnectWorkspace", defaultValue: "Disconnect Workspace"), - isMulti: isMulti) - let pinLabel = shouldPin - ? contextMenuLabel( - multi: String(localized: "contextMenu.pinWorkspaces", defaultValue: "Pin Workspaces"), - single: String(localized: "contextMenu.pinWorkspace", defaultValue: "Pin Workspace"), - isMulti: isMulti) - : contextMenuLabel( - multi: String(localized: "contextMenu.unpinWorkspaces", defaultValue: "Unpin Workspaces"), - single: String(localized: "contextMenu.unpinWorkspace", defaultValue: "Unpin Workspace"), - isMulti: isMulti) - let closeLabel = contextMenuLabel( - multi: String(localized: "contextMenu.closeWorkspaces", defaultValue: "Close Workspaces"), - single: String(localized: "contextMenu.closeWorkspace", defaultValue: "Close Workspace"), - isMulti: isMulti) - let markReadLabel = contextMenuLabel( - multi: String(localized: "contextMenu.markWorkspacesRead", defaultValue: "Mark Workspaces as Read"), - single: String(localized: "contextMenu.markWorkspaceRead", defaultValue: "Mark Workspace as Read"), - isMulti: isMulti) - let markUnreadLabel = contextMenuLabel( - multi: String(localized: "contextMenu.markWorkspacesUnread", defaultValue: "Mark Workspaces as Unread"), - single: String(localized: "contextMenu.markWorkspaceUnread", defaultValue: "Mark Workspace as Unread"), - isMulti: isMulti) - let clearLatestNotificationLabel = contextMenuLabel( - multi: String(localized: "contextMenu.clearLatestNotifications", defaultValue: "Clear Latest Notifications"), - single: String(localized: "contextMenu.clearLatestNotification", defaultValue: "Clear Latest Notification"), - isMulti: isMulti) - let renameWorkspaceShortcut = KeyboardShortcutSettings.shortcut(for: .renameWorkspace) - let editWorkspaceDescriptionShortcut = KeyboardShortcutSettings.shortcut(for: .editWorkspaceDescription) - let closeWorkspaceShortcut = KeyboardShortcutSettings.shortcut(for: .closeWorkspace) - Button(pinLabel) { - for id in targetIds { - if let tab = tabManager.tabs.first(where: { $0.id == id }) { - tabManager.setPinned(tab, pinned: shouldPin) - } - } - syncSelectionAfterMutation() - } - - if let key = renameWorkspaceShortcut.keyEquivalent { - Button(String(localized: "contextMenu.renameWorkspace", defaultValue: "Rename Workspace…")) { - promptRename() - } - .keyboardShortcut(key, modifiers: renameWorkspaceShortcut.eventModifiers) - } else { - Button(String(localized: "contextMenu.renameWorkspace", defaultValue: "Rename Workspace…")) { - promptRename() - } - } - - if tab.hasCustomTitle { - Button(String(localized: "contextMenu.removeCustomWorkspaceName", defaultValue: "Remove Custom Workspace Name")) { - tabManager.clearCustomTitle(tabId: tab.id) - } - } - - if !isMulti { - if let key = editWorkspaceDescriptionShortcut.keyEquivalent { - Button(String(localized: "contextMenu.editWorkspaceDescription", defaultValue: "Edit Workspace Description…")) { - beginWorkspaceDescriptionEditFromContextMenu() - } - .keyboardShortcut(key, modifiers: editWorkspaceDescriptionShortcut.eventModifiers) - } else { - Button(String(localized: "contextMenu.editWorkspaceDescription", defaultValue: "Edit Workspace Description…")) { - beginWorkspaceDescriptionEditFromContextMenu() - } - } - - if tab.hasCustomDescription { - Button(String(localized: "contextMenu.clearWorkspaceDescription", defaultValue: "Clear Workspace Description")) { - tabManager.clearCustomDescription(tabId: tab.id) - } - } - } - - if !remoteContextMenuWorkspaceIds.isEmpty { - Divider() - - Button(reconnectLabel) { - for workspace in remoteContextMenuWorkspaces() { - workspace.reconnectRemoteConnection() - } - } - .disabled(allRemoteContextMenuTargetsConnecting) - - Button(disconnectLabel) { - for workspace in remoteContextMenuWorkspaces() { - workspace.disconnectRemoteConnection(clearConfiguration: false) - } - } - .disabled(allRemoteContextMenuTargetsDisconnected) - } - - Menu(String(localized: "contextMenu.workspaceColor", defaultValue: "Workspace Color")) { - if tab.customColor != nil { - Button { - applyTabColor(nil, targetIds: targetIds) - } label: { - Label(String(localized: "contextMenu.clearColor", defaultValue: "Clear Color"), systemImage: "xmark.circle") - } - } - - Button { - promptCustomColor(targetIds: targetIds) - } label: { - Label(String(localized: "contextMenu.chooseCustomColor", defaultValue: "Choose Custom Color…"), systemImage: "paintpalette") - } - - if !tabColorPalette.isEmpty { - Divider() - } - - ForEach(tabColorPalette, id: \.id) { entry in - Button { - applyTabColor(entry.hex, targetIds: targetIds) - } label: { - Label { - Text(entry.name) - } icon: { - Image(nsImage: coloredCircleImage(color: tabColorSwatchColor(for: entry.hex))) - } - } - } - } - - if let copyableSidebarSSHError { - Button(String(localized: "contextMenu.copySshError", defaultValue: "Copy SSH Error")) { - copyTextToPasteboard(copyableSidebarSSHError) - } - } - - Divider() - - Button(String(localized: "contextMenu.moveUp", defaultValue: "Move Up")) { - moveBy(-1) - } - .disabled(index == 0) - - Button(String(localized: "contextMenu.moveDown", defaultValue: "Move Down")) { - moveBy(1) - } - .disabled(index >= tabManager.tabs.count - 1) - - Button(String(localized: "contextMenu.moveToTop", defaultValue: "Move to Top")) { - tabManager.moveTabsToTop(Set(targetIds)) - syncSelectionAfterMutation() - } - .disabled(targetIds.isEmpty) - - let referenceWindowId = AppDelegate.shared?.windowId(for: tabManager) - let windowMoveTargets = AppDelegate.shared?.windowMoveTargets(referenceWindowId: referenceWindowId) ?? [] - let moveMenuTitle = targetIds.count > 1 - ? String(localized: "contextMenu.moveWorkspacesToWindow", defaultValue: "Move Workspaces to Window") - : String(localized: "contextMenu.moveWorkspaceToWindow", defaultValue: "Move Workspace to Window") - Menu(moveMenuTitle) { - Button(String(localized: "contextMenu.newWindow", defaultValue: "New Window")) { - moveWorkspacesToNewWindow(targetIds) - } - .disabled(targetIds.isEmpty) - - if !windowMoveTargets.isEmpty { - Divider() - } - - ForEach(windowMoveTargets) { target in - Button(target.label) { - moveWorkspaces(targetIds, toWindow: target.windowId) - } - .disabled(target.isCurrentWindow || targetIds.isEmpty) - } - } - .disabled(targetIds.isEmpty) - - Divider() - - if let key = closeWorkspaceShortcut.keyEquivalent { - Button(closeLabel) { - closeTabs(targetIds, allowPinned: true) - } - .keyboardShortcut(key, modifiers: closeWorkspaceShortcut.eventModifiers) - .disabled(targetIds.isEmpty) - } else { - Button(closeLabel) { - closeTabs(targetIds, allowPinned: true) - } - .disabled(targetIds.isEmpty) - } - - Button(String(localized: "contextMenu.closeOtherWorkspaces", defaultValue: "Close Other Workspaces")) { - closeOtherTabs(targetIds) - } - .disabled(tabManager.tabs.count <= 1 || targetIds.count == tabManager.tabs.count) - - Button(String(localized: "contextMenu.closeWorkspacesBelow", defaultValue: "Close Workspaces Below")) { - closeTabsBelow(tabId: tab.id) - } - .disabled(index >= tabManager.tabs.count - 1) - - Button(String(localized: "contextMenu.closeWorkspacesAbove", defaultValue: "Close Workspaces Above")) { - closeTabsAbove(tabId: tab.id) - } - .disabled(index == 0) - - Divider() - - Button(markReadLabel) { - markTabsRead(targetIds) - } - .disabled(!hasUnreadNotifications(in: targetIds)) - - Button(markUnreadLabel) { - markTabsUnread(targetIds) - } - .disabled(!hasReadNotifications(in: targetIds)) - - Button(clearLatestNotificationLabel) { - clearLatestNotifications(targetIds) - } - .disabled(!hasLatestNotifications(in: targetIds)) - } - - private var selectionBackgroundColor: NSColor { - if let hex = sidebarSelectionColorHex, let parsed = NSColor(hex: hex) { - return parsed - } - return programaAccentNSColor(for: colorScheme) - } - - private var backgroundColor: Color { - switch activeTabIndicatorStyle { - case .leftRail: - if isActive { return Color(nsColor: selectionBackgroundColor) } - if isMultiSelected { return programaAccentColor().opacity(0.25) } - return Color.clear - case .solidFill: - if isActive { return Color(nsColor: selectionBackgroundColor) } - if let custom = resolvedCustomTabColor { - if isMultiSelected { return custom.opacity(0.35) } - return custom.opacity(0.7) - } - if isMultiSelected { return programaAccentColor().opacity(0.25) } - return Color.clear - } - } - - private var railColor: Color { - explicitRailColor ?? .clear - } - - private var explicitRailColor: Color? { - guard activeTabIndicatorStyle == .leftRail, - let custom = resolvedCustomTabColor else { - return nil - } - return custom.opacity(0.95) - } - - private var resolvedCustomTabColor: Color? { - guard let hex = tab.customColor else { return nil } - return WorkspaceTabColorSettings.displayColor( - hex: hex, - colorScheme: colorScheme, - forceBright: activeTabIndicatorStyle == .leftRail - ) - } - - private func tabColorSwatchColor(for hex: String) -> NSColor { - WorkspaceTabColorSettings.displayNSColor( - hex: hex, - colorScheme: colorScheme, - forceBright: activeTabIndicatorStyle == .leftRail - ) ?? NSColor(hex: hex) ?? .gray - } - - private var showsCenteredTopDropIndicator: Bool { - guard draggedTabId != nil, let indicator = dropIndicator else { return false } - if indicator.tabId == tab.id && indicator.edge == .top { - return true - } - - guard indicator.edge == .bottom, - let currentIndex = tabManager.tabs.firstIndex(where: { $0.id == tab.id }), - currentIndex > 0 - else { - return false - } - return tabManager.tabs[currentIndex - 1].id == indicator.tabId - } - - private var accessibilityTitle: String { - String(localized: "accessibility.workspacePosition", defaultValue: "\(tab.title), workspace \(index + 1) of \(accessibilityWorkspaceCount)") - } - - private func moveBy(_ delta: Int) { - let targetIndex = index + delta - guard targetIndex >= 0, targetIndex < tabManager.tabs.count else { return } - guard tabManager.reorderWorkspace(tabId: tab.id, toIndex: targetIndex) else { return } - selectedTabIds = [tab.id] - lastSidebarSelectionIndex = tabManager.tabs.firstIndex { $0.id == tab.id } - tabManager.selectTab(tab) - setSelectionToTabs() - } - - private func updateSelection() { - #if DEBUG - let mods = NSEvent.modifierFlags - var modStr = "" - if mods.contains(.command) { modStr += "cmd " } - if mods.contains(.shift) { modStr += "shift " } - if mods.contains(.option) { modStr += "opt " } - if mods.contains(.control) { modStr += "ctrl " } - dlog("sidebar.select workspace=\(tab.id.uuidString.prefix(5)) modifiers=\(modStr.isEmpty ? "none" : modStr.trimmingCharacters(in: .whitespaces))") - #endif - let modifiers = NSEvent.modifierFlags - let isCommand = modifiers.contains(.command) - let isShift = modifiers.contains(.shift) - let wasSelected = tabManager.selectedTabId == tab.id - - if isShift, let lastIndex = lastSidebarSelectionIndex { - let lower = min(lastIndex, index) - let upper = max(lastIndex, index) - let rangeIds = tabManager.tabs[lower...upper].map { $0.id } - if isCommand { - selectedTabIds.formUnion(rangeIds) - } else { - selectedTabIds = Set(rangeIds) - } - } else if isCommand { - if selectedTabIds.contains(tab.id) { - selectedTabIds.remove(tab.id) - } else { - selectedTabIds.insert(tab.id) - } - } else { - selectedTabIds = [tab.id] - } - - lastSidebarSelectionIndex = index - tabManager.selectTab(tab) - if wasSelected, !isCommand, !isShift { - tabManager.dismissNotificationOnDirectInteraction( - tabId: tab.id, - surfaceId: tabManager.focusedSurfaceId(for: tab.id) - ) - } - setSelectionToTabs() - } - - private func closeTabs(_ targetIds: [UUID], allowPinned: Bool) { - tabManager.closeWorkspacesWithConfirmation(targetIds, allowPinned: allowPinned) - syncSelectionAfterMutation() - } - - private func closeOtherTabs(_ targetIds: [UUID]) { - let keepIds = Set(targetIds) - let idsToClose = tabManager.tabs.compactMap { keepIds.contains($0.id) ? nil : $0.id } - closeTabs(idsToClose, allowPinned: true) - } - - private func closeTabsBelow(tabId: UUID) { - guard let anchorIndex = tabManager.tabs.firstIndex(where: { $0.id == tabId }) else { return } - let idsToClose = tabManager.tabs.suffix(from: anchorIndex + 1).map { $0.id } - closeTabs(idsToClose, allowPinned: true) - } - - private func closeTabsAbove(tabId: UUID) { - guard let anchorIndex = tabManager.tabs.firstIndex(where: { $0.id == tabId }) else { return } - let idsToClose = tabManager.tabs.prefix(upTo: anchorIndex).map { $0.id } - closeTabs(idsToClose, allowPinned: true) - } - - private func markTabsRead(_ targetIds: [UUID]) { - for id in targetIds { - notificationStore.markRead(forTabId: id) - } - } - - private func markTabsUnread(_ targetIds: [UUID]) { - for id in targetIds { - notificationStore.markUnread(forTabId: id) - } - } - - private func clearLatestNotifications(_ targetIds: [UUID]) { - for id in targetIds { - notificationStore.clearLatestNotification(forTabId: id) - } - } - - private func hasUnreadNotifications(in targetIds: [UUID]) -> Bool { - let targetSet = Set(targetIds) - return notificationStore.notifications.contains { targetSet.contains($0.tabId) && !$0.isRead } - } - - private func hasReadNotifications(in targetIds: [UUID]) -> Bool { - let targetSet = Set(targetIds) - return notificationStore.notifications.contains { targetSet.contains($0.tabId) && $0.isRead } - } - - private func hasLatestNotifications(in targetIds: [UUID]) -> Bool { - targetIds.contains { notificationStore.latestNotification(forTabId: $0) != nil } - } - - private func syncSelectionAfterMutation() { - let existingIds = Set(tabManager.tabs.map { $0.id }) - selectedTabIds = selectedTabIds.filter { existingIds.contains($0) } - if selectedTabIds.isEmpty, let selectedId = tabManager.selectedTabId { - selectedTabIds = [selectedId] - } - if let selectedId = tabManager.selectedTabId { - lastSidebarSelectionIndex = tabManager.tabs.firstIndex { $0.id == selectedId } - } - } - - private var remoteStateHelpText: String { - let target = tab.remoteDisplayTarget ?? String( - localized: "sidebar.remote.help.targetFallback", - defaultValue: "remote host" - ) - let detail = tab.remoteConnectionDetail?.trimmingCharacters(in: .whitespacesAndNewlines) - switch tab.remoteConnectionState { - case .connected: - return String( - format: String( - localized: "sidebar.remote.help.connected", - defaultValue: "SSH connected to %@" - ), - locale: .current, - target - ) - case .connecting: - return String( - format: String( - localized: "sidebar.remote.help.connecting", - defaultValue: "SSH connecting to %@" - ), - locale: .current, - target - ) - case .error: - if let detail, !detail.isEmpty { - return String( - format: String( - localized: "sidebar.remote.help.errorWithDetail", - defaultValue: "SSH error for %@: %@" - ), - locale: .current, - target, - detail - ) - } - return String( - format: String( - localized: "sidebar.remote.help.error", - defaultValue: "SSH error for %@" - ), - locale: .current, - target - ) - case .disconnected: - return String( - format: String( - localized: "sidebar.remote.help.disconnected", - defaultValue: "SSH disconnected from %@" - ), - locale: .current, - target - ) - } - } - private func moveWorkspaces(_ workspaceIds: [UUID], toWindow windowId: UUID) { - guard let app = AppDelegate.shared else { return } - let orderedWorkspaceIds = tabManager.tabs.compactMap { workspaceIds.contains($0.id) ? $0.id : nil } - guard !orderedWorkspaceIds.isEmpty else { return } - - for (index, workspaceId) in orderedWorkspaceIds.enumerated() { - let shouldFocus = index == orderedWorkspaceIds.count - 1 - _ = app.moveWorkspaceToWindow(workspaceId: workspaceId, windowId: windowId, focus: shouldFocus) - } - - selectedTabIds.subtract(orderedWorkspaceIds) - syncSelectionAfterMutation() - } - - private func moveWorkspacesToNewWindow(_ workspaceIds: [UUID]) { - guard let app = AppDelegate.shared else { return } - let orderedWorkspaceIds = tabManager.tabs.compactMap { workspaceIds.contains($0.id) ? $0.id : nil } - guard let firstWorkspaceId = orderedWorkspaceIds.first else { return } - - let shouldFocusImmediately = orderedWorkspaceIds.count == 1 - guard let newWindowId = app.moveWorkspaceToNewWindow(workspaceId: firstWorkspaceId, focus: shouldFocusImmediately) else { - return - } - - if orderedWorkspaceIds.count > 1 { - for workspaceId in orderedWorkspaceIds.dropFirst() { - _ = app.moveWorkspaceToWindow(workspaceId: workspaceId, windowId: newWindowId, focus: false) - } - if let finalWorkspaceId = orderedWorkspaceIds.last { - _ = app.moveWorkspaceToWindow(workspaceId: finalWorkspaceId, windowId: newWindowId, focus: true) - } - } - - selectedTabIds.subtract(orderedWorkspaceIds) - syncSelectionAfterMutation() - } - - // latestNotificationText is now passed as a parameter from the parent view - // to avoid subscribing to notificationStore changes in every TabItemView. - - private func branchDirectoryRow( - gitSummary: String?, - directorySummary: String? - ) -> String? { - var parts: [String] = [] - - if let gitSummary { - parts.append(gitSummary) - } - - if let directorySummary { - parts.append(directorySummary) - } - - let result = parts.joined(separator: " · ") - return result.isEmpty ? nil : result - } - - private func gitBranchSummaryText(orderedPanelIds: [UUID]) -> String? { - let lines = gitBranchSummaryLines(orderedPanelIds: orderedPanelIds) - guard !lines.isEmpty else { return nil } - return lines.joined(separator: " | ") - } - - private func gitBranchSummaryLines(orderedPanelIds: [UUID]) -> [String] { - tab.sidebarGitBranchesInDisplayOrder(orderedPanelIds: orderedPanelIds).map { branch in - "\(branch.branch)\(branch.isDirty ? "*" : "")" - } - } - - private struct VerticalBranchDirectoryLine { - let branch: String? - let directory: String? - } - - /// Recomputes the expensive sidebar detail caches (bonsplit tree walk, - /// branch/directory lines, PR snapshot) and writes them into @State. - /// Must be called only from the debounced publisher, onAppear, and - /// settings-change handlers — never from the immediate (title) publisher. - private func recomputeSidebarDetailCache() { - let detail = visibleAuxiliaryDetails - let needsDetail = detail.showsBranchDirectory || detail.showsPullRequests - let ids: [UUID]? = needsDetail ? tab.sidebarOrderedPanelIds() : nil - cachedOrderedPanelIds = ids - cachedBranchDirectoryLines = { - guard detail.showsBranchDirectory, sidebarBranchVerticalLayout, let ids else { return [] } - return verticalBranchDirectoryLines(orderedPanelIds: ids) - }() - cachedPullRequestRows = { - guard detail.showsPullRequests, let ids else { return [] } - return pullRequestDisplays(orderedPanelIds: ids) - }() - } - - private func verticalBranchDirectoryLines(orderedPanelIds: [UUID]) -> [VerticalBranchDirectoryLine] { - let entries = tab.sidebarBranchDirectoryEntriesInDisplayOrder(orderedPanelIds: orderedPanelIds) - let home = SidebarPathFormatter.homeDirectoryPath - return entries.compactMap { entry in - let branchText: String? = { - guard sidebarShowGitBranch, let branch = entry.branch else { return nil } - return "\(branch)\(entry.isDirty ? "*" : "")" - }() - - let directoryText: String? = { - guard let directory = entry.directory else { return nil } - let shortened = SidebarPathFormatter.shortenedPath(directory, homeDirectoryPath: home) - return shortened.isEmpty ? nil : shortened - }() - - switch (branchText, directoryText) { - case let (branch?, directory?): - return VerticalBranchDirectoryLine(branch: branch, directory: directory) - case let (branch?, nil): - return VerticalBranchDirectoryLine(branch: branch, directory: nil) - case let (nil, directory?): - return VerticalBranchDirectoryLine(branch: nil, directory: directory) - default: - return nil - } - } - } - - private func directorySummaryText(orderedPanelIds: [UUID]) -> String? { - let home = SidebarPathFormatter.homeDirectoryPath - let entries = tab.sidebarDirectoriesInDisplayOrder(orderedPanelIds: orderedPanelIds).compactMap { directory in - let shortened = SidebarPathFormatter.shortenedPath(directory, homeDirectoryPath: home) - return shortened.isEmpty ? nil : shortened - } - return entries.isEmpty ? nil : entries.joined(separator: " | ") - } - - private struct PullRequestDisplay: Identifiable { - let id: String - let number: Int - let label: String - let url: URL - let status: SidebarPullRequestStatus - let checks: SidebarPullRequestChecksStatus? - } - - private func pullRequestDisplays(orderedPanelIds: [UUID]) -> [PullRequestDisplay] { - tab.sidebarPullRequestsInDisplayOrder(orderedPanelIds: orderedPanelIds).map { pullRequest in - PullRequestDisplay( - id: "\(pullRequest.label.lowercased())#\(pullRequest.number)|\(pullRequest.url.absoluteString)", - number: pullRequest.number, - label: pullRequest.label, - url: pullRequest.url, - status: pullRequest.status, - checks: pullRequest.checks - ) - } - } - - private var pullRequestForegroundColor: Color { - isActive ? .white.opacity(0.75) : .secondary - } - - private func openPullRequestLink(_ url: URL) { - updateSelection() - if openSidebarPullRequestLinksInProgramaBrowser { - if tabManager.openBrowser( - inWorkspace: tab.id, - url: url, - preferSplitRight: true, - insertAtEnd: true - ) == nil { - NSWorkspace.shared.open(url) - } - return - } - NSWorkspace.shared.open(url) - } - - private func openPortLink(_ port: Int) { - guard let url = URL(string: "http://localhost:\(port)") else { return } - updateSelection() - if openSidebarPortLinksInProgramaBrowser { - if tabManager.openBrowser( - inWorkspace: tab.id, - url: url, - preferSplitRight: true, - insertAtEnd: true - ) == nil { - NSWorkspace.shared.open(url) - } - return - } - NSWorkspace.shared.open(url) - } - - private func pullRequestStatusLabel( - _ status: SidebarPullRequestStatus, - checks _: SidebarPullRequestChecksStatus? - ) -> String { - switch status { - case .open: return String(localized: "sidebar.pullRequest.statusOpen", defaultValue: "open") - case .merged: return String(localized: "sidebar.pullRequest.statusMerged", defaultValue: "merged") - case .closed: return String(localized: "sidebar.pullRequest.statusClosed", defaultValue: "closed") - } - } - - private func logLevelIcon(_ level: SidebarLogLevel) -> String { - switch level { - case .info: return "circle.fill" - case .progress: return "arrowtriangle.right.fill" - case .success: return "checkmark.circle.fill" - case .warning: return "exclamationmark.triangle.fill" - case .error: return "xmark.circle.fill" - } - } - - private func logLevelColor(_ level: SidebarLogLevel, isActive: Bool) -> Color { - if isActive { - switch level { - case .info: - return Color(nsColor: sidebarSelectedWorkspaceForegroundNSColor(opacity: 0.5)) - case .progress: - return Color(nsColor: sidebarSelectedWorkspaceForegroundNSColor(opacity: 0.8)) - case .success: - return Color(nsColor: sidebarSelectedWorkspaceForegroundNSColor(opacity: 0.9)) - case .warning: - return Color(nsColor: sidebarSelectedWorkspaceForegroundNSColor(opacity: 0.9)) - case .error: - return Color(nsColor: sidebarSelectedWorkspaceForegroundNSColor(opacity: 0.9)) - } - } - switch level { - case .info: return .secondary - case .progress: return .blue - case .success: return .green - case .warning: return .orange - case .error: return .red - } - } - - private func shortenPath(_ path: String, home: String) -> String { - let trimmed = path.trimmingCharacters(in: .whitespacesAndNewlines) - guard !trimmed.isEmpty else { return path } - if trimmed == home { - return "~" - } - if trimmed.hasPrefix(home + "/") { - return "~" + trimmed.dropFirst(home.count) - } - return trimmed - } - - private struct PullRequestStatusIcon: View { - let status: SidebarPullRequestStatus - let color: Color - private static let frameSize: CGFloat = 12 - - var body: some View { - switch status { - case .open: - PullRequestOpenIcon(color: color) - case .merged: - PullRequestMergedIcon(color: color) - case .closed: - Image(systemName: "xmark.circle") - .font(.system(size: 7, weight: .regular)) - .foregroundColor(color) - .frame(width: Self.frameSize, height: Self.frameSize) - } - } - } - - private struct PullRequestOpenIcon: View { - let color: Color - private static let stroke = StrokeStyle(lineWidth: 1.2, lineCap: .round, lineJoin: .round) - private static let nodeDiameter: CGFloat = 3.0 - private static let frameSize: CGFloat = 13 - - var body: some View { - ZStack { - Path { path in - path.move(to: CGPoint(x: 3.0, y: 4.8)) - path.addLine(to: CGPoint(x: 3.0, y: 9.2)) - - path.move(to: CGPoint(x: 4.8, y: 3.0)) - path.addLine(to: CGPoint(x: 9.4, y: 3.0)) - path.addLine(to: CGPoint(x: 11.0, y: 4.6)) - path.addLine(to: CGPoint(x: 11.0, y: 9.2)) - } - .stroke(color, style: Self.stroke) - - Circle() - .stroke(color, lineWidth: Self.stroke.lineWidth) - .frame(width: Self.nodeDiameter, height: Self.nodeDiameter) - .position(x: 3.0, y: 3.0) - - Circle() - .stroke(color, lineWidth: Self.stroke.lineWidth) - .frame(width: Self.nodeDiameter, height: Self.nodeDiameter) - .position(x: 3.0, y: 11.0) - - Circle() - .stroke(color, lineWidth: Self.stroke.lineWidth) - .frame(width: Self.nodeDiameter, height: Self.nodeDiameter) - .position(x: 11.0, y: 11.0) - } - .frame(width: Self.frameSize, height: Self.frameSize) - } - } - - private struct PullRequestMergedIcon: View { - let color: Color - private static let stroke = StrokeStyle(lineWidth: 1.2, lineCap: .round, lineJoin: .round) - private static let nodeDiameter: CGFloat = 3.0 - private static let frameSize: CGFloat = 13 - - var body: some View { - ZStack { - Path { path in - path.move(to: CGPoint(x: 4.6, y: 4.6)) - path.addLine(to: CGPoint(x: 7.1, y: 7.0)) - path.addLine(to: CGPoint(x: 9.2, y: 7.0)) - - path.move(to: CGPoint(x: 4.6, y: 9.4)) - path.addLine(to: CGPoint(x: 7.1, y: 7.0)) - } - .stroke(color, style: Self.stroke) - - Circle() - .stroke(color, lineWidth: Self.stroke.lineWidth) - .frame(width: Self.nodeDiameter, height: Self.nodeDiameter) - .position(x: 3.0, y: 3.0) - - Circle() - .stroke(color, lineWidth: Self.stroke.lineWidth) - .frame(width: Self.nodeDiameter, height: Self.nodeDiameter) - .position(x: 3.0, y: 11.0) - - Circle() - .stroke(color, lineWidth: Self.stroke.lineWidth) - .frame(width: Self.nodeDiameter, height: Self.nodeDiameter) - .position(x: 11.0, y: 7.0) - } - .frame(width: Self.frameSize, height: Self.frameSize) - } - } - - private func applyTabColor(_ hex: String?, targetIds: [UUID]) { - for targetId in targetIds { - tabManager.setTabColor(tabId: targetId, color: hex) - } - } - - private func promptCustomColor(targetIds: [UUID]) { - let alert = NSAlert() - alert.messageText = String(localized: "alert.customColor.title", defaultValue: "Custom Workspace Color") - alert.informativeText = String(localized: "alert.customColor.message", defaultValue: "Enter a hex color in the format #RRGGBB.") - - let seed = tab.customColor ?? WorkspaceTabColorSettings.customPaletteEntries().first?.hex ?? "" - let input = NSTextField(string: seed) - input.placeholderString = "#1565C0" - input.frame = NSRect(x: 0, y: 0, width: 240, height: 22) - alert.accessoryView = input - alert.addButton(withTitle: String(localized: "alert.customColor.apply", defaultValue: "Apply")) - alert.addButton(withTitle: String(localized: "alert.customColor.cancel", defaultValue: "Cancel")) - - let alertWindow = alert.window - alertWindow.initialFirstResponder = input - DispatchQueue.main.async { - alertWindow.makeFirstResponder(input) - input.selectText(nil) - } - - let response = alert.runModal() - guard response == .alertFirstButtonReturn else { return } - guard let normalized = WorkspaceTabColorSettings.addCustomColor(input.stringValue) else { - showInvalidColorAlert(input.stringValue) - return - } - applyTabColor(normalized, targetIds: targetIds) - } - - private func showInvalidColorAlert(_ value: String) { - let alert = NSAlert() - alert.alertStyle = .warning - alert.messageText = String(localized: "alert.invalidColor.title", defaultValue: "Invalid Color") - let trimmed = value.trimmingCharacters(in: .whitespacesAndNewlines) - if trimmed.isEmpty { - alert.informativeText = String(localized: "alert.invalidColor.emptyMessage", defaultValue: "Enter a hex color in the format #RRGGBB.") - } else { - alert.informativeText = String(localized: "alert.invalidColor.invalidMessage", defaultValue: "\"\(trimmed)\" is not a valid hex color. Use #RRGGBB.") - } - alert.addButton(withTitle: String(localized: "alert.invalidColor.ok", defaultValue: "OK")) - _ = alert.runModal() - } - - private func promptRename() { - let alert = NSAlert() - alert.messageText = String(localized: "alert.renameWorkspace.title", defaultValue: "Rename Workspace") - alert.informativeText = String(localized: "alert.renameWorkspace.message", defaultValue: "Enter a custom name for this workspace.") - let input = NSTextField(string: tab.customTitle ?? tab.title) - input.placeholderString = String(localized: "alert.renameWorkspace.placeholder", defaultValue: "Workspace name") - input.frame = NSRect(x: 0, y: 0, width: 240, height: 22) - alert.accessoryView = input - alert.addButton(withTitle: String(localized: "alert.renameWorkspace.rename", defaultValue: "Rename")) - alert.addButton(withTitle: String(localized: "alert.renameWorkspace.cancel", defaultValue: "Cancel")) - let alertWindow = alert.window - alertWindow.initialFirstResponder = input - DispatchQueue.main.async { - alertWindow.makeFirstResponder(input) - input.selectText(nil) - } - let response = alert.runModal() - guard response == .alertFirstButtonReturn else { return } - tabManager.setCustomTitle(tabId: tab.id, title: input.stringValue) - } - - private func beginWorkspaceDescriptionEditFromContextMenu() { - selectedTabIds = [tab.id] - lastSidebarSelectionIndex = index - tabManager.selectTab(tab) - setSelectionToTabs() - _ = AppDelegate.shared?.requestEditWorkspaceDescriptionViaCommandPalette() - } -} - -private struct SidebarWorkspaceDescriptionText: View { - let markdown: String - let isActive: Bool - - var body: some View { - let renderedMarkdown = SidebarMarkdownRenderer.renderWorkspaceDescription(markdown) - Group { - if let renderedMarkdown { - Text(renderedMarkdown) - } else { - Text(markdown) - } - } - .font(.system(size: 10.5)) - .foregroundColor(foregroundColor) - .multilineTextAlignment(.leading) - .fixedSize(horizontal: false, vertical: true) - .frame(maxWidth: .infinity, alignment: .leading) - .accessibilityIdentifier("SidebarWorkspaceDescriptionText") - .accessibilityLabel(accessibilityText(renderedMarkdown: renderedMarkdown)) - .onAppear { -#if DEBUG - let newlineCount = markdown.reduce(into: 0) { count, character in - if character == "\n" { count += 1 } - } - dlog( - "sidebar.description.render workspaceState=appear " + - "len=\((markdown as NSString).length) " + - "newlines=\(newlineCount) " + - "text=\"\(debugCommandPaletteTextPreview(markdown))\"" - ) -#endif - } - .onChange(of: markdown) { newValue in -#if DEBUG - let newlineCount = newValue.reduce(into: 0) { count, character in - if character == "\n" { count += 1 } - } - dlog( - "sidebar.description.render workspaceState=change " + - "len=\((newValue as NSString).length) " + - "newlines=\(newlineCount) " + - "text=\"\(debugCommandPaletteTextPreview(newValue))\"" - ) -#endif - } - } - - private var foregroundColor: Color { - isActive ? .white.opacity(0.84) : .secondary.opacity(0.95) - } - - private func accessibilityText(renderedMarkdown: AttributedString?) -> String { - if let renderedMarkdown { - return String(renderedMarkdown.characters) - } - return markdown - } -} - -enum SidebarMarkdownRenderer { - static func renderWorkspaceDescription(_ markdown: String) -> AttributedString? { - try? AttributedString( - markdown: markdown, - options: .init(interpretedSyntax: .inlineOnlyPreservingWhitespace) - ) - } -} - -private struct SidebarMetadataRows: View { - let entries: [SidebarStatusEntry] - let isActive: Bool - let onFocus: () -> Void - - @State private var isExpanded: Bool = false - private let collapsedEntryLimit = 3 - - var body: some View { - VStack(alignment: .leading, spacing: 2) { - ForEach(visibleEntries, id: \.key) { entry in - SidebarMetadataEntryRow(entry: entry, isActive: isActive, onFocus: onFocus) - } - - if shouldShowToggle { - Button(isExpanded ? String(localized: "sidebar.metadata.showLess", defaultValue: "Show less") : String(localized: "sidebar.metadata.showMore", defaultValue: "Show more")) { - onFocus() - withAnimation(.easeInOut(duration: 0.15)) { - isExpanded.toggle() - } - } - .buttonStyle(.plain) - .font(.system(size: 10, weight: .semibold)) - .foregroundColor(isActive ? activeSecondaryTextColor : .secondary.opacity(0.9)) - .frame(maxWidth: .infinity, alignment: .leading) - } - } - .safeHelp(helpText) - } - - private var activeSecondaryTextColor: Color { - Color(nsColor: sidebarSelectedWorkspaceForegroundNSColor(opacity: 0.65)) - } - - private var visibleEntries: [SidebarStatusEntry] { - guard !isExpanded, entries.count > collapsedEntryLimit else { return entries } - return Array(entries.prefix(collapsedEntryLimit)) - } - - private var helpText: String { - entries.map { entry in - let trimmed = entry.value.trimmingCharacters(in: .whitespacesAndNewlines) - return trimmed.isEmpty ? entry.key : trimmed - } - .joined(separator: "\n") - } - - private var shouldShowToggle: Bool { - entries.count > collapsedEntryLimit - } -} - -private struct SidebarMetadataEntryRow: View { - let entry: SidebarStatusEntry - let isActive: Bool - let onFocus: () -> Void - - var body: some View { - Group { - if let url = entry.url { - Button { - onFocus() - NSWorkspace.shared.open(url) - } label: { - rowContent(underlined: true) - } - .buttonStyle(.plain) - .safeHelp(url.absoluteString) - } else { - rowContent(underlined: false) - .contentShape(Rectangle()) - .onTapGesture { onFocus() } - } - } - } - - @ViewBuilder - private func rowContent(underlined: Bool) -> some View { - HStack(spacing: 4) { - if let icon = iconView { - icon - .foregroundColor(foregroundColor.opacity(0.95)) - } - metadataText(underlined: underlined) - .lineLimit(1) - .truncationMode(.tail) - Spacer(minLength: 0) - } - .font(.system(size: 10)) - .frame(maxWidth: .infinity, alignment: .leading) - } - - private var foregroundColor: Color { - if isActive, - let raw = entry.color, - Color(hex: raw) != nil { - return Color(nsColor: sidebarSelectedWorkspaceForegroundNSColor(opacity: 0.95)) - } - if let raw = entry.color, let explicit = Color(hex: raw) { - return explicit - } - return isActive ? .white.opacity(0.8) : .secondary - } - - private var iconView: AnyView? { - guard let iconRaw = entry.icon?.trimmingCharacters(in: .whitespacesAndNewlines), - !iconRaw.isEmpty else { - return nil - } - if iconRaw.hasPrefix("emoji:") { - let value = String(iconRaw.dropFirst("emoji:".count)) - guard !value.isEmpty else { return nil } - return AnyView(Text(value).font(.system(size: 9))) - } - if iconRaw.hasPrefix("text:") { - let value = String(iconRaw.dropFirst("text:".count)) - guard !value.isEmpty else { return nil } - return AnyView(Text(value).font(.system(size: 8, weight: .semibold))) - } - let symbolName: String - if iconRaw.hasPrefix("sf:") { - symbolName = String(iconRaw.dropFirst("sf:".count)) - } else { - symbolName = iconRaw - } - guard !symbolName.isEmpty else { return nil } - return AnyView(Image(systemName: symbolName).font(.system(size: 8, weight: .medium))) - } - - @ViewBuilder - private func metadataText(underlined: Bool) -> some View { - let trimmed = entry.value.trimmingCharacters(in: .whitespacesAndNewlines) - let display = trimmed.isEmpty ? entry.key : trimmed - if entry.format == .markdown, - let attributed = try? AttributedString( - markdown: display, - options: .init(interpretedSyntax: .inlineOnlyPreservingWhitespace) - ) { - Text(attributed) - .underline(underlined) - .foregroundColor(foregroundColor) - } else { - Text(display) - .underline(underlined) - .foregroundColor(foregroundColor) - } - } -} - -private struct SidebarMetadataMarkdownBlocks: View { - let blocks: [SidebarMetadataBlock] - let isActive: Bool - let onFocus: () -> Void - - @State private var isExpanded: Bool = false - private let collapsedBlockLimit = 1 - - var body: some View { - VStack(alignment: .leading, spacing: 3) { - ForEach(visibleBlocks, id: \.key) { block in - SidebarMetadataMarkdownBlockRow( - block: block, - isActive: isActive, - onFocus: onFocus - ) - } - - if shouldShowToggle { - Button(isExpanded ? String(localized: "sidebar.metadata.showLessDetails", defaultValue: "Show less details") : String(localized: "sidebar.metadata.showMoreDetails", defaultValue: "Show more details")) { - onFocus() - withAnimation(.easeInOut(duration: 0.15)) { - isExpanded.toggle() - } - } - .buttonStyle(.plain) - .font(.system(size: 10, weight: .semibold)) - .foregroundColor(isActive ? .white.opacity(0.65) : .secondary.opacity(0.9)) - .frame(maxWidth: .infinity, alignment: .leading) - } - } - } - - private var visibleBlocks: [SidebarMetadataBlock] { - guard !isExpanded, blocks.count > collapsedBlockLimit else { return blocks } - return Array(blocks.prefix(collapsedBlockLimit)) - } - - private var shouldShowToggle: Bool { - blocks.count > collapsedBlockLimit - } -} - -private struct SidebarMetadataMarkdownBlockRow: View { - let block: SidebarMetadataBlock - let isActive: Bool - let onFocus: () -> Void - - @State private var renderedMarkdown: AttributedString? - - var body: some View { - Group { - if let renderedMarkdown { - Text(renderedMarkdown) - .foregroundColor(foregroundColor) - } else { - Text(block.markdown) - .foregroundColor(foregroundColor) - } - } - .font(.system(size: 10)) - .multilineTextAlignment(.leading) - .fixedSize(horizontal: false, vertical: true) - .contentShape(Rectangle()) - .onTapGesture { onFocus() } - .onAppear(perform: renderMarkdown) - .onChange(of: block.markdown) { _ in - renderMarkdown() - } - } - - private var foregroundColor: Color { - isActive ? .white.opacity(0.8) : .secondary - } - - private func renderMarkdown() { - renderedMarkdown = try? AttributedString( - markdown: block.markdown, - options: .init(interpretedSyntax: .full) - ) - } -} - enum SidebarDropEdge: Equatable { case top case bottom diff --git a/Sources/TabItemView.swift b/Sources/TabItemView.swift new file mode 100644 index 00000000000..3194f78a2fc --- /dev/null +++ b/Sources/TabItemView.swift @@ -0,0 +1,1965 @@ +// TabItemView + its exclusive helper-view cluster, extracted from ContentView.swift (nuclear-review #94.2). +// Pure move — behavior-identical relocation. Do NOT touch TabItemView's Equatable +// conformance, its precomputed let parameters, or the .equatable() call site in +// VerticalTabsSidebar (ContentView.swift) — see CLAUDE.md typing-latency-sensitive paths. +// +// Access-level widening: TabItemView was `private struct` (file-private to the old +// ContentView.swift); widened to internal (default) so VerticalTabsSidebar, which stays +// in ContentView.swift, can still construct it. No other behavior change. + +import AppKit +import Bonsplit +import Combine +import SwiftUI + + +// PERF: TabItemView is Equatable so SwiftUI skips body re-evaluation when +// the parent rebuilds with unchanged values. Without this, every TabManager +// or NotificationStore publish causes ALL tab items to re-evaluate (~18% of +// main thread during typing). If you add new properties, update == below. +// Reactive workspace state inside the row must not rely on parent diffs alone: +// `.equatable()` can otherwise leave sidebar badges/details stale until an +// unrelated parent change sneaks through. Keep the workspace reference plain +// and bridge only sidebar-visible workspace changes into local state. +// Do NOT add @EnvironmentObject or new @Binding without updating ==. +// Do NOT remove .equatable() from the ForEach call site in VerticalTabsSidebar. +struct TabItemView: View, Equatable { + private static let workspaceObservationCoalesceInterval: RunLoop.SchedulerTimeType.Stride = .milliseconds(40) + + // Closures, Bindings, and object references are excluded from == + // because they're recreated every parent eval but don't affect rendering. + nonisolated static func == (lhs: TabItemView, rhs: TabItemView) -> Bool { + lhs.tab === rhs.tab && + lhs.index == rhs.index && + lhs.isActive == rhs.isActive && + lhs.workspaceShortcutDigit == rhs.workspaceShortcutDigit && + lhs.workspaceShortcutModifierSymbol == rhs.workspaceShortcutModifierSymbol && + lhs.canCloseWorkspace == rhs.canCloseWorkspace && + lhs.accessibilityWorkspaceCount == rhs.accessibilityWorkspaceCount && + lhs.unreadCount == rhs.unreadCount && + lhs.latestNotificationText == rhs.latestNotificationText && + lhs.rowSpacing == rhs.rowSpacing && + lhs.showsModifierShortcutHints == rhs.showsModifierShortcutHints && + lhs.contextMenuWorkspaceIds == rhs.contextMenuWorkspaceIds && + lhs.remoteContextMenuWorkspaceIds == rhs.remoteContextMenuWorkspaceIds && + lhs.allRemoteContextMenuTargetsConnecting == rhs.allRemoteContextMenuTargetsConnecting && + lhs.allRemoteContextMenuTargetsDisconnected == rhs.allRemoteContextMenuTargetsDisconnected && + lhs.settings == rhs.settings + } + + // Use plain references instead of @EnvironmentObject to avoid subscribing + // to ALL changes on these objects. Body reads use precomputed parameters; + // action handlers use the plain references without triggering re-evaluation. + let tabManager: TabManager + let notificationStore: TerminalNotificationStore + @Environment(\.colorScheme) private var colorScheme + let tab: Tab + let index: Int + let isActive: Bool + let workspaceShortcutDigit: Int? + let workspaceShortcutModifierSymbol: String + let canCloseWorkspace: Bool + let accessibilityWorkspaceCount: Int + let unreadCount: Int + let latestNotificationText: String? + let rowSpacing: CGFloat + let setSelectionToTabs: () -> Void + @Binding var selectedTabIds: Set + @Binding var lastSidebarSelectionIndex: Int? + let showsModifierShortcutHints: Bool + let dragAutoScrollController: SidebarDragAutoScrollController + @Binding var draggedTabId: UUID? + @Binding var dropIndicator: SidebarDropIndicator? + let contextMenuWorkspaceIds: [UUID] + let remoteContextMenuWorkspaceIds: [UUID] + let allRemoteContextMenuTargetsConnecting: Bool + let allRemoteContextMenuTargetsDisconnected: Bool + let settings: SidebarTabItemSettingsSnapshot + @State private var workspaceObservationGeneration: UInt64 = 0 + @State private var isHovering = false + @State private var rowHeight: CGFloat = 1 + // Cached results of the expensive bonsplit tree walk + branch/dir/PR snapshot. + // Updated only by the debounced publisher, onAppear, and settings changes — + // NOT by the immediate publisher (title keystrokes). This prevents the tree + // walk from running on every keystroke in single-panel workspaces. + @State private var cachedOrderedPanelIds: [UUID]? = nil + @State private var cachedBranchDirectoryLines: [VerticalBranchDirectoryLine] = [] + @State private var cachedPullRequestRows: [PullRequestDisplay] = [] + + var isMultiSelected: Bool { + selectedTabIds.contains(tab.id) + } + + private var isBeingDragged: Bool { + draggedTabId == tab.id + } + + private var sidebarShortcutHintXOffset: Double { + settings.sidebarShortcutHintXOffset + } + + private var sidebarShortcutHintYOffset: Double { + settings.sidebarShortcutHintYOffset + } + + private var alwaysShowShortcutHints: Bool { + settings.alwaysShowShortcutHints + } + + private var sidebarShowGitBranch: Bool { + settings.showsGitBranch + } + + private var sidebarBranchVerticalLayout: Bool { + settings.usesVerticalBranchLayout + } + + private var sidebarShowGitBranchIcon: Bool { + settings.showsGitBranchIcon + } + + private var sidebarShowSSH: Bool { + settings.showsSSH + } + + private var activeTabIndicatorStyle: SidebarActiveTabIndicatorStyle { + settings.activeTabIndicatorStyle + } + + private var sidebarSelectionColorHex: String? { + settings.selectionColorHex + } + + private var sidebarNotificationBadgeColorHex: String? { + settings.notificationBadgeColorHex + } + + private var openSidebarPullRequestLinksInProgramaBrowser: Bool { + settings.openPullRequestLinksInProgramaBrowser + } + + private var openSidebarPortLinksInProgramaBrowser: Bool { + settings.openPortLinksInProgramaBrowser + } + + private var titleFontWeight: Font.Weight { + .semibold + } + + private var showsLeadingRail: Bool { + explicitRailColor != nil + } + + private var activeBorderLineWidth: CGFloat { + switch activeTabIndicatorStyle { + case .leftRail: + return 0 + case .solidFill: + return isActive ? 1.5 : 0 + } + } + + private var activeBorderColor: Color { + guard isActive else { return .clear } + switch activeTabIndicatorStyle { + case .leftRail: + return .clear + case .solidFill: + return Color.primary.opacity(0.5) + } + } + + private var usesInvertedActiveForeground: Bool { + isActive + } + + private var activePrimaryTextColor: Color { + usesInvertedActiveForeground + ? Color(nsColor: sidebarSelectedWorkspaceForegroundNSColor(opacity: 1.0)) + : .primary + } + + private func activeSecondaryColor(_ opacity: Double = 0.75) -> Color { + usesInvertedActiveForeground + ? Color(nsColor: sidebarSelectedWorkspaceForegroundNSColor(opacity: CGFloat(opacity))) + : .secondary + } + + private var activeUnreadBadgeFillColor: Color { + if let hex = sidebarNotificationBadgeColorHex, let nsColor = NSColor(hex: hex) { + return Color(nsColor: nsColor) + } + return usesInvertedActiveForeground ? Color.white.opacity(0.25) : programaAccentColor() + } + + private var activeProgressTrackColor: Color { + usesInvertedActiveForeground ? Color.white.opacity(0.15) : Color.secondary.opacity(0.2) + } + + private var activeProgressFillColor: Color { + usesInvertedActiveForeground ? Color.white.opacity(0.8) : programaAccentColor() + } + + private var shortcutHintEmphasis: Double { + usesInvertedActiveForeground ? 1.0 : 0.9 + } + + private var showCloseButton: Bool { + isHovering && canCloseWorkspace && !(showsModifierShortcutHints || alwaysShowShortcutHints) + } + + private var workspaceShortcutLabel: String? { + guard let workspaceShortcutDigit else { return nil } + return "\(workspaceShortcutModifierSymbol)\(workspaceShortcutDigit)" + } + + private var showsWorkspaceShortcutHint: Bool { + (showsModifierShortcutHints || alwaysShowShortcutHints) && workspaceShortcutLabel != nil + } + + private var trailingAccessoryWidth: CGFloat { + SidebarTrailingAccessoryWidthPolicy.width( + canCloseWorkspace: canCloseWorkspace, + showsWorkspaceShortcutHint: showsWorkspaceShortcutHint, + workspaceShortcutLabel: workspaceShortcutLabel, + debugXOffset: sidebarShortcutHintXOffset + ) + } + + private var remoteWorkspaceSidebarText: String? { + guard tab.hasActiveRemoteTerminalSessions else { return nil } + let trimmedTarget = tab.remoteDisplayTarget?.trimmingCharacters(in: .whitespacesAndNewlines) + if let trimmedTarget, !trimmedTarget.isEmpty { + return trimmedTarget + } + return String(localized: "sidebar.remote.subtitleFallback", defaultValue: "SSH workspace") + } + + private var copyableSidebarSSHError: String? { + let fallbackTarget = tab.remoteDisplayTarget ?? String( + localized: "sidebar.remote.help.targetFallback", + defaultValue: "remote host" + ) + let trimmedDetail = tab.remoteConnectionDetail?.trimmingCharacters(in: .whitespacesAndNewlines) + if tab.remoteConnectionState == .error, let trimmedDetail, !trimmedDetail.isEmpty { + let entry = SidebarRemoteErrorCopyEntry( + workspaceTitle: tab.title, + target: fallbackTarget, + detail: trimmedDetail + ) + return SidebarRemoteErrorCopySupport.clipboardText(for: [entry]) + } + if let statusValue = tab.statusEntries["remote.error"]?.value + .trimmingCharacters(in: .whitespacesAndNewlines), + !statusValue.isEmpty { + let entry = SidebarRemoteErrorCopyEntry( + workspaceTitle: tab.title, + target: fallbackTarget, + detail: statusValue + ) + return SidebarRemoteErrorCopySupport.clipboardText(for: [entry]) + } + return nil + } + + private var remoteConnectionStatusText: String { + switch tab.remoteConnectionState { + case .connected: + return String(localized: "remote.status.connected", defaultValue: "Connected") + case .connecting: + return String(localized: "remote.status.connecting", defaultValue: "Connecting") + case .error: + return String(localized: "remote.status.error", defaultValue: "Error") + case .disconnected: + return String(localized: "remote.status.disconnected", defaultValue: "Disconnected") + } + } + + @ViewBuilder + private var remoteWorkspaceSection: some View { + if sidebarShowSSH, let remoteWorkspaceSidebarText { + VStack(alignment: .leading, spacing: 2) { + HStack(spacing: 6) { + Text(remoteWorkspaceSidebarText) + .font(.system(size: 10, design: .monospaced)) + .foregroundColor(activeSecondaryColor(0.8)) + .lineLimit(1) + .truncationMode(.middle) + + Spacer(minLength: 0) + + Text(remoteConnectionStatusText) + .font(.system(size: 9, weight: .medium)) + .foregroundColor(activeSecondaryColor(0.58)) + .lineLimit(1) + } + } + .padding(.top, latestNotificationText == nil ? 1 : 2) + .safeHelp(remoteStateHelpText) + } + } + + private func copyTextToPasteboard(_ text: String) { + let pasteboard = NSPasteboard.general + pasteboard.clearContents() + pasteboard.setString(text, forType: .string) + } + + private var visibleAuxiliaryDetails: SidebarWorkspaceAuxiliaryDetailVisibility { + settings.visibleAuxiliaryDetails + } + + var body: some View { + let _ = workspaceObservationGeneration + let closeWorkspaceTooltip = String(localized: "sidebar.closeWorkspace.tooltip", defaultValue: "Close Workspace") + let protectedWorkspaceTooltip = String( + localized: "sidebar.pinnedWorkspaceProtected.tooltip", + defaultValue: "Pinned workspace. Closing requires confirmation." + ) + let closeButtonTooltip = tab.isPinned + ? protectedWorkspaceTooltip + : KeyboardShortcutSettings.Action.closeWorkspace.tooltip(closeWorkspaceTooltip) + let accessibilityHintText = String(localized: "sidebar.workspace.accessibilityHint", defaultValue: "Activate to focus this workspace. Drag to reorder, or use Move Up and Move Down actions.") + let moveUpActionText = String(localized: "sidebar.workspace.moveUpAction", defaultValue: "Move Up") + let moveDownActionText = String(localized: "sidebar.workspace.moveDownAction", defaultValue: "Move Down") + let latestNotificationSubtitle = latestNotificationText + let effectiveSubtitle = latestNotificationSubtitle + let detailVisibility = visibleAuxiliaryDetails + // Read from cache — updated only by the debounced publisher, onAppear, + // and settings changes. Title-keystroke re-renders skip this tree walk. + let orderedPanelIds: [UUID]? = cachedOrderedPanelIds + let branchDirectoryLines: [VerticalBranchDirectoryLine] = cachedBranchDirectoryLines + let pullRequestRows: [PullRequestDisplay] = cachedPullRequestRows + let compactGitBranchSummaryText: String? = { + guard detailVisibility.showsBranchDirectory, + !sidebarBranchVerticalLayout, + sidebarShowGitBranch, + let orderedPanelIds else { + return nil + } + return gitBranchSummaryText(orderedPanelIds: orderedPanelIds) + }() + let compactDirectorySummaryText: String? = { + guard detailVisibility.showsBranchDirectory, + !sidebarBranchVerticalLayout, + let orderedPanelIds else { + return nil + } + return directorySummaryText(orderedPanelIds: orderedPanelIds) + }() + let compactBranchDirectoryRow = branchDirectoryRow( + gitSummary: compactGitBranchSummaryText, + directorySummary: compactDirectorySummaryText + ) + let branchLinesContainBranch = sidebarShowGitBranch && branchDirectoryLines.contains { $0.branch != nil } + + VStack(alignment: .leading, spacing: 4) { + HStack(spacing: 8) { + if unreadCount > 0 { + ZStack { + Circle() + .fill(activeUnreadBadgeFillColor) + Text("\(unreadCount)") + .font(.system(size: 9, weight: .semibold)) + .foregroundColor(.white) + } + .frame(width: 16, height: 16) + } + + if tab.isPinned { + Image(systemName: "pin.fill") + .font(.system(size: 9, weight: .semibold)) + .foregroundColor(activeSecondaryColor(0.8)) + .safeHelp(protectedWorkspaceTooltip) + } + + Text(tab.title) + .font(.system(size: 12.5, weight: titleFontWeight)) + .foregroundColor(activePrimaryTextColor) + .lineLimit(1) + .truncationMode(.tail) + .layoutPriority(1) + + Spacer(minLength: 0) + + ZStack(alignment: .trailing) { + Button(action: { + #if DEBUG + dlog("sidebar.close workspace=\(tab.id.uuidString.prefix(5)) method=button") + #endif + tabManager.closeWorkspaceWithConfirmation(tab) + }) { + Image(systemName: "xmark") + .font(.system(size: 9, weight: .medium)) + .foregroundColor(activeSecondaryColor(0.7)) + } + .buttonStyle(.plain) + .safeHelp(closeButtonTooltip) + .frame(width: SidebarTrailingAccessoryWidthPolicy.closeButtonWidth, height: 16, alignment: .center) + .opacity(showCloseButton && !showsWorkspaceShortcutHint ? 1 : 0) + .allowsHitTesting(showCloseButton && !showsWorkspaceShortcutHint) + + if showsWorkspaceShortcutHint, let workspaceShortcutLabel { + Text(workspaceShortcutLabel) + .lineLimit(1) + .fixedSize(horizontal: true, vertical: false) + .font(.system(size: 10, weight: .semibold, design: .rounded)) + .monospacedDigit() + .foregroundColor(activePrimaryTextColor) + .padding(.horizontal, 6) + .padding(.vertical, 2) + .background(ShortcutHintPillBackground(emphasis: shortcutHintEmphasis)) + .offset( + x: ShortcutHintDebugSettings.clamped(sidebarShortcutHintXOffset), + y: ShortcutHintDebugSettings.clamped(sidebarShortcutHintYOffset) + ) + .transition(.opacity) + } + } + .animation(.easeInOut(duration: 0.14), value: showsModifierShortcutHints || alwaysShowShortcutHints) + .frame(width: trailingAccessoryWidth, height: 16, alignment: .trailing) + } + + if let description = tab.customDescription { + SidebarWorkspaceDescriptionText( + markdown: description, + isActive: usesInvertedActiveForeground + ) + .id(description) + } + + if let subtitle = effectiveSubtitle { + Text(subtitle) + .font(.system(size: 10)) + .foregroundColor(activeSecondaryColor(0.8)) + .lineLimit(2) + .truncationMode(.tail) + .multilineTextAlignment(.leading) + } + + remoteWorkspaceSection + + if detailVisibility.showsMetadata { + let metadataEntries = tab.sidebarStatusEntriesInDisplayOrder() + let metadataBlocks = tab.sidebarMetadataBlocksInDisplayOrder() + if !metadataEntries.isEmpty { + SidebarMetadataRows( + entries: metadataEntries, + isActive: usesInvertedActiveForeground, + onFocus: { updateSelection() } + ) + .transition(.opacity.combined(with: .move(edge: .top))) + } + if !metadataBlocks.isEmpty { + SidebarMetadataMarkdownBlocks( + blocks: metadataBlocks, + isActive: usesInvertedActiveForeground, + onFocus: { updateSelection() } + ) + .transition(.opacity.combined(with: .move(edge: .top))) + } + } + + // Latest log entry + if detailVisibility.showsLog, let latestLog = tab.logEntries.last { + HStack(spacing: 4) { + Image(systemName: logLevelIcon(latestLog.level)) + .font(.system(size: 8)) + .foregroundColor(logLevelColor(latestLog.level, isActive: usesInvertedActiveForeground)) + Text(latestLog.message) + .font(.system(size: 10)) + .foregroundColor(activeSecondaryColor(0.8)) + .lineLimit(1) + .truncationMode(.tail) + } + .transition(.opacity.combined(with: .move(edge: .top))) + } + + // Progress bar + if detailVisibility.showsProgress, let progress = tab.progress { + VStack(alignment: .leading, spacing: 2) { + GeometryReader { geo in + ZStack(alignment: .leading) { + Capsule() + .fill(activeProgressTrackColor) + Capsule() + .fill(activeProgressFillColor) + .frame(width: max(0, geo.size.width * CGFloat(progress.value))) + } + } + .frame(height: 3) + + if let label = progress.label { + Text(label) + .font(.system(size: 9)) + .foregroundColor(activeSecondaryColor(0.6)) + .lineLimit(1) + } + } + .transition(.opacity.combined(with: .move(edge: .top))) + } + + // Branch + directory row + if detailVisibility.showsBranchDirectory { + if sidebarBranchVerticalLayout { + if !branchDirectoryLines.isEmpty { + HStack(alignment: .top, spacing: 3) { + if sidebarShowGitBranchIcon, branchLinesContainBranch { + Image(systemName: "arrow.triangle.branch") + .font(.system(size: 9)) + .foregroundColor(activeSecondaryColor(0.6)) + } + VStack(alignment: .leading, spacing: 1) { + ForEach(Array(branchDirectoryLines.enumerated()), id: \.offset) { _, line in + HStack(spacing: 3) { + if let branch = line.branch { + Text(branch) + .font(.system(size: 10, design: .monospaced)) + .foregroundColor(activeSecondaryColor(0.75)) + .lineLimit(1) + .truncationMode(.tail) + } + if line.branch != nil, line.directory != nil { + Image(systemName: "circle.fill") + .font(.system(size: 3)) + .foregroundColor(activeSecondaryColor(0.6)) + .padding(.horizontal, 1) + } + if let directory = line.directory { + Text(directory) + .font(.system(size: 10, design: .monospaced)) + .foregroundColor(activeSecondaryColor(0.75)) + .lineLimit(1) + .truncationMode(.tail) + } + } + } + } + } + } + } else if let dirRow = compactBranchDirectoryRow { + HStack(spacing: 3) { + if sidebarShowGitBranchIcon, compactGitBranchSummaryText != nil { + Image(systemName: "arrow.triangle.branch") + .font(.system(size: 9)) + .foregroundColor(activeSecondaryColor(0.6)) + } + Text(dirRow) + .font(.system(size: 10, design: .monospaced)) + .foregroundColor(activeSecondaryColor(0.75)) + .lineLimit(1) + .truncationMode(.tail) + } + } + } + + // Pull request rows + if detailVisibility.showsPullRequests, !pullRequestRows.isEmpty { + VStack(alignment: .leading, spacing: 1) { + ForEach(pullRequestRows) { pullRequest in + Button(action: { + openPullRequestLink(pullRequest.url) + }) { + HStack(spacing: 4) { + PullRequestStatusIcon( + status: pullRequest.status, + color: pullRequestForegroundColor + ) + Text("\(pullRequest.label) #\(pullRequest.number)") + .underline() + .lineLimit(1) + .truncationMode(.tail) + Text(pullRequestStatusLabel(pullRequest.status, checks: pullRequest.checks)) + .lineLimit(1) + Spacer(minLength: 0) + } + .font(.system(size: 10, weight: .semibold)) + .foregroundColor(pullRequestForegroundColor) + } + .buttonStyle(.plain) + .safeHelp(String(localized: "sidebar.pullRequest.openTooltip", defaultValue: "Open \(pullRequest.label) #\(pullRequest.number)")) + } + } + } + + // Ports row + if detailVisibility.showsPorts, !tab.listeningPorts.isEmpty { + HStack(spacing: 4) { + ForEach(tab.listeningPorts, id: \.self) { port in + Button(action: { + openPortLink(port) + }) { + Text(String(localized: "sidebar.port.label", defaultValue: ":\(port)")) + .underline() + } + .buttonStyle(.plain) + .safeHelp(String(localized: "sidebar.port.openTooltip", defaultValue: "Open localhost:\(port)")) + } + Spacer(minLength: 0) + } + .font(.system(size: 10, design: .monospaced)) + .foregroundColor(activeSecondaryColor(0.75)) + .lineLimit(1) + } + } + .animation(.easeInOut(duration: 0.2), value: tab.logEntries.count) + .animation(.easeInOut(duration: 0.2), value: tab.progress != nil) + .animation(.easeInOut(duration: 0.2), value: tab.metadataBlocks.count) + .padding(.horizontal, 10) + .padding(.vertical, 8) + .background( + RoundedRectangle(cornerRadius: 6) + .fill(backgroundColor) + .overlay { + RoundedRectangle(cornerRadius: 6) + .strokeBorder(activeBorderColor, lineWidth: activeBorderLineWidth) + } + .overlay(alignment: .leading) { + if showsLeadingRail { + Capsule(style: .continuous) + .fill(railColor) + .frame(width: 3) + .padding(.leading, 4) + .padding(.vertical, 5) + .offset(x: -1) + } + } + ) + .padding(.horizontal, 6) + .background { + GeometryReader { proxy in + Color.clear + .onAppear { + rowHeight = max(proxy.size.height, 1) + } + .onChange(of: proxy.size.height) { newHeight in + rowHeight = max(newHeight, 1) + } + } + } + .contentShape(Rectangle()) + .opacity(isBeingDragged ? 0.6 : 1) + .overlay { + MiddleClickCapture { + #if DEBUG + dlog("sidebar.close workspace=\(tab.id.uuidString.prefix(5)) method=middleClick") + #endif + tabManager.closeWorkspaceWithConfirmation(tab) + } + } + .overlay(alignment: .top) { + if showsCenteredTopDropIndicator { + Rectangle() + .fill(programaAccentColor()) + .frame(height: 2) + .padding(.horizontal, 8) + .offset(y: index == 0 ? 0 : -(rowSpacing / 2)) + } + } + .onReceive( + tab.sidebarImmediateObservationPublisher + .receive(on: RunLoop.main) + ) { _ in +#if DEBUG + let description = tab.customDescription ?? "" + dlog( + "sidebar.row.invalidate workspace=\(tab.id.uuidString.prefix(8)) " + + "source=immediate " + + "title=\"\(debugCommandPaletteTextPreview(tab.title))\" " + + "descLen=\((description as NSString).length) " + + "desc=\"\(debugCommandPaletteTextPreview(description))\"" + ) +#endif + workspaceObservationGeneration &+= 1 + } + .onReceive( + tab.sidebarObservationPublisher + .receive(on: RunLoop.main) + // Prompt-time sidebar telemetry can arrive as a short burst + // (pwd, branch, PR, shell state). Coalesce that burst so the + // row redraws once with the settled state instead of blinking. + .debounce(for: Self.workspaceObservationCoalesceInterval, scheduler: RunLoop.main) + ) { _ in +#if DEBUG + let description = tab.customDescription ?? "" + dlog( + "sidebar.row.invalidate workspace=\(tab.id.uuidString.prefix(8)) " + + "source=debounced " + + "title=\"\(debugCommandPaletteTextPreview(tab.title))\" " + + "descLen=\((description as NSString).length) " + + "desc=\"\(debugCommandPaletteTextPreview(description))\"" + ) +#endif + // Refresh expensive caches (tree walk, branch/dir/PR) before + // signalling a redraw. The immediate publisher intentionally does + // NOT call this so that title keystrokes skip the tree walk. + recomputeSidebarDetailCache() + workspaceObservationGeneration &+= 1 + } + .onDrag { + #if DEBUG + dlog("sidebar.onDrag tab=\(tab.id.uuidString.prefix(5))") + #endif + draggedTabId = tab.id + dropIndicator = nil + return SidebarTabDragPayload.provider(for: tab.id) + } + .internalOnlyTabDrag() + .onDrop(of: SidebarTabDragPayload.dropContentTypes, delegate: SidebarTabDropDelegate( + targetTabId: tab.id, + tabManager: tabManager, + draggedTabId: $draggedTabId, + selectedTabIds: $selectedTabIds, + lastSidebarSelectionIndex: $lastSidebarSelectionIndex, + targetRowHeight: rowHeight, + dragAutoScrollController: dragAutoScrollController, + dropIndicator: $dropIndicator + )) + .onDrop(of: BonsplitTabDragPayload.dropContentTypes, delegate: SidebarBonsplitTabDropDelegate( + targetWorkspaceId: tab.id, + tabManager: tabManager, + selectedTabIds: $selectedTabIds, + lastSidebarSelectionIndex: $lastSidebarSelectionIndex + )) + .onTapGesture { + updateSelection() + } + .onHover { hovering in + isHovering = hovering + } + .accessibilityElement(children: .combine) + .accessibilityLabel(Text(accessibilityTitle)) + .accessibilityHint(Text(accessibilityHintText)) + .accessibilityAction(named: Text(moveUpActionText)) { + moveBy(-1) + } + .accessibilityAction(named: Text(moveDownActionText)) { + moveBy(1) + } + .onAppear { + // Prime the cache so branch/dir/PR rows appear immediately, + // before the first debounced publisher fires. + recomputeSidebarDetailCache() + } + .onChange(of: visibleAuxiliaryDetails) { _ in + // Toggling branch/PR columns changes which data we need to cache. + recomputeSidebarDetailCache() + } + .contextMenu { workspaceContextMenu } + } + + private func contextMenuLabel(multi: String, single: String, isMulti: Bool) -> String { + isMulti ? multi : single + } + + private func remoteContextMenuWorkspaces() -> [Workspace] { + guard !remoteContextMenuWorkspaceIds.isEmpty else { return [] } + return remoteContextMenuWorkspaceIds.compactMap { workspaceId in + tabManager.tabs.first(where: { $0.id == workspaceId }) + } + } + + @ViewBuilder + private var workspaceContextMenu: some View { + let targetIds = contextMenuWorkspaceIds + let isMulti = targetIds.count > 1 + let tabColorPalette = WorkspaceTabColorSettings.palette() + let shouldPin = !tab.isPinned + let reconnectLabel = contextMenuLabel( + multi: String(localized: "contextMenu.reconnectWorkspaces", defaultValue: "Reconnect Workspaces"), + single: String(localized: "contextMenu.reconnectWorkspace", defaultValue: "Reconnect Workspace"), + isMulti: isMulti) + let disconnectLabel = contextMenuLabel( + multi: String(localized: "contextMenu.disconnectWorkspaces", defaultValue: "Disconnect Workspaces"), + single: String(localized: "contextMenu.disconnectWorkspace", defaultValue: "Disconnect Workspace"), + isMulti: isMulti) + let pinLabel = shouldPin + ? contextMenuLabel( + multi: String(localized: "contextMenu.pinWorkspaces", defaultValue: "Pin Workspaces"), + single: String(localized: "contextMenu.pinWorkspace", defaultValue: "Pin Workspace"), + isMulti: isMulti) + : contextMenuLabel( + multi: String(localized: "contextMenu.unpinWorkspaces", defaultValue: "Unpin Workspaces"), + single: String(localized: "contextMenu.unpinWorkspace", defaultValue: "Unpin Workspace"), + isMulti: isMulti) + let closeLabel = contextMenuLabel( + multi: String(localized: "contextMenu.closeWorkspaces", defaultValue: "Close Workspaces"), + single: String(localized: "contextMenu.closeWorkspace", defaultValue: "Close Workspace"), + isMulti: isMulti) + let markReadLabel = contextMenuLabel( + multi: String(localized: "contextMenu.markWorkspacesRead", defaultValue: "Mark Workspaces as Read"), + single: String(localized: "contextMenu.markWorkspaceRead", defaultValue: "Mark Workspace as Read"), + isMulti: isMulti) + let markUnreadLabel = contextMenuLabel( + multi: String(localized: "contextMenu.markWorkspacesUnread", defaultValue: "Mark Workspaces as Unread"), + single: String(localized: "contextMenu.markWorkspaceUnread", defaultValue: "Mark Workspace as Unread"), + isMulti: isMulti) + let clearLatestNotificationLabel = contextMenuLabel( + multi: String(localized: "contextMenu.clearLatestNotifications", defaultValue: "Clear Latest Notifications"), + single: String(localized: "contextMenu.clearLatestNotification", defaultValue: "Clear Latest Notification"), + isMulti: isMulti) + let renameWorkspaceShortcut = KeyboardShortcutSettings.shortcut(for: .renameWorkspace) + let editWorkspaceDescriptionShortcut = KeyboardShortcutSettings.shortcut(for: .editWorkspaceDescription) + let closeWorkspaceShortcut = KeyboardShortcutSettings.shortcut(for: .closeWorkspace) + Button(pinLabel) { + for id in targetIds { + if let tab = tabManager.tabs.first(where: { $0.id == id }) { + tabManager.setPinned(tab, pinned: shouldPin) + } + } + syncSelectionAfterMutation() + } + + if let key = renameWorkspaceShortcut.keyEquivalent { + Button(String(localized: "contextMenu.renameWorkspace", defaultValue: "Rename Workspace…")) { + promptRename() + } + .keyboardShortcut(key, modifiers: renameWorkspaceShortcut.eventModifiers) + } else { + Button(String(localized: "contextMenu.renameWorkspace", defaultValue: "Rename Workspace…")) { + promptRename() + } + } + + if tab.hasCustomTitle { + Button(String(localized: "contextMenu.removeCustomWorkspaceName", defaultValue: "Remove Custom Workspace Name")) { + tabManager.clearCustomTitle(tabId: tab.id) + } + } + + if !isMulti { + if let key = editWorkspaceDescriptionShortcut.keyEquivalent { + Button(String(localized: "contextMenu.editWorkspaceDescription", defaultValue: "Edit Workspace Description…")) { + beginWorkspaceDescriptionEditFromContextMenu() + } + .keyboardShortcut(key, modifiers: editWorkspaceDescriptionShortcut.eventModifiers) + } else { + Button(String(localized: "contextMenu.editWorkspaceDescription", defaultValue: "Edit Workspace Description…")) { + beginWorkspaceDescriptionEditFromContextMenu() + } + } + + if tab.hasCustomDescription { + Button(String(localized: "contextMenu.clearWorkspaceDescription", defaultValue: "Clear Workspace Description")) { + tabManager.clearCustomDescription(tabId: tab.id) + } + } + } + + if !remoteContextMenuWorkspaceIds.isEmpty { + Divider() + + Button(reconnectLabel) { + for workspace in remoteContextMenuWorkspaces() { + workspace.reconnectRemoteConnection() + } + } + .disabled(allRemoteContextMenuTargetsConnecting) + + Button(disconnectLabel) { + for workspace in remoteContextMenuWorkspaces() { + workspace.disconnectRemoteConnection(clearConfiguration: false) + } + } + .disabled(allRemoteContextMenuTargetsDisconnected) + } + + Menu(String(localized: "contextMenu.workspaceColor", defaultValue: "Workspace Color")) { + if tab.customColor != nil { + Button { + applyTabColor(nil, targetIds: targetIds) + } label: { + Label(String(localized: "contextMenu.clearColor", defaultValue: "Clear Color"), systemImage: "xmark.circle") + } + } + + Button { + promptCustomColor(targetIds: targetIds) + } label: { + Label(String(localized: "contextMenu.chooseCustomColor", defaultValue: "Choose Custom Color…"), systemImage: "paintpalette") + } + + if !tabColorPalette.isEmpty { + Divider() + } + + ForEach(tabColorPalette, id: \.id) { entry in + Button { + applyTabColor(entry.hex, targetIds: targetIds) + } label: { + Label { + Text(entry.name) + } icon: { + Image(nsImage: coloredCircleImage(color: tabColorSwatchColor(for: entry.hex))) + } + } + } + } + + if let copyableSidebarSSHError { + Button(String(localized: "contextMenu.copySshError", defaultValue: "Copy SSH Error")) { + copyTextToPasteboard(copyableSidebarSSHError) + } + } + + Divider() + + Button(String(localized: "contextMenu.moveUp", defaultValue: "Move Up")) { + moveBy(-1) + } + .disabled(index == 0) + + Button(String(localized: "contextMenu.moveDown", defaultValue: "Move Down")) { + moveBy(1) + } + .disabled(index >= tabManager.tabs.count - 1) + + Button(String(localized: "contextMenu.moveToTop", defaultValue: "Move to Top")) { + tabManager.moveTabsToTop(Set(targetIds)) + syncSelectionAfterMutation() + } + .disabled(targetIds.isEmpty) + + let referenceWindowId = AppDelegate.shared?.windowId(for: tabManager) + let windowMoveTargets = AppDelegate.shared?.windowMoveTargets(referenceWindowId: referenceWindowId) ?? [] + let moveMenuTitle = targetIds.count > 1 + ? String(localized: "contextMenu.moveWorkspacesToWindow", defaultValue: "Move Workspaces to Window") + : String(localized: "contextMenu.moveWorkspaceToWindow", defaultValue: "Move Workspace to Window") + Menu(moveMenuTitle) { + Button(String(localized: "contextMenu.newWindow", defaultValue: "New Window")) { + moveWorkspacesToNewWindow(targetIds) + } + .disabled(targetIds.isEmpty) + + if !windowMoveTargets.isEmpty { + Divider() + } + + ForEach(windowMoveTargets) { target in + Button(target.label) { + moveWorkspaces(targetIds, toWindow: target.windowId) + } + .disabled(target.isCurrentWindow || targetIds.isEmpty) + } + } + .disabled(targetIds.isEmpty) + + Divider() + + if let key = closeWorkspaceShortcut.keyEquivalent { + Button(closeLabel) { + closeTabs(targetIds, allowPinned: true) + } + .keyboardShortcut(key, modifiers: closeWorkspaceShortcut.eventModifiers) + .disabled(targetIds.isEmpty) + } else { + Button(closeLabel) { + closeTabs(targetIds, allowPinned: true) + } + .disabled(targetIds.isEmpty) + } + + Button(String(localized: "contextMenu.closeOtherWorkspaces", defaultValue: "Close Other Workspaces")) { + closeOtherTabs(targetIds) + } + .disabled(tabManager.tabs.count <= 1 || targetIds.count == tabManager.tabs.count) + + Button(String(localized: "contextMenu.closeWorkspacesBelow", defaultValue: "Close Workspaces Below")) { + closeTabsBelow(tabId: tab.id) + } + .disabled(index >= tabManager.tabs.count - 1) + + Button(String(localized: "contextMenu.closeWorkspacesAbove", defaultValue: "Close Workspaces Above")) { + closeTabsAbove(tabId: tab.id) + } + .disabled(index == 0) + + Divider() + + Button(markReadLabel) { + markTabsRead(targetIds) + } + .disabled(!hasUnreadNotifications(in: targetIds)) + + Button(markUnreadLabel) { + markTabsUnread(targetIds) + } + .disabled(!hasReadNotifications(in: targetIds)) + + Button(clearLatestNotificationLabel) { + clearLatestNotifications(targetIds) + } + .disabled(!hasLatestNotifications(in: targetIds)) + } + + private var selectionBackgroundColor: NSColor { + if let hex = sidebarSelectionColorHex, let parsed = NSColor(hex: hex) { + return parsed + } + return programaAccentNSColor(for: colorScheme) + } + + private var backgroundColor: Color { + switch activeTabIndicatorStyle { + case .leftRail: + if isActive { return Color(nsColor: selectionBackgroundColor) } + if isMultiSelected { return programaAccentColor().opacity(0.25) } + return Color.clear + case .solidFill: + if isActive { return Color(nsColor: selectionBackgroundColor) } + if let custom = resolvedCustomTabColor { + if isMultiSelected { return custom.opacity(0.35) } + return custom.opacity(0.7) + } + if isMultiSelected { return programaAccentColor().opacity(0.25) } + return Color.clear + } + } + + private var railColor: Color { + explicitRailColor ?? .clear + } + + private var explicitRailColor: Color? { + guard activeTabIndicatorStyle == .leftRail, + let custom = resolvedCustomTabColor else { + return nil + } + return custom.opacity(0.95) + } + + private var resolvedCustomTabColor: Color? { + guard let hex = tab.customColor else { return nil } + return WorkspaceTabColorSettings.displayColor( + hex: hex, + colorScheme: colorScheme, + forceBright: activeTabIndicatorStyle == .leftRail + ) + } + + private func tabColorSwatchColor(for hex: String) -> NSColor { + WorkspaceTabColorSettings.displayNSColor( + hex: hex, + colorScheme: colorScheme, + forceBright: activeTabIndicatorStyle == .leftRail + ) ?? NSColor(hex: hex) ?? .gray + } + + private var showsCenteredTopDropIndicator: Bool { + guard draggedTabId != nil, let indicator = dropIndicator else { return false } + if indicator.tabId == tab.id && indicator.edge == .top { + return true + } + + guard indicator.edge == .bottom, + let currentIndex = tabManager.tabs.firstIndex(where: { $0.id == tab.id }), + currentIndex > 0 + else { + return false + } + return tabManager.tabs[currentIndex - 1].id == indicator.tabId + } + + private var accessibilityTitle: String { + String(localized: "accessibility.workspacePosition", defaultValue: "\(tab.title), workspace \(index + 1) of \(accessibilityWorkspaceCount)") + } + + private func moveBy(_ delta: Int) { + let targetIndex = index + delta + guard targetIndex >= 0, targetIndex < tabManager.tabs.count else { return } + guard tabManager.reorderWorkspace(tabId: tab.id, toIndex: targetIndex) else { return } + selectedTabIds = [tab.id] + lastSidebarSelectionIndex = tabManager.tabs.firstIndex { $0.id == tab.id } + tabManager.selectTab(tab) + setSelectionToTabs() + } + + private func updateSelection() { + #if DEBUG + let mods = NSEvent.modifierFlags + var modStr = "" + if mods.contains(.command) { modStr += "cmd " } + if mods.contains(.shift) { modStr += "shift " } + if mods.contains(.option) { modStr += "opt " } + if mods.contains(.control) { modStr += "ctrl " } + dlog("sidebar.select workspace=\(tab.id.uuidString.prefix(5)) modifiers=\(modStr.isEmpty ? "none" : modStr.trimmingCharacters(in: .whitespaces))") + #endif + let modifiers = NSEvent.modifierFlags + let isCommand = modifiers.contains(.command) + let isShift = modifiers.contains(.shift) + let wasSelected = tabManager.selectedTabId == tab.id + + if isShift, let lastIndex = lastSidebarSelectionIndex { + let lower = min(lastIndex, index) + let upper = max(lastIndex, index) + let rangeIds = tabManager.tabs[lower...upper].map { $0.id } + if isCommand { + selectedTabIds.formUnion(rangeIds) + } else { + selectedTabIds = Set(rangeIds) + } + } else if isCommand { + if selectedTabIds.contains(tab.id) { + selectedTabIds.remove(tab.id) + } else { + selectedTabIds.insert(tab.id) + } + } else { + selectedTabIds = [tab.id] + } + + lastSidebarSelectionIndex = index + tabManager.selectTab(tab) + if wasSelected, !isCommand, !isShift { + tabManager.dismissNotificationOnDirectInteraction( + tabId: tab.id, + surfaceId: tabManager.focusedSurfaceId(for: tab.id) + ) + } + setSelectionToTabs() + } + + private func closeTabs(_ targetIds: [UUID], allowPinned: Bool) { + tabManager.closeWorkspacesWithConfirmation(targetIds, allowPinned: allowPinned) + syncSelectionAfterMutation() + } + + private func closeOtherTabs(_ targetIds: [UUID]) { + let keepIds = Set(targetIds) + let idsToClose = tabManager.tabs.compactMap { keepIds.contains($0.id) ? nil : $0.id } + closeTabs(idsToClose, allowPinned: true) + } + + private func closeTabsBelow(tabId: UUID) { + guard let anchorIndex = tabManager.tabs.firstIndex(where: { $0.id == tabId }) else { return } + let idsToClose = tabManager.tabs.suffix(from: anchorIndex + 1).map { $0.id } + closeTabs(idsToClose, allowPinned: true) + } + + private func closeTabsAbove(tabId: UUID) { + guard let anchorIndex = tabManager.tabs.firstIndex(where: { $0.id == tabId }) else { return } + let idsToClose = tabManager.tabs.prefix(upTo: anchorIndex).map { $0.id } + closeTabs(idsToClose, allowPinned: true) + } + + private func markTabsRead(_ targetIds: [UUID]) { + for id in targetIds { + notificationStore.markRead(forTabId: id) + } + } + + private func markTabsUnread(_ targetIds: [UUID]) { + for id in targetIds { + notificationStore.markUnread(forTabId: id) + } + } + + private func clearLatestNotifications(_ targetIds: [UUID]) { + for id in targetIds { + notificationStore.clearLatestNotification(forTabId: id) + } + } + + private func hasUnreadNotifications(in targetIds: [UUID]) -> Bool { + let targetSet = Set(targetIds) + return notificationStore.notifications.contains { targetSet.contains($0.tabId) && !$0.isRead } + } + + private func hasReadNotifications(in targetIds: [UUID]) -> Bool { + let targetSet = Set(targetIds) + return notificationStore.notifications.contains { targetSet.contains($0.tabId) && $0.isRead } + } + + private func hasLatestNotifications(in targetIds: [UUID]) -> Bool { + targetIds.contains { notificationStore.latestNotification(forTabId: $0) != nil } + } + + private func syncSelectionAfterMutation() { + let existingIds = Set(tabManager.tabs.map { $0.id }) + selectedTabIds = selectedTabIds.filter { existingIds.contains($0) } + if selectedTabIds.isEmpty, let selectedId = tabManager.selectedTabId { + selectedTabIds = [selectedId] + } + if let selectedId = tabManager.selectedTabId { + lastSidebarSelectionIndex = tabManager.tabs.firstIndex { $0.id == selectedId } + } + } + + private var remoteStateHelpText: String { + let target = tab.remoteDisplayTarget ?? String( + localized: "sidebar.remote.help.targetFallback", + defaultValue: "remote host" + ) + let detail = tab.remoteConnectionDetail?.trimmingCharacters(in: .whitespacesAndNewlines) + switch tab.remoteConnectionState { + case .connected: + return String( + format: String( + localized: "sidebar.remote.help.connected", + defaultValue: "SSH connected to %@" + ), + locale: .current, + target + ) + case .connecting: + return String( + format: String( + localized: "sidebar.remote.help.connecting", + defaultValue: "SSH connecting to %@" + ), + locale: .current, + target + ) + case .error: + if let detail, !detail.isEmpty { + return String( + format: String( + localized: "sidebar.remote.help.errorWithDetail", + defaultValue: "SSH error for %@: %@" + ), + locale: .current, + target, + detail + ) + } + return String( + format: String( + localized: "sidebar.remote.help.error", + defaultValue: "SSH error for %@" + ), + locale: .current, + target + ) + case .disconnected: + return String( + format: String( + localized: "sidebar.remote.help.disconnected", + defaultValue: "SSH disconnected from %@" + ), + locale: .current, + target + ) + } + } + private func moveWorkspaces(_ workspaceIds: [UUID], toWindow windowId: UUID) { + guard let app = AppDelegate.shared else { return } + let orderedWorkspaceIds = tabManager.tabs.compactMap { workspaceIds.contains($0.id) ? $0.id : nil } + guard !orderedWorkspaceIds.isEmpty else { return } + + for (index, workspaceId) in orderedWorkspaceIds.enumerated() { + let shouldFocus = index == orderedWorkspaceIds.count - 1 + _ = app.moveWorkspaceToWindow(workspaceId: workspaceId, windowId: windowId, focus: shouldFocus) + } + + selectedTabIds.subtract(orderedWorkspaceIds) + syncSelectionAfterMutation() + } + + private func moveWorkspacesToNewWindow(_ workspaceIds: [UUID]) { + guard let app = AppDelegate.shared else { return } + let orderedWorkspaceIds = tabManager.tabs.compactMap { workspaceIds.contains($0.id) ? $0.id : nil } + guard let firstWorkspaceId = orderedWorkspaceIds.first else { return } + + let shouldFocusImmediately = orderedWorkspaceIds.count == 1 + guard let newWindowId = app.moveWorkspaceToNewWindow(workspaceId: firstWorkspaceId, focus: shouldFocusImmediately) else { + return + } + + if orderedWorkspaceIds.count > 1 { + for workspaceId in orderedWorkspaceIds.dropFirst() { + _ = app.moveWorkspaceToWindow(workspaceId: workspaceId, windowId: newWindowId, focus: false) + } + if let finalWorkspaceId = orderedWorkspaceIds.last { + _ = app.moveWorkspaceToWindow(workspaceId: finalWorkspaceId, windowId: newWindowId, focus: true) + } + } + + selectedTabIds.subtract(orderedWorkspaceIds) + syncSelectionAfterMutation() + } + + // latestNotificationText is now passed as a parameter from the parent view + // to avoid subscribing to notificationStore changes in every TabItemView. + + private func branchDirectoryRow( + gitSummary: String?, + directorySummary: String? + ) -> String? { + var parts: [String] = [] + + if let gitSummary { + parts.append(gitSummary) + } + + if let directorySummary { + parts.append(directorySummary) + } + + let result = parts.joined(separator: " · ") + return result.isEmpty ? nil : result + } + + private func gitBranchSummaryText(orderedPanelIds: [UUID]) -> String? { + let lines = gitBranchSummaryLines(orderedPanelIds: orderedPanelIds) + guard !lines.isEmpty else { return nil } + return lines.joined(separator: " | ") + } + + private func gitBranchSummaryLines(orderedPanelIds: [UUID]) -> [String] { + tab.sidebarGitBranchesInDisplayOrder(orderedPanelIds: orderedPanelIds).map { branch in + "\(branch.branch)\(branch.isDirty ? "*" : "")" + } + } + + private struct VerticalBranchDirectoryLine { + let branch: String? + let directory: String? + } + + /// Recomputes the expensive sidebar detail caches (bonsplit tree walk, + /// branch/directory lines, PR snapshot) and writes them into @State. + /// Must be called only from the debounced publisher, onAppear, and + /// settings-change handlers — never from the immediate (title) publisher. + private func recomputeSidebarDetailCache() { + let detail = visibleAuxiliaryDetails + let needsDetail = detail.showsBranchDirectory || detail.showsPullRequests + let ids: [UUID]? = needsDetail ? tab.sidebarOrderedPanelIds() : nil + cachedOrderedPanelIds = ids + cachedBranchDirectoryLines = { + guard detail.showsBranchDirectory, sidebarBranchVerticalLayout, let ids else { return [] } + return verticalBranchDirectoryLines(orderedPanelIds: ids) + }() + cachedPullRequestRows = { + guard detail.showsPullRequests, let ids else { return [] } + return pullRequestDisplays(orderedPanelIds: ids) + }() + } + + private func verticalBranchDirectoryLines(orderedPanelIds: [UUID]) -> [VerticalBranchDirectoryLine] { + let entries = tab.sidebarBranchDirectoryEntriesInDisplayOrder(orderedPanelIds: orderedPanelIds) + let home = SidebarPathFormatter.homeDirectoryPath + return entries.compactMap { entry in + let branchText: String? = { + guard sidebarShowGitBranch, let branch = entry.branch else { return nil } + return "\(branch)\(entry.isDirty ? "*" : "")" + }() + + let directoryText: String? = { + guard let directory = entry.directory else { return nil } + let shortened = SidebarPathFormatter.shortenedPath(directory, homeDirectoryPath: home) + return shortened.isEmpty ? nil : shortened + }() + + switch (branchText, directoryText) { + case let (branch?, directory?): + return VerticalBranchDirectoryLine(branch: branch, directory: directory) + case let (branch?, nil): + return VerticalBranchDirectoryLine(branch: branch, directory: nil) + case let (nil, directory?): + return VerticalBranchDirectoryLine(branch: nil, directory: directory) + default: + return nil + } + } + } + + private func directorySummaryText(orderedPanelIds: [UUID]) -> String? { + let home = SidebarPathFormatter.homeDirectoryPath + let entries = tab.sidebarDirectoriesInDisplayOrder(orderedPanelIds: orderedPanelIds).compactMap { directory in + let shortened = SidebarPathFormatter.shortenedPath(directory, homeDirectoryPath: home) + return shortened.isEmpty ? nil : shortened + } + return entries.isEmpty ? nil : entries.joined(separator: " | ") + } + + private struct PullRequestDisplay: Identifiable { + let id: String + let number: Int + let label: String + let url: URL + let status: SidebarPullRequestStatus + let checks: SidebarPullRequestChecksStatus? + } + + private func pullRequestDisplays(orderedPanelIds: [UUID]) -> [PullRequestDisplay] { + tab.sidebarPullRequestsInDisplayOrder(orderedPanelIds: orderedPanelIds).map { pullRequest in + PullRequestDisplay( + id: "\(pullRequest.label.lowercased())#\(pullRequest.number)|\(pullRequest.url.absoluteString)", + number: pullRequest.number, + label: pullRequest.label, + url: pullRequest.url, + status: pullRequest.status, + checks: pullRequest.checks + ) + } + } + + private var pullRequestForegroundColor: Color { + isActive ? .white.opacity(0.75) : .secondary + } + + private func openPullRequestLink(_ url: URL) { + updateSelection() + if openSidebarPullRequestLinksInProgramaBrowser { + if tabManager.openBrowser( + inWorkspace: tab.id, + url: url, + preferSplitRight: true, + insertAtEnd: true + ) == nil { + NSWorkspace.shared.open(url) + } + return + } + NSWorkspace.shared.open(url) + } + + private func openPortLink(_ port: Int) { + guard let url = URL(string: "http://localhost:\(port)") else { return } + updateSelection() + if openSidebarPortLinksInProgramaBrowser { + if tabManager.openBrowser( + inWorkspace: tab.id, + url: url, + preferSplitRight: true, + insertAtEnd: true + ) == nil { + NSWorkspace.shared.open(url) + } + return + } + NSWorkspace.shared.open(url) + } + + private func pullRequestStatusLabel( + _ status: SidebarPullRequestStatus, + checks _: SidebarPullRequestChecksStatus? + ) -> String { + switch status { + case .open: return String(localized: "sidebar.pullRequest.statusOpen", defaultValue: "open") + case .merged: return String(localized: "sidebar.pullRequest.statusMerged", defaultValue: "merged") + case .closed: return String(localized: "sidebar.pullRequest.statusClosed", defaultValue: "closed") + } + } + + private func logLevelIcon(_ level: SidebarLogLevel) -> String { + switch level { + case .info: return "circle.fill" + case .progress: return "arrowtriangle.right.fill" + case .success: return "checkmark.circle.fill" + case .warning: return "exclamationmark.triangle.fill" + case .error: return "xmark.circle.fill" + } + } + + private func logLevelColor(_ level: SidebarLogLevel, isActive: Bool) -> Color { + if isActive { + switch level { + case .info: + return Color(nsColor: sidebarSelectedWorkspaceForegroundNSColor(opacity: 0.5)) + case .progress: + return Color(nsColor: sidebarSelectedWorkspaceForegroundNSColor(opacity: 0.8)) + case .success: + return Color(nsColor: sidebarSelectedWorkspaceForegroundNSColor(opacity: 0.9)) + case .warning: + return Color(nsColor: sidebarSelectedWorkspaceForegroundNSColor(opacity: 0.9)) + case .error: + return Color(nsColor: sidebarSelectedWorkspaceForegroundNSColor(opacity: 0.9)) + } + } + switch level { + case .info: return .secondary + case .progress: return .blue + case .success: return .green + case .warning: return .orange + case .error: return .red + } + } + + private func shortenPath(_ path: String, home: String) -> String { + let trimmed = path.trimmingCharacters(in: .whitespacesAndNewlines) + guard !trimmed.isEmpty else { return path } + if trimmed == home { + return "~" + } + if trimmed.hasPrefix(home + "/") { + return "~" + trimmed.dropFirst(home.count) + } + return trimmed + } + + private struct PullRequestStatusIcon: View { + let status: SidebarPullRequestStatus + let color: Color + private static let frameSize: CGFloat = 12 + + var body: some View { + switch status { + case .open: + PullRequestOpenIcon(color: color) + case .merged: + PullRequestMergedIcon(color: color) + case .closed: + Image(systemName: "xmark.circle") + .font(.system(size: 7, weight: .regular)) + .foregroundColor(color) + .frame(width: Self.frameSize, height: Self.frameSize) + } + } + } + + private struct PullRequestOpenIcon: View { + let color: Color + private static let stroke = StrokeStyle(lineWidth: 1.2, lineCap: .round, lineJoin: .round) + private static let nodeDiameter: CGFloat = 3.0 + private static let frameSize: CGFloat = 13 + + var body: some View { + ZStack { + Path { path in + path.move(to: CGPoint(x: 3.0, y: 4.8)) + path.addLine(to: CGPoint(x: 3.0, y: 9.2)) + + path.move(to: CGPoint(x: 4.8, y: 3.0)) + path.addLine(to: CGPoint(x: 9.4, y: 3.0)) + path.addLine(to: CGPoint(x: 11.0, y: 4.6)) + path.addLine(to: CGPoint(x: 11.0, y: 9.2)) + } + .stroke(color, style: Self.stroke) + + Circle() + .stroke(color, lineWidth: Self.stroke.lineWidth) + .frame(width: Self.nodeDiameter, height: Self.nodeDiameter) + .position(x: 3.0, y: 3.0) + + Circle() + .stroke(color, lineWidth: Self.stroke.lineWidth) + .frame(width: Self.nodeDiameter, height: Self.nodeDiameter) + .position(x: 3.0, y: 11.0) + + Circle() + .stroke(color, lineWidth: Self.stroke.lineWidth) + .frame(width: Self.nodeDiameter, height: Self.nodeDiameter) + .position(x: 11.0, y: 11.0) + } + .frame(width: Self.frameSize, height: Self.frameSize) + } + } + + private struct PullRequestMergedIcon: View { + let color: Color + private static let stroke = StrokeStyle(lineWidth: 1.2, lineCap: .round, lineJoin: .round) + private static let nodeDiameter: CGFloat = 3.0 + private static let frameSize: CGFloat = 13 + + var body: some View { + ZStack { + Path { path in + path.move(to: CGPoint(x: 4.6, y: 4.6)) + path.addLine(to: CGPoint(x: 7.1, y: 7.0)) + path.addLine(to: CGPoint(x: 9.2, y: 7.0)) + + path.move(to: CGPoint(x: 4.6, y: 9.4)) + path.addLine(to: CGPoint(x: 7.1, y: 7.0)) + } + .stroke(color, style: Self.stroke) + + Circle() + .stroke(color, lineWidth: Self.stroke.lineWidth) + .frame(width: Self.nodeDiameter, height: Self.nodeDiameter) + .position(x: 3.0, y: 3.0) + + Circle() + .stroke(color, lineWidth: Self.stroke.lineWidth) + .frame(width: Self.nodeDiameter, height: Self.nodeDiameter) + .position(x: 3.0, y: 11.0) + + Circle() + .stroke(color, lineWidth: Self.stroke.lineWidth) + .frame(width: Self.nodeDiameter, height: Self.nodeDiameter) + .position(x: 11.0, y: 7.0) + } + .frame(width: Self.frameSize, height: Self.frameSize) + } + } + + private func applyTabColor(_ hex: String?, targetIds: [UUID]) { + for targetId in targetIds { + tabManager.setTabColor(tabId: targetId, color: hex) + } + } + + private func promptCustomColor(targetIds: [UUID]) { + let alert = NSAlert() + alert.messageText = String(localized: "alert.customColor.title", defaultValue: "Custom Workspace Color") + alert.informativeText = String(localized: "alert.customColor.message", defaultValue: "Enter a hex color in the format #RRGGBB.") + + let seed = tab.customColor ?? WorkspaceTabColorSettings.customPaletteEntries().first?.hex ?? "" + let input = NSTextField(string: seed) + input.placeholderString = "#1565C0" + input.frame = NSRect(x: 0, y: 0, width: 240, height: 22) + alert.accessoryView = input + alert.addButton(withTitle: String(localized: "alert.customColor.apply", defaultValue: "Apply")) + alert.addButton(withTitle: String(localized: "alert.customColor.cancel", defaultValue: "Cancel")) + + let alertWindow = alert.window + alertWindow.initialFirstResponder = input + DispatchQueue.main.async { + alertWindow.makeFirstResponder(input) + input.selectText(nil) + } + + let response = alert.runModal() + guard response == .alertFirstButtonReturn else { return } + guard let normalized = WorkspaceTabColorSettings.addCustomColor(input.stringValue) else { + showInvalidColorAlert(input.stringValue) + return + } + applyTabColor(normalized, targetIds: targetIds) + } + + private func showInvalidColorAlert(_ value: String) { + let alert = NSAlert() + alert.alertStyle = .warning + alert.messageText = String(localized: "alert.invalidColor.title", defaultValue: "Invalid Color") + let trimmed = value.trimmingCharacters(in: .whitespacesAndNewlines) + if trimmed.isEmpty { + alert.informativeText = String(localized: "alert.invalidColor.emptyMessage", defaultValue: "Enter a hex color in the format #RRGGBB.") + } else { + alert.informativeText = String(localized: "alert.invalidColor.invalidMessage", defaultValue: "\"\(trimmed)\" is not a valid hex color. Use #RRGGBB.") + } + alert.addButton(withTitle: String(localized: "alert.invalidColor.ok", defaultValue: "OK")) + _ = alert.runModal() + } + + private func promptRename() { + let alert = NSAlert() + alert.messageText = String(localized: "alert.renameWorkspace.title", defaultValue: "Rename Workspace") + alert.informativeText = String(localized: "alert.renameWorkspace.message", defaultValue: "Enter a custom name for this workspace.") + let input = NSTextField(string: tab.customTitle ?? tab.title) + input.placeholderString = String(localized: "alert.renameWorkspace.placeholder", defaultValue: "Workspace name") + input.frame = NSRect(x: 0, y: 0, width: 240, height: 22) + alert.accessoryView = input + alert.addButton(withTitle: String(localized: "alert.renameWorkspace.rename", defaultValue: "Rename")) + alert.addButton(withTitle: String(localized: "alert.renameWorkspace.cancel", defaultValue: "Cancel")) + let alertWindow = alert.window + alertWindow.initialFirstResponder = input + DispatchQueue.main.async { + alertWindow.makeFirstResponder(input) + input.selectText(nil) + } + let response = alert.runModal() + guard response == .alertFirstButtonReturn else { return } + tabManager.setCustomTitle(tabId: tab.id, title: input.stringValue) + } + + private func beginWorkspaceDescriptionEditFromContextMenu() { + selectedTabIds = [tab.id] + lastSidebarSelectionIndex = index + tabManager.selectTab(tab) + setSelectionToTabs() + _ = AppDelegate.shared?.requestEditWorkspaceDescriptionViaCommandPalette() + } +} + +private struct SidebarWorkspaceDescriptionText: View { + let markdown: String + let isActive: Bool + + var body: some View { + let renderedMarkdown = SidebarMarkdownRenderer.renderWorkspaceDescription(markdown) + Group { + if let renderedMarkdown { + Text(renderedMarkdown) + } else { + Text(markdown) + } + } + .font(.system(size: 10.5)) + .foregroundColor(foregroundColor) + .multilineTextAlignment(.leading) + .fixedSize(horizontal: false, vertical: true) + .frame(maxWidth: .infinity, alignment: .leading) + .accessibilityIdentifier("SidebarWorkspaceDescriptionText") + .accessibilityLabel(accessibilityText(renderedMarkdown: renderedMarkdown)) + .onAppear { +#if DEBUG + let newlineCount = markdown.reduce(into: 0) { count, character in + if character == "\n" { count += 1 } + } + dlog( + "sidebar.description.render workspaceState=appear " + + "len=\((markdown as NSString).length) " + + "newlines=\(newlineCount) " + + "text=\"\(debugCommandPaletteTextPreview(markdown))\"" + ) +#endif + } + .onChange(of: markdown) { newValue in +#if DEBUG + let newlineCount = newValue.reduce(into: 0) { count, character in + if character == "\n" { count += 1 } + } + dlog( + "sidebar.description.render workspaceState=change " + + "len=\((newValue as NSString).length) " + + "newlines=\(newlineCount) " + + "text=\"\(debugCommandPaletteTextPreview(newValue))\"" + ) +#endif + } + } + + private var foregroundColor: Color { + isActive ? .white.opacity(0.84) : .secondary.opacity(0.95) + } + + private func accessibilityText(renderedMarkdown: AttributedString?) -> String { + if let renderedMarkdown { + return String(renderedMarkdown.characters) + } + return markdown + } +} + +enum SidebarMarkdownRenderer { + static func renderWorkspaceDescription(_ markdown: String) -> AttributedString? { + try? AttributedString( + markdown: markdown, + options: .init(interpretedSyntax: .inlineOnlyPreservingWhitespace) + ) + } +} + +private struct SidebarMetadataRows: View { + let entries: [SidebarStatusEntry] + let isActive: Bool + let onFocus: () -> Void + + @State private var isExpanded: Bool = false + private let collapsedEntryLimit = 3 + + var body: some View { + VStack(alignment: .leading, spacing: 2) { + ForEach(visibleEntries, id: \.key) { entry in + SidebarMetadataEntryRow(entry: entry, isActive: isActive, onFocus: onFocus) + } + + if shouldShowToggle { + Button(isExpanded ? String(localized: "sidebar.metadata.showLess", defaultValue: "Show less") : String(localized: "sidebar.metadata.showMore", defaultValue: "Show more")) { + onFocus() + withAnimation(.easeInOut(duration: 0.15)) { + isExpanded.toggle() + } + } + .buttonStyle(.plain) + .font(.system(size: 10, weight: .semibold)) + .foregroundColor(isActive ? activeSecondaryTextColor : .secondary.opacity(0.9)) + .frame(maxWidth: .infinity, alignment: .leading) + } + } + .safeHelp(helpText) + } + + private var activeSecondaryTextColor: Color { + Color(nsColor: sidebarSelectedWorkspaceForegroundNSColor(opacity: 0.65)) + } + + private var visibleEntries: [SidebarStatusEntry] { + guard !isExpanded, entries.count > collapsedEntryLimit else { return entries } + return Array(entries.prefix(collapsedEntryLimit)) + } + + private var helpText: String { + entries.map { entry in + let trimmed = entry.value.trimmingCharacters(in: .whitespacesAndNewlines) + return trimmed.isEmpty ? entry.key : trimmed + } + .joined(separator: "\n") + } + + private var shouldShowToggle: Bool { + entries.count > collapsedEntryLimit + } +} + +private struct SidebarMetadataEntryRow: View { + let entry: SidebarStatusEntry + let isActive: Bool + let onFocus: () -> Void + + var body: some View { + Group { + if let url = entry.url { + Button { + onFocus() + NSWorkspace.shared.open(url) + } label: { + rowContent(underlined: true) + } + .buttonStyle(.plain) + .safeHelp(url.absoluteString) + } else { + rowContent(underlined: false) + .contentShape(Rectangle()) + .onTapGesture { onFocus() } + } + } + } + + @ViewBuilder + private func rowContent(underlined: Bool) -> some View { + HStack(spacing: 4) { + if let icon = iconView { + icon + .foregroundColor(foregroundColor.opacity(0.95)) + } + metadataText(underlined: underlined) + .lineLimit(1) + .truncationMode(.tail) + Spacer(minLength: 0) + } + .font(.system(size: 10)) + .frame(maxWidth: .infinity, alignment: .leading) + } + + private var foregroundColor: Color { + if isActive, + let raw = entry.color, + Color(hex: raw) != nil { + return Color(nsColor: sidebarSelectedWorkspaceForegroundNSColor(opacity: 0.95)) + } + if let raw = entry.color, let explicit = Color(hex: raw) { + return explicit + } + return isActive ? .white.opacity(0.8) : .secondary + } + + private var iconView: AnyView? { + guard let iconRaw = entry.icon?.trimmingCharacters(in: .whitespacesAndNewlines), + !iconRaw.isEmpty else { + return nil + } + if iconRaw.hasPrefix("emoji:") { + let value = String(iconRaw.dropFirst("emoji:".count)) + guard !value.isEmpty else { return nil } + return AnyView(Text(value).font(.system(size: 9))) + } + if iconRaw.hasPrefix("text:") { + let value = String(iconRaw.dropFirst("text:".count)) + guard !value.isEmpty else { return nil } + return AnyView(Text(value).font(.system(size: 8, weight: .semibold))) + } + let symbolName: String + if iconRaw.hasPrefix("sf:") { + symbolName = String(iconRaw.dropFirst("sf:".count)) + } else { + symbolName = iconRaw + } + guard !symbolName.isEmpty else { return nil } + return AnyView(Image(systemName: symbolName).font(.system(size: 8, weight: .medium))) + } + + @ViewBuilder + private func metadataText(underlined: Bool) -> some View { + let trimmed = entry.value.trimmingCharacters(in: .whitespacesAndNewlines) + let display = trimmed.isEmpty ? entry.key : trimmed + if entry.format == .markdown, + let attributed = try? AttributedString( + markdown: display, + options: .init(interpretedSyntax: .inlineOnlyPreservingWhitespace) + ) { + Text(attributed) + .underline(underlined) + .foregroundColor(foregroundColor) + } else { + Text(display) + .underline(underlined) + .foregroundColor(foregroundColor) + } + } +} + +private struct SidebarMetadataMarkdownBlocks: View { + let blocks: [SidebarMetadataBlock] + let isActive: Bool + let onFocus: () -> Void + + @State private var isExpanded: Bool = false + private let collapsedBlockLimit = 1 + + var body: some View { + VStack(alignment: .leading, spacing: 3) { + ForEach(visibleBlocks, id: \.key) { block in + SidebarMetadataMarkdownBlockRow( + block: block, + isActive: isActive, + onFocus: onFocus + ) + } + + if shouldShowToggle { + Button(isExpanded ? String(localized: "sidebar.metadata.showLessDetails", defaultValue: "Show less details") : String(localized: "sidebar.metadata.showMoreDetails", defaultValue: "Show more details")) { + onFocus() + withAnimation(.easeInOut(duration: 0.15)) { + isExpanded.toggle() + } + } + .buttonStyle(.plain) + .font(.system(size: 10, weight: .semibold)) + .foregroundColor(isActive ? .white.opacity(0.65) : .secondary.opacity(0.9)) + .frame(maxWidth: .infinity, alignment: .leading) + } + } + } + + private var visibleBlocks: [SidebarMetadataBlock] { + guard !isExpanded, blocks.count > collapsedBlockLimit else { return blocks } + return Array(blocks.prefix(collapsedBlockLimit)) + } + + private var shouldShowToggle: Bool { + blocks.count > collapsedBlockLimit + } +} + +private struct SidebarMetadataMarkdownBlockRow: View { + let block: SidebarMetadataBlock + let isActive: Bool + let onFocus: () -> Void + + @State private var renderedMarkdown: AttributedString? + + var body: some View { + Group { + if let renderedMarkdown { + Text(renderedMarkdown) + .foregroundColor(foregroundColor) + } else { + Text(block.markdown) + .foregroundColor(foregroundColor) + } + } + .font(.system(size: 10)) + .multilineTextAlignment(.leading) + .fixedSize(horizontal: false, vertical: true) + .contentShape(Rectangle()) + .onTapGesture { onFocus() } + .onAppear(perform: renderMarkdown) + .onChange(of: block.markdown) { _ in + renderMarkdown() + } + } + + private var foregroundColor: Color { + isActive ? .white.opacity(0.8) : .secondary + } + + private func renderMarkdown() { + renderedMarkdown = try? AttributedString( + markdown: block.markdown, + options: .init(interpretedSyntax: .full) + ) + } +} + From 3a6620dc8a74f1e072ce5f101f21c192a1788b4e Mon Sep 17 00:00:00 2001 From: arzafran Date: Thu, 9 Jul 2026 13:52:24 -0300 Subject: [PATCH 03/19] refactor(contentview): extract FileDropOverlayView.swift (mechanical move) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Move the FileDropOverlayView NSView class out of ContentView.swift into its own file, verbatim (no access-level changes needed — it was already internal). Refs #94. --- GhosttyTabs.xcodeproj/project.pbxproj | 4 + Sources/ContentView.swift | 482 ------------------------- Sources/FileDropOverlayView.swift | 488 ++++++++++++++++++++++++++ 3 files changed, 492 insertions(+), 482 deletions(-) create mode 100644 Sources/FileDropOverlayView.swift diff --git a/GhosttyTabs.xcodeproj/project.pbxproj b/GhosttyTabs.xcodeproj/project.pbxproj index c973b5ea48c..1df369db106 100644 --- a/GhosttyTabs.xcodeproj/project.pbxproj +++ b/GhosttyTabs.xcodeproj/project.pbxproj @@ -12,6 +12,7 @@ A5001002 /* ContentView.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5001012 /* ContentView.swift */; }; A5FF0005 /* ContentView+CommandPalette.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5FF0015 /* ContentView+CommandPalette.swift */; }; NRCV00000000000000000005 /* TabItemView.swift in Sources */ = {isa = PBXBuildFile; fileRef = NRCV00000000000000000006 /* TabItemView.swift */; }; + NRCV00000000000000000003 /* FileDropOverlayView.swift in Sources */ = {isa = PBXBuildFile; fileRef = NRCV00000000000000000004 /* FileDropOverlayView.swift */; }; E62155868BB29FEB5DAAAF25 /* SidebarSelectionState.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9AD52285508B1D6A9875E7B3 /* SidebarSelectionState.swift */; }; B9000018A1B2C3D4E5F60719 /* WindowDragHandleView.swift in Sources */ = {isa = PBXBuildFile; fileRef = B9000017A1B2C3D4E5F60719 /* WindowDragHandleView.swift */; }; A5001003 /* TabManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5001013 /* TabManager.swift */; }; @@ -237,6 +238,7 @@ A5001012 /* ContentView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ContentView.swift; sourceTree = ""; }; A5FF0015 /* ContentView+CommandPalette.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "ContentView+CommandPalette.swift"; sourceTree = ""; }; NRCV00000000000000000006 /* TabItemView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TabItemView.swift; sourceTree = ""; }; + NRCV00000000000000000004 /* FileDropOverlayView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FileDropOverlayView.swift; sourceTree = ""; }; 9AD52285508B1D6A9875E7B3 /* SidebarSelectionState.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SidebarSelectionState.swift; sourceTree = ""; }; B9000017A1B2C3D4E5F60719 /* WindowDragHandleView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WindowDragHandleView.swift; sourceTree = ""; }; A5001013 /* TabManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TabManager.swift; sourceTree = ""; }; @@ -507,6 +509,7 @@ A5001012 /* ContentView.swift */, A5FF0015 /* ContentView+CommandPalette.swift */, NRCV00000000000000000006 /* TabItemView.swift */, + NRCV00000000000000000004 /* FileDropOverlayView.swift */, 9AD52285508B1D6A9875E7B3 /* SidebarSelectionState.swift */, B9000017A1B2C3D4E5F60719 /* WindowDragHandleView.swift */, A50012F0 /* Backport.swift */, @@ -838,6 +841,7 @@ A5001002 /* ContentView.swift in Sources */, A5FF0005 /* ContentView+CommandPalette.swift in Sources */, NRCV00000000000000000005 /* TabItemView.swift in Sources */, + NRCV00000000000000000003 /* FileDropOverlayView.swift in Sources */, E62155868BB29FEB5DAAAF25 /* SidebarSelectionState.swift in Sources */, B9000018A1B2C3D4E5F60719 /* WindowDragHandleView.swift in Sources */, A50012F1 /* Backport.swift in Sources */, diff --git a/Sources/ContentView.swift b/Sources/ContentView.swift index a49d2eec076..5500ee6ba19 100644 --- a/Sources/ContentView.swift +++ b/Sources/ContentView.swift @@ -471,488 +471,6 @@ enum DragOverlayRoutingPolicy { } } -/// Transparent NSView installed on the window's theme frame (above the NSHostingView) to -/// handle file/URL drags from Finder. Nested NSHostingController layers (created by bonsplit's -/// SinglePaneWrapper) prevent AppKit's NSDraggingDestination routing from reaching deeply -/// embedded terminal views. This overlay sits above the entire content view hierarchy and -/// intercepts file drags, forwarding drops to the GhosttyNSView under the cursor. -/// -/// Mouse events are forwarded to the views below via a hide-send-unhide pattern so clicks, -/// scrolls, and other interactions pass through normally. -final class FileDropOverlayView: NSView { - /// Fallback handler when no terminal is found under the drop point. - var onDrop: (([URL]) -> Bool)? - private var isForwardingMouseEvent = false - private weak var forwardedMouseDragTarget: NSView? - private var forwardedMouseDragButton: ForwardedMouseDragButton? - /// The WKWebView currently receiving forwarded drag events, so we can - /// synthesize draggingExited/draggingEntered as the cursor moves. - private weak var activeDragWebView: WKWebView? - /// The WKWebView that accepted prepareForDragOperation so conclude can be - /// delivered to the same browser target after the drop completes. - private weak var preparedDragWebView: WKWebView? - private var lastHitTestLogSignature: String? - private var lastDragRouteLogSignatureByPhase: [String: String] = [:] - - override var acceptsFirstResponder: Bool { false } - - override init(frame frameRect: NSRect) { - super.init(frame: frameRect) - registerForDraggedTypes([.fileURL]) - } - - required init?(coder: NSCoder) { fatalError("init(coder:) not implemented") } - - private enum ForwardedMouseDragButton: Equatable { - case left - case right - case other(Int) - } - - private func dragButton(for event: NSEvent) -> ForwardedMouseDragButton? { - switch event.type { - case .leftMouseDown, .leftMouseUp, .leftMouseDragged: - return .left - case .rightMouseDown, .rightMouseUp, .rightMouseDragged: - return .right - case .otherMouseDown, .otherMouseUp, .otherMouseDragged: - return .other(Int(event.buttonNumber)) - default: - return nil - } - } - - private func shouldTrackForwardedMouseDragStart(for eventType: NSEvent.EventType) -> Bool { - switch eventType { - case .leftMouseDown, .rightMouseDown, .otherMouseDown: - return true - default: - return false - } - } - - private func shouldTrackForwardedMouseDragEnd(for eventType: NSEvent.EventType) -> Bool { - switch eventType { - case .leftMouseUp, .rightMouseUp, .otherMouseUp: - return true - default: - return false - } - } - - // MARK: Hit-testing — participation is routed by DragOverlayRoutingPolicy so - // file-drop, bonsplit tab drags, and sidebar tab reorder drags cannot conflict. - - override func hitTest(_ point: NSPoint) -> NSView? { - let pb = NSPasteboard(name: .drag) - let eventType = NSApp.currentEvent?.type - let shouldCapture = DragOverlayRoutingPolicy.shouldCaptureFileDropOverlay( - pasteboardTypes: pb.types, - eventType: eventType - ) -#if DEBUG - logHitTestDecision( - pasteboardTypes: pb.types, - eventType: eventType, - shouldCapture: shouldCapture - ) -#endif - guard shouldCapture else { return nil } - - return super.hitTest(point) - } - - // MARK: Mouse forwarding — safety net for the rare case where stale drag pasteboard - // data causes hitTest to return self when no drag is actually active. - // We hit-test contentView directly and dispatch to the target rather than using - // window.sendEvent(), which caches the mouse target and causes infinite recursion. - - private func forwardEvent(_ event: NSEvent) { - guard !isForwardingMouseEvent else { return } - guard let window, let contentView = window.contentView else { return } - let eventButton = dragButton(for: event) - - isForwardingMouseEvent = true - isHidden = true - defer { - isHidden = false - isForwardingMouseEvent = false - } - - let target: NSView? - if let eventButton, - forwardedMouseDragButton == eventButton, - let activeTarget = forwardedMouseDragTarget, - activeTarget.window != nil { - // Preserve normal AppKit mouse-delivery semantics: once a drag starts, - // keep routing dragged/up events to the original mouseDown target. - target = activeTarget - } else { - let point = contentView.convert(event.locationInWindow, from: nil) - target = contentView.hitTest(point) - } - - guard let target, target !== self else { - if shouldTrackForwardedMouseDragEnd(for: event.type), - let eventButton, - forwardedMouseDragButton == eventButton { - forwardedMouseDragTarget = nil - forwardedMouseDragButton = nil - } - return - } - - if shouldTrackForwardedMouseDragStart(for: event.type), let eventButton { - forwardedMouseDragTarget = target - forwardedMouseDragButton = eventButton - } - - switch event.type { - case .leftMouseDown: target.mouseDown(with: event) - case .leftMouseUp: target.mouseUp(with: event) - case .leftMouseDragged: target.mouseDragged(with: event) - case .rightMouseDown: target.rightMouseDown(with: event) - case .rightMouseUp: target.rightMouseUp(with: event) - case .rightMouseDragged: target.rightMouseDragged(with: event) - case .otherMouseDown: target.otherMouseDown(with: event) - case .otherMouseUp: target.otherMouseUp(with: event) - case .otherMouseDragged: target.otherMouseDragged(with: event) - case .scrollWheel: target.scrollWheel(with: event) - default: break - } - - if shouldTrackForwardedMouseDragEnd(for: event.type), - let eventButton, - forwardedMouseDragButton == eventButton { - forwardedMouseDragTarget = nil - forwardedMouseDragButton = nil - } - } - - override func mouseDown(with event: NSEvent) { forwardEvent(event) } - override func mouseUp(with event: NSEvent) { forwardEvent(event) } - override func mouseDragged(with event: NSEvent) { forwardEvent(event) } - override func rightMouseDown(with event: NSEvent) { forwardEvent(event) } - override func rightMouseUp(with event: NSEvent) { forwardEvent(event) } - override func rightMouseDragged(with event: NSEvent) { forwardEvent(event) } - override func otherMouseDown(with event: NSEvent) { forwardEvent(event) } - override func otherMouseUp(with event: NSEvent) { forwardEvent(event) } - override func otherMouseDragged(with event: NSEvent) { forwardEvent(event) } - override func scrollWheel(with event: NSEvent) { forwardEvent(event) } - - // MARK: NSDraggingDestination – accept file drops over terminal and browser views. - // - // AppKit sends draggingEntered once when the drag enters this overlay, then - // draggingUpdated as the cursor moves within it. We track which WKWebView (if - // any) is under the cursor and synthesize enter/exit calls so the browser's - // HTML5 drag events (dragenter, dragleave, drop) fire correctly. - - override func draggingEntered(_ sender: any NSDraggingInfo) -> NSDragOperation { - return updateDragTarget(sender, phase: "entered") - } - - override func draggingUpdated(_ sender: any NSDraggingInfo) -> NSDragOperation { - return updateDragTarget(sender, phase: "updated") - } - - override func draggingExited(_ sender: (any NSDraggingInfo)?) { - preparedDragWebView = nil - if let prev = activeDragWebView { - prev.draggingExited(sender) - activeDragWebView = nil - } - } - - override func prepareForDragOperation(_ sender: any NSDraggingInfo) -> Bool { - let hasLocalDraggingSource = sender.draggingSource != nil - let types = sender.draggingPasteboard.types - let shouldCapture = DragOverlayRoutingPolicy.shouldCaptureFileDropDestination( - pasteboardTypes: types, - hasLocalDraggingSource: hasLocalDraggingSource - ) - let webView = shouldCapture ? (activeDragWebView ?? webViewUnderPoint(sender.draggingLocation)) : nil - let terminal = terminalUnderPoint(sender.draggingLocation) - let hasTerminalTarget = terminal != nil -#if DEBUG - logDragRouteDecision( - phase: "prepare", - pasteboardTypes: types, - shouldCapture: shouldCapture, - hasLocalDraggingSource: hasLocalDraggingSource, - hasTerminalTarget: hasTerminalTarget - ) -#endif - guard shouldCapture else { - preparedDragWebView = nil - return false - } - if let webView { - preparedDragWebView = webView - return webView.prepareForDragOperation(sender) - } - preparedDragWebView = nil - return hasTerminalTarget - } - - override func performDragOperation(_ sender: any NSDraggingInfo) -> Bool { - let hasLocalDraggingSource = sender.draggingSource != nil - let types = sender.draggingPasteboard.types - let shouldCapture = DragOverlayRoutingPolicy.shouldCaptureFileDropDestination( - pasteboardTypes: types, - hasLocalDraggingSource: hasLocalDraggingSource - ) - let webView = shouldCapture - ? (preparedDragWebView ?? activeDragWebView ?? webViewUnderPoint(sender.draggingLocation)) - : nil - let terminal = terminalUnderPoint(sender.draggingLocation) - let hasTerminalTarget = terminal != nil -#if DEBUG - logDragRouteDecision( - phase: "perform", - pasteboardTypes: types, - shouldCapture: shouldCapture, - hasLocalDraggingSource: hasLocalDraggingSource, - hasTerminalTarget: hasTerminalTarget - ) -#endif - guard shouldCapture else { - preparedDragWebView = nil - activeDragWebView = nil - return false - } - if let webView { - preparedDragWebView = webView - return webView.performDragOperation(sender) - } - preparedDragWebView = nil - activeDragWebView = nil - guard let terminal else { return false } - return terminal.performDragOperation(sender) - } - - override func concludeDragOperation(_ sender: (any NSDraggingInfo)?) { - defer { - preparedDragWebView = nil - activeDragWebView = nil - } - guard let sender else { return } - let webView = preparedDragWebView ?? activeDragWebView ?? webViewUnderPoint(sender.draggingLocation) - webView?.concludeDragOperation(sender) - } - - private func updateDragTarget(_ sender: any NSDraggingInfo, phase: String) -> NSDragOperation { - let loc = sender.draggingLocation - let hasLocalDraggingSource = sender.draggingSource != nil - let types = sender.draggingPasteboard.types - let shouldCapture = DragOverlayRoutingPolicy.shouldCaptureFileDropDestination( - pasteboardTypes: types, - hasLocalDraggingSource: hasLocalDraggingSource - ) - let webView = shouldCapture ? webViewUnderPoint(loc) : nil - - if let prev = activeDragWebView, prev !== webView { - prev.draggingExited(sender) - activeDragWebView = nil - } - - if let webView { - if activeDragWebView !== webView { - activeDragWebView = webView - return webView.draggingEntered(sender) - } - return webView.draggingUpdated(sender) - } - - let hasTerminalTarget = terminalUnderPoint(loc) != nil -#if DEBUG - logDragRouteDecision( - phase: phase, - pasteboardTypes: types, - shouldCapture: shouldCapture, - hasLocalDraggingSource: hasLocalDraggingSource, - hasTerminalTarget: hasTerminalTarget - ) -#endif - guard shouldCapture, hasTerminalTarget else { return [] } - return .copy - } - - private func debugPasteboardTypes(_ types: [NSPasteboard.PasteboardType]?) -> String { - guard let types, !types.isEmpty else { return "-" } - return types.map(\.rawValue).joined(separator: ",") - } - - /// Hit-tests the window to find a WKWebView (browser panel) under the cursor. - func webViewUnderPoint(_ windowPoint: NSPoint) -> WKWebView? { - if let window, - let portalWebView = BrowserWindowPortalRegistry.webViewAtWindowPoint(windowPoint, in: window) { - return portalWebView - } - - guard let window, let contentView = window.contentView else { return nil } - isHidden = true - defer { isHidden = false } - let point = contentView.convert(windowPoint, from: nil) - let hitView = contentView.hitTest(point) - - var current: NSView? = hitView - while let view = current { - if let webView = view as? WKWebView { return webView } - current = view.superview - } - return nil - } - - private func debugTopHitViewForCurrentEvent() -> String { - guard let window, - let currentEvent = NSApp.currentEvent, - let contentView = window.contentView, - let themeFrame = contentView.superview else { return "-" } - - let pointInTheme = themeFrame.convert(currentEvent.locationInWindow, from: nil) - // Don't toggle isHidden here — it triggers setNeedsDisplay which can - // exceed AppKit's display-pass limit during cursor-update display cycles. - guard let hit = themeFrame.hitTest(pointInTheme) else { return "nil" } - var chain: [String] = [] - var current: NSView? = hit - var depth = 0 - while let view = current, depth < 6 { - chain.append(debugHitViewDescriptor(view)) - current = view.superview - depth += 1 - } - return chain.joined(separator: "->") - } - - private func debugHitViewDescriptor(_ view: NSView) -> String { - let className = String(describing: type(of: view)) - let ptr = String(describing: Unmanaged.passUnretained(view).toOpaque()) - let dragTypes = debugRegisteredDragTypes(view) - return "\(className)@\(ptr){dragTypes=\(dragTypes)}" - } - - private func debugRegisteredDragTypes(_ view: NSView) -> String { - let types = view.registeredDraggedTypes - guard !types.isEmpty else { return "-" } - - let interestingTypes = types.filter { type in - let raw = type.rawValue - return raw == NSPasteboard.PasteboardType.fileURL.rawValue - || raw == DragOverlayRoutingPolicy.bonsplitTabTransferType.rawValue - || raw == DragOverlayRoutingPolicy.sidebarTabReorderType.rawValue - || raw.contains("public.text") - || raw.contains("public.url") - || raw.contains("public.data") - } - let selected = interestingTypes.isEmpty ? Array(types.prefix(3)) : interestingTypes - let rendered = selected.map(\.rawValue).joined(separator: ",") - if selected.count < types.count { - return "\(rendered),+\(types.count - selected.count)" - } - return rendered - } - - private func hasRelevantDragTypes(_ types: [NSPasteboard.PasteboardType]?) -> Bool { - guard let types else { return false } - return types.contains(.fileURL) - || types.contains(DragOverlayRoutingPolicy.bonsplitTabTransferType) - || types.contains(DragOverlayRoutingPolicy.sidebarTabReorderType) - } - - private func debugEventName(_ eventType: NSEvent.EventType?) -> String { - guard let eventType else { return "none" } - switch eventType { - case .cursorUpdate: return "cursorUpdate" - case .appKitDefined: return "appKitDefined" - case .systemDefined: return "systemDefined" - case .applicationDefined: return "applicationDefined" - case .periodic: return "periodic" - case .mouseMoved: return "mouseMoved" - case .mouseEntered: return "mouseEntered" - case .mouseExited: return "mouseExited" - case .flagsChanged: return "flagsChanged" - case .leftMouseDown: return "leftMouseDown" - case .leftMouseUp: return "leftMouseUp" - case .leftMouseDragged: return "leftMouseDragged" - case .rightMouseDown: return "rightMouseDown" - case .rightMouseUp: return "rightMouseUp" - case .rightMouseDragged: return "rightMouseDragged" - case .otherMouseDown: return "otherMouseDown" - case .otherMouseUp: return "otherMouseUp" - case .otherMouseDragged: return "otherMouseDragged" - case .scrollWheel: return "scrollWheel" - default: return "other(\(eventType.rawValue))" - } - } - -#if DEBUG - private func logHitTestDecision( - pasteboardTypes: [NSPasteboard.PasteboardType]?, - eventType: NSEvent.EventType?, - shouldCapture: Bool - ) { - let isDragEvent = eventType == .leftMouseDragged - || eventType == .rightMouseDragged - || eventType == .otherMouseDragged - guard shouldCapture || isDragEvent || hasRelevantDragTypes(pasteboardTypes) else { return } - - let signature = "\(shouldCapture ? 1 : 0)|\(debugEventName(eventType))|\(debugPasteboardTypes(pasteboardTypes))" - guard lastHitTestLogSignature != signature else { return } - lastHitTestLogSignature = signature - dlog( - "overlay.fileDrop.hitTest capture=\(shouldCapture ? 1 : 0) " + - "event=\(debugEventName(eventType)) " + - "topHit=\(debugTopHitViewForCurrentEvent()) " + - "types=\(debugPasteboardTypes(pasteboardTypes))" - ) - } - - private func logDragRouteDecision( - phase: String, - pasteboardTypes: [NSPasteboard.PasteboardType]?, - shouldCapture: Bool, - hasLocalDraggingSource: Bool, - hasTerminalTarget: Bool - ) { - guard shouldCapture || hasRelevantDragTypes(pasteboardTypes) else { return } - let signature = [ - shouldCapture ? "1" : "0", - hasLocalDraggingSource ? "1" : "0", - hasTerminalTarget ? "1" : "0", - debugPasteboardTypes(pasteboardTypes) - ].joined(separator: "|") - guard lastDragRouteLogSignatureByPhase[phase] != signature else { return } - lastDragRouteLogSignatureByPhase[phase] = signature - dlog( - "overlay.fileDrop.\(phase) capture=\(shouldCapture ? 1 : 0) " + - "localSource=\(hasLocalDraggingSource ? 1 : 0) " + - "hasTerminal=\(hasTerminalTarget ? 1 : 0) " + - "types=\(debugPasteboardTypes(pasteboardTypes))" - ) - } -#endif - /// Hit-tests the window to find the GhosttyNSView under the cursor. - func terminalUnderPoint(_ windowPoint: NSPoint) -> GhosttyNSView? { - if let window, - let portalTerminal = TerminalWindowPortalRegistry.terminalViewAtWindowPoint(windowPoint, in: window) { - return portalTerminal - } - - guard let window, let contentView = window.contentView else { return nil } - isHidden = true - defer { isHidden = false } - let point = contentView.convert(windowPoint, from: nil) - let hitView = contentView.hitTest(point) - - var current: NSView? = hitView - while let view = current { - if let terminal = view as? GhosttyNSView { return terminal } - current = view.superview - } - return nil - } -} - var fileDropOverlayKey: UInt8 = 0 private var commandPaletteWindowOverlayKey: UInt8 = 0 private var tmuxWorkspacePaneWindowOverlayKey: UInt8 = 0 diff --git a/Sources/FileDropOverlayView.swift b/Sources/FileDropOverlayView.swift new file mode 100644 index 00000000000..6d403707d14 --- /dev/null +++ b/Sources/FileDropOverlayView.swift @@ -0,0 +1,488 @@ +// File-drop overlay NSView, extracted from ContentView.swift (nuclear-review #94.3). +// Pure move — behavior-identical relocation. + +import AppKit +import WebKit + + +/// Transparent NSView installed on the window's theme frame (above the NSHostingView) to +/// handle file/URL drags from Finder. Nested NSHostingController layers (created by bonsplit's +/// SinglePaneWrapper) prevent AppKit's NSDraggingDestination routing from reaching deeply +/// embedded terminal views. This overlay sits above the entire content view hierarchy and +/// intercepts file drags, forwarding drops to the GhosttyNSView under the cursor. +/// +/// Mouse events are forwarded to the views below via a hide-send-unhide pattern so clicks, +/// scrolls, and other interactions pass through normally. +final class FileDropOverlayView: NSView { + /// Fallback handler when no terminal is found under the drop point. + var onDrop: (([URL]) -> Bool)? + private var isForwardingMouseEvent = false + private weak var forwardedMouseDragTarget: NSView? + private var forwardedMouseDragButton: ForwardedMouseDragButton? + /// The WKWebView currently receiving forwarded drag events, so we can + /// synthesize draggingExited/draggingEntered as the cursor moves. + private weak var activeDragWebView: WKWebView? + /// The WKWebView that accepted prepareForDragOperation so conclude can be + /// delivered to the same browser target after the drop completes. + private weak var preparedDragWebView: WKWebView? + private var lastHitTestLogSignature: String? + private var lastDragRouteLogSignatureByPhase: [String: String] = [:] + + override var acceptsFirstResponder: Bool { false } + + override init(frame frameRect: NSRect) { + super.init(frame: frameRect) + registerForDraggedTypes([.fileURL]) + } + + required init?(coder: NSCoder) { fatalError("init(coder:) not implemented") } + + private enum ForwardedMouseDragButton: Equatable { + case left + case right + case other(Int) + } + + private func dragButton(for event: NSEvent) -> ForwardedMouseDragButton? { + switch event.type { + case .leftMouseDown, .leftMouseUp, .leftMouseDragged: + return .left + case .rightMouseDown, .rightMouseUp, .rightMouseDragged: + return .right + case .otherMouseDown, .otherMouseUp, .otherMouseDragged: + return .other(Int(event.buttonNumber)) + default: + return nil + } + } + + private func shouldTrackForwardedMouseDragStart(for eventType: NSEvent.EventType) -> Bool { + switch eventType { + case .leftMouseDown, .rightMouseDown, .otherMouseDown: + return true + default: + return false + } + } + + private func shouldTrackForwardedMouseDragEnd(for eventType: NSEvent.EventType) -> Bool { + switch eventType { + case .leftMouseUp, .rightMouseUp, .otherMouseUp: + return true + default: + return false + } + } + + // MARK: Hit-testing — participation is routed by DragOverlayRoutingPolicy so + // file-drop, bonsplit tab drags, and sidebar tab reorder drags cannot conflict. + + override func hitTest(_ point: NSPoint) -> NSView? { + let pb = NSPasteboard(name: .drag) + let eventType = NSApp.currentEvent?.type + let shouldCapture = DragOverlayRoutingPolicy.shouldCaptureFileDropOverlay( + pasteboardTypes: pb.types, + eventType: eventType + ) +#if DEBUG + logHitTestDecision( + pasteboardTypes: pb.types, + eventType: eventType, + shouldCapture: shouldCapture + ) +#endif + guard shouldCapture else { return nil } + + return super.hitTest(point) + } + + // MARK: Mouse forwarding — safety net for the rare case where stale drag pasteboard + // data causes hitTest to return self when no drag is actually active. + // We hit-test contentView directly and dispatch to the target rather than using + // window.sendEvent(), which caches the mouse target and causes infinite recursion. + + private func forwardEvent(_ event: NSEvent) { + guard !isForwardingMouseEvent else { return } + guard let window, let contentView = window.contentView else { return } + let eventButton = dragButton(for: event) + + isForwardingMouseEvent = true + isHidden = true + defer { + isHidden = false + isForwardingMouseEvent = false + } + + let target: NSView? + if let eventButton, + forwardedMouseDragButton == eventButton, + let activeTarget = forwardedMouseDragTarget, + activeTarget.window != nil { + // Preserve normal AppKit mouse-delivery semantics: once a drag starts, + // keep routing dragged/up events to the original mouseDown target. + target = activeTarget + } else { + let point = contentView.convert(event.locationInWindow, from: nil) + target = contentView.hitTest(point) + } + + guard let target, target !== self else { + if shouldTrackForwardedMouseDragEnd(for: event.type), + let eventButton, + forwardedMouseDragButton == eventButton { + forwardedMouseDragTarget = nil + forwardedMouseDragButton = nil + } + return + } + + if shouldTrackForwardedMouseDragStart(for: event.type), let eventButton { + forwardedMouseDragTarget = target + forwardedMouseDragButton = eventButton + } + + switch event.type { + case .leftMouseDown: target.mouseDown(with: event) + case .leftMouseUp: target.mouseUp(with: event) + case .leftMouseDragged: target.mouseDragged(with: event) + case .rightMouseDown: target.rightMouseDown(with: event) + case .rightMouseUp: target.rightMouseUp(with: event) + case .rightMouseDragged: target.rightMouseDragged(with: event) + case .otherMouseDown: target.otherMouseDown(with: event) + case .otherMouseUp: target.otherMouseUp(with: event) + case .otherMouseDragged: target.otherMouseDragged(with: event) + case .scrollWheel: target.scrollWheel(with: event) + default: break + } + + if shouldTrackForwardedMouseDragEnd(for: event.type), + let eventButton, + forwardedMouseDragButton == eventButton { + forwardedMouseDragTarget = nil + forwardedMouseDragButton = nil + } + } + + override func mouseDown(with event: NSEvent) { forwardEvent(event) } + override func mouseUp(with event: NSEvent) { forwardEvent(event) } + override func mouseDragged(with event: NSEvent) { forwardEvent(event) } + override func rightMouseDown(with event: NSEvent) { forwardEvent(event) } + override func rightMouseUp(with event: NSEvent) { forwardEvent(event) } + override func rightMouseDragged(with event: NSEvent) { forwardEvent(event) } + override func otherMouseDown(with event: NSEvent) { forwardEvent(event) } + override func otherMouseUp(with event: NSEvent) { forwardEvent(event) } + override func otherMouseDragged(with event: NSEvent) { forwardEvent(event) } + override func scrollWheel(with event: NSEvent) { forwardEvent(event) } + + // MARK: NSDraggingDestination – accept file drops over terminal and browser views. + // + // AppKit sends draggingEntered once when the drag enters this overlay, then + // draggingUpdated as the cursor moves within it. We track which WKWebView (if + // any) is under the cursor and synthesize enter/exit calls so the browser's + // HTML5 drag events (dragenter, dragleave, drop) fire correctly. + + override func draggingEntered(_ sender: any NSDraggingInfo) -> NSDragOperation { + return updateDragTarget(sender, phase: "entered") + } + + override func draggingUpdated(_ sender: any NSDraggingInfo) -> NSDragOperation { + return updateDragTarget(sender, phase: "updated") + } + + override func draggingExited(_ sender: (any NSDraggingInfo)?) { + preparedDragWebView = nil + if let prev = activeDragWebView { + prev.draggingExited(sender) + activeDragWebView = nil + } + } + + override func prepareForDragOperation(_ sender: any NSDraggingInfo) -> Bool { + let hasLocalDraggingSource = sender.draggingSource != nil + let types = sender.draggingPasteboard.types + let shouldCapture = DragOverlayRoutingPolicy.shouldCaptureFileDropDestination( + pasteboardTypes: types, + hasLocalDraggingSource: hasLocalDraggingSource + ) + let webView = shouldCapture ? (activeDragWebView ?? webViewUnderPoint(sender.draggingLocation)) : nil + let terminal = terminalUnderPoint(sender.draggingLocation) + let hasTerminalTarget = terminal != nil +#if DEBUG + logDragRouteDecision( + phase: "prepare", + pasteboardTypes: types, + shouldCapture: shouldCapture, + hasLocalDraggingSource: hasLocalDraggingSource, + hasTerminalTarget: hasTerminalTarget + ) +#endif + guard shouldCapture else { + preparedDragWebView = nil + return false + } + if let webView { + preparedDragWebView = webView + return webView.prepareForDragOperation(sender) + } + preparedDragWebView = nil + return hasTerminalTarget + } + + override func performDragOperation(_ sender: any NSDraggingInfo) -> Bool { + let hasLocalDraggingSource = sender.draggingSource != nil + let types = sender.draggingPasteboard.types + let shouldCapture = DragOverlayRoutingPolicy.shouldCaptureFileDropDestination( + pasteboardTypes: types, + hasLocalDraggingSource: hasLocalDraggingSource + ) + let webView = shouldCapture + ? (preparedDragWebView ?? activeDragWebView ?? webViewUnderPoint(sender.draggingLocation)) + : nil + let terminal = terminalUnderPoint(sender.draggingLocation) + let hasTerminalTarget = terminal != nil +#if DEBUG + logDragRouteDecision( + phase: "perform", + pasteboardTypes: types, + shouldCapture: shouldCapture, + hasLocalDraggingSource: hasLocalDraggingSource, + hasTerminalTarget: hasTerminalTarget + ) +#endif + guard shouldCapture else { + preparedDragWebView = nil + activeDragWebView = nil + return false + } + if let webView { + preparedDragWebView = webView + return webView.performDragOperation(sender) + } + preparedDragWebView = nil + activeDragWebView = nil + guard let terminal else { return false } + return terminal.performDragOperation(sender) + } + + override func concludeDragOperation(_ sender: (any NSDraggingInfo)?) { + defer { + preparedDragWebView = nil + activeDragWebView = nil + } + guard let sender else { return } + let webView = preparedDragWebView ?? activeDragWebView ?? webViewUnderPoint(sender.draggingLocation) + webView?.concludeDragOperation(sender) + } + + private func updateDragTarget(_ sender: any NSDraggingInfo, phase: String) -> NSDragOperation { + let loc = sender.draggingLocation + let hasLocalDraggingSource = sender.draggingSource != nil + let types = sender.draggingPasteboard.types + let shouldCapture = DragOverlayRoutingPolicy.shouldCaptureFileDropDestination( + pasteboardTypes: types, + hasLocalDraggingSource: hasLocalDraggingSource + ) + let webView = shouldCapture ? webViewUnderPoint(loc) : nil + + if let prev = activeDragWebView, prev !== webView { + prev.draggingExited(sender) + activeDragWebView = nil + } + + if let webView { + if activeDragWebView !== webView { + activeDragWebView = webView + return webView.draggingEntered(sender) + } + return webView.draggingUpdated(sender) + } + + let hasTerminalTarget = terminalUnderPoint(loc) != nil +#if DEBUG + logDragRouteDecision( + phase: phase, + pasteboardTypes: types, + shouldCapture: shouldCapture, + hasLocalDraggingSource: hasLocalDraggingSource, + hasTerminalTarget: hasTerminalTarget + ) +#endif + guard shouldCapture, hasTerminalTarget else { return [] } + return .copy + } + + private func debugPasteboardTypes(_ types: [NSPasteboard.PasteboardType]?) -> String { + guard let types, !types.isEmpty else { return "-" } + return types.map(\.rawValue).joined(separator: ",") + } + + /// Hit-tests the window to find a WKWebView (browser panel) under the cursor. + func webViewUnderPoint(_ windowPoint: NSPoint) -> WKWebView? { + if let window, + let portalWebView = BrowserWindowPortalRegistry.webViewAtWindowPoint(windowPoint, in: window) { + return portalWebView + } + + guard let window, let contentView = window.contentView else { return nil } + isHidden = true + defer { isHidden = false } + let point = contentView.convert(windowPoint, from: nil) + let hitView = contentView.hitTest(point) + + var current: NSView? = hitView + while let view = current { + if let webView = view as? WKWebView { return webView } + current = view.superview + } + return nil + } + + private func debugTopHitViewForCurrentEvent() -> String { + guard let window, + let currentEvent = NSApp.currentEvent, + let contentView = window.contentView, + let themeFrame = contentView.superview else { return "-" } + + let pointInTheme = themeFrame.convert(currentEvent.locationInWindow, from: nil) + // Don't toggle isHidden here — it triggers setNeedsDisplay which can + // exceed AppKit's display-pass limit during cursor-update display cycles. + guard let hit = themeFrame.hitTest(pointInTheme) else { return "nil" } + var chain: [String] = [] + var current: NSView? = hit + var depth = 0 + while let view = current, depth < 6 { + chain.append(debugHitViewDescriptor(view)) + current = view.superview + depth += 1 + } + return chain.joined(separator: "->") + } + + private func debugHitViewDescriptor(_ view: NSView) -> String { + let className = String(describing: type(of: view)) + let ptr = String(describing: Unmanaged.passUnretained(view).toOpaque()) + let dragTypes = debugRegisteredDragTypes(view) + return "\(className)@\(ptr){dragTypes=\(dragTypes)}" + } + + private func debugRegisteredDragTypes(_ view: NSView) -> String { + let types = view.registeredDraggedTypes + guard !types.isEmpty else { return "-" } + + let interestingTypes = types.filter { type in + let raw = type.rawValue + return raw == NSPasteboard.PasteboardType.fileURL.rawValue + || raw == DragOverlayRoutingPolicy.bonsplitTabTransferType.rawValue + || raw == DragOverlayRoutingPolicy.sidebarTabReorderType.rawValue + || raw.contains("public.text") + || raw.contains("public.url") + || raw.contains("public.data") + } + let selected = interestingTypes.isEmpty ? Array(types.prefix(3)) : interestingTypes + let rendered = selected.map(\.rawValue).joined(separator: ",") + if selected.count < types.count { + return "\(rendered),+\(types.count - selected.count)" + } + return rendered + } + + private func hasRelevantDragTypes(_ types: [NSPasteboard.PasteboardType]?) -> Bool { + guard let types else { return false } + return types.contains(.fileURL) + || types.contains(DragOverlayRoutingPolicy.bonsplitTabTransferType) + || types.contains(DragOverlayRoutingPolicy.sidebarTabReorderType) + } + + private func debugEventName(_ eventType: NSEvent.EventType?) -> String { + guard let eventType else { return "none" } + switch eventType { + case .cursorUpdate: return "cursorUpdate" + case .appKitDefined: return "appKitDefined" + case .systemDefined: return "systemDefined" + case .applicationDefined: return "applicationDefined" + case .periodic: return "periodic" + case .mouseMoved: return "mouseMoved" + case .mouseEntered: return "mouseEntered" + case .mouseExited: return "mouseExited" + case .flagsChanged: return "flagsChanged" + case .leftMouseDown: return "leftMouseDown" + case .leftMouseUp: return "leftMouseUp" + case .leftMouseDragged: return "leftMouseDragged" + case .rightMouseDown: return "rightMouseDown" + case .rightMouseUp: return "rightMouseUp" + case .rightMouseDragged: return "rightMouseDragged" + case .otherMouseDown: return "otherMouseDown" + case .otherMouseUp: return "otherMouseUp" + case .otherMouseDragged: return "otherMouseDragged" + case .scrollWheel: return "scrollWheel" + default: return "other(\(eventType.rawValue))" + } + } + +#if DEBUG + private func logHitTestDecision( + pasteboardTypes: [NSPasteboard.PasteboardType]?, + eventType: NSEvent.EventType?, + shouldCapture: Bool + ) { + let isDragEvent = eventType == .leftMouseDragged + || eventType == .rightMouseDragged + || eventType == .otherMouseDragged + guard shouldCapture || isDragEvent || hasRelevantDragTypes(pasteboardTypes) else { return } + + let signature = "\(shouldCapture ? 1 : 0)|\(debugEventName(eventType))|\(debugPasteboardTypes(pasteboardTypes))" + guard lastHitTestLogSignature != signature else { return } + lastHitTestLogSignature = signature + dlog( + "overlay.fileDrop.hitTest capture=\(shouldCapture ? 1 : 0) " + + "event=\(debugEventName(eventType)) " + + "topHit=\(debugTopHitViewForCurrentEvent()) " + + "types=\(debugPasteboardTypes(pasteboardTypes))" + ) + } + + private func logDragRouteDecision( + phase: String, + pasteboardTypes: [NSPasteboard.PasteboardType]?, + shouldCapture: Bool, + hasLocalDraggingSource: Bool, + hasTerminalTarget: Bool + ) { + guard shouldCapture || hasRelevantDragTypes(pasteboardTypes) else { return } + let signature = [ + shouldCapture ? "1" : "0", + hasLocalDraggingSource ? "1" : "0", + hasTerminalTarget ? "1" : "0", + debugPasteboardTypes(pasteboardTypes) + ].joined(separator: "|") + guard lastDragRouteLogSignatureByPhase[phase] != signature else { return } + lastDragRouteLogSignatureByPhase[phase] = signature + dlog( + "overlay.fileDrop.\(phase) capture=\(shouldCapture ? 1 : 0) " + + "localSource=\(hasLocalDraggingSource ? 1 : 0) " + + "hasTerminal=\(hasTerminalTarget ? 1 : 0) " + + "types=\(debugPasteboardTypes(pasteboardTypes))" + ) + } +#endif + /// Hit-tests the window to find the GhosttyNSView under the cursor. + func terminalUnderPoint(_ windowPoint: NSPoint) -> GhosttyNSView? { + if let window, + let portalTerminal = TerminalWindowPortalRegistry.terminalViewAtWindowPoint(windowPoint, in: window) { + return portalTerminal + } + + guard let window, let contentView = window.contentView else { return nil } + isHidden = true + defer { isHidden = false } + let point = contentView.convert(windowPoint, from: nil) + let hitView = contentView.hitTest(point) + + var current: NSView? = hitView + while let view = current { + if let terminal = view as? GhosttyNSView { return terminal } + current = view.superview + } + return nil + } +} From 3938784096f0aeca42fdac74b233bf3879939597 Mon Sep 17 00:00:00 2001 From: arzafran Date: Thu, 9 Jul 2026 13:53:12 -0300 Subject: [PATCH 04/19] refactor(contentview): extract CommandPaletteSearchEngine.swift (mechanical move) Move the command-palette fuzzy-search engine (switcher search metadata/indexer, fuzzy matcher, search corpus entry/result types, search engine) out of ContentView.swift into its own file, verbatim. Zero coupling to ContentView state. Refs #94. --- GhosttyTabs.xcodeproj/project.pbxproj | 4 + Sources/CommandPaletteSearchEngine.swift | 993 +++++++++++++++++++++++ Sources/ContentView.swift | 987 ---------------------- 3 files changed, 997 insertions(+), 987 deletions(-) create mode 100644 Sources/CommandPaletteSearchEngine.swift diff --git a/GhosttyTabs.xcodeproj/project.pbxproj b/GhosttyTabs.xcodeproj/project.pbxproj index 1df369db106..009e2b37251 100644 --- a/GhosttyTabs.xcodeproj/project.pbxproj +++ b/GhosttyTabs.xcodeproj/project.pbxproj @@ -13,6 +13,7 @@ A5FF0005 /* ContentView+CommandPalette.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5FF0015 /* ContentView+CommandPalette.swift */; }; NRCV00000000000000000005 /* TabItemView.swift in Sources */ = {isa = PBXBuildFile; fileRef = NRCV00000000000000000006 /* TabItemView.swift */; }; NRCV00000000000000000003 /* FileDropOverlayView.swift in Sources */ = {isa = PBXBuildFile; fileRef = NRCV00000000000000000004 /* FileDropOverlayView.swift */; }; + NRCV00000000000000000001 /* CommandPaletteSearchEngine.swift in Sources */ = {isa = PBXBuildFile; fileRef = NRCV00000000000000000002 /* CommandPaletteSearchEngine.swift */; }; E62155868BB29FEB5DAAAF25 /* SidebarSelectionState.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9AD52285508B1D6A9875E7B3 /* SidebarSelectionState.swift */; }; B9000018A1B2C3D4E5F60719 /* WindowDragHandleView.swift in Sources */ = {isa = PBXBuildFile; fileRef = B9000017A1B2C3D4E5F60719 /* WindowDragHandleView.swift */; }; A5001003 /* TabManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5001013 /* TabManager.swift */; }; @@ -239,6 +240,7 @@ A5FF0015 /* ContentView+CommandPalette.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "ContentView+CommandPalette.swift"; sourceTree = ""; }; NRCV00000000000000000006 /* TabItemView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TabItemView.swift; sourceTree = ""; }; NRCV00000000000000000004 /* FileDropOverlayView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FileDropOverlayView.swift; sourceTree = ""; }; + NRCV00000000000000000002 /* CommandPaletteSearchEngine.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CommandPaletteSearchEngine.swift; sourceTree = ""; }; 9AD52285508B1D6A9875E7B3 /* SidebarSelectionState.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SidebarSelectionState.swift; sourceTree = ""; }; B9000017A1B2C3D4E5F60719 /* WindowDragHandleView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WindowDragHandleView.swift; sourceTree = ""; }; A5001013 /* TabManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TabManager.swift; sourceTree = ""; }; @@ -510,6 +512,7 @@ A5FF0015 /* ContentView+CommandPalette.swift */, NRCV00000000000000000006 /* TabItemView.swift */, NRCV00000000000000000004 /* FileDropOverlayView.swift */, + NRCV00000000000000000002 /* CommandPaletteSearchEngine.swift */, 9AD52285508B1D6A9875E7B3 /* SidebarSelectionState.swift */, B9000017A1B2C3D4E5F60719 /* WindowDragHandleView.swift */, A50012F0 /* Backport.swift */, @@ -842,6 +845,7 @@ A5FF0005 /* ContentView+CommandPalette.swift in Sources */, NRCV00000000000000000005 /* TabItemView.swift in Sources */, NRCV00000000000000000003 /* FileDropOverlayView.swift in Sources */, + NRCV00000000000000000001 /* CommandPaletteSearchEngine.swift in Sources */, E62155868BB29FEB5DAAAF25 /* SidebarSelectionState.swift in Sources */, B9000018A1B2C3D4E5F60719 /* WindowDragHandleView.swift in Sources */, A50012F1 /* Backport.swift in Sources */, diff --git a/Sources/CommandPaletteSearchEngine.swift b/Sources/CommandPaletteSearchEngine.swift new file mode 100644 index 00000000000..de3444a9fab --- /dev/null +++ b/Sources/CommandPaletteSearchEngine.swift @@ -0,0 +1,993 @@ +// Command palette fuzzy-search engine, extracted from ContentView.swift (nuclear-review #94.1). +// Pure move: search metadata, indexer, fuzzy matcher, corpus types, and the search engine. +// Zero coupling to ContentView state — behavior-identical relocation. + +import Foundation + +struct CommandPaletteSwitcherSearchMetadata: Equatable, Sendable { + let directories: [String] + let branches: [String] + let ports: [Int] + let description: String? + + init( + directories: [String] = [], + branches: [String] = [], + ports: [Int] = [], + description: String? = nil + ) { + self.directories = directories + self.branches = branches + self.ports = ports + self.description = description + } +} + +enum CommandPaletteSwitcherSearchIndexer { + enum MetadataDetail { + case workspace + case surface + } + + private static let metadataDelimiters = CharacterSet(charactersIn: "/\\.:_- ") + + static func keywords( + baseKeywords: [String], + metadata: CommandPaletteSwitcherSearchMetadata, + detail: MetadataDetail = .surface + ) -> [String] { + let metadataKeywords = metadataKeywordsForSearch(metadata, detail: detail) + return uniqueNormalizedPreservingOrder(baseKeywords + metadataKeywords) + } + + private static func metadataKeywordsForSearch( + _ metadata: CommandPaletteSwitcherSearchMetadata, + detail: MetadataDetail + ) -> [String] { + let directoryTokens = metadata.directories.flatMap { directoryTokensForSearch($0, detail: detail) } + let branchTokens = metadata.branches.flatMap { branchTokensForSearch($0, detail: detail) } + let portTokens = metadata.ports.flatMap(portTokensForSearch) + let descriptionTokens = descriptionTokensForSearch(metadata.description) + + var contextKeywords: [String] = [] + if !directoryTokens.isEmpty { + contextKeywords.append(contentsOf: ["directory", "dir", "cwd", "path"]) + } + if !branchTokens.isEmpty { + contextKeywords.append(contentsOf: ["branch", "git"]) + } + if !portTokens.isEmpty { + contextKeywords.append(contentsOf: ["port", "ports"]) + } + if !descriptionTokens.isEmpty { + contextKeywords.append(contentsOf: ["description", "descriptions", "notes", "note"]) + } + + return contextKeywords + directoryTokens + branchTokens + portTokens + descriptionTokens + } + + private static func directoryTokensForSearch( + _ rawDirectory: String, + detail: MetadataDetail + ) -> [String] { + let trimmed = rawDirectory.trimmingCharacters(in: .whitespacesAndNewlines) + guard !trimmed.isEmpty else { return [] } + + let standardized = (trimmed as NSString).standardizingPath + let canonical = standardized.isEmpty ? trimmed : standardized + let abbreviated = (canonical as NSString).abbreviatingWithTildeInPath + switch detail { + case .workspace: + return uniqueNormalizedPreservingOrder([trimmed, canonical, abbreviated]) + case .surface: + let basename = URL(fileURLWithPath: canonical, isDirectory: true).lastPathComponent + let components = canonical.components(separatedBy: metadataDelimiters).filter { !$0.isEmpty } + return uniqueNormalizedPreservingOrder( + [trimmed, canonical, abbreviated, basename] + components + ) + } + } + + private static func branchTokensForSearch( + _ rawBranch: String, + detail: MetadataDetail + ) -> [String] { + let trimmed = rawBranch.trimmingCharacters(in: .whitespacesAndNewlines) + guard !trimmed.isEmpty else { return [] } + switch detail { + case .workspace: + return [trimmed] + case .surface: + let components = trimmed.components(separatedBy: metadataDelimiters).filter { !$0.isEmpty } + return uniqueNormalizedPreservingOrder([trimmed] + components) + } + } + + private static func portTokensForSearch(_ port: Int) -> [String] { + guard (1...65535).contains(port) else { return [] } + let portText = String(port) + return [portText, ":\(portText)"] + } + + private static func descriptionTokensForSearch(_ rawDescription: String?) -> [String] { + let trimmed = rawDescription?.trimmingCharacters(in: .whitespacesAndNewlines) ?? "" + guard !trimmed.isEmpty else { return [] } + let normalizedWhitespace = trimmed.replacingOccurrences( + of: "\\s+", + with: " ", + options: .regularExpression + ) + let components = normalizedWhitespace.components(separatedBy: metadataDelimiters).filter { !$0.isEmpty } + return uniqueNormalizedPreservingOrder([trimmed, normalizedWhitespace] + components) + } + + private static func uniqueNormalizedPreservingOrder(_ values: [String]) -> [String] { + var result: [String] = [] + var seen: Set = [] + result.reserveCapacity(values.count) + + for value in values { + let trimmed = value.trimmingCharacters(in: .whitespacesAndNewlines) + guard !trimmed.isEmpty else { continue } + let normalizedKey = trimmed + .folding(options: [.diacriticInsensitive, .caseInsensitive], locale: .current) + .lowercased() + guard seen.insert(normalizedKey).inserted else { continue } + result.append(trimmed) + } + return result + } +} + +enum CommandPaletteFuzzyMatcher { + private static let tokenBoundaryChars: Set = [" ", "-", "_", "/", ".", ":"] + + private enum SingleEditWordPrefixEditKind { + case candidateExtraCharacter + case tokenExtraCharacter + case substitutedCharacter + case transposedCharacters + + var basePenalty: Int { + switch self { + case .candidateExtraCharacter: + return 0 + case .tokenExtraCharacter: + return 10 + case .transposedCharacters: + return 24 + case .substitutedCharacter: + return 40 + } + } + } + + private struct SingleEditWordPrefixMatch { + let matchedIndices: Set + let segmentStart: Int + let segmentLength: Int + let prefixLength: Int + let editPosition: Int + let editKind: SingleEditWordPrefixEditKind + } + + struct PreparedQuery { + let normalizedText: String + let tokens: [String] + + var isEmpty: Bool { + tokens.isEmpty + } + } + + static func preparedQuery(_ query: String) -> PreparedQuery { + let normalizedQuery = normalizeForSearch(query) + return PreparedQuery( + normalizedText: normalizedQuery, + tokens: normalizedQuery.split(separator: " ").map(String.init).filter { !$0.isEmpty } + ) + } + + static func normalizeForSearch(_ text: String) -> String { + text + .trimmingCharacters(in: .whitespacesAndNewlines) + .folding(options: [.diacriticInsensitive, .caseInsensitive], locale: .current) + .lowercased() + } + + static func score(query: String, candidate: String) -> Int? { + score(query: query, candidates: [candidate]) + } + + static func score(query: String, candidates: [String]) -> Int? { + score( + preparedQuery: preparedQuery(query), + normalizedCandidates: candidates + .map(normalizeForSearch) + .filter { !$0.isEmpty } + ) + } + + static func score(preparedQuery: PreparedQuery, normalizedCandidates: [String]) -> Int? { + guard !preparedQuery.isEmpty else { return 0 } + guard !normalizedCandidates.isEmpty else { return nil } + + var totalScore = 0 + for token in preparedQuery.tokens { + var bestTokenScore: Int? + for candidate in normalizedCandidates { + guard let candidateScore = scoreToken(token, in: candidate) else { continue } + bestTokenScore = max(bestTokenScore ?? candidateScore, candidateScore) + } + guard let bestTokenScore else { return nil } + totalScore += bestTokenScore + } + return totalScore + } + + static func matchCharacterIndices(query: String, candidate: String) -> Set { + matchCharacterIndices(preparedQuery: preparedQuery(query), candidate: candidate) + } + + static func matchCharacterIndices(preparedQuery: PreparedQuery, candidate: String) -> Set { + guard !preparedQuery.isEmpty else { return [] } + + let loweredCandidate = normalizeForSearch(candidate) + guard !loweredCandidate.isEmpty else { return [] } + + let candidateChars = Array(loweredCandidate) + var matched: Set = [] + + for token in preparedQuery.tokens { + if token == loweredCandidate { + matched.formUnion(0.. Int? { + guard !token.isEmpty else { return 0 } + + let candidateChars = Array(candidate) + let tokenChars = Array(token) + guard tokenChars.count <= candidateChars.count else { return nil } + + if token == candidate { + return 8000 + } + if candidate.hasPrefix(token) { + return 6800 - max(0, candidate.count - token.count) + } + + var bestScore: Int? + if let wordExactScore = bestWordScore(tokenChars: tokenChars, candidateChars: candidateChars, requireExactWord: true) { + bestScore = max(bestScore ?? wordExactScore, wordExactScore) + } + if let wordPrefixScore = bestWordScore(tokenChars: tokenChars, candidateChars: candidateChars, requireExactWord: false) { + bestScore = max(bestScore ?? wordPrefixScore, wordPrefixScore) + } + if let singleEditPrefixScore = singleEditWordPrefixScore( + tokenChars: tokenChars, + candidateChars: candidateChars + ) { + bestScore = max(bestScore ?? singleEditPrefixScore, singleEditPrefixScore) + } + + if let range = candidate.range(of: token) { + let distance = candidate.distance(from: candidate.startIndex, to: range.lowerBound) + let lengthPenalty = max(0, candidate.count - token.count) + let boundaryBoost: Int = { + guard distance > 0 else { return 220 } + let prior = candidateChars[distance - 1] + return tokenBoundaryChars.contains(prior) ? 180 : 0 + }() + let containsScore = 4200 + boundaryBoost - (distance * 9) - lengthPenalty + bestScore = max(bestScore ?? containsScore, containsScore) + } + + if let initialismScore = initialismScore(tokenChars: tokenChars, candidateChars: candidateChars) { + bestScore = max(bestScore ?? initialismScore, initialismScore) + } + + if let stitchedScore = stitchedWordPrefixScore(tokenChars: tokenChars, candidateChars: candidateChars) { + bestScore = max(bestScore ?? stitchedScore, stitchedScore) + } + + if tokenChars.count <= 3, let subsequence = subsequenceScore(token: token, candidate: candidate) { + bestScore = max(bestScore ?? subsequence, subsequence) + } + + guard let bestScore else { return nil } + return max(1, bestScore) + } + + private static func bestWordScore( + tokenChars: [Character], + candidateChars: [Character], + requireExactWord: Bool + ) -> Int? { + guard !tokenChars.isEmpty else { return nil } + + var best: Int? + for segment in wordSegments(candidateChars) { + let wordLength = segment.end - segment.start + guard tokenChars.count <= wordLength else { continue } + + var matchesPrefix = true + for offset in 0.. Int? { + guard let match = singleEditWordPrefixMatch( + tokenChars: tokenChars, + candidateChars: candidateChars + ) else { + return nil + } + return singleEditWordPrefixScore(match: match, candidateLength: candidateChars.count) + } + + private static func singleEditWordPrefixScore( + match: SingleEditWordPrefixMatch, + candidateLength: Int + ) -> Int { + let lengthPenalty = max(0, match.segmentLength - match.prefixLength) * 6 + let distancePenalty = match.segmentStart * 8 + let trailingPenalty = max(0, candidateLength - match.segmentLength) + let editPositionPenalty = max(0, match.editPosition - match.segmentStart) * 10 + return 5000 + - match.editKind.basePenalty + - distancePenalty + - lengthPenalty + - trailingPenalty + - editPositionPenalty + } + + private static func initialismScore(tokenChars: [Character], candidateChars: [Character]) -> Int? { + guard !tokenChars.isEmpty else { return nil } + let segments = wordSegments(candidateChars) + guard tokenChars.count <= segments.count else { return nil } + + var matchedStarts: [Int] = [] + var searchWordIndex = 0 + + for tokenChar in tokenChars { + var found = false + while searchWordIndex < segments.count { + let segment = segments[searchWordIndex] + searchWordIndex += 1 + if candidateChars[segment.start] == tokenChar { + matchedStarts.append(segment.start) + found = true + break + } + } + if !found { return nil } + } + + let firstStart = matchedStarts.first ?? 0 + let skippedWords = max(0, segments.count - tokenChars.count) + return 3000 + (tokenChars.count * 160) - (firstStart * 5) - (skippedWords * 30) + } + + private static func tokenPrefixMatches( + tokenChars: [Character], + tokenStart: Int, + length: Int, + candidateChars: [Character], + candidateStart: Int + ) -> Bool { + guard length >= 0 else { return false } + guard tokenStart + length <= tokenChars.count else { return false } + guard candidateStart + length <= candidateChars.count else { return false } + guard length > 0 else { return true } + + for offset in 0.. Int? { + guard tokenChars.count >= 4 else { return nil } + let segments = wordSegments(candidateChars) + guard segments.count >= 2 else { return nil } + + struct StitchState: Hashable { + let tokenIndex: Int + let wordIndex: Int + let usedWords: Int + } + + var memo: [StitchState: Int?] = [:] + + func dfs(tokenIndex: Int, wordIndex: Int, usedWords: Int) -> Int? { + if tokenIndex == tokenChars.count { + return usedWords >= 2 ? 0 : nil + } + guard wordIndex < segments.count else { return nil } + + let state = StitchState(tokenIndex: tokenIndex, wordIndex: wordIndex, usedWords: usedWords) + if let cached = memo[state] { + return cached + } + + var best: Int? + let remainingChars = tokenChars.count - tokenIndex + for segmentIndex in wordIndex.. 0 else { continue } + + let skippedWords = max(0, segmentIndex - wordIndex) + let skipPenalty = skippedWords * 120 + for chunkLength in stride(from: maxChunk, through: 1, by: -1) { + guard tokenPrefixMatches( + tokenChars: tokenChars, + tokenStart: tokenIndex, + length: chunkLength, + candidateChars: candidateChars, + candidateStart: segment.start + ) else { + continue + } + guard let suffixScore = dfs( + tokenIndex: tokenIndex + chunkLength, + wordIndex: segmentIndex + 1, + usedWords: min(2, usedWords + 1) + ) else { + continue + } + + let chunkCoverage = chunkLength * 220 + let contiguityBonus = segmentIndex == wordIndex ? 80 : 0 + let segmentRemainderPenalty = max(0, segmentLength - chunkLength) * 9 + let distancePenalty = segment.start * 4 + let chunkScore = chunkCoverage + contiguityBonus - segmentRemainderPenalty - distancePenalty - skipPenalty + let totalScore = suffixScore + chunkScore + best = max(best ?? totalScore, totalScore) + } + } + + memo[state] = best + return best + } + + guard let stitchedScore = dfs(tokenIndex: 0, wordIndex: 0, usedWords: 0) else { return nil } + let lengthPenalty = max(0, candidateChars.count - tokenChars.count) + return 3500 + stitchedScore - lengthPenalty + } + + private static func stitchedWordPrefixMatchIndices(token: String, candidate: String) -> Set? { + let tokenChars = Array(token) + let candidateChars = Array(candidate) + guard tokenChars.count >= 4 else { return nil } + + let segments = wordSegments(candidateChars) + guard segments.count >= 2 else { return nil } + + var tokenIndex = 0 + var nextWordIndex = 0 + var usedWords = 0 + var matchedIndices: Set = [] + + while tokenIndex < tokenChars.count { + let remainingChars = tokenChars.count - tokenIndex + var foundMatch = false + + for segmentIndex in nextWordIndex.. 0 else { continue } + + for chunkLength in stride(from: maxChunk, through: 1, by: -1) { + guard tokenPrefixMatches( + tokenChars: tokenChars, + tokenStart: tokenIndex, + length: chunkLength, + candidateChars: candidateChars, + candidateStart: segment.start + ) else { + continue + } + + matchedIndices.formUnion(segment.start..<(segment.start + chunkLength)) + tokenIndex += chunkLength + nextWordIndex = segmentIndex + 1 + usedWords += 1 + foundMatch = true + break + } + + if foundMatch { break } + } + + if !foundMatch { return nil } + } + + guard usedWords >= 2 else { return nil } + return matchedIndices + } + + private static func singleEditWordPrefixMatch( + token: String, + candidate: String + ) -> SingleEditWordPrefixMatch? { + singleEditWordPrefixMatch( + tokenChars: Array(token), + candidateChars: Array(candidate) + ) + } + + private static func singleEditWordPrefixMatch( + tokenChars: [Character], + candidateChars: [Character] + ) -> SingleEditWordPrefixMatch? { + guard tokenChars.count >= 4 else { return nil } + + var bestMatch: SingleEditWordPrefixMatch? + var bestScore: Int? + + for segment in wordSegments(candidateChars) { + guard let match = singleEditWordPrefixMatch( + tokenChars: tokenChars, + candidateChars: candidateChars, + segment: segment + ) else { + continue + } + + let score = singleEditWordPrefixScore(match: match, candidateLength: candidateChars.count) + if let bestScore, score <= bestScore { + continue + } + bestScore = score + bestMatch = match + } + + return bestMatch + } + + private static func singleEditWordPrefixMatch( + tokenChars: [Character], + candidateChars: [Character], + segment: (start: Int, end: Int) + ) -> SingleEditWordPrefixMatch? { + guard tokenChars.count >= 4 else { return nil } + + let segmentLength = segment.end - segment.start + guard segmentLength + 1 >= tokenChars.count else { return nil } + + let exactPrefixLength = min(tokenChars.count, segmentLength) + var mismatchOffset = 0 + while mismatchOffset < exactPrefixLength, + candidateChars[segment.start + mismatchOffset] == tokenChars[mismatchOffset] + { + mismatchOffset += 1 + } + + if mismatchOffset == tokenChars.count { + let prefixLength = tokenChars.count + 1 + guard segmentLength >= prefixLength else { return nil } + return SingleEditWordPrefixMatch( + matchedIndices: Set(segment.start..<(segment.start + tokenChars.count)), + segmentStart: segment.start, + segmentLength: segmentLength, + prefixLength: prefixLength, + editPosition: segment.start + tokenChars.count, + editKind: .candidateExtraCharacter + ) + } + + if mismatchOffset == segmentLength { + let prefixLength = tokenChars.count - 1 + guard prefixLength > 0 else { return nil } + guard tokenChars.count == segmentLength + 1 else { return nil } + return SingleEditWordPrefixMatch( + matchedIndices: Set(segment.start..<(segment.start + prefixLength)), + segmentStart: segment.start, + segmentLength: segmentLength, + prefixLength: prefixLength, + editPosition: segment.start + prefixLength, + editKind: .tokenExtraCharacter + ) + } + + let mismatchCandidateIndex = segment.start + mismatchOffset + + if segmentLength >= tokenChars.count + 1, + tokenPrefixMatches( + tokenChars: tokenChars, + tokenStart: mismatchOffset, + length: tokenChars.count - mismatchOffset, + candidateChars: candidateChars, + candidateStart: mismatchCandidateIndex + 1 + ) + { + var matchedIndices = Set(segment.start..<(segment.start + tokenChars.count + 1)) + matchedIndices.remove(mismatchCandidateIndex) + return SingleEditWordPrefixMatch( + matchedIndices: matchedIndices, + segmentStart: segment.start, + segmentLength: segmentLength, + prefixLength: tokenChars.count + 1, + editPosition: mismatchCandidateIndex, + editKind: .candidateExtraCharacter + ) + } + + if tokenChars.count >= 2, + segmentLength >= tokenChars.count - 1, + tokenPrefixMatches( + tokenChars: tokenChars, + tokenStart: mismatchOffset + 1, + length: tokenChars.count - mismatchOffset - 1, + candidateChars: candidateChars, + candidateStart: mismatchCandidateIndex + ) + { + return SingleEditWordPrefixMatch( + matchedIndices: Set(segment.start..<(segment.start + tokenChars.count - 1)), + segmentStart: segment.start, + segmentLength: segmentLength, + prefixLength: tokenChars.count - 1, + editPosition: mismatchCandidateIndex, + editKind: .tokenExtraCharacter + ) + } + + if segmentLength >= tokenChars.count, + tokenPrefixMatches( + tokenChars: tokenChars, + tokenStart: mismatchOffset + 1, + length: tokenChars.count - mismatchOffset - 1, + candidateChars: candidateChars, + candidateStart: mismatchCandidateIndex + 1 + ) + { + var matchedIndices = Set(segment.start..<(segment.start + tokenChars.count)) + matchedIndices.remove(mismatchCandidateIndex) + return SingleEditWordPrefixMatch( + matchedIndices: matchedIndices, + segmentStart: segment.start, + segmentLength: segmentLength, + prefixLength: tokenChars.count, + editPosition: mismatchCandidateIndex, + editKind: .substitutedCharacter + ) + } + + if segmentLength >= tokenChars.count, + mismatchOffset + 1 < tokenChars.count, + mismatchCandidateIndex + 1 < segment.end, + tokenChars[mismatchOffset] == candidateChars[mismatchCandidateIndex + 1], + tokenChars[mismatchOffset + 1] == candidateChars[mismatchCandidateIndex], + tokenPrefixMatches( + tokenChars: tokenChars, + tokenStart: mismatchOffset + 2, + length: tokenChars.count - mismatchOffset - 2, + candidateChars: candidateChars, + candidateStart: mismatchCandidateIndex + 2 + ) + { + return SingleEditWordPrefixMatch( + matchedIndices: Set(segment.start..<(segment.start + tokenChars.count)), + segmentStart: segment.start, + segmentLength: segmentLength, + prefixLength: tokenChars.count, + editPosition: mismatchCandidateIndex, + editKind: .transposedCharacters + ) + } + + return nil + } + + private static func wordSegments(_ candidateChars: [Character]) -> [(start: Int, end: Int)] { + var segments: [(start: Int, end: Int)] = [] + var index = 0 + + while index < candidateChars.count { + while index < candidateChars.count, tokenBoundaryChars.contains(candidateChars[index]) { + index += 1 + } + guard index < candidateChars.count else { break } + let start = index + while index < candidateChars.count, !tokenBoundaryChars.contains(candidateChars[index]) { + index += 1 + } + segments.append((start: start, end: index)) + } + + return segments + } + + private static func subsequenceScore(token: String, candidate: String) -> Int? { + let tokenChars = Array(token) + let candidateChars = Array(candidate) + guard tokenChars.count <= candidateChars.count else { return nil } + + var searchIndex = 0 + var previousMatch = -1 + var consecutiveRun = 0 + var score = 0 + + for tokenChar in tokenChars { + var foundIndex: Int? + while searchIndex < candidateChars.count { + if candidateChars[searchIndex] == tokenChar { + foundIndex = searchIndex + break + } + searchIndex += 1 + } + guard let matchedIndex = foundIndex else { return nil } + + score += 90 + if matchedIndex == 0 || tokenBoundaryChars.contains(candidateChars[matchedIndex - 1]) { + score += 140 + } + if matchedIndex == previousMatch + 1 { + consecutiveRun += 1 + score += min(200, consecutiveRun * 45) + } else { + consecutiveRun = 0 + score -= min(120, max(0, matchedIndex - previousMatch - 1) * 4) + } + + previousMatch = matchedIndex + searchIndex = matchedIndex + 1 + } + + score -= max(0, candidateChars.count - tokenChars.count) + return max(1, score) + } + + private static func subsequenceMatchIndices(token: String, candidate: String) -> Set? { + let tokenChars = Array(token) + let candidateChars = Array(candidate) + guard tokenChars.count <= candidateChars.count else { return nil } + + var indices: Set = [] + var searchIndex = 0 + + for tokenChar in tokenChars { + var foundIndex: Int? + while searchIndex < candidateChars.count { + if candidateChars[searchIndex] == tokenChar { + foundIndex = searchIndex + break + } + searchIndex += 1 + } + guard let matchIndex = foundIndex else { return nil } + indices.insert(matchIndex) + searchIndex = matchIndex + 1 + } + + return indices + } + + private static func initialismMatchIndices(token: String, candidate: String) -> Set? { + let tokenChars = Array(token) + let candidateChars = Array(candidate) + guard !tokenChars.isEmpty else { return nil } + + let segments = wordSegments(candidateChars) + guard tokenChars.count <= segments.count else { return nil } + + var matched: Set = [] + var searchWordIndex = 0 + + for tokenChar in tokenChars { + var found = false + while searchWordIndex < segments.count { + let segment = segments[searchWordIndex] + searchWordIndex += 1 + if candidateChars[segment.start] == tokenChar { + matched.insert(segment.start) + found = true + break + } + } + if !found { return nil } + } + + return matched + } +} + +struct CommandPaletteSearchCorpusEntry: Sendable where Payload: Sendable { + let payload: Payload + let rank: Int + let title: String + let normalizedTitle: String + let normalizedSearchableTexts: [String] + + init(payload: Payload, rank: Int, title: String, searchableTexts: [String]) { + self.payload = payload + self.rank = rank + self.title = title + self.normalizedTitle = CommandPaletteFuzzyMatcher.normalizeForSearch(title) + self.normalizedSearchableTexts = searchableTexts + .map(CommandPaletteFuzzyMatcher.normalizeForSearch) + .filter { !$0.isEmpty } + } +} + +struct CommandPaletteSearchCorpusResult: Sendable where Payload: Sendable { + let payload: Payload + let rank: Int + let title: String + let score: Int + let titleMatchIndices: Set +} + +enum CommandPaletteSearchEngine { + private static let titleMatchBonus = 2000 + + static func search( + entries: [CommandPaletteSearchCorpusEntry], + query: String, + historyBoost: (Payload, Bool) -> Int + ) -> [CommandPaletteSearchCorpusResult] { + search( + entries: entries, + query: query, + historyBoost: historyBoost, + shouldCancel: nil + ) + } + + static func search( + entries: [CommandPaletteSearchCorpusEntry], + query: String, + historyBoost: (Payload, Bool) -> Int, + shouldCancel: @escaping () -> Bool + ) -> [CommandPaletteSearchCorpusResult] { + search( + entries: entries, + query: query, + historyBoost: historyBoost, + shouldCancel: Optional(shouldCancel) + ) + } + + private static func search( + entries: [CommandPaletteSearchCorpusEntry], + query: String, + historyBoost: (Payload, Bool) -> Int, + shouldCancel: (() -> Bool)? + ) -> [CommandPaletteSearchCorpusResult] { + let preparedQuery = CommandPaletteFuzzyMatcher.preparedQuery(query) + let queryIsEmpty = preparedQuery.isEmpty + var results: [CommandPaletteSearchCorpusResult] = [] + results.reserveCapacity(entries.count) + + func shouldCancelSearch(at index: Int) -> Bool { + guard let shouldCancel else { return false } + return index % 16 == 0 && shouldCancel() + } + + if queryIsEmpty { + for (index, entry) in entries.enumerated() { + if shouldCancelSearch(at: index) { return [] } + results.append( + CommandPaletteSearchCorpusResult( + payload: entry.payload, + rank: entry.rank, + title: entry.title, + score: historyBoost(entry.payload, true), + titleMatchIndices: [] + ) + ) + } + } else { + for (index, entry) in entries.enumerated() { + if shouldCancelSearch(at: index) { return [] } + guard let fuzzyScore = weightedScore( + preparedQuery: preparedQuery, + entry: entry + ) else { + continue + } + results.append( + CommandPaletteSearchCorpusResult( + payload: entry.payload, + rank: entry.rank, + title: entry.title, + score: fuzzyScore + historyBoost(entry.payload, false), + titleMatchIndices: CommandPaletteFuzzyMatcher.matchCharacterIndices( + preparedQuery: preparedQuery, + candidate: entry.title + ) + ) + ) + } + } + + if shouldCancel?() == true { return [] } + + return results.sorted { lhs, rhs in + if lhs.score != rhs.score { return lhs.score > rhs.score } + if lhs.rank != rhs.rank { return lhs.rank < rhs.rank } + return lhs.title.localizedCaseInsensitiveCompare(rhs.title) == .orderedAscending + } + } + + private static func weightedScore( + preparedQuery: CommandPaletteFuzzyMatcher.PreparedQuery, + entry: CommandPaletteSearchCorpusEntry + ) -> Int? { + guard let fuzzyScore = CommandPaletteFuzzyMatcher.score( + preparedQuery: preparedQuery, + normalizedCandidates: entry.normalizedSearchableTexts + ) else { + return nil + } + guard !entry.normalizedTitle.isEmpty, + let titleScore = CommandPaletteFuzzyMatcher.score( + preparedQuery: preparedQuery, + normalizedCandidates: [entry.normalizedTitle] + ) else { + return fuzzyScore + } + return max(fuzzyScore, titleScore + titleMatchBonus) + } +} + diff --git a/Sources/ContentView.swift b/Sources/ContentView.swift index 5500ee6ba19..656147c7d8c 100644 --- a/Sources/ContentView.swift +++ b/Sources/ContentView.swift @@ -7249,993 +7249,6 @@ struct ContentView: View { #endif } -struct CommandPaletteSwitcherSearchMetadata: Equatable, Sendable { - let directories: [String] - let branches: [String] - let ports: [Int] - let description: String? - - init( - directories: [String] = [], - branches: [String] = [], - ports: [Int] = [], - description: String? = nil - ) { - self.directories = directories - self.branches = branches - self.ports = ports - self.description = description - } -} - -enum CommandPaletteSwitcherSearchIndexer { - enum MetadataDetail { - case workspace - case surface - } - - private static let metadataDelimiters = CharacterSet(charactersIn: "/\\.:_- ") - - static func keywords( - baseKeywords: [String], - metadata: CommandPaletteSwitcherSearchMetadata, - detail: MetadataDetail = .surface - ) -> [String] { - let metadataKeywords = metadataKeywordsForSearch(metadata, detail: detail) - return uniqueNormalizedPreservingOrder(baseKeywords + metadataKeywords) - } - - private static func metadataKeywordsForSearch( - _ metadata: CommandPaletteSwitcherSearchMetadata, - detail: MetadataDetail - ) -> [String] { - let directoryTokens = metadata.directories.flatMap { directoryTokensForSearch($0, detail: detail) } - let branchTokens = metadata.branches.flatMap { branchTokensForSearch($0, detail: detail) } - let portTokens = metadata.ports.flatMap(portTokensForSearch) - let descriptionTokens = descriptionTokensForSearch(metadata.description) - - var contextKeywords: [String] = [] - if !directoryTokens.isEmpty { - contextKeywords.append(contentsOf: ["directory", "dir", "cwd", "path"]) - } - if !branchTokens.isEmpty { - contextKeywords.append(contentsOf: ["branch", "git"]) - } - if !portTokens.isEmpty { - contextKeywords.append(contentsOf: ["port", "ports"]) - } - if !descriptionTokens.isEmpty { - contextKeywords.append(contentsOf: ["description", "descriptions", "notes", "note"]) - } - - return contextKeywords + directoryTokens + branchTokens + portTokens + descriptionTokens - } - - private static func directoryTokensForSearch( - _ rawDirectory: String, - detail: MetadataDetail - ) -> [String] { - let trimmed = rawDirectory.trimmingCharacters(in: .whitespacesAndNewlines) - guard !trimmed.isEmpty else { return [] } - - let standardized = (trimmed as NSString).standardizingPath - let canonical = standardized.isEmpty ? trimmed : standardized - let abbreviated = (canonical as NSString).abbreviatingWithTildeInPath - switch detail { - case .workspace: - return uniqueNormalizedPreservingOrder([trimmed, canonical, abbreviated]) - case .surface: - let basename = URL(fileURLWithPath: canonical, isDirectory: true).lastPathComponent - let components = canonical.components(separatedBy: metadataDelimiters).filter { !$0.isEmpty } - return uniqueNormalizedPreservingOrder( - [trimmed, canonical, abbreviated, basename] + components - ) - } - } - - private static func branchTokensForSearch( - _ rawBranch: String, - detail: MetadataDetail - ) -> [String] { - let trimmed = rawBranch.trimmingCharacters(in: .whitespacesAndNewlines) - guard !trimmed.isEmpty else { return [] } - switch detail { - case .workspace: - return [trimmed] - case .surface: - let components = trimmed.components(separatedBy: metadataDelimiters).filter { !$0.isEmpty } - return uniqueNormalizedPreservingOrder([trimmed] + components) - } - } - - private static func portTokensForSearch(_ port: Int) -> [String] { - guard (1...65535).contains(port) else { return [] } - let portText = String(port) - return [portText, ":\(portText)"] - } - - private static func descriptionTokensForSearch(_ rawDescription: String?) -> [String] { - let trimmed = rawDescription?.trimmingCharacters(in: .whitespacesAndNewlines) ?? "" - guard !trimmed.isEmpty else { return [] } - let normalizedWhitespace = trimmed.replacingOccurrences( - of: "\\s+", - with: " ", - options: .regularExpression - ) - let components = normalizedWhitespace.components(separatedBy: metadataDelimiters).filter { !$0.isEmpty } - return uniqueNormalizedPreservingOrder([trimmed, normalizedWhitespace] + components) - } - - private static func uniqueNormalizedPreservingOrder(_ values: [String]) -> [String] { - var result: [String] = [] - var seen: Set = [] - result.reserveCapacity(values.count) - - for value in values { - let trimmed = value.trimmingCharacters(in: .whitespacesAndNewlines) - guard !trimmed.isEmpty else { continue } - let normalizedKey = trimmed - .folding(options: [.diacriticInsensitive, .caseInsensitive], locale: .current) - .lowercased() - guard seen.insert(normalizedKey).inserted else { continue } - result.append(trimmed) - } - return result - } -} - -enum CommandPaletteFuzzyMatcher { - private static let tokenBoundaryChars: Set = [" ", "-", "_", "/", ".", ":"] - - private enum SingleEditWordPrefixEditKind { - case candidateExtraCharacter - case tokenExtraCharacter - case substitutedCharacter - case transposedCharacters - - var basePenalty: Int { - switch self { - case .candidateExtraCharacter: - return 0 - case .tokenExtraCharacter: - return 10 - case .transposedCharacters: - return 24 - case .substitutedCharacter: - return 40 - } - } - } - - private struct SingleEditWordPrefixMatch { - let matchedIndices: Set - let segmentStart: Int - let segmentLength: Int - let prefixLength: Int - let editPosition: Int - let editKind: SingleEditWordPrefixEditKind - } - - struct PreparedQuery { - let normalizedText: String - let tokens: [String] - - var isEmpty: Bool { - tokens.isEmpty - } - } - - static func preparedQuery(_ query: String) -> PreparedQuery { - let normalizedQuery = normalizeForSearch(query) - return PreparedQuery( - normalizedText: normalizedQuery, - tokens: normalizedQuery.split(separator: " ").map(String.init).filter { !$0.isEmpty } - ) - } - - static func normalizeForSearch(_ text: String) -> String { - text - .trimmingCharacters(in: .whitespacesAndNewlines) - .folding(options: [.diacriticInsensitive, .caseInsensitive], locale: .current) - .lowercased() - } - - static func score(query: String, candidate: String) -> Int? { - score(query: query, candidates: [candidate]) - } - - static func score(query: String, candidates: [String]) -> Int? { - score( - preparedQuery: preparedQuery(query), - normalizedCandidates: candidates - .map(normalizeForSearch) - .filter { !$0.isEmpty } - ) - } - - static func score(preparedQuery: PreparedQuery, normalizedCandidates: [String]) -> Int? { - guard !preparedQuery.isEmpty else { return 0 } - guard !normalizedCandidates.isEmpty else { return nil } - - var totalScore = 0 - for token in preparedQuery.tokens { - var bestTokenScore: Int? - for candidate in normalizedCandidates { - guard let candidateScore = scoreToken(token, in: candidate) else { continue } - bestTokenScore = max(bestTokenScore ?? candidateScore, candidateScore) - } - guard let bestTokenScore else { return nil } - totalScore += bestTokenScore - } - return totalScore - } - - static func matchCharacterIndices(query: String, candidate: String) -> Set { - matchCharacterIndices(preparedQuery: preparedQuery(query), candidate: candidate) - } - - static func matchCharacterIndices(preparedQuery: PreparedQuery, candidate: String) -> Set { - guard !preparedQuery.isEmpty else { return [] } - - let loweredCandidate = normalizeForSearch(candidate) - guard !loweredCandidate.isEmpty else { return [] } - - let candidateChars = Array(loweredCandidate) - var matched: Set = [] - - for token in preparedQuery.tokens { - if token == loweredCandidate { - matched.formUnion(0.. Int? { - guard !token.isEmpty else { return 0 } - - let candidateChars = Array(candidate) - let tokenChars = Array(token) - guard tokenChars.count <= candidateChars.count else { return nil } - - if token == candidate { - return 8000 - } - if candidate.hasPrefix(token) { - return 6800 - max(0, candidate.count - token.count) - } - - var bestScore: Int? - if let wordExactScore = bestWordScore(tokenChars: tokenChars, candidateChars: candidateChars, requireExactWord: true) { - bestScore = max(bestScore ?? wordExactScore, wordExactScore) - } - if let wordPrefixScore = bestWordScore(tokenChars: tokenChars, candidateChars: candidateChars, requireExactWord: false) { - bestScore = max(bestScore ?? wordPrefixScore, wordPrefixScore) - } - if let singleEditPrefixScore = singleEditWordPrefixScore( - tokenChars: tokenChars, - candidateChars: candidateChars - ) { - bestScore = max(bestScore ?? singleEditPrefixScore, singleEditPrefixScore) - } - - if let range = candidate.range(of: token) { - let distance = candidate.distance(from: candidate.startIndex, to: range.lowerBound) - let lengthPenalty = max(0, candidate.count - token.count) - let boundaryBoost: Int = { - guard distance > 0 else { return 220 } - let prior = candidateChars[distance - 1] - return tokenBoundaryChars.contains(prior) ? 180 : 0 - }() - let containsScore = 4200 + boundaryBoost - (distance * 9) - lengthPenalty - bestScore = max(bestScore ?? containsScore, containsScore) - } - - if let initialismScore = initialismScore(tokenChars: tokenChars, candidateChars: candidateChars) { - bestScore = max(bestScore ?? initialismScore, initialismScore) - } - - if let stitchedScore = stitchedWordPrefixScore(tokenChars: tokenChars, candidateChars: candidateChars) { - bestScore = max(bestScore ?? stitchedScore, stitchedScore) - } - - if tokenChars.count <= 3, let subsequence = subsequenceScore(token: token, candidate: candidate) { - bestScore = max(bestScore ?? subsequence, subsequence) - } - - guard let bestScore else { return nil } - return max(1, bestScore) - } - - private static func bestWordScore( - tokenChars: [Character], - candidateChars: [Character], - requireExactWord: Bool - ) -> Int? { - guard !tokenChars.isEmpty else { return nil } - - var best: Int? - for segment in wordSegments(candidateChars) { - let wordLength = segment.end - segment.start - guard tokenChars.count <= wordLength else { continue } - - var matchesPrefix = true - for offset in 0.. Int? { - guard let match = singleEditWordPrefixMatch( - tokenChars: tokenChars, - candidateChars: candidateChars - ) else { - return nil - } - return singleEditWordPrefixScore(match: match, candidateLength: candidateChars.count) - } - - private static func singleEditWordPrefixScore( - match: SingleEditWordPrefixMatch, - candidateLength: Int - ) -> Int { - let lengthPenalty = max(0, match.segmentLength - match.prefixLength) * 6 - let distancePenalty = match.segmentStart * 8 - let trailingPenalty = max(0, candidateLength - match.segmentLength) - let editPositionPenalty = max(0, match.editPosition - match.segmentStart) * 10 - return 5000 - - match.editKind.basePenalty - - distancePenalty - - lengthPenalty - - trailingPenalty - - editPositionPenalty - } - - private static func initialismScore(tokenChars: [Character], candidateChars: [Character]) -> Int? { - guard !tokenChars.isEmpty else { return nil } - let segments = wordSegments(candidateChars) - guard tokenChars.count <= segments.count else { return nil } - - var matchedStarts: [Int] = [] - var searchWordIndex = 0 - - for tokenChar in tokenChars { - var found = false - while searchWordIndex < segments.count { - let segment = segments[searchWordIndex] - searchWordIndex += 1 - if candidateChars[segment.start] == tokenChar { - matchedStarts.append(segment.start) - found = true - break - } - } - if !found { return nil } - } - - let firstStart = matchedStarts.first ?? 0 - let skippedWords = max(0, segments.count - tokenChars.count) - return 3000 + (tokenChars.count * 160) - (firstStart * 5) - (skippedWords * 30) - } - - private static func tokenPrefixMatches( - tokenChars: [Character], - tokenStart: Int, - length: Int, - candidateChars: [Character], - candidateStart: Int - ) -> Bool { - guard length >= 0 else { return false } - guard tokenStart + length <= tokenChars.count else { return false } - guard candidateStart + length <= candidateChars.count else { return false } - guard length > 0 else { return true } - - for offset in 0.. Int? { - guard tokenChars.count >= 4 else { return nil } - let segments = wordSegments(candidateChars) - guard segments.count >= 2 else { return nil } - - struct StitchState: Hashable { - let tokenIndex: Int - let wordIndex: Int - let usedWords: Int - } - - var memo: [StitchState: Int?] = [:] - - func dfs(tokenIndex: Int, wordIndex: Int, usedWords: Int) -> Int? { - if tokenIndex == tokenChars.count { - return usedWords >= 2 ? 0 : nil - } - guard wordIndex < segments.count else { return nil } - - let state = StitchState(tokenIndex: tokenIndex, wordIndex: wordIndex, usedWords: usedWords) - if let cached = memo[state] { - return cached - } - - var best: Int? - let remainingChars = tokenChars.count - tokenIndex - for segmentIndex in wordIndex.. 0 else { continue } - - let skippedWords = max(0, segmentIndex - wordIndex) - let skipPenalty = skippedWords * 120 - for chunkLength in stride(from: maxChunk, through: 1, by: -1) { - guard tokenPrefixMatches( - tokenChars: tokenChars, - tokenStart: tokenIndex, - length: chunkLength, - candidateChars: candidateChars, - candidateStart: segment.start - ) else { - continue - } - guard let suffixScore = dfs( - tokenIndex: tokenIndex + chunkLength, - wordIndex: segmentIndex + 1, - usedWords: min(2, usedWords + 1) - ) else { - continue - } - - let chunkCoverage = chunkLength * 220 - let contiguityBonus = segmentIndex == wordIndex ? 80 : 0 - let segmentRemainderPenalty = max(0, segmentLength - chunkLength) * 9 - let distancePenalty = segment.start * 4 - let chunkScore = chunkCoverage + contiguityBonus - segmentRemainderPenalty - distancePenalty - skipPenalty - let totalScore = suffixScore + chunkScore - best = max(best ?? totalScore, totalScore) - } - } - - memo[state] = best - return best - } - - guard let stitchedScore = dfs(tokenIndex: 0, wordIndex: 0, usedWords: 0) else { return nil } - let lengthPenalty = max(0, candidateChars.count - tokenChars.count) - return 3500 + stitchedScore - lengthPenalty - } - - private static func stitchedWordPrefixMatchIndices(token: String, candidate: String) -> Set? { - let tokenChars = Array(token) - let candidateChars = Array(candidate) - guard tokenChars.count >= 4 else { return nil } - - let segments = wordSegments(candidateChars) - guard segments.count >= 2 else { return nil } - - var tokenIndex = 0 - var nextWordIndex = 0 - var usedWords = 0 - var matchedIndices: Set = [] - - while tokenIndex < tokenChars.count { - let remainingChars = tokenChars.count - tokenIndex - var foundMatch = false - - for segmentIndex in nextWordIndex.. 0 else { continue } - - for chunkLength in stride(from: maxChunk, through: 1, by: -1) { - guard tokenPrefixMatches( - tokenChars: tokenChars, - tokenStart: tokenIndex, - length: chunkLength, - candidateChars: candidateChars, - candidateStart: segment.start - ) else { - continue - } - - matchedIndices.formUnion(segment.start..<(segment.start + chunkLength)) - tokenIndex += chunkLength - nextWordIndex = segmentIndex + 1 - usedWords += 1 - foundMatch = true - break - } - - if foundMatch { break } - } - - if !foundMatch { return nil } - } - - guard usedWords >= 2 else { return nil } - return matchedIndices - } - - private static func singleEditWordPrefixMatch( - token: String, - candidate: String - ) -> SingleEditWordPrefixMatch? { - singleEditWordPrefixMatch( - tokenChars: Array(token), - candidateChars: Array(candidate) - ) - } - - private static func singleEditWordPrefixMatch( - tokenChars: [Character], - candidateChars: [Character] - ) -> SingleEditWordPrefixMatch? { - guard tokenChars.count >= 4 else { return nil } - - var bestMatch: SingleEditWordPrefixMatch? - var bestScore: Int? - - for segment in wordSegments(candidateChars) { - guard let match = singleEditWordPrefixMatch( - tokenChars: tokenChars, - candidateChars: candidateChars, - segment: segment - ) else { - continue - } - - let score = singleEditWordPrefixScore(match: match, candidateLength: candidateChars.count) - if let bestScore, score <= bestScore { - continue - } - bestScore = score - bestMatch = match - } - - return bestMatch - } - - private static func singleEditWordPrefixMatch( - tokenChars: [Character], - candidateChars: [Character], - segment: (start: Int, end: Int) - ) -> SingleEditWordPrefixMatch? { - guard tokenChars.count >= 4 else { return nil } - - let segmentLength = segment.end - segment.start - guard segmentLength + 1 >= tokenChars.count else { return nil } - - let exactPrefixLength = min(tokenChars.count, segmentLength) - var mismatchOffset = 0 - while mismatchOffset < exactPrefixLength, - candidateChars[segment.start + mismatchOffset] == tokenChars[mismatchOffset] - { - mismatchOffset += 1 - } - - if mismatchOffset == tokenChars.count { - let prefixLength = tokenChars.count + 1 - guard segmentLength >= prefixLength else { return nil } - return SingleEditWordPrefixMatch( - matchedIndices: Set(segment.start..<(segment.start + tokenChars.count)), - segmentStart: segment.start, - segmentLength: segmentLength, - prefixLength: prefixLength, - editPosition: segment.start + tokenChars.count, - editKind: .candidateExtraCharacter - ) - } - - if mismatchOffset == segmentLength { - let prefixLength = tokenChars.count - 1 - guard prefixLength > 0 else { return nil } - guard tokenChars.count == segmentLength + 1 else { return nil } - return SingleEditWordPrefixMatch( - matchedIndices: Set(segment.start..<(segment.start + prefixLength)), - segmentStart: segment.start, - segmentLength: segmentLength, - prefixLength: prefixLength, - editPosition: segment.start + prefixLength, - editKind: .tokenExtraCharacter - ) - } - - let mismatchCandidateIndex = segment.start + mismatchOffset - - if segmentLength >= tokenChars.count + 1, - tokenPrefixMatches( - tokenChars: tokenChars, - tokenStart: mismatchOffset, - length: tokenChars.count - mismatchOffset, - candidateChars: candidateChars, - candidateStart: mismatchCandidateIndex + 1 - ) - { - var matchedIndices = Set(segment.start..<(segment.start + tokenChars.count + 1)) - matchedIndices.remove(mismatchCandidateIndex) - return SingleEditWordPrefixMatch( - matchedIndices: matchedIndices, - segmentStart: segment.start, - segmentLength: segmentLength, - prefixLength: tokenChars.count + 1, - editPosition: mismatchCandidateIndex, - editKind: .candidateExtraCharacter - ) - } - - if tokenChars.count >= 2, - segmentLength >= tokenChars.count - 1, - tokenPrefixMatches( - tokenChars: tokenChars, - tokenStart: mismatchOffset + 1, - length: tokenChars.count - mismatchOffset - 1, - candidateChars: candidateChars, - candidateStart: mismatchCandidateIndex - ) - { - return SingleEditWordPrefixMatch( - matchedIndices: Set(segment.start..<(segment.start + tokenChars.count - 1)), - segmentStart: segment.start, - segmentLength: segmentLength, - prefixLength: tokenChars.count - 1, - editPosition: mismatchCandidateIndex, - editKind: .tokenExtraCharacter - ) - } - - if segmentLength >= tokenChars.count, - tokenPrefixMatches( - tokenChars: tokenChars, - tokenStart: mismatchOffset + 1, - length: tokenChars.count - mismatchOffset - 1, - candidateChars: candidateChars, - candidateStart: mismatchCandidateIndex + 1 - ) - { - var matchedIndices = Set(segment.start..<(segment.start + tokenChars.count)) - matchedIndices.remove(mismatchCandidateIndex) - return SingleEditWordPrefixMatch( - matchedIndices: matchedIndices, - segmentStart: segment.start, - segmentLength: segmentLength, - prefixLength: tokenChars.count, - editPosition: mismatchCandidateIndex, - editKind: .substitutedCharacter - ) - } - - if segmentLength >= tokenChars.count, - mismatchOffset + 1 < tokenChars.count, - mismatchCandidateIndex + 1 < segment.end, - tokenChars[mismatchOffset] == candidateChars[mismatchCandidateIndex + 1], - tokenChars[mismatchOffset + 1] == candidateChars[mismatchCandidateIndex], - tokenPrefixMatches( - tokenChars: tokenChars, - tokenStart: mismatchOffset + 2, - length: tokenChars.count - mismatchOffset - 2, - candidateChars: candidateChars, - candidateStart: mismatchCandidateIndex + 2 - ) - { - return SingleEditWordPrefixMatch( - matchedIndices: Set(segment.start..<(segment.start + tokenChars.count)), - segmentStart: segment.start, - segmentLength: segmentLength, - prefixLength: tokenChars.count, - editPosition: mismatchCandidateIndex, - editKind: .transposedCharacters - ) - } - - return nil - } - - private static func wordSegments(_ candidateChars: [Character]) -> [(start: Int, end: Int)] { - var segments: [(start: Int, end: Int)] = [] - var index = 0 - - while index < candidateChars.count { - while index < candidateChars.count, tokenBoundaryChars.contains(candidateChars[index]) { - index += 1 - } - guard index < candidateChars.count else { break } - let start = index - while index < candidateChars.count, !tokenBoundaryChars.contains(candidateChars[index]) { - index += 1 - } - segments.append((start: start, end: index)) - } - - return segments - } - - private static func subsequenceScore(token: String, candidate: String) -> Int? { - let tokenChars = Array(token) - let candidateChars = Array(candidate) - guard tokenChars.count <= candidateChars.count else { return nil } - - var searchIndex = 0 - var previousMatch = -1 - var consecutiveRun = 0 - var score = 0 - - for tokenChar in tokenChars { - var foundIndex: Int? - while searchIndex < candidateChars.count { - if candidateChars[searchIndex] == tokenChar { - foundIndex = searchIndex - break - } - searchIndex += 1 - } - guard let matchedIndex = foundIndex else { return nil } - - score += 90 - if matchedIndex == 0 || tokenBoundaryChars.contains(candidateChars[matchedIndex - 1]) { - score += 140 - } - if matchedIndex == previousMatch + 1 { - consecutiveRun += 1 - score += min(200, consecutiveRun * 45) - } else { - consecutiveRun = 0 - score -= min(120, max(0, matchedIndex - previousMatch - 1) * 4) - } - - previousMatch = matchedIndex - searchIndex = matchedIndex + 1 - } - - score -= max(0, candidateChars.count - tokenChars.count) - return max(1, score) - } - - private static func subsequenceMatchIndices(token: String, candidate: String) -> Set? { - let tokenChars = Array(token) - let candidateChars = Array(candidate) - guard tokenChars.count <= candidateChars.count else { return nil } - - var indices: Set = [] - var searchIndex = 0 - - for tokenChar in tokenChars { - var foundIndex: Int? - while searchIndex < candidateChars.count { - if candidateChars[searchIndex] == tokenChar { - foundIndex = searchIndex - break - } - searchIndex += 1 - } - guard let matchIndex = foundIndex else { return nil } - indices.insert(matchIndex) - searchIndex = matchIndex + 1 - } - - return indices - } - - private static func initialismMatchIndices(token: String, candidate: String) -> Set? { - let tokenChars = Array(token) - let candidateChars = Array(candidate) - guard !tokenChars.isEmpty else { return nil } - - let segments = wordSegments(candidateChars) - guard tokenChars.count <= segments.count else { return nil } - - var matched: Set = [] - var searchWordIndex = 0 - - for tokenChar in tokenChars { - var found = false - while searchWordIndex < segments.count { - let segment = segments[searchWordIndex] - searchWordIndex += 1 - if candidateChars[segment.start] == tokenChar { - matched.insert(segment.start) - found = true - break - } - } - if !found { return nil } - } - - return matched - } -} - -struct CommandPaletteSearchCorpusEntry: Sendable where Payload: Sendable { - let payload: Payload - let rank: Int - let title: String - let normalizedTitle: String - let normalizedSearchableTexts: [String] - - init(payload: Payload, rank: Int, title: String, searchableTexts: [String]) { - self.payload = payload - self.rank = rank - self.title = title - self.normalizedTitle = CommandPaletteFuzzyMatcher.normalizeForSearch(title) - self.normalizedSearchableTexts = searchableTexts - .map(CommandPaletteFuzzyMatcher.normalizeForSearch) - .filter { !$0.isEmpty } - } -} - -struct CommandPaletteSearchCorpusResult: Sendable where Payload: Sendable { - let payload: Payload - let rank: Int - let title: String - let score: Int - let titleMatchIndices: Set -} - -enum CommandPaletteSearchEngine { - private static let titleMatchBonus = 2000 - - static func search( - entries: [CommandPaletteSearchCorpusEntry], - query: String, - historyBoost: (Payload, Bool) -> Int - ) -> [CommandPaletteSearchCorpusResult] { - search( - entries: entries, - query: query, - historyBoost: historyBoost, - shouldCancel: nil - ) - } - - static func search( - entries: [CommandPaletteSearchCorpusEntry], - query: String, - historyBoost: (Payload, Bool) -> Int, - shouldCancel: @escaping () -> Bool - ) -> [CommandPaletteSearchCorpusResult] { - search( - entries: entries, - query: query, - historyBoost: historyBoost, - shouldCancel: Optional(shouldCancel) - ) - } - - private static func search( - entries: [CommandPaletteSearchCorpusEntry], - query: String, - historyBoost: (Payload, Bool) -> Int, - shouldCancel: (() -> Bool)? - ) -> [CommandPaletteSearchCorpusResult] { - let preparedQuery = CommandPaletteFuzzyMatcher.preparedQuery(query) - let queryIsEmpty = preparedQuery.isEmpty - var results: [CommandPaletteSearchCorpusResult] = [] - results.reserveCapacity(entries.count) - - func shouldCancelSearch(at index: Int) -> Bool { - guard let shouldCancel else { return false } - return index % 16 == 0 && shouldCancel() - } - - if queryIsEmpty { - for (index, entry) in entries.enumerated() { - if shouldCancelSearch(at: index) { return [] } - results.append( - CommandPaletteSearchCorpusResult( - payload: entry.payload, - rank: entry.rank, - title: entry.title, - score: historyBoost(entry.payload, true), - titleMatchIndices: [] - ) - ) - } - } else { - for (index, entry) in entries.enumerated() { - if shouldCancelSearch(at: index) { return [] } - guard let fuzzyScore = weightedScore( - preparedQuery: preparedQuery, - entry: entry - ) else { - continue - } - results.append( - CommandPaletteSearchCorpusResult( - payload: entry.payload, - rank: entry.rank, - title: entry.title, - score: fuzzyScore + historyBoost(entry.payload, false), - titleMatchIndices: CommandPaletteFuzzyMatcher.matchCharacterIndices( - preparedQuery: preparedQuery, - candidate: entry.title - ) - ) - ) - } - } - - if shouldCancel?() == true { return [] } - - return results.sorted { lhs, rhs in - if lhs.score != rhs.score { return lhs.score > rhs.score } - if lhs.rank != rhs.rank { return lhs.rank < rhs.rank } - return lhs.title.localizedCaseInsensitiveCompare(rhs.title) == .orderedAscending - } - } - - private static func weightedScore( - preparedQuery: CommandPaletteFuzzyMatcher.PreparedQuery, - entry: CommandPaletteSearchCorpusEntry - ) -> Int? { - guard let fuzzyScore = CommandPaletteFuzzyMatcher.score( - preparedQuery: preparedQuery, - normalizedCandidates: entry.normalizedSearchableTexts - ) else { - return nil - } - guard !entry.normalizedTitle.isEmpty, - let titleScore = CommandPaletteFuzzyMatcher.score( - preparedQuery: preparedQuery, - normalizedCandidates: [entry.normalizedTitle] - ) else { - return fuzzyScore - } - return max(fuzzyScore, titleScore + titleMatchBonus) - } -} - private struct SidebarResizerAccessibilityModifier: ViewModifier { let accessibilityIdentifier: String? From dd1fe832d49a539436d55c2ae6a3b89b7f22a89a Mon Sep 17 00:00:00 2001 From: arzafran Date: Thu, 9 Jul 2026 13:56:10 -0300 Subject: [PATCH 05/19] fix(terminal): fix lost Ghostty focus push after reparent resume TerminalSurface.desiredFocusState and GhosttyNSView.desiredFocus both track intended focus, and could diverge silently. The concrete bug: clearSuppressReparentFocus() called recordExternalFocusState(true) unconditionally, then (on the immediate-apply branch) called reassertTerminalSurfaceFocus() -> setFocus(true). setFocus's dedup guard (focused != desiredFocusState) saw no change, because recordExternalFocusState had already set desiredFocusState = true moments earlier, so the real ghostty_surface_set_focus push was silently skipped. A surface could end up looking focused in the UI (bonsplit selection, desiredFocus true) while the Ghostty renderer never actually received focus, after a programmatic split/reparent. Fix: recordExternalFocusState(true) is now only called on the branches that don't reach reassertTerminalSurfaceFocus (deferred: window not key, or hidden/tiny geometry). The immediate-apply branch lets setFocus own desiredFocusState as its sole writer, so the dedup guard sees the real transition and pushes ghostty_surface_set_focus. Each branch now has exactly one writer of desiredFocusState for its transition. No change to forceRefresh or keyDown paths. Refs #86. --- Sources/GhosttyTerminalView.swift | 25 +++++++++++++++++++++++-- 1 file changed, 23 insertions(+), 2 deletions(-) diff --git a/Sources/GhosttyTerminalView.swift b/Sources/GhosttyTerminalView.swift index 4aeee7b661c..872d3f7fa0c 100644 --- a/Sources/GhosttyTerminalView.swift +++ b/Sources/GhosttyTerminalView.swift @@ -4339,6 +4339,13 @@ final class TerminalSurface: Identifiable, ObservableObject { /// Keep `desiredFocusState` in sync when the hosted view's responder chain /// calls `ghostty_surface_set_focus` directly (bypassing `setFocus`). /// Without this, `createSurface` would replay a stale state on recreation. + /// + /// `desiredFocusState` must have exactly one writer per focus transition: + /// either this method (paired with a direct `ghostty_surface_set_focus` call, + /// or standing in for one that's deferred) or `setFocus`, never both for the + /// same transition. Calling this immediately before `setFocus` for the same + /// transition pre-sets the value `setFocus`'s dedup guard checks against, + /// which makes it silently skip the real `ghostty_surface_set_focus` push. func recordExternalFocusState(_ focused: Bool) { desiredFocusState = focused } @@ -7710,9 +7717,19 @@ final class GhosttySurfaceScrollView: NSView { guard surfaceView.desiredFocus || surfaceOwnsFirstResponder else { return } guard surfaceView.isVisibleInUI else { return } - surfaceView.terminalSurface?.recordExternalFocusState(true) - guard let window, window.isKeyWindow else { return } + // NOTE: recordExternalFocusState(true) is only called in the branches below that + // do NOT reach reassertTerminalSurfaceFocus(). Calling it unconditionally here used + // to pre-set TerminalSurface.desiredFocusState to true before reassertTerminalSurfaceFocus + // -> setFocus(true) ran; setFocus's dedup guard (`focused != desiredFocusState`) then + // saw no change and silently skipped the real ghostty_surface_set_focus push, leaving + // the surface's actual Ghostty-level focus stuck false after a reparent/split. Each + // branch below now has exactly one writer of desiredFocusState for this transition. + guard let window, window.isKeyWindow else { + surfaceView.terminalSurface?.recordExternalFocusState(true) + return + } guard !isHiddenForFocus, hasUsablePortalGeometry else { + surfaceView.terminalSurface?.recordExternalFocusState(true) #if DEBUG dlog( "focus.reparent.resume.defer surface=\(surfaceShort) " + @@ -7735,6 +7752,10 @@ final class GhosttySurfaceScrollView: NSView { #if DEBUG dlog("focus.reparent.resume surface=\(surfaceShort) firstResponder=\(String(describing: window.firstResponder))") #endif + // reassertTerminalSurfaceFocus() -> setFocus(true) is the sole writer of + // desiredFocusState on this path; it both updates the dedup guard and pushes + // ghostty_surface_set_focus in one place, so no separate recordExternalFocusState + // call is made here (see NOTE above). reassertTerminalSurfaceFocus(reason: "clearSuppressReparentFocus") } From 4b17278d625aa5996858fe7464bb4ead2bb17f35 Mon Sep 17 00:00:00 2001 From: arzafran Date: Thu, 9 Jul 2026 13:58:01 -0300 Subject: [PATCH 06/19] refactor(terminal): dedupe triplicated hide/retry block in synchronizeHostedView MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit WindowTerminalPortal.synchronizeHostedView tripled a near-identical hide-and-schedule-transient-recovery block across the missingAnchorOrWindow, anchorWindowMismatch, and hostBoundsNotReady guards. Only the hostBoundsNotReady copy had the production-build fallback (calling scheduleDeferredFullSynchronizeAll() when Self.transientRecoveryEnabled is false) — the other two copies would silently drop the hide with no follow-up resync scheduled in that configuration, and a genuinely-transient condition could leave a hosted view stuck hidden. Extracted hideHostedViewSchedulingRecovery(hostedId:entry:hostedView:reason:) with the fallback included, and call it from all three sites. Behavior is now consistent across all three guards; each caller's own distinct diagnostic dlog (anchorWindowMismatch's anchorWindow=, hostBoundsNotReady's portal.sync.defer) is kept as a separate pre-log alongside the shared generic 'portal.hidden value=1 reason=X' log the helper emits, so a couple of call sites may emit one additional DEBUG-only log line per hide compared to before; no functional/production behavior change beyond fixing the missing fallback for the two previously-unprotected reasons. Refs #85. --- Sources/TerminalWindowPortal.swift | 166 +++++++++++++---------------- 1 file changed, 73 insertions(+), 93 deletions(-) diff --git a/Sources/TerminalWindowPortal.swift b/Sources/TerminalWindowPortal.swift index 3446b720b3e..ba40e6b7115 100644 --- a/Sources/TerminalWindowPortal.swift +++ b/Sources/TerminalWindowPortal.swift @@ -1183,48 +1183,78 @@ final class WindowTerminalPortal: HostedViewPortalRegistry { return true } - private func synchronizeHostedView(withId hostedId: ObjectIdentifier) { - guard ensureInstalled() else { return } - guard var entry = entriesByHostedId[hostedId] else { return } - guard let hostedView = entry.hostedView else { - entriesByHostedId.removeValue(forKey: hostedId) - return - } - guard let anchorView = entry.anchorView, let window else { - if entry.visibleInUI { - let shouldPreserveVisibleOnTransient = !hostedView.isHidden && - scheduleTransientRecoveryRetryIfNeeded( - forHostedId: hostedId, - entry: &entry, - hostedView: hostedView, - reason: "missingAnchorOrWindow" - ) - if shouldPreserveVisibleOnTransient { + /// Hide `hostedView` for a `synchronizeHostedView` entry that can't be positioned this + /// pass (missing anchor/window, anchor moved to a different window, or host bounds not + /// yet laid out), scheduling a transient-recovery retry so a genuinely-transient geometry + /// hiccup doesn't get stuck hidden. Falls back to a full `synchronizeAllHostedViews` when + /// transient recovery is disabled (production builds), so that path isn't left with no + /// recovery at all. + /// + /// Returns `true` if the caller should return immediately because the current visible + /// frame was preserved pending a transient-recovery retry (i.e. `hostedView.isHidden` + /// was deliberately left unchanged this pass). + @discardableResult + private func hideHostedViewSchedulingRecovery( + hostedId: ObjectIdentifier, + entry: inout Entry, + hostedView: GhosttySurfaceScrollView, + reason: String + ) -> Bool { + if entry.visibleInUI { + let shouldPreserveVisibleOnTransient = !hostedView.isHidden && + scheduleTransientRecoveryRetryIfNeeded( + forHostedId: hostedId, + entry: &entry, + hostedView: hostedView, + reason: reason + ) + if shouldPreserveVisibleOnTransient { #if DEBUG - dlog( - "portal.hidden.deferKeep hosted=\(portalDebugToken(hostedView)) " + - "reason=missingAnchorOrWindow frame=\(portalDebugFrame(hostedView.frame))" - ) + dlog( + "portal.hidden.deferKeep hosted=\(portalDebugToken(hostedView)) " + + "reason=\(reason) frame=\(portalDebugFrame(hostedView.frame))" + ) #endif - return - } - } else { - resetTransientRecoveryRetryIfNeeded(forHostedId: hostedId, entry: &entry) + return true } + } else { + resetTransientRecoveryRetryIfNeeded(forHostedId: hostedId, entry: &entry) + } #if DEBUG - if !hostedView.isHidden { - dlog("portal.hidden hosted=\(portalDebugToken(hostedView)) value=1 reason=missingAnchorOrWindow") - } + if !hostedView.isHidden { + dlog("portal.hidden hosted=\(portalDebugToken(hostedView)) value=1 reason=\(reason)") + } #endif - hostedView.isHidden = true - if entry.visibleInUI { + hostedView.isHidden = true + if entry.visibleInUI { + if Self.transientRecoveryEnabled { _ = scheduleTransientRecoveryRetryIfNeeded( forHostedId: hostedId, entry: &entry, hostedView: hostedView, - reason: "missingAnchorOrWindow" + reason: reason ) + } else { + scheduleDeferredFullSynchronizeAll() } + } + return false + } + + private func synchronizeHostedView(withId hostedId: ObjectIdentifier) { + guard ensureInstalled() else { return } + guard var entry = entriesByHostedId[hostedId] else { return } + guard let hostedView = entry.hostedView else { + entriesByHostedId.removeValue(forKey: hostedId) + return + } + guard let anchorView = entry.anchorView, let window else { + _ = hideHostedViewSchedulingRecovery( + hostedId: hostedId, + entry: &entry, + hostedView: hostedView, + reason: "missingAnchorOrWindow" + ) return } guard anchorView.window === window else { @@ -1236,35 +1266,12 @@ final class WindowTerminalPortal: HostedViewPortalRegistry { ) } #endif - if entry.visibleInUI { - let shouldPreserveVisibleOnTransient = !hostedView.isHidden && - scheduleTransientRecoveryRetryIfNeeded( - forHostedId: hostedId, - entry: &entry, - hostedView: hostedView, - reason: "anchorWindowMismatch" - ) - if shouldPreserveVisibleOnTransient { -#if DEBUG - dlog( - "portal.hidden.deferKeep hosted=\(portalDebugToken(hostedView)) " + - "reason=anchorWindowMismatch frame=\(portalDebugFrame(hostedView.frame))" - ) -#endif - return - } - } else { - resetTransientRecoveryRetryIfNeeded(forHostedId: hostedId, entry: &entry) - } - hostedView.isHidden = true - if entry.visibleInUI { - _ = scheduleTransientRecoveryRetryIfNeeded( - forHostedId: hostedId, - entry: &entry, - hostedView: hostedView, - reason: "anchorWindowMismatch" - ) - } + _ = hideHostedViewSchedulingRecovery( + hostedId: hostedId, + entry: &entry, + hostedView: hostedView, + reason: "anchorWindowMismatch" + ) return } @@ -1290,39 +1297,12 @@ final class WindowTerminalPortal: HostedViewPortalRegistry { "anchor=\(portalDebugFrame(frameInHost)) visibleInUI=\(entry.visibleInUI ? 1 : 0)" ) #endif - if entry.visibleInUI { - let shouldPreserveVisibleOnTransient = !hostedView.isHidden && - scheduleTransientRecoveryRetryIfNeeded( - forHostedId: hostedId, - entry: &entry, - hostedView: hostedView, - reason: "hostBoundsNotReady" - ) - if shouldPreserveVisibleOnTransient { -#if DEBUG - dlog( - "portal.hidden.deferKeep hosted=\(portalDebugToken(hostedView)) " + - "reason=hostBoundsNotReady frame=\(portalDebugFrame(hostedView.frame))" - ) -#endif - return - } - } else { - resetTransientRecoveryRetryIfNeeded(forHostedId: hostedId, entry: &entry) - } - hostedView.isHidden = true - if entry.visibleInUI { - if Self.transientRecoveryEnabled { - _ = scheduleTransientRecoveryRetryIfNeeded( - forHostedId: hostedId, - entry: &entry, - hostedView: hostedView, - reason: "hostBoundsNotReady" - ) - } else { - scheduleDeferredFullSynchronizeAll() - } - } + _ = hideHostedViewSchedulingRecovery( + hostedId: hostedId, + entry: &entry, + hostedView: hostedView, + reason: "hostBoundsNotReady" + ) return } let hasFiniteFrame = From 5b236db5e7ad3904e67d5df91bd7cfbab4177b0a Mon Sep 17 00:00:00 2001 From: arzafran Date: Thu, 9 Jul 2026 13:58:03 -0300 Subject: [PATCH 07/19] refactor: split free-standing types out of AppDelegate.swift Mechanical eviction of ~2,950 zero-dependency-on-AppDelegate free-standing types into their own files: - MainWindowHostingView.swift: MainWindowHostingView (SwiftUI hosting view) - TypingProfiler.swift: ProgramaTypingTiming, ProgramaMainRunLoopStallMonitor, ProgramaMainThreadTurnProfiler (all #if DEBUG) - TerminalDirectoryOpener.swift: FinderServicePathResolver, TerminalDirectoryOpenTarget - VSCodeIntegration.swift: VSCodeServeWebURLBuilder, VSCodeCLILaunchConfiguration(Builder), VSCodeServeWebController, ServeWebOutputCollector, ServeWebPortStore - WorkspaceShortcutMapper.swift: WorkspaceShortcutMapper - CLIInstaller.swift: ProgramaCLIPathInstaller - MenuBarIconRenderer.swift: MenuBarExtraController, notification menu snapshot/formatting types, MenuBarIconRenderer and its settings - WindowSwizzles.swift: the NSWindow/NSApplication method-swizzle implementations and their supporting file-scoped state Access-level widening required for cross-file visibility (no other code changes): - AppDelegate.recordTypingActivity(): fileprivate -> internal. Called from NSWindow.programa_sendEvent(_:), now in WindowSwizzles.swift. - extension NSWindow / extension NSApplication (swizzle methods): private -> internal. AppDelegate.installWindowResponderSwizzles() references programa_makeFirstResponder/programa_sendEvent/programa_performKeyEquivalent/ programa_applicationSendEvent via #selector(...) from AppDelegate.swift. - programaFirstResponderGuardCurrentEventOverride/HitViewOverride: private -> internal. AppDelegate.setWindowFirstResponderGuardTesting(...) and .clearWindowFirstResponderGuardTesting() write these directly. private extension AppDelegate (handleThemesReloadNotification) stays in AppDelegate.swift since it extends AppDelegate itself, not a zero-dependency type. Refs #95. --- GhosttyTabs.xcodeproj/project.pbxproj | 32 + Sources/AppDelegate.swift | 16661 ++++++++++-------------- Sources/CLIInstaller.swift | 324 + Sources/MainWindowHostingView.swift | 33 + Sources/MenuBarIconRenderer.swift | 669 + Sources/TerminalDirectoryOpener.swift | 297 + Sources/TypingProfiler.swift | 354 + Sources/VSCodeIntegration.swift | 593 + Sources/WindowSwizzles.swift | 734 ++ Sources/WorkspaceShortcutMapper.swift | 37 + 10 files changed, 9927 insertions(+), 9807 deletions(-) create mode 100644 Sources/CLIInstaller.swift create mode 100644 Sources/MainWindowHostingView.swift create mode 100644 Sources/MenuBarIconRenderer.swift create mode 100644 Sources/TerminalDirectoryOpener.swift create mode 100644 Sources/TypingProfiler.swift create mode 100644 Sources/VSCodeIntegration.swift create mode 100644 Sources/WindowSwizzles.swift create mode 100644 Sources/WorkspaceShortcutMapper.swift diff --git a/GhosttyTabs.xcodeproj/project.pbxproj b/GhosttyTabs.xcodeproj/project.pbxproj index 5ba0f619fc7..922e4bb722c 100644 --- a/GhosttyTabs.xcodeproj/project.pbxproj +++ b/GhosttyTabs.xcodeproj/project.pbxproj @@ -56,6 +56,14 @@ A5FF0011 /* WorkspaceRemoteDaemon.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5FF0001 /* WorkspaceRemoteDaemon.swift */; }; A5001407 /* WorkspaceContentView.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5001417 /* WorkspaceContentView.swift */; }; A5001093 /* AppDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5001090 /* AppDelegate.swift */; }; + NRAD1001 /* MainWindowHostingView.swift in Sources */ = {isa = PBXBuildFile; fileRef = NRAD0001 /* MainWindowHostingView.swift */; }; + NRAD1002 /* TypingProfiler.swift in Sources */ = {isa = PBXBuildFile; fileRef = NRAD0002 /* TypingProfiler.swift */; }; + NRAD1003 /* TerminalDirectoryOpener.swift in Sources */ = {isa = PBXBuildFile; fileRef = NRAD0003 /* TerminalDirectoryOpener.swift */; }; + NRAD1004 /* VSCodeIntegration.swift in Sources */ = {isa = PBXBuildFile; fileRef = NRAD0004 /* VSCodeIntegration.swift */; }; + NRAD1005 /* WorkspaceShortcutMapper.swift in Sources */ = {isa = PBXBuildFile; fileRef = NRAD0005 /* WorkspaceShortcutMapper.swift */; }; + NRAD1006 /* CLIInstaller.swift in Sources */ = {isa = PBXBuildFile; fileRef = NRAD0006 /* CLIInstaller.swift */; }; + NRAD1007 /* MenuBarIconRenderer.swift in Sources */ = {isa = PBXBuildFile; fileRef = NRAD0007 /* MenuBarIconRenderer.swift */; }; + NRAD1008 /* WindowSwizzles.swift in Sources */ = {isa = PBXBuildFile; fileRef = NRAD0008 /* WindowSwizzles.swift */; }; A5FF0031 /* AppDelegate+UITestCmdClick.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5FF0021 /* AppDelegate+UITestCmdClick.swift */; }; A5FF0032 /* AppDelegate+UITestStressWorkspaces.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5FF0022 /* AppDelegate+UITestStressWorkspaces.swift */; }; A5FF0033 /* AppDelegate+UITestHarnesses.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5FF0023 /* AppDelegate+UITestHarnesses.swift */; }; @@ -280,6 +288,14 @@ A5FF0001 /* WorkspaceRemoteDaemon.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WorkspaceRemoteDaemon.swift; sourceTree = ""; }; A5001417 /* WorkspaceContentView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WorkspaceContentView.swift; sourceTree = ""; }; A5001090 /* AppDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppDelegate.swift; sourceTree = ""; }; + NRAD0001 /* MainWindowHostingView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MainWindowHostingView.swift; sourceTree = ""; }; + NRAD0002 /* TypingProfiler.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TypingProfiler.swift; sourceTree = ""; }; + NRAD0003 /* TerminalDirectoryOpener.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TerminalDirectoryOpener.swift; sourceTree = ""; }; + NRAD0004 /* VSCodeIntegration.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VSCodeIntegration.swift; sourceTree = ""; }; + NRAD0005 /* WorkspaceShortcutMapper.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WorkspaceShortcutMapper.swift; sourceTree = ""; }; + NRAD0006 /* CLIInstaller.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CLIInstaller.swift; sourceTree = ""; }; + NRAD0007 /* MenuBarIconRenderer.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MenuBarIconRenderer.swift; sourceTree = ""; }; + NRAD0008 /* WindowSwizzles.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WindowSwizzles.swift; sourceTree = ""; }; A5FF0021 /* AppDelegate+UITestCmdClick.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "AppDelegate+UITestCmdClick.swift"; sourceTree = ""; }; A5FF0022 /* AppDelegate+UITestStressWorkspaces.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "AppDelegate+UITestStressWorkspaces.swift"; sourceTree = ""; }; A5FF0023 /* AppDelegate+UITestHarnesses.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "AppDelegate+UITestHarnesses.swift"; sourceTree = ""; }; @@ -538,6 +554,14 @@ A5001620 /* AppleScriptSupport.swift */, D1320AA0D1320AA0D1320AA4 /* AppIconDockTilePlugin.swift */, A5001090 /* AppDelegate.swift */, + NRAD0001 /* MainWindowHostingView.swift */, + NRAD0002 /* TypingProfiler.swift */, + NRAD0003 /* TerminalDirectoryOpener.swift */, + NRAD0004 /* VSCodeIntegration.swift */, + NRAD0005 /* WorkspaceShortcutMapper.swift */, + NRAD0006 /* CLIInstaller.swift */, + NRAD0007 /* MenuBarIconRenderer.swift */, + NRAD0008 /* WindowSwizzles.swift */, A5FF0021 /* AppDelegate+UITestCmdClick.swift */, A5FF0022 /* AppDelegate+UITestStressWorkspaces.swift */, A5FF0023 /* AppDelegate+UITestHarnesses.swift */, @@ -867,6 +891,14 @@ A5001226 /* SocketControlSettings.swift in Sources */, A5001621 /* AppleScriptSupport.swift in Sources */, A5001093 /* AppDelegate.swift in Sources */, + NRAD1001 /* MainWindowHostingView.swift in Sources */, + NRAD1002 /* TypingProfiler.swift in Sources */, + NRAD1003 /* TerminalDirectoryOpener.swift in Sources */, + NRAD1004 /* VSCodeIntegration.swift in Sources */, + NRAD1005 /* WorkspaceShortcutMapper.swift in Sources */, + NRAD1006 /* CLIInstaller.swift in Sources */, + NRAD1007 /* MenuBarIconRenderer.swift in Sources */, + NRAD1008 /* WindowSwizzles.swift in Sources */, A5FF0031 /* AppDelegate+UITestCmdClick.swift in Sources */, A5FF0032 /* AppDelegate+UITestStressWorkspaces.swift in Sources */, A5FF0033 /* AppDelegate+UITestHarnesses.swift in Sources */, diff --git a/Sources/AppDelegate.swift b/Sources/AppDelegate.swift index 72285c20bed..0f6bb677d90 100644 --- a/Sources/AppDelegate.swift +++ b/Sources/AppDelegate.swift @@ -8,29 +8,7 @@ import Combine import ObjectiveC.runtime import Darwin -final class MainWindowHostingView: NSHostingView { - private let zeroSafeAreaLayoutGuide = NSLayoutGuide() - - override var safeAreaInsets: NSEdgeInsets { NSEdgeInsetsZero } - override var safeAreaRect: NSRect { bounds } - override var safeAreaLayoutGuide: NSLayoutGuide { zeroSafeAreaLayoutGuide } - - required init(rootView: Content) { - super.init(rootView: rootView) - addLayoutGuide(zeroSafeAreaLayoutGuide) - NSLayoutConstraint.activate([ - zeroSafeAreaLayoutGuide.leadingAnchor.constraint(equalTo: leadingAnchor), - zeroSafeAreaLayoutGuide.trailingAnchor.constraint(equalTo: trailingAnchor), - zeroSafeAreaLayoutGuide.topAnchor.constraint(equalTo: topAnchor), - zeroSafeAreaLayoutGuide.bottomAnchor.constraint(equalTo: bottomAnchor), - ]) - } - @available(*, unavailable) - required init?(coder: NSCoder) { - fatalError("init(coder:) has not been implemented") - } -} private enum ProgramaThemeNotifications { static let reloadConfig = Notification.Name("com.darkroom.programa.themes.reload-config") @@ -70,5553 +48,4001 @@ func isCommandPaletteFocusStealingTerminalOrBrowserView(_ view: NSView) -> Bool return false } -#if DEBUG -enum ProgramaTypingTiming { - static let isEnabled: Bool = { - let environment = ProcessInfo.processInfo.environment - if environment["PROGRAMA_TYPING_TIMING_LOGS"] == "1" || environment["PROGRAMA_KEY_LATENCY_PROBE"] == "1" { - return true - } - let defaults = UserDefaults.standard - return defaults.bool(forKey: "programaTypingTimingLogs") || defaults.bool(forKey: "programaKeyLatencyProbe") - }() - static let isVerboseProbeEnabled: Bool = { - let environment = ProcessInfo.processInfo.environment - if environment["PROGRAMA_KEY_LATENCY_PROBE"] == "1" { - return true - } - return UserDefaults.standard.bool(forKey: "programaKeyLatencyProbe") - }() - private static let delayLogThresholdMs: Double = 6.0 - private static let durationLogThresholdMs: Double = 1.0 - - @inline(__always) - static func start() -> TimeInterval? { - guard isEnabled else { return nil } - return ProcessInfo.processInfo.systemUptime - } - - @inline(__always) - static func logEventDelay(path: String, event: NSEvent) { - guard isEnabled else { return } - guard event.timestamp > 0 else { return } - let delayMs = max(0, (ProcessInfo.processInfo.systemUptime - event.timestamp) * 1000.0) - guard shouldLog(delayMs: delayMs, elapsedMs: nil) else { return } - dlog("typing.delay path=\(path) delayMs=\(format(delayMs)) \(eventFields(event))") - } - - @inline(__always) - static func logDuration(path: String, startedAt: TimeInterval?, event: NSEvent? = nil, extra: String? = nil) { - ProgramaMainThreadTurnProfiler.endMeasure(path, startedAt: startedAt) - guard let startedAt else { return } - let elapsedMs = max(0, (ProcessInfo.processInfo.systemUptime - startedAt) * 1000.0) - let delayMs: Double? = { - guard let event, event.timestamp > 0 else { return nil } - return max(0, (ProcessInfo.processInfo.systemUptime - event.timestamp) * 1000.0) - }() - guard shouldLog(delayMs: delayMs, elapsedMs: elapsedMs) else { return } - var line = "typing.timing path=\(path) elapsedMs=\(format(elapsedMs))" - if let event { - line += " \(eventFields(event))" - if let delayMs { - line += " delayMs=\(format(delayMs))" - } - } - if let extra, !extra.isEmpty { - line += " \(extra)" - } - dlog(line) - } - @inline(__always) - static func logBreakdown( - path: String, - totalMs: Double, - event: NSEvent? = nil, - thresholdMs: Double = 2.0, - parts: [(String, Double)], - extra: String? = nil - ) { - guard isEnabled else { return } - let delayMs: Double? = { - guard let event, event.timestamp > 0 else { return nil } - return max(0, (ProcessInfo.processInfo.systemUptime - event.timestamp) * 1000.0) - }() - let hasSlowPart = parts.contains { $0.1 >= thresholdMs } - guard isVerboseProbeEnabled || totalMs >= thresholdMs || hasSlowPart || (delayMs ?? 0) >= delayLogThresholdMs else { - return - } - var line = "typing.phase path=\(path) totalMs=\(format(totalMs))" - if let event { - line += " \(eventFields(event))" - } - if let delayMs { - line += " delayMs=\(format(delayMs))" - } - for (name, value) in parts where isVerboseProbeEnabled || value >= 0.05 { - line += " \(name)=\(format(value))" - } - if let extra, !extra.isEmpty { - line += " \(extra)" - } - dlog(line) - } - @inline(__always) - private static func eventFields(_ event: NSEvent) -> String { - "eventType=\(event.type.rawValue) keyCode=\(event.keyCode) mods=\(event.modifierFlags.rawValue) repeat=\(event.isARepeat ? 1 : 0)" - } - @inline(__always) - private static func shouldLog(delayMs: Double?, elapsedMs: Double?) -> Bool { - if isVerboseProbeEnabled { - return true - } - if let delayMs, delayMs >= delayLogThresholdMs { - return true - } - if let elapsedMs, elapsedMs >= durationLogThresholdMs { - return true - } - return false - } - @inline(__always) - private static func format(_ value: Double) -> String { - String(format: "%.2f", value) + + +private extension NSScreen { + var programaDisplayID: UInt32? { + let key = NSDeviceDescriptionKey("NSScreenNumber") + guard let value = deviceDescription[key] as? NSNumber else { return nil } + return value.uint32Value } } -final class ProgramaMainRunLoopStallMonitor { - static let shared = ProgramaMainRunLoopStallMonitor() - - private let thresholdMs: Double = 8.0 - private var observer: CFRunLoopObserver? - private var installed = false - private var lastActivity: CFRunLoopActivity? - private var lastTimestamp: TimeInterval? +func browserOmnibarSelectionDeltaForCommandNavigation( + hasFocusedAddressBar: Bool, + flags: NSEvent.ModifierFlags, + chars: String +) -> Int? { + guard hasFocusedAddressBar else { return nil } + let normalizedFlags = browserOmnibarNormalizedModifierFlags(flags) + let isCommandOrControlOnly = normalizedFlags == [.command] || normalizedFlags == [.control] + guard isCommandOrControlOnly else { return nil } + if chars == "n" { return 1 } + if chars == "p" { return -1 } + return nil +} - private init() {} +func browserOmnibarSelectionDeltaForArrowNavigation( + hasFocusedAddressBar: Bool, + flags: NSEvent.ModifierFlags, + keyCode: UInt16 +) -> Int? { + guard hasFocusedAddressBar else { return nil } + let normalizedFlags = browserOmnibarNormalizedModifierFlags(flags) + guard normalizedFlags == [] else { return nil } + switch keyCode { + case 125: return 1 + case 126: return -1 + default: return nil + } +} - func installIfNeeded() { - guard ProgramaTypingTiming.isEnabled else { return } - guard !installed else { return } +func browserOmnibarNormalizedModifierFlags(_ flags: NSEvent.ModifierFlags) -> NSEvent.ModifierFlags { + flags + .intersection(.deviceIndependentFlagsMask) + .subtracting([.numericPad, .function, .capsLock]) +} - var context = CFRunLoopObserverContext( - version: 0, - info: Unmanaged.passUnretained(self).toOpaque(), - retain: nil, - release: nil, - copyDescription: nil - ) +func browserOmnibarShouldSubmitOnReturn(flags: NSEvent.ModifierFlags) -> Bool { + let normalizedFlags = browserOmnibarNormalizedModifierFlags(flags) + return normalizedFlags == [] || normalizedFlags == [.shift] +} - observer = CFRunLoopObserverCreate( - kCFAllocatorDefault, - CFRunLoopActivity.allActivities.rawValue, - true, - CFIndex.max, - { _, activity, info in - guard let info else { return } - let monitor = Unmanaged.fromOpaque(info).takeUnretainedValue() - monitor.handle(activity: activity) - }, - &context - ) +func browserResponderHasMarkedText(_ responder: NSResponder?) -> Bool { + guard let responder else { return false } - guard let observer else { return } - CFRunLoopAddObserver(CFRunLoopGetMain(), observer, .commonModes) - installed = true + // During IME composition, Return/Enter belongs to the text system so the + // candidate list can commit or confirm the marked text. + if let textInputClient = responder as? NSTextInputClient { + if textInputClient.hasMarkedText() { return true } } - private func handle(activity: CFRunLoopActivity) { - let now = ProcessInfo.processInfo.systemUptime - defer { - lastActivity = activity - lastTimestamp = now - } - - guard let lastActivity, let lastTimestamp else { return } - let elapsedMs = max(0, (now - lastTimestamp) * 1000.0) - guard elapsedMs >= thresholdMs else { return } - if lastActivity == .beforeWaiting && activity == .afterWaiting { - return - } + if let textField = responder as? NSTextField, + let editor = textField.currentEditor() as? NSTextView { + if editor.hasMarkedText() { return true } + } - let mode = CFRunLoopCopyCurrentMode(CFRunLoopGetMain()).map { String(describing: $0) } ?? "nil" - let firstResponder = NSApp.keyWindow?.firstResponder.map { String(describing: type(of: $0)) } ?? "nil" - let currentEvent = NSApp.currentEvent.map { - "eventType=\($0.type.rawValue) keyCode=\($0.keyCode) mods=\($0.modifierFlags.rawValue)" - } ?? "event=nil" - dlog( - "runloop.stall gapMs=\(String(format: "%.2f", elapsedMs)) prev=\(label(for: lastActivity)) " + - "next=\(label(for: activity)) mode=\(mode) firstResponder=\(firstResponder) \(currentEvent)" - ) + // WKWebView clears marked text before performKeyEquivalent fires, so the + // synchronous hasMarkedText() check above can return false even though an IME + // composition just ended on the same Enter keystroke. Check the JS bridge's + // composition timestamp to detect this race condition (#2626). + if let webView = responder.programaEnclosingProgramaWebView { + if webView.webViewIsComposing { return true } + let age = ProcessInfo.processInfo.systemUptime - webView.recentCompositionEndTimestamp + if age >= 0 && age < 0.15 { return true } } - private func label(for activity: CFRunLoopActivity) -> String { - switch activity { - case .entry: - return "entry" - case .beforeTimers: - return "beforeTimers" - case .beforeSources: - return "beforeSources" - case .beforeWaiting: - return "beforeWaiting" - case .afterWaiting: - return "afterWaiting" - case .exit: - return "exit" - default: - return "unknown(\(activity.rawValue))" + return false +} + +private extension NSResponder { + /// Walk the responder chain to find the enclosing ProgramaWebView. + var programaEnclosingProgramaWebView: ProgramaWebView? { + var current: NSResponder? = self + while let responder = current { + if let webView = responder as? ProgramaWebView { return webView } + current = responder.nextResponder } + return nil } } -final class ProgramaMainThreadTurnProfiler { - static let shared = ProgramaMainThreadTurnProfiler() +func shouldDispatchBrowserReturnViaFirstResponderKeyDown( + keyCode: UInt16, + firstResponderIsBrowser: Bool, + firstResponderHasMarkedText: Bool = false, + flags: NSEvent.ModifierFlags +) -> Bool { + guard firstResponderIsBrowser else { return false } + guard !firstResponderHasMarkedText else { return false } + guard keyCode == 36 || keyCode == 76 else { return false } + // Keep browser Return forwarding narrow: only plain/Shift Return should be + // treated as submit-intent. Command-modified Return is reserved for app shortcuts + // like Toggle Pane Zoom (Cmd+Shift+Enter). + return browserOmnibarShouldSubmitOnReturn(flags: flags) +} - private struct BucketStats { - var count: Int = 0 - var totalMs: Double = 0 - var maxMs: Double = 0 +func shouldToggleMainWindowFullScreenForCommandControlFShortcut( + flags: NSEvent.ModifierFlags, + chars: String, + keyCode: UInt16, + layoutCharacterProvider: (UInt16, NSEvent.ModifierFlags) -> String? = KeyboardLayout.character(forKeyCode:modifierFlags:) +) -> Bool { + let normalizedFlags = flags + .intersection(.deviceIndependentFlagsMask) + .subtracting([.numericPad, .function, .capsLock]) + guard normalizedFlags == [.command, .control] else { return false } + let normalizedChars = chars.lowercased() + if normalizedChars == "f" { + return true } - - private let trackedThresholdMs: Double = 3.0 - private let countThreshold: Int = 16 - private var observer: CFRunLoopObserver? - private var installed = false - private var turnStart: TimeInterval? - private var buckets: [String: BucketStats] = [:] - - private init() {} - - @inline(__always) - static func endMeasure(_ bucket: String, startedAt: TimeInterval?) { - guard let startedAt, ProgramaTypingTiming.isEnabled, Thread.isMainThread else { return } - let elapsedMs = max(0, (ProcessInfo.processInfo.systemUptime - startedAt) * 1000.0) - shared.record(bucket: bucket, elapsedMs: elapsedMs, count: 1) + let charsAreControlSequence = !normalizedChars.isEmpty + && normalizedChars.unicodeScalars.allSatisfy { CharacterSet.controlCharacters.contains($0) } + if !normalizedChars.isEmpty && !charsAreControlSequence { + return false } - func installIfNeeded() { - guard ProgramaTypingTiming.isEnabled else { return } - guard !installed else { return } - - var context = CFRunLoopObserverContext( - version: 0, - info: Unmanaged.passUnretained(self).toOpaque(), - retain: nil, - release: nil, - copyDescription: nil - ) - - observer = CFRunLoopObserverCreate( - kCFAllocatorDefault, - CFRunLoopActivity.allActivities.rawValue, - true, - CFIndex.max, - { _, activity, info in - guard let info else { return } - let profiler = Unmanaged.fromOpaque(info).takeUnretainedValue() - profiler.handle(activity: activity) - }, - &context - ) - - guard let observer else { return } - CFRunLoopAddObserver(CFRunLoopGetMain(), observer, .commonModes) - installed = true + // Fallback to layout translation only when characters are unavailable (for + // synthetic/key-equivalent paths that can report an empty string). + if let translatedCharacter = layoutCharacterProvider(keyCode, flags), !translatedCharacter.isEmpty { + return translatedCharacter == "f" } - private func handle(activity: CFRunLoopActivity) { - let now = ProcessInfo.processInfo.systemUptime - switch activity { - case .entry, .afterWaiting: - turnStart = now - buckets.removeAll(keepingCapacity: true) - case .beforeWaiting, .exit: - flushTurn(at: now, nextActivity: activity) - default: - break - } - } + // Keep ANSI fallback as a final safety net when layout translation is unavailable. + return keyCode == 3 +} + +func commandPaletteSelectionDeltaForKeyboardNavigation( + flags: NSEvent.ModifierFlags, + chars: String, + keyCode: UInt16 +) -> Int? { + let normalizedFlags = flags + .intersection(.deviceIndependentFlagsMask) + .subtracting([.numericPad, .function]) + let normalizedChars = chars.lowercased() - private func record(bucket: String, elapsedMs: Double, count: Int) { - if turnStart == nil { - turnStart = ProcessInfo.processInfo.systemUptime + if normalizedFlags == [] { + switch keyCode { + case 125: return 1 // Down arrow + case 126: return -1 // Up arrow + default: break } - var stats = buckets[bucket, default: BucketStats()] - stats.count += count - stats.totalMs += elapsedMs - stats.maxMs = max(stats.maxMs, elapsedMs) - buckets[bucket] = stats } - private func flushTurn(at now: TimeInterval, nextActivity: CFRunLoopActivity) { - defer { - turnStart = nil - buckets.removeAll(keepingCapacity: true) - } - - guard let turnStart else { return } - guard !buckets.isEmpty else { return } - - let turnMs = max(0, (now - turnStart) * 1000.0) - let trackedMs = buckets.values.reduce(0) { $0 + $1.totalMs } - let totalCount = buckets.values.reduce(0) { $0 + $1.count } - guard trackedMs >= trackedThresholdMs || totalCount >= countThreshold else { return } - - let mode = CFRunLoopCopyCurrentMode(CFRunLoopGetMain()).map { String(describing: $0) } ?? "nil" - let firstResponder = NSApp.keyWindow?.firstResponder.map { String(describing: type(of: $0)) } ?? "nil" - let eventSummary = NSApp.currentEvent.map { - "eventType=\($0.type.rawValue) keyCode=\($0.keyCode) mods=\($0.modifierFlags.rawValue)" - } ?? "event=nil" - let bucketSummary = buckets - .sorted { - if abs($0.value.totalMs - $1.value.totalMs) > 0.01 { - return $0.value.totalMs > $1.value.totalMs - } - return $0.value.count > $1.value.count - } - .prefix(8) - .map { key, value in - if value.totalMs > 0.05 || value.maxMs > 0.05 { - return "\(key)=\(value.count)/\(String(format: "%.2f", value.totalMs))/\(String(format: "%.2f", value.maxMs))" - } - return "\(key)=\(value.count)" - } - .joined(separator: " ") - - dlog( - "main.turn.work turnMs=\(String(format: "%.2f", turnMs)) trackedMs=\(String(format: "%.2f", trackedMs)) totalCount=\(totalCount) " + - "next=\(label(for: nextActivity)) mode=\(mode) firstResponder=\(firstResponder) \(eventSummary) " + - "\(bucketSummary)" - ) + if normalizedFlags == [.control] { + // Control modifiers can surface as either printable chars or ASCII control chars. + // Keep Emacs-style next/previous navigation, but leave other control bindings + // (for example Ctrl+K text editing in the palette search field) to AppKit. + if keyCode == 45 || normalizedChars == "n" || normalizedChars == "\u{0e}" { return 1 } // Ctrl+N + if keyCode == 35 || normalizedChars == "p" || normalizedChars == "\u{10}" { return -1 } // Ctrl+P } - private func label(for activity: CFRunLoopActivity) -> String { - switch activity { - case .entry: - return "entry" - case .beforeTimers: - return "beforeTimers" - case .beforeSources: - return "beforeSources" - case .beforeWaiting: - return "beforeWaiting" - case .afterWaiting: - return "afterWaiting" - case .exit: - return "exit" - default: - return "unknown(\(activity.rawValue))" - } - } + return nil } -#endif -enum FinderServicePathResolver { - private static func canonicalDirectoryPath(_ path: String) -> String { - guard path.count > 1 else { return path } - var canonical = path - while canonical.count > 1 && canonical.hasSuffix("/") { - canonical.removeLast() - } - return canonical - } +func shouldRouteCommandPaletteSelectionNavigation( + delta: Int?, + isInteractive: Bool, + usesInlineTextHandling: Bool +) -> Bool { + guard delta != nil, isInteractive else { return false } + return !usesInlineTextHandling +} - private static func normalizedComparisonURL(_ url: URL) -> URL { - url.standardizedFileURL.resolvingSymlinksInPath() - } +func shouldConsumeShortcutWhileCommandPaletteVisible( + isCommandPaletteVisible: Bool, + normalizedFlags: NSEvent.ModifierFlags, + chars: String, + keyCode: UInt16 +) -> Bool { + guard isCommandPaletteVisible else { return false } - private static func isSameOrDescendant(_ url: URL, of rootURL: URL) -> Bool { - let urlPathComponents = normalizedComparisonURL(url).pathComponents - let rootPathComponents = normalizedComparisonURL(rootURL).pathComponents - guard urlPathComponents.count >= rootPathComponents.count else { return false } - return Array(urlPathComponents.prefix(rootPathComponents.count)) == rootPathComponents + // Escape dismisses the palette, and must not leak through to the + // underlying terminal or browser content. + if normalizedFlags.isEmpty, keyCode == 53 { + return true } - private static func resolvedDirectoryURL(from url: URL) -> URL { - let standardized = url.standardizedFileURL - if standardized.hasDirectoryPath { - return standardized - } - if let resourceValues = try? standardized.resourceValues(forKeys: [.isDirectoryKey]), - resourceValues.isDirectory == true { - return standardized - } - return standardized.deletingLastPathComponent() - } + guard normalizedFlags.contains(.command) else { return false } - static func orderedUniqueDirectories( - from pathURLs: [URL], - excludingDescendantsOf excludedRootURLs: [URL] = [] - ) -> [String] { - var seen: Set = [] - var directories: [String] = [] + let normalizedChars = chars.lowercased() - for url in pathURLs { - let directoryURL = resolvedDirectoryURL(from: url) - guard !excludedRootURLs.contains(where: { isSameOrDescendant(directoryURL, of: $0) }) else { - continue - } - let path = canonicalDirectoryPath(directoryURL.path(percentEncoded: false)) - guard !path.isEmpty else { continue } - if seen.insert(path).inserted { - directories.append(path) - } + if normalizedFlags == [.command] { + if normalizedChars == "a" + || normalizedChars == "c" + || normalizedChars == "v" + || normalizedChars == "x" + || normalizedChars == "z" + || normalizedChars == "y" { + return false } - return directories + switch keyCode { + case 51, 117, 123, 124: + return false + default: + break + } } -} -enum TerminalDirectoryOpenTarget: String, CaseIterable { - case androidStudio - case antigravity - case cursor - case finder - case ghostty - case intellij - case iterm2 - case terminal - case tower - case vscode - case vscodeInline - case warp - case windsurf - case xcode - case zed - - struct DetectionEnvironment { - let homeDirectoryPath: String - let fileExistsAtPath: (String) -> Bool - let isExecutableFileAtPath: (String) -> Bool - let applicationPathForName: (String) -> String? - - static let live = DetectionEnvironment( - homeDirectoryPath: FileManager.default.homeDirectoryForCurrentUser.path, - fileExistsAtPath: { FileManager.default.fileExists(atPath: $0) }, - isExecutableFileAtPath: { FileManager.default.isExecutableFile(atPath: $0) }, - applicationPathForName: { NSWorkspace.shared.fullPath(forApplication: $0) } - ) + if normalizedFlags == [.command, .shift], normalizedChars == "z" { + return false } - static var commandPaletteShortcutTargets: [Self] { - Array(allCases) + return true +} + +func shouldSubmitCommandPaletteWithReturn( + keyCode: UInt16, + flags: NSEvent.ModifierFlags, + mode: String +) -> Bool { + guard keyCode == 36 || keyCode == 76 else { return false } + let normalizedFlags = flags + .intersection(.deviceIndependentFlagsMask) + .subtracting([.numericPad, .function, .capsLock]) + if normalizedFlags.isEmpty { + return true + } + if normalizedFlags == [.shift] { + return mode != "workspace_description_input" } + return false +} - static func availableTargets(in environment: DetectionEnvironment = .live) -> Set { - Set(commandPaletteShortcutTargets.filter { $0.isAvailable(in: environment) }) +func commandPaletteFieldEditorHasMarkedText(in window: NSWindow) -> Bool { + if let editor = window.firstResponder as? NSTextView { + return editor.hasMarkedText() + } + if let textField = window.firstResponder as? NSTextField, + let editor = textField.currentEditor() as? NSTextView { + return editor.hasMarkedText() } + return false +} - var commandPaletteCommandId: String { - "palette.terminalOpenDirectory.\(rawValue)" +func shouldHandleCommandPaletteShortcutEvent( + _ event: NSEvent, + paletteWindow: NSWindow? +) -> Bool { + guard let paletteWindow else { return false } + if let eventWindow = event.window { + return eventWindow === paletteWindow + } + let eventWindowNumber = event.windowNumber + if eventWindowNumber > 0 { + return eventWindowNumber == paletteWindow.windowNumber + } + if let keyWindow = NSApp.keyWindow { + return keyWindow === paletteWindow } + return false +} - var commandPaletteTitle: String { - switch self { - case .androidStudio: - return String(localized: "menu.openInAndroidStudio", defaultValue: "Open Current Directory in Android Studio") - case .antigravity: - return String(localized: "menu.openInAntigravity", defaultValue: "Open Current Directory in Antigravity") - case .cursor: - return String(localized: "menu.openInCursor", defaultValue: "Open Current Directory in Cursor") - case .finder: - return String(localized: "menu.openInFinder", defaultValue: "Open Current Directory in Finder") - case .ghostty: - return String(localized: "menu.openInGhostty", defaultValue: "Open Current Directory in Ghostty") - case .intellij: - return String(localized: "menu.openInIntelliJ", defaultValue: "Open Current Directory in IntelliJ IDEA") - case .iterm2: - return String(localized: "menu.openInITerm2", defaultValue: "Open Current Directory in iTerm2") - case .terminal: - return String(localized: "menu.openInTerminal", defaultValue: "Open Current Directory in Terminal") - case .tower: - return String(localized: "menu.openInTower", defaultValue: "Open Current Directory in Tower") - case .vscode: - return String(localized: "menu.openInVSCodeDesktop", defaultValue: "Open Current Directory in VS Code") - case .vscodeInline: - return String(localized: "menu.openInVSCode", defaultValue: "Open Current Directory in VS Code (Inline)") - case .warp: - return String(localized: "menu.openInWarp", defaultValue: "Open Current Directory in Warp") - case .windsurf: - return String(localized: "menu.openInWindsurf", defaultValue: "Open Current Directory in Windsurf") - case .xcode: - return String(localized: "menu.openInXcode", defaultValue: "Open Current Directory in Xcode") - case .zed: - return String(localized: "menu.openInZed", defaultValue: "Open Current Directory in Zed") - } - } - - var commandPaletteKeywords: [String] { - let common = ["terminal", "directory", "open", "ide"] - switch self { - case .androidStudio: - return common + ["android", "studio"] - case .antigravity: - return common + ["antigravity"] - case .cursor: - return common + ["cursor"] - case .finder: - return common + ["finder", "file", "manager", "reveal"] - case .ghostty: - return common + ["ghostty", "terminal", "shell"] - case .intellij: - return common + ["intellij", "idea", "jetbrains"] - case .iterm2: - return common + ["iterm", "iterm2", "terminal", "shell"] - case .terminal: - return common + ["terminal", "shell"] - case .tower: - return common + ["tower", "git", "client"] - case .vscode: - return common + ["vs", "code", "visual", "studio", "desktop", "app"] - case .vscodeInline: - return common + ["vs", "code", "visual", "studio", "inline", "browser", "serve-web"] - case .warp: - return common + ["warp", "terminal", "shell"] - case .windsurf: - return common + ["windsurf"] - case .xcode: - return common + ["xcode", "apple"] - case .zed: - return common + ["zed"] - } - } - - func isAvailable(in environment: DetectionEnvironment = .live) -> Bool { - guard let applicationPath = applicationPath(in: environment) else { return false } - guard self == .vscodeInline else { return true } - return VSCodeCLILaunchConfigurationBuilder.launchConfiguration( - vscodeApplicationURL: URL(fileURLWithPath: applicationPath, isDirectory: true), - isExecutableAtPath: environment.isExecutableFileAtPath - ) != nil - } - - func applicationURL(in environment: DetectionEnvironment = .live) -> URL? { - guard let path = applicationPath(in: environment) else { return nil } - return URL(fileURLWithPath: path, isDirectory: true) - } - - private func applicationPath(in environment: DetectionEnvironment) -> String? { - for path in expandedCandidatePaths(in: environment) where environment.fileExistsAtPath(path) { - return path - } - - // Fall back to LaunchServices so apps outside the standard bundle paths - // still appear in the command palette. - for applicationName in applicationSearchNames { - guard let resolvedPath = environment.applicationPathForName(applicationName), - environment.fileExistsAtPath(resolvedPath) else { - continue - } - return resolvedPath - } +enum BrowserZoomShortcutAction: Equatable { + case zoomIn + case zoomOut + case reset +} - return nil - } +struct CommandPaletteDebugResultRow { + let commandId: String + let title: String + let shortcutHint: String? + let trailingLabel: String? + let score: Int +} + +struct CommandPaletteDebugSnapshot { + let query: String + let mode: String + let results: [CommandPaletteDebugResultRow] - private func expandedCandidatePaths(in environment: DetectionEnvironment) -> [String] { - let globalPrefix = "/Applications/" - let userPrefix = "\(environment.homeDirectoryPath)/Applications/" - var expanded: [String] = [] + static let empty = CommandPaletteDebugSnapshot(query: "", mode: "commands", results: []) +} - for candidate in applicationBundlePathCandidates { - expanded.append(candidate) - if candidate.hasPrefix(globalPrefix) { - let suffix = String(candidate.dropFirst(globalPrefix.count)) - expanded.append(userPrefix + suffix) - } - } +func browserZoomShortcutAction( + flags: NSEvent.ModifierFlags, + chars: String, + keyCode: UInt16, + literalChars: String? = nil +) -> BrowserZoomShortcutAction? { + let normalizedFlags = flags + .intersection(.deviceIndependentFlagsMask) + .subtracting([.numericPad, .function]) + let hasCommand = normalizedFlags.contains(.command) + let hasOnlyCommandAndOptionalShift = hasCommand && normalizedFlags.isDisjoint(with: [.control, .option]) + + guard hasOnlyCommandAndOptionalShift else { return nil } + let keys = browserZoomShortcutKeyCandidates( + chars: chars, + literalChars: literalChars, + keyCode: keyCode + ) - return uniquePreservingOrder(expanded) + if keys.contains("=") || keys.contains("+") || keyCode == 24 || keyCode == 69 { // kVK_ANSI_Equal / kVK_ANSI_KeypadPlus + return .zoomIn } - private var applicationSearchNames: [String] { - uniquePreservingOrder( - applicationBundlePathCandidates.map { - URL(fileURLWithPath: $0).deletingPathExtension().lastPathComponent - } - ) + if keys.contains("-") || keys.contains("_") || keyCode == 27 || keyCode == 78 { // kVK_ANSI_Minus / kVK_ANSI_KeypadMinus + return .zoomOut } - private var applicationBundlePathCandidates: [String] { - switch self { - case .androidStudio: - return ["/Applications/Android Studio.app"] - case .antigravity: - return ["/Applications/Antigravity.app"] - case .cursor: - return [ - "/Applications/Cursor.app", - "/Applications/Cursor Preview.app", - "/Applications/Cursor Nightly.app", - ] - case .finder: - return ["/System/Library/CoreServices/Finder.app"] - case .ghostty: - return ["/Applications/Ghostty.app"] - case .intellij: - return ["/Applications/IntelliJ IDEA.app"] - case .iterm2: - return [ - "/Applications/iTerm.app", - "/Applications/iTerm2.app", - ] - case .terminal: - return ["/System/Applications/Utilities/Terminal.app"] - case .tower: - return ["/Applications/Tower.app"] - case .vscode: - return [ - "/Applications/Visual Studio Code.app", - "/Applications/Code.app", - ] - case .vscodeInline: - return [ - "/Applications/Visual Studio Code.app", - "/Applications/Code.app", - ] - case .warp: - return ["/Applications/Warp.app"] - case .windsurf: - return ["/Applications/Windsurf.app"] - case .xcode: - return ["/Applications/Xcode.app"] - case .zed: - return [ - "/Applications/Zed.app", - "/Applications/Zed Preview.app", - "/Applications/Zed Nightly.app", - ] - } - } - - private func uniquePreservingOrder(_ paths: [String]) -> [String] { - var seen: Set = [] - var deduped: [String] = [] - for path in paths where seen.insert(path).inserted { - deduped.append(path) - } - return deduped + if keys.contains("0") || keyCode == 29 || keyCode == 82 { // kVK_ANSI_0 / kVK_ANSI_Keypad0 + return .reset } + + return nil } -enum VSCodeServeWebURLBuilder { - static func extractWebUIURL(from output: String) -> URL? { - let prefix = "Web UI available at " - for line in output.split(whereSeparator: \.isNewline).reversed() { - guard let range = line.range(of: prefix) else { continue } - let rawURL = line[range.upperBound...].trimmingCharacters(in: .whitespacesAndNewlines) - guard !rawURL.isEmpty, let url = URL(string: rawURL) else { continue } - return url - } - return nil +func browserZoomShortcutKeyCandidates( + chars: String, + literalChars: String?, + keyCode: UInt16 +) -> Set { + var keys: Set = [chars.lowercased()] + + if let literalChars, !literalChars.isEmpty { + keys.insert(literalChars.lowercased()) } - static func openFolderURL(baseWebUIURL: URL, directoryPath: String) -> URL? { - var components = URLComponents(url: baseWebUIURL, resolvingAgainstBaseURL: false) - var queryItems = components?.queryItems ?? [] - queryItems.removeAll { $0.name == "folder" } - queryItems.append(URLQueryItem(name: "folder", value: directoryPath)) - components?.queryItems = queryItems - return components?.url + if let layoutChar = KeyboardLayout.character(forKeyCode: keyCode), !layoutChar.isEmpty { + keys.insert(layoutChar) } -} -struct VSCodeCLILaunchConfiguration { - let executableURL: URL - let argumentsPrefix: [String] - let environment: [String: String] + return keys } -enum VSCodeCLILaunchConfigurationBuilder { - static func launchConfiguration( - vscodeApplicationURL: URL, - baseEnvironment: [String: String] = ProcessInfo.processInfo.environment, - isExecutableAtPath: (String) -> Bool = { FileManager.default.isExecutableFile(atPath: $0) } - ) -> VSCodeCLILaunchConfiguration? { - let contentsURL = vscodeApplicationURL.appendingPathComponent("Contents", isDirectory: true) - let codeTunnelURL = contentsURL.appendingPathComponent("Resources/app/bin/code-tunnel", isDirectory: false) - guard isExecutableAtPath(codeTunnelURL.path) else { return nil } - - var environment = baseEnvironment - environment["ELECTRON_RUN_AS_NODE"] = "1" - environment.removeValue(forKey: "VSCODE_NODE_OPTIONS") - environment.removeValue(forKey: "VSCODE_NODE_REPL_EXTERNAL_MODULE") - if let nodeOptions = environment["NODE_OPTIONS"] { - environment["VSCODE_NODE_OPTIONS"] = nodeOptions - } - if let nodeReplExternalModule = environment["NODE_REPL_EXTERNAL_MODULE"] { - environment["VSCODE_NODE_REPL_EXTERNAL_MODULE"] = nodeReplExternalModule - } - environment.removeValue(forKey: "NODE_OPTIONS") - environment.removeValue(forKey: "NODE_REPL_EXTERNAL_MODULE") - environment["VSCODE_CLI_USE_FILE_KEYRING"] = "1" - - return VSCodeCLILaunchConfiguration( - executableURL: codeTunnelURL, - argumentsPrefix: [], - environment: environment - ) - } +func shouldSuppressSplitShortcutForTransientTerminalFocusInputs( + firstResponderIsWindow: Bool, + hostedSize: CGSize, + hostedHiddenInHierarchy: Bool, + hostedAttachedToWindow: Bool +) -> Bool { + guard firstResponderIsWindow else { return false } + let tinyGeometry = hostedSize.width <= 1 || hostedSize.height <= 1 + return tinyGeometry || hostedHiddenInHierarchy || !hostedAttachedToWindow } -final class VSCodeServeWebController { - static let shared = VSCodeServeWebController() - private static let serveWebStartupTimeoutSeconds: TimeInterval = 60 - - private let queue = DispatchQueue(label: "programa.vscode.serveWeb") - private let launchQueue = DispatchQueue(label: "programa.vscode.serveWeb.launch") - private let launchProcessOverride: ((URL, UInt64) -> (process: Process, url: URL)?)? - private var serveWebProcess: Process? - private var launchingProcess: Process? - private var connectionTokenFilesByProcessID: [ObjectIdentifier: URL] = [:] - private var serveWebURL: URL? - private var pendingCompletions: [(generation: UInt64, completion: (URL?) -> Void)] = [] - private var isLaunching = false - private var activeLaunchGeneration: UInt64? - private var lifecycleGeneration: UInt64 = 0 -#if DEBUG - private var testingTrackedProcesses: [Process] = [] -#endif +func shouldRouteTerminalFontZoomShortcutToGhostty( + firstResponderIsGhostty: Bool, + flags: NSEvent.ModifierFlags, + chars: String, + keyCode: UInt16, + literalChars: String? = nil +) -> Bool { + guard firstResponderIsGhostty else { return false } + return browserZoomShortcutAction( + flags: flags, + chars: chars, + keyCode: keyCode, + literalChars: literalChars + ) != nil +} - private init(launchProcessOverride: ((URL, UInt64) -> (process: Process, url: URL)?)? = nil) { - self.launchProcessOverride = launchProcessOverride +@discardableResult +func startOrFocusTerminalSearch( + _ terminalSurface: TerminalSurface, + searchFocusNotifier: @escaping (TerminalSurface) -> Void = { + NotificationCenter.default.post(name: .ghosttySearchFocus, object: $0) } - -#if DEBUG - static func makeForTesting( - launchProcessOverride: @escaping (URL, UInt64) -> (process: Process, url: URL)? - ) -> VSCodeServeWebController { - VSCodeServeWebController(launchProcessOverride: launchProcessOverride) +) -> Bool { + if terminalSurface.searchState != nil { + searchFocusNotifier(terminalSurface) + return true } - func trackConnectionTokenFileForTesting( - _ connectionTokenFileURL: URL, - setAsLaunchingProcess: Bool = false, - setAsServeWebProcess: Bool = false - ) { - let process = Process() - queue.sync { - if setAsLaunchingProcess { - self.launchingProcess = process - } - if setAsServeWebProcess { - self.serveWebProcess = process - } - if !setAsLaunchingProcess && !setAsServeWebProcess { - self.testingTrackedProcesses.append(process) - } - self.connectionTokenFilesByProcessID[ObjectIdentifier(process)] = connectionTokenFileURL + if terminalSurface.performBindingAction("start_search") { + DispatchQueue.main.async { [weak terminalSurface] in + guard let terminalSurface, terminalSurface.searchState == nil else { return } + terminalSurface.searchState = TerminalSurface.SearchState() + searchFocusNotifier(terminalSurface) } + return true } -#endif - func ensureServeWebURL(vscodeApplicationURL: URL, completion: @escaping (URL?) -> Void) { - queue.async { - if let process = self.serveWebProcess, - process.isRunning, - let url = self.serveWebURL { - DispatchQueue.main.async { - completion(url) - } - return - } + terminalSurface.searchState = TerminalSurface.SearchState() + searchFocusNotifier(terminalSurface) + return true +} - let completionGeneration = self.lifecycleGeneration - self.pendingCompletions.append((generation: completionGeneration, completion: completion)) - guard !self.isLaunching else { return } +/// Let AppKit own native Cmd+` window cycling so key-window changes do not +/// re-enter our direct-to-menu shortcut path. +func shouldRouteCommandEquivalentDirectlyToMainMenu(_ event: NSEvent) -> Bool { + let flags = event.modifierFlags.intersection(.deviceIndependentFlagsMask) + guard flags.contains(.command) else { return false } - self.isLaunching = true - let launchGeneration = completionGeneration - self.activeLaunchGeneration = launchGeneration + let normalizedFlags = flags.subtracting([.numericPad, .function, .capsLock]) + if event.keyCode == 50, + normalizedFlags == [.command] || normalizedFlags == [.command, .shift] { + return false + } - self.launchQueue.async { - let shouldLaunch = self.queue.sync { - self.lifecycleGeneration == launchGeneration - } - guard shouldLaunch else { - self.queue.async { - guard self.activeLaunchGeneration == launchGeneration else { return } - self.isLaunching = false - self.activeLaunchGeneration = nil - } - return - } - let launchResult = self.launchServeWebProcess( - vscodeApplicationURL: vscodeApplicationURL, - expectedGeneration: launchGeneration - ) - self.queue.async { - guard self.activeLaunchGeneration == launchGeneration else { - if let process = launchResult?.process, process.isRunning { - process.terminate() - } - return - } - self.isLaunching = false - self.activeLaunchGeneration = nil - - guard self.lifecycleGeneration == launchGeneration else { - if let launchedProcess = launchResult?.process, - self.launchingProcess === launchedProcess { - self.launchingProcess = nil - } - if let process = launchResult?.process, process.isRunning { - process.terminate() - } - return - } + return true +} - if let launchResult { - self.launchingProcess = nil - self.serveWebProcess = launchResult.process - self.serveWebURL = launchResult.url - } else { - self.launchingProcess = nil - self.serveWebProcess = nil - self.serveWebURL = nil - } +private enum BrowserFindCommandEquivalent { + case find + case findNext + case findPrevious + case hideFind + case useSelection - var completions: [(URL?) -> Void] = [] - var remaining: [(generation: UInt64, completion: (URL?) -> Void)] = [] - for pending in self.pendingCompletions { - if pending.generation == launchGeneration { - completions.append(pending.completion) - } else { - remaining.append(pending) - } - } - self.pendingCompletions = remaining - let resolvedURL = self.serveWebURL - DispatchQueue.main.async { - completions.forEach { $0(resolvedURL) } - } - } - } + var keepsProgramaBrowserFindBarOwnershipWhenVisible: Bool { + switch self { + case .find, .findNext, .findPrevious, .hideFind: + return true + case .useSelection: + return false } } +} - func stop() { - let (processes, tokenFileURLs, completions): ([Process], [URL], [(URL?) -> Void]) = queue.sync { - self.lifecycleGeneration &+= 1 - self.isLaunching = false - self.activeLaunchGeneration = nil - var processes: [Process] = [] - if let process = self.serveWebProcess { - processes.append(process) - } - if let process = self.launchingProcess, - !processes.contains(where: { $0 === process }) { - processes.append(process) - } - self.serveWebProcess = nil - self.launchingProcess = nil -#if DEBUG - self.testingTrackedProcesses.removeAll() -#endif - var tokenFileURLs = processes.compactMap { - self.connectionTokenFilesByProcessID.removeValue(forKey: ObjectIdentifier($0)) - } - tokenFileURLs.append(contentsOf: self.connectionTokenFilesByProcessID.values) - self.connectionTokenFilesByProcessID.removeAll() - self.serveWebURL = nil - let completions = self.pendingCompletions.map(\.completion) - self.pendingCompletions.removeAll() - return (processes, tokenFileURLs, completions) +private func programaIsLikelyWebInspectorResponder(_ responder: NSResponder?) -> Bool { + guard let responder else { return false } + let responderType = String(describing: type(of: responder)) + if responderType.contains("WKInspector") { + return true + } + guard let view = responder as? NSView else { return false } + var node: NSView? = view + var hops = 0 + while let current = node, hops < 64 { + if String(describing: type(of: current)).contains("WKInspector") { + return true } + node = current.superview + hops += 1 + } + return false +} - for tokenFileURL in tokenFileURLs where tokenFileURL.path.hasPrefix(NSTemporaryDirectory()) { - Self.removeConnectionTokenFile(at: tokenFileURL) - } +private func browserFindCommandEquivalent(for event: NSEvent) -> BrowserFindCommandEquivalent? { + let flags = event.modifierFlags + .intersection(.deviceIndependentFlagsMask) + .subtracting([.numericPad, .function, .capsLock]) - for process in processes where process.isRunning { - process.terminate() + let normalizedChars = KeyboardLayout.normalizedCharacters(for: event).lowercased() + let hasSingleASCIIShortcutChar = + normalizedChars.count == 1 && normalizedChars.allSatisfy(\.isASCII) + let producedAnyASCIIShortcutChar = normalizedChars.contains(where: \.isASCII) + func matches(_ chars: String, keyCode: UInt16) -> Bool { + if hasSingleASCIIShortcutChar { + return normalizedChars == chars } - - if !completions.isEmpty { - DispatchQueue.main.async { - completions.forEach { $0(nil) } - } + if !producedAnyASCIIShortcutChar { + return event.keyCode == keyCode } + return false } - func restart(vscodeApplicationURL: URL, completion: @escaping (URL?) -> Void) { - stop() - ensureServeWebURL(vscodeApplicationURL: vscodeApplicationURL, completion: completion) - } - - private func launchServeWebProcess( - vscodeApplicationURL: URL, - expectedGeneration: UInt64 - ) -> (process: Process, url: URL)? { - if let launchProcessOverride { - return launchProcessOverride(vscodeApplicationURL, expectedGeneration) - } - - guard let launchConfiguration = VSCodeCLILaunchConfigurationBuilder.launchConfiguration( - vscodeApplicationURL: vscodeApplicationURL - ) else { return nil } - - guard let connectionTokenFileURL = Self.makeConnectionTokenFile() else { - return nil + switch flags { + case [.command]: + if matches("e", keyCode: 14) { // kVK_ANSI_E + return .useSelection } - - let process = Process() - process.executableURL = launchConfiguration.executableURL - // #21: reuse the port VS Code assigned on a previous run so the embedded browser - // keeps the same URL across restarts. ServeWebPortStore returns the persisted port - // only when it is still bindable, otherwise "0" (OS-assigned) — so a now-occupied - // port falls back gracefully instead of failing the launch. The --server-data-dir - // and persistent connection-token already fix Settings Sync / OAuth auth. - process.arguments = launchConfiguration.argumentsPrefix + [ - "serve-web", - "--accept-server-license-terms", - "--host", "127.0.0.1", - "--port", ServeWebPortStore.portArgument(persistedIn: Self.vscodeServerDataDir), - "--server-data-dir", Self.vscodeServerDataDir?.path ?? NSTemporaryDirectory(), - "--connection-token-file", connectionTokenFileURL.path, - ] - process.environment = launchConfiguration.environment - - let stdoutPipe = Pipe() - let stderrPipe = Pipe() - process.standardOutput = stdoutPipe - process.standardError = stderrPipe - - let collector = ServeWebOutputCollector() - let outputReader: (FileHandle) -> Void = { fileHandle in - let data = fileHandle.availableData - guard !data.isEmpty else { return } - collector.append(data) - } - stdoutPipe.fileHandleForReading.readabilityHandler = outputReader - stderrPipe.fileHandleForReading.readabilityHandler = outputReader - - process.terminationHandler = { [weak self] terminatedProcess in - stdoutPipe.fileHandleForReading.readabilityHandler = nil - stderrPipe.fileHandleForReading.readabilityHandler = nil - Self.drainAvailableOutput(from: stdoutPipe.fileHandleForReading, collector: collector) - Self.drainAvailableOutput(from: stderrPipe.fileHandleForReading, collector: collector) - collector.markProcessExited() - self?.queue.async { - guard let self else { return } - if self.launchingProcess === terminatedProcess { - self.launchingProcess = nil - } - if self.serveWebProcess === terminatedProcess { - self.serveWebProcess = nil - self.serveWebURL = nil - } - if let tokenFileURL = self.connectionTokenFilesByProcessID.removeValue( - forKey: ObjectIdentifier(terminatedProcess) - ), tokenFileURL.path.hasPrefix(NSTemporaryDirectory()) { - Self.removeConnectionTokenFile(at: tokenFileURL) - } - } + if matches("f", keyCode: 3) { // kVK_ANSI_F + return .find } - - let didStart: Bool = queue.sync { - guard self.lifecycleGeneration == expectedGeneration, - self.activeLaunchGeneration == expectedGeneration else { - return false - } - self.launchingProcess = process - self.connectionTokenFilesByProcessID[ObjectIdentifier(process)] = connectionTokenFileURL - do { - try process.run() - return true - } catch { - if self.launchingProcess === process { - self.launchingProcess = nil - } - if let tokenFileURL = self.connectionTokenFilesByProcessID.removeValue( - forKey: ObjectIdentifier(process) - ), tokenFileURL.path.hasPrefix(NSTemporaryDirectory()) { - Self.removeConnectionTokenFile(at: tokenFileURL) - } - return false - } + if matches("g", keyCode: 5) { // kVK_ANSI_G + return .findNext } - guard didStart else { - stdoutPipe.fileHandleForReading.readabilityHandler = nil - stderrPipe.fileHandleForReading.readabilityHandler = nil - if connectionTokenFileURL.path.hasPrefix(NSTemporaryDirectory()) { - Self.removeConnectionTokenFile(at: connectionTokenFileURL) - } - return nil + return nil + case [.command, .shift]: + if matches("f", keyCode: 3) { // kVK_ANSI_F + return .hideFind } - - guard collector.waitForURL(timeoutSeconds: Self.serveWebStartupTimeoutSeconds), - let serveWebURL = collector.webUIURL else { - stdoutPipe.fileHandleForReading.readabilityHandler = nil - stderrPipe.fileHandleForReading.readabilityHandler = nil - if process.isRunning { - process.terminate() - } else { - queue.sync { - if self.launchingProcess === process { - self.launchingProcess = nil - } - if self.serveWebProcess === process { - self.serveWebProcess = nil - self.serveWebURL = nil - } - if let tokenFileURL = self.connectionTokenFilesByProcessID.removeValue( - forKey: ObjectIdentifier(process) - ), tokenFileURL.path.hasPrefix(NSTemporaryDirectory()) { - Self.removeConnectionTokenFile(at: tokenFileURL) - } - } - } - return nil + if matches("g", keyCode: 5) { // kVK_ANSI_G + return .findPrevious } + return nil + default: + return nil + } +} - // #21: remember the assigned port so the next launch can request it again. - if let assignedPort = serveWebURL.port { - ServeWebPortStore.persist(port: assignedPort, in: Self.vscodeServerDataDir) - } +/// For browser content, let the page try the Find command family before cmux's menu fallback. +/// This preserves native web-app shortcuts like VS Code's Cmd+F while still allowing cmux's +/// browser find overlay to keep owning its visible Find UI shortcuts. +func shouldRouteBrowserFindCommandEquivalentThroughWebContentFirst( + _ event: NSEvent, + responder: NSResponder? = nil, + owningWebView: ProgramaWebView? = nil +) -> Bool { + guard let shortcut = browserFindCommandEquivalent(for: event) else { + return false + } - return (process, serveWebURL) + if programaIsLikelyWebInspectorResponder(responder) { + return false } - private static func drainAvailableOutput(from fileHandle: FileHandle, collector: ServeWebOutputCollector) { - while true { - let data = fileHandle.availableData - guard !data.isEmpty else { return } - collector.append(data) + if shortcut.keepsProgramaBrowserFindBarOwnershipWhenVisible, + let owningWebView { + let browserFindBarIsVisible = MainActor.assumeIsolated { + AppDelegate.shared?.browserFindBarIsVisible(for: owningWebView) == true + } + if browserFindBarIsVisible { + return false } } - /// Stable Application Support directory for VS Code serve-web state. - /// Mirrors the "programa" subdirectory convention used elsewhere in the app. - private static var vscodeServerDataDir: URL? { - FileManager.default.urls(for: .applicationSupportDirectory, in: .userDomainMask) - .first? - .appendingPathComponent("programa", isDirectory: true) - .appendingPathComponent("vscode-server", isDirectory: true) - } + return true +} - private static func randomConnectionToken() -> String { - UUID().uuidString.replacingOccurrences(of: "-", with: "") +func cmuxOwningGhosttyView(for responder: NSResponder?) -> GhosttyNSView? { + guard let responder else { return nil } + if let ghosttyView = responder as? GhosttyNSView { + return ghosttyView } - /// Returns a URL for the connection token file. Prefers a persistent file - /// under Application Support so the token (and the browser's vscode-tkn - /// cookie) survives Programa restarts (issue #21). Falls back to an - /// ephemeral temp-dir file when Application Support is unavailable. - private static func makeConnectionTokenFile() -> URL? { - if let persistentURL = vscodeServerDataDir? - .appendingPathComponent("connection-token", isDirectory: false) { - if let url = makePersistentConnectionTokenFile(at: persistentURL) { - return url - } - } - return makeEphemeralConnectionTokenFile() + if let view = responder as? NSView, + let ghosttyView = cmuxOwningGhosttyView(for: view) { + return ghosttyView } - private static func makePersistentConnectionTokenFile(at tokenFileURL: URL) -> URL? { - let dir = tokenFileURL.deletingLastPathComponent() - try? FileManager.default.createDirectory(at: dir, withIntermediateDirectories: true) - // Reuse an existing non-empty token so the browser cookie stays valid. - if let existingData = try? Data(contentsOf: tokenFileURL), !existingData.isEmpty { - return tokenFileURL - } - let token = randomConnectionToken() - guard let tokenData = token.data(using: .utf8) else { return nil } - let fd = open(tokenFileURL.path, O_WRONLY | O_CREAT | O_TRUNC, S_IRUSR | S_IWUSR) - guard fd >= 0 else { return nil } - defer { _ = close(fd) } - let wroteAllBytes = tokenData.withUnsafeBytes { rawBuffer in - guard let baseAddress = rawBuffer.baseAddress else { return false } - return write(fd, baseAddress, rawBuffer.count) == rawBuffer.count + if let textView = responder as? NSTextView { + if textView.isFieldEditor, + let ownerView = programaFieldEditorOwnerView(textView), + let ghosttyView = cmuxOwningGhosttyView(for: ownerView) { + return ghosttyView } - guard wroteAllBytes else { - try? FileManager.default.removeItem(at: tokenFileURL) - return nil + + if !textView.isFieldEditor, + let delegateView = textView.delegate as? NSView, + let ghosttyView = cmuxOwningGhosttyView(for: delegateView) { + return ghosttyView } - return tokenFileURL } - private static func makeEphemeralConnectionTokenFile() -> URL? { - let token = randomConnectionToken() - let tokenFileName = "cmux-vscode-token-\(UUID().uuidString)" - let tokenFileURL = URL(fileURLWithPath: NSTemporaryDirectory(), isDirectory: true) - .appendingPathComponent(tokenFileName, isDirectory: false) - guard let tokenData = token.data(using: .utf8) else { return nil } - let fileDescriptor = open(tokenFileURL.path, O_WRONLY | O_CREAT | O_EXCL, S_IRUSR | S_IWUSR) - guard fileDescriptor >= 0 else { return nil } - defer { _ = close(fileDescriptor) } - let wroteAllBytes = tokenData.withUnsafeBytes { rawBuffer in - guard let baseAddress = rawBuffer.baseAddress else { return false } - return write(fileDescriptor, baseAddress, rawBuffer.count) == rawBuffer.count + var current = responder.nextResponder + while let next = current { + if let ghosttyView = next as? GhosttyNSView { + return ghosttyView } - guard wroteAllBytes else { - removeConnectionTokenFile(at: tokenFileURL) - return nil + if let view = next as? NSView, + let ghosttyView = cmuxOwningGhosttyView(for: view) { + return ghosttyView } - return tokenFileURL + current = next.nextResponder } - private static func removeConnectionTokenFile(at url: URL) { - try? FileManager.default.removeItem(at: url) - } + return nil } -final class ServeWebOutputCollector { - private let lock = NSLock() - private let semaphore = DispatchSemaphore(value: 0) - private var outputBuffer = "" - private var resolvedURL: URL? - private var didSignal = false - - var webUIURL: URL? { - lock.lock() - defer { lock.unlock() } - return resolvedURL - } - - func append(_ data: Data) { - guard let text = String(data: data, encoding: .utf8), !text.isEmpty else { return } - lock.lock() - defer { lock.unlock() } - guard resolvedURL == nil else { return } - outputBuffer.append(text) - while let newlineIndex = outputBuffer.firstIndex(where: \.isNewline) { - let line = String(outputBuffer[.. NSView? { + guard editor.isFieldEditor else { return nil } - func markProcessExited() { - lock.lock() - defer { lock.unlock() } - if resolvedURL == nil, !outputBuffer.isEmpty, - let parsedURL = VSCodeServeWebURLBuilder.extractWebUIURL(from: outputBuffer) { - resolvedURL = parsedURL - outputBuffer.removeAll(keepingCapacity: false) + var current = editor.nextResponder + while let next = current { + if let view = next as? NSView { + return view } - guard !didSignal else { return } - didSignal = true - semaphore.signal() + current = next.nextResponder } - func waitForURL(timeoutSeconds: TimeInterval) -> Bool { - if webUIURL != nil { return true } - _ = semaphore.wait(timeout: .now() + timeoutSeconds) - return webUIURL != nil - } + return editor.superview } -/// Persists the VS Code serve-web port across restarts (#21) so the embedded browser -/// keeps the same URL. The port `code serve-web` assigns is written under the serve-web -/// data dir and reused on the next launch — but only when it is still bindable, so a -/// now-occupied port falls back to an OS-assigned one instead of failing the launch. -enum ServeWebPortStore { - static let fileName = "serve-web-port" - - /// The `--port` argument for `code serve-web`: the persisted port when it is valid and - /// currently free, otherwise "0" (let the OS assign one). `isPortAvailable` is injectable - /// for testing; it defaults to a real loopback bind probe. - static func portArgument( - persistedIn directory: URL?, - isPortAvailable: (Int) -> Bool = ServeWebPortStore.isPortAvailable - ) -> String { - guard let url = portFileURL(in: directory), - let data = try? Data(contentsOf: url), - let raw = String(data: data, encoding: .utf8), - let port = parsePort(raw), - isPortAvailable(port) else { - return "0" - } - return String(port) - } - - /// Records the port VS Code assigned so the next launch can request it again. No-op for - /// out-of-range ports or when the data dir is unavailable. - static func persist(port: Int, in directory: URL?) { - guard isValidPort(port), let url = portFileURL(in: directory) else { return } - let dir = url.deletingLastPathComponent() - try? FileManager.default.createDirectory(at: dir, withIntermediateDirectories: true) - try? Data(String(port).utf8).write(to: url, options: .atomic) - } - - /// Parses a stored port string, returning nil for malformed or out-of-range values. - static func parsePort(_ raw: String) -> Int? { - let trimmed = raw.trimmingCharacters(in: .whitespacesAndNewlines) - guard let port = Int(trimmed), isValidPort(port) else { return nil } - return port +private func cmuxOwningGhosttyView(for view: NSView) -> GhosttyNSView? { + if let ghosttyView = view as? GhosttyNSView { + return ghosttyView } - /// True when a TCP socket can bind 127.0.0.1:port right now. - static func isPortAvailable(_ port: Int) -> Bool { - guard isValidPort(port) else { return false } - let fd = socket(AF_INET, SOCK_STREAM, 0) - guard fd >= 0 else { return false } - defer { close(fd) } - var reuse: Int32 = 1 - _ = setsockopt(fd, SOL_SOCKET, SO_REUSEADDR, &reuse, socklen_t(MemoryLayout.size)) - var addr = sockaddr_in() - addr.sin_family = sa_family_t(AF_INET) - addr.sin_port = in_port_t(UInt16(port)).bigEndian - addr.sin_addr.s_addr = inet_addr("127.0.0.1") - let bound = withUnsafePointer(to: &addr) { pointer in - pointer.withMemoryRebound(to: sockaddr.self, capacity: 1) { sockaddrPointer in - bind(fd, sockaddrPointer, socklen_t(MemoryLayout.size)) - } + var current: NSView? = view.superview + while let candidate = current { + if let ghosttyView = candidate as? GhosttyNSView { + return ghosttyView } - return bound == 0 + current = candidate.superview } - private static func isValidPort(_ port: Int) -> Bool { (1...65535).contains(port) } - - private static func portFileURL(in directory: URL?) -> URL? { - directory?.appendingPathComponent(fileName, isDirectory: false) - } + return nil } -enum WorkspaceShortcutMapper { - /// Maps numbered workspace shortcuts to a zero-based workspace index. - /// 1...8 target fixed indices; 9 always targets the last workspace. - static func workspaceIndex(forDigit digit: Int, workspaceCount: Int) -> Int? { - guard workspaceCount > 0 else { return nil } - guard (1...9).contains(digit) else { return nil } - - if digit == 9 { - return workspaceCount - 1 - } +#if DEBUG +func browserZoomShortcutTraceCandidate( + flags: NSEvent.ModifierFlags, + chars: String, + keyCode: UInt16, + literalChars: String? = nil +) -> Bool { + let normalizedFlags = flags + .intersection(.deviceIndependentFlagsMask) + .subtracting([.numericPad, .function]) + guard normalizedFlags.contains(.command) else { return false } - let index = digit - 1 - return index < workspaceCount ? index : nil + let keys = browserZoomShortcutKeyCandidates( + chars: chars, + literalChars: literalChars, + keyCode: keyCode + ) + if keys.contains("=") || keys.contains("+") || keys.contains("-") || keys.contains("_") || keys.contains("0") { + return true } - - /// Returns the primary digit badge to display for a workspace row. - /// Picks the lowest digit that maps to that row index. - static func digitForWorkspace(at index: Int, workspaceCount: Int) -> Int? { - guard index >= 0 && index < workspaceCount else { return nil } - for digit in 1...9 { - if workspaceIndex(forDigit: digit, workspaceCount: workspaceCount) == index { - return digit - } - } - return nil + switch keyCode { + case 24, 27, 29, 69, 78, 82: // ANSI and keypad zoom keys + return true + default: + return false } } -struct ProgramaCLIPathInstaller { - struct InstallOutcome { - let usedAdministratorPrivileges: Bool - let destinationURL: URL - let sourceURL: URL - } - - struct UninstallOutcome { - let usedAdministratorPrivileges: Bool - let destinationURL: URL - let removedExistingEntry: Bool - } - - enum InstallerError: LocalizedError { - case bundledCLIMissing(expectedPath: String) - case destinationParentNotDirectory(path: String) - case destinationIsDirectory(path: String) - case installVerificationFailed(path: String) - case uninstallVerificationFailed(path: String) - case privilegedCommandFailed(message: String) - - var errorDescription: String? { - switch self { - case .bundledCLIMissing(let expectedPath): - return "Bundled Programa CLI was not found at \(expectedPath)." - case .destinationParentNotDirectory(let path): - return "Expected \(path) to be a directory." - case .destinationIsDirectory(let path): - return "\(path) is a directory. Remove or rename it and try again." - case .installVerificationFailed(let path): - return "Installed symlink at \(path) did not point to the bundled programa CLI." - case .uninstallVerificationFailed(let path): - return "Failed to remove \(path)." - case .privilegedCommandFailed(let message): - return "Administrator action failed: \(message)" - } - } - } - - typealias PrivilegedInstallHandler = (_ sourceURL: URL, _ destinationURL: URL) throws -> Void - typealias PrivilegedUninstallHandler = (_ destinationURL: URL) throws -> Void - - let fileManager: FileManager - let destinationURL: URL - private let bundledCLIURLProvider: () -> URL? - private let expectedBundledCLIPath: String - private let privilegedInstaller: PrivilegedInstallHandler - private let privilegedUninstaller: PrivilegedUninstallHandler - - init( - fileManager: FileManager = .default, - destinationURL: URL = URL(fileURLWithPath: "/usr/local/bin/programa"), - bundledCLIURLProvider: @escaping () -> URL? = { - ProgramaCLIPathInstaller.defaultBundledCLIURL() - }, - expectedBundledCLIPath: String = ProgramaCLIPathInstaller.defaultBundledCLIExpectedPath(), - privilegedInstaller: PrivilegedInstallHandler? = nil, - privilegedUninstaller: PrivilegedUninstallHandler? = nil - ) { - self.fileManager = fileManager - self.destinationURL = destinationURL - self.bundledCLIURLProvider = bundledCLIURLProvider - self.expectedBundledCLIPath = expectedBundledCLIPath - self.privilegedInstaller = privilegedInstaller ?? Self.installWithAdministratorPrivileges(sourceURL:destinationURL:) - self.privilegedUninstaller = privilegedUninstaller ?? Self.uninstallWithAdministratorPrivileges(destinationURL:) - } - - var destinationPath: String { - destinationURL.path - } +func browserZoomShortcutTraceFlagsString(_ flags: NSEvent.ModifierFlags) -> String { + let normalizedFlags = flags + .intersection(.deviceIndependentFlagsMask) + .subtracting([.numericPad, .function]) + var parts: [String] = [] + if normalizedFlags.contains(.command) { parts.append("Cmd") } + if normalizedFlags.contains(.shift) { parts.append("Shift") } + if normalizedFlags.contains(.option) { parts.append("Opt") } + if normalizedFlags.contains(.control) { parts.append("Ctrl") } + return parts.isEmpty ? "none" : parts.joined(separator: "+") +} - func install() throws -> InstallOutcome { - let sourceURL = try resolveBundledCLIURL() - do { - try installWithoutAdministratorPrivileges(sourceURL: sourceURL) - return InstallOutcome( - usedAdministratorPrivileges: false, - destinationURL: destinationURL, - sourceURL: sourceURL - ) - } catch { - guard Self.isPermissionDenied(error) else { throw error } - try ensureDestinationIsNotDirectory() - try privilegedInstaller(sourceURL, destinationURL) - try verifyInstalledSymlinkTarget(sourceURL: sourceURL) - return InstallOutcome( - usedAdministratorPrivileges: true, - destinationURL: destinationURL, - sourceURL: sourceURL - ) - } +func browserZoomShortcutTraceActionString(_ action: BrowserZoomShortcutAction?) -> String { + guard let action else { return "none" } + switch action { + case .zoomIn: return "zoomIn" + case .zoomOut: return "zoomOut" + case .reset: return "reset" } +} +#endif - func uninstall() throws -> UninstallOutcome { - do { - let removedExistingEntry = try uninstallWithoutAdministratorPrivileges() - return UninstallOutcome( - usedAdministratorPrivileges: false, - destinationURL: destinationURL, - removedExistingEntry: removedExistingEntry - ) - } catch { - guard Self.isPermissionDenied(error) else { throw error } - try ensureDestinationIsNotDirectory() - let removedExistingEntry = destinationEntryExists() - try privilegedUninstaller(destinationURL) - if destinationEntryExists() { - throw InstallerError.uninstallVerificationFailed(path: destinationURL.path) - } - return UninstallOutcome( - usedAdministratorPrivileges: true, - destinationURL: destinationURL, - removedExistingEntry: removedExistingEntry - ) +func shouldSuppressWindowMoveForFolderDrag(hitView: NSView?) -> Bool { + var candidate = hitView + while let view = candidate { + if view is DraggableFolderNSView { + return true } + candidate = view.superview } + return false +} - func isInstalled() -> Bool { - guard let sourceURL = bundledCLIURLProvider()?.standardizedFileURL else { return false } - guard let installedTargetURL = symlinkDestinationURL() else { return false } - return installedTargetURL == sourceURL +func shouldSuppressWindowMoveForFolderDrag(window: NSWindow, event: NSEvent) -> Bool { + guard event.type == .leftMouseDown, + window.isMovable, + let contentView = window.contentView else { + return false } - private func resolveBundledCLIURL() throws -> URL { - guard let sourceURL = bundledCLIURLProvider()?.standardizedFileURL else { - throw InstallerError.bundledCLIMissing(expectedPath: expectedBundledCLIPath) - } + let contentPoint = contentView.convert(event.locationInWindow, from: nil) + let hitView = contentView.hitTest(contentPoint) + return shouldSuppressWindowMoveForFolderDrag(hitView: hitView) +} - var isDirectory: ObjCBool = false - guard fileManager.fileExists(atPath: sourceURL.path, isDirectory: &isDirectory), !isDirectory.boolValue else { - throw InstallerError.bundledCLIMissing(expectedPath: sourceURL.path) - } - return sourceURL - } +@MainActor +final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCenterDelegate, NSMenuItemValidation { + nonisolated(unsafe) static var shared: AppDelegate? - private func installWithoutAdministratorPrivileges(sourceURL: URL) throws { - try ensureDestinationParentDirectoryExists() - try ensureDestinationIsNotDirectory() - if destinationEntryExists() { - try fileManager.removeItem(at: destinationURL) - } - try fileManager.createSymbolicLink(at: destinationURL, withDestinationURL: sourceURL) - try verifyInstalledSymlinkTarget(sourceURL: sourceURL) - } + private static let cachedIsRunningUnderXCTest = detectRunningUnderXCTest(ProcessInfo.processInfo.environment) - @discardableResult - private func uninstallWithoutAdministratorPrivileges() throws -> Bool { - try ensureDestinationIsNotDirectory() - let existed = destinationEntryExists() - if existed { - try fileManager.removeItem(at: destinationURL) - } - if destinationEntryExists() { - throw InstallerError.uninstallVerificationFailed(path: destinationURL.path) - } - return existed + private var isRunningUnderXCTestCached: Bool { + Self.cachedIsRunningUnderXCTest } - /// Check if the destination path has any filesystem entry (including dangling symlinks). - /// `FileManager.fileExists` follows symlinks, so a dangling symlink returns false. - private func destinationEntryExists() -> Bool { - (try? fileManager.attributesOfItem(atPath: destinationURL.path)) != nil + private static func detectRunningUnderXCTest(_ env: [String: String]) -> Bool { + if env["XCTestConfigurationFilePath"] != nil { return true } + if env["XCTestBundlePath"] != nil { return true } + if env["XCTestSessionIdentifier"] != nil { return true } + if env["XCInjectBundle"] != nil { return true } + if env["XCInjectBundleInto"] != nil { return true } + if env["DYLD_INSERT_LIBRARIES"]?.contains("libXCTest") == true { return true } + if env.keys.contains(where: { $0.hasPrefix("PROGRAMA_UI_TEST_") }) { return true } + return false } - private func verifyInstalledSymlinkTarget(sourceURL: URL) throws { - guard let installedTargetURL = symlinkDestinationURL(), - installedTargetURL == sourceURL.standardizedFileURL else { - throw InstallerError.installVerificationFailed(path: destinationURL.path) - } + private func isRunningUnderXCTest(_ env: [String: String]) -> Bool { + // On some macOS/Xcode setups, the app-under-test process doesn't get + // `XCTestConfigurationFilePath`. Use a broader set of signals so UI tests + // can reliably skip heavyweight startup work and bring up a window. + Self.detectRunningUnderXCTest(env) } - private func symlinkDestinationURL() -> URL? { - guard fileManager.fileExists(atPath: destinationURL.path) else { return nil } - guard let destinationPath = try? fileManager.destinationOfSymbolicLink(atPath: destinationURL.path) else { - return nil - } - return URL( - fileURLWithPath: destinationPath, - relativeTo: destinationURL.deletingLastPathComponent() - ).standardizedFileURL - } + final class MainWindowContext { + let windowId: UUID + let tabManager: TabManager + let sidebarState: SidebarState + let sidebarSelectionState: SidebarSelectionState + weak var window: NSWindow? - private func ensureDestinationParentDirectoryExists() throws { - let parentURL = destinationURL.deletingLastPathComponent() - var isDirectory: ObjCBool = false - if fileManager.fileExists(atPath: parentURL.path, isDirectory: &isDirectory) { - guard isDirectory.boolValue else { - throw InstallerError.destinationParentNotDirectory(path: parentURL.path) - } - return - } - try fileManager.createDirectory(at: parentURL, withIntermediateDirectories: true) - } - - private func ensureDestinationIsNotDirectory() throws { - guard let values = try resourceValuesIfFileExists( - at: destinationURL, - keys: [.isDirectoryKey, .isSymbolicLinkKey] - ) else { - return - } - - if values.isDirectory == true, values.isSymbolicLink != true { - throw InstallerError.destinationIsDirectory(path: destinationURL.path) + init( + windowId: UUID, + tabManager: TabManager, + sidebarState: SidebarState, + sidebarSelectionState: SidebarSelectionState, + window: NSWindow? + ) { + self.windowId = windowId + self.tabManager = tabManager + self.sidebarState = sidebarState + self.sidebarSelectionState = sidebarSelectionState + self.window = window } } - private func resourceValuesIfFileExists( - at url: URL, - keys: Set - ) throws -> URLResourceValues? { - do { - return try url.resourceValues(forKeys: keys) - } catch { - let nsError = error as NSError - if nsError.domain == NSCocoaErrorDomain && nsError.code == NSFileReadNoSuchFileError { - return nil - } - if nsError.domain == NSPOSIXErrorDomain, - POSIXErrorCode(rawValue: Int32(nsError.code)) == .ENOENT { - return nil - } - throw error + private final class MainWindowController: NSWindowController, NSWindowDelegate { + var onClose: (() -> Void)? + + func windowWillClose(_ notification: Notification) { + onClose?() } } - private static func defaultBundledCLIURL(bundle: Bundle = .main) -> URL? { - bundle.resourceURL?.appendingPathComponent("bin/programa", isDirectory: false) + struct ScriptableMainWindowState { + let windowId: UUID + let tabManager: TabManager + let window: NSWindow? } - private static func defaultBundledCLIExpectedPath(bundle: Bundle = .main) -> String { - bundle.bundleURL - .appendingPathComponent("Contents/Resources/bin/programa", isDirectory: false) - .path + struct SessionDisplayGeometry { + let displayID: UInt32? + let frame: CGRect + let visibleFrame: CGRect } - private static func installWithAdministratorPrivileges(sourceURL: URL, destinationURL: URL) throws { - let destinationPath = destinationURL.path - let parentPath = destinationURL.deletingLastPathComponent().path - let command = "/bin/mkdir -p \(shellQuoted(parentPath)) && " + - "/bin/rm -f \(shellQuoted(destinationPath)) && " + - "/bin/ln -s \(shellQuoted(sourceURL.path)) \(shellQuoted(destinationPath))" - try runPrivilegedShellCommand(command) + struct PersistedWindowGeometry: Codable, Sendable { + let version: Int + let frame: SessionRectSnapshot + let display: SessionDisplaySnapshot? } - private static func uninstallWithAdministratorPrivileges(destinationURL: URL) throws { - let command = "/bin/rm -f \(shellQuoted(destinationURL.path))" - try runPrivilegedShellCommand(command) - } + nonisolated static let persistedWindowGeometrySchemaVersion = 2 + private nonisolated static let persistedWindowGeometryDefaultsKey = "programa.session.lastWindowGeometry.v2" + private nonisolated static let legacyPersistedWindowGeometryDefaultsKeys = [ + "programa.session.lastWindowGeometry.v1" + ] - private static func runPrivilegedShellCommand(_ command: String) throws { - let process = Process() - process.executableURL = URL(fileURLWithPath: "/usr/bin/osascript") - process.arguments = [ - "-e", "on run argv", - "-e", "do shell script (item 1 of argv) with administrator privileges", - "-e", "end run", - command - ] - let stdout = Pipe() - let stderr = Pipe() - process.standardOutput = stdout - process.standardError = stderr - try process.run() - process.waitUntilExit() - - guard process.terminationStatus == 0 else { - let stderrText = String( - data: stderr.fileHandleForReading.readDataToEndOfFile(), - encoding: .utf8 - )?.trimmingCharacters(in: .whitespacesAndNewlines) ?? "" - let stdoutText = String( - data: stdout.fileHandleForReading.readDataToEndOfFile(), - encoding: .utf8 - )?.trimmingCharacters(in: .whitespacesAndNewlines) ?? "" - let details = stderrText.isEmpty ? stdoutText : stderrText - let message = details.isEmpty - ? "osascript exited with status \(process.terminationStatus)." - : details - throw InstallerError.privilegedCommandFailed(message: message) - } - } - - private static func shellQuoted(_ value: String) -> String { - "'" + value.replacingOccurrences(of: "'", with: "'\\''") + "'" - } - - private static func isPermissionDenied(_ error: Error) -> Bool { - isPermissionDenied(error as NSError) - } - - private static func isPermissionDenied(_ error: NSError) -> Bool { - if error.domain == NSPOSIXErrorDomain, - let code = POSIXErrorCode(rawValue: Int32(error.code)), - code == .EACCES || code == .EPERM || code == .EROFS { - return true + weak var tabManager: TabManager? + weak var notificationStore: TerminalNotificationStore? + weak var sidebarState: SidebarState? + weak var fullscreenControlsViewModel: TitlebarControlsViewModel? + weak var sidebarSelectionState: SidebarSelectionState? + var shortcutLayoutCharacterProvider: (UInt16, NSEvent.ModifierFlags) -> String? = KeyboardLayout.character(forKeyCode:modifierFlags:) + private var workspaceObserver: NSObjectProtocol? + private var lifecycleSnapshotObservers: [NSObjectProtocol] = [] + private var windowKeyObserver: NSObjectProtocol? + private var shortcutMonitor: Any? + private var shortcutDefaultsObserver: NSObjectProtocol? + private var menuBarVisibilityObserver: NSObjectProtocol? + private var splitButtonTooltipRefreshScheduled = false + private struct PendingConfiguredShortcutChord { + let firstStroke: ShortcutStroke + let windowNumber: Int? + } + private var pendingConfiguredShortcutChord: PendingConfiguredShortcutChord? + private var activeConfiguredShortcutChordPrefixForCurrentEvent: ShortcutStroke? + private var configuredShortcutChordActions: [KeyboardShortcutSettings.Action] = [] + private var ghosttyConfigObserver: NSObjectProtocol? + var ghosttyGotoSplitLeftShortcut: StoredShortcut? + var ghosttyGotoSplitRightShortcut: StoredShortcut? + var ghosttyGotoSplitUpShortcut: StoredShortcut? + var ghosttyGotoSplitDownShortcut: StoredShortcut? + private var browserAddressBarFocusedPanelId: UUID? + private var browserOmnibarRepeatStartWorkItem: DispatchWorkItem? + private var browserOmnibarRepeatTickWorkItem: DispatchWorkItem? + private var browserOmnibarRepeatKeyCode: UInt16? + private var browserOmnibarRepeatDelta: Int = 0 + private var browserAddressBarFocusObserver: NSObjectProtocol? + private var browserAddressBarBlurObserver: NSObjectProtocol? + private let updateController = UpdateController() + private lazy var titlebarAccessoryController = UpdateTitlebarAccessoryController(viewModel: updateViewModel) + private let windowDecorationsController = WindowDecorationsController() + private var menuBarExtraController: MenuBarExtraController? + private static let serviceErrorNoPath = NSString(string: String(localized: "error.clipboardFolderPath", defaultValue: "Could not load any folder path from the clipboard.")) + private static let didInstallWindowKeyEquivalentSwizzle: Void = { + let targetClass: AnyClass = NSWindow.self + let originalSelector = #selector(NSWindow.performKeyEquivalent(with:)) + let swizzledSelector = #selector(NSWindow.programa_performKeyEquivalent(with:)) + guard let originalMethod = class_getInstanceMethod(targetClass, originalSelector), + let swizzledMethod = class_getInstanceMethod(targetClass, swizzledSelector) else { + return } - - if error.domain == NSCocoaErrorDomain { - switch error.code { - case NSFileWriteNoPermissionError, NSFileReadNoPermissionError, NSFileWriteVolumeReadOnlyError: - return true - default: - break - } + method_exchangeImplementations(originalMethod, swizzledMethod) + }() + private static let didInstallWindowFirstResponderSwizzle: Void = { + let targetClass: AnyClass = NSWindow.self + let originalSelector = #selector(NSWindow.makeFirstResponder(_:)) + let swizzledSelector = #selector(NSWindow.programa_makeFirstResponder(_:)) + guard let originalMethod = class_getInstanceMethod(targetClass, originalSelector), + let swizzledMethod = class_getInstanceMethod(targetClass, swizzledSelector) else { + return } - - if let underlying = error.userInfo[NSUnderlyingErrorKey] as? NSError { - return isPermissionDenied(underlying) + method_exchangeImplementations(originalMethod, swizzledMethod) + }() + private static let didInstallWindowSendEventSwizzle: Void = { + let targetClass: AnyClass = NSWindow.self + let originalSelector = #selector(NSWindow.sendEvent(_:)) + let swizzledSelector = #selector(NSWindow.programa_sendEvent(_:)) + guard let originalMethod = class_getInstanceMethod(targetClass, originalSelector), + let swizzledMethod = class_getInstanceMethod(targetClass, swizzledSelector) else { + return } + method_exchangeImplementations(originalMethod, swizzledMethod) + }() + private static let didInstallApplicationSendEventSwizzle: Void = { + let targetClass: AnyClass = NSApplication.self + let originalSelector = #selector(NSApplication.sendEvent(_:)) + let swizzledSelector = #selector(NSApplication.programa_applicationSendEvent(_:)) + guard let originalMethod = class_getInstanceMethod(targetClass, originalSelector), + let swizzledMethod = class_getInstanceMethod(targetClass, swizzledSelector) else { + return + } + method_exchangeImplementations(originalMethod, swizzledMethod) + }() - return false - } -} - -private extension NSScreen { - var programaDisplayID: UInt32? { - let key = NSDeviceDescriptionKey("NSScreenNumber") - guard let value = deviceDescription[key] as? NSNumber else { return nil } - return value.uint32Value +#if DEBUG + var didSetupJumpUnreadUITest = false + var jumpUnreadFocusExpectation: (tabId: UUID, surfaceId: UUID)? + var jumpUnreadFocusObserver: NSObjectProtocol? + var didSetupTerminalCmdClickUITest = false + var didSetupGotoSplitUITest = false + var didSetupBonsplitTabDragUITest = false + var terminalCmdClickUITestPoller: DispatchSourceTimer? + var bonsplitTabDragUITestRecorder: DispatchSourceTimer? + var gotoSplitUITestRecorder: DispatchSourceTimer? + var gotoSplitUITestObservers: [NSObjectProtocol] = [] + var didSetupMultiWindowNotificationsUITest = false + var didSetupDisplayResolutionUITestDiagnostics = false + var displayResolutionUITestObservers: [NSObjectProtocol] = [] + private struct UITestRenderDiagnosticsSnapshot { + let panelId: UUID + let drawCount: Int + let presentCount: Int + let lastPresentTime: Double + let windowVisible: Bool + let appIsActive: Bool + let desiredFocus: Bool + let isFirstResponder: Bool } -} - -func browserOmnibarSelectionDeltaForCommandNavigation( - hasFocusedAddressBar: Bool, - flags: NSEvent.ModifierFlags, - chars: String -) -> Int? { - guard hasFocusedAddressBar else { return nil } - let normalizedFlags = browserOmnibarNormalizedModifierFlags(flags) - let isCommandOrControlOnly = normalizedFlags == [.command] || normalizedFlags == [.control] - guard isCommandOrControlOnly else { return nil } - if chars == "n" { return 1 } - if chars == "p" { return -1 } - return nil -} + var debugCloseMainWindowConfirmationHandler: ((NSWindow) -> Bool)? + var debugCreateMainWindowSourceIsNativeFullScreenOverride: Bool? + // Keep debug-only windows alive when tests intentionally inject key mismatches. + private var debugDetachedContextWindows: [NSWindow] = [] -func browserOmnibarSelectionDeltaForArrowNavigation( - hasFocusedAddressBar: Bool, - flags: NSEvent.ModifierFlags, - keyCode: UInt16 -) -> Int? { - guard hasFocusedAddressBar else { return nil } - let normalizedFlags = browserOmnibarNormalizedModifierFlags(flags) - guard normalizedFlags == [] else { return nil } - switch keyCode { - case 125: return 1 - case 126: return -1 - default: return nil + private func childExitKeyboardProbePath() -> String? { + let env = ProcessInfo.processInfo.environment + guard env["PROGRAMA_UI_TEST_CHILD_EXIT_KEYBOARD_SETUP"] == "1", + let path = env["PROGRAMA_UI_TEST_CHILD_EXIT_KEYBOARD_PATH"], + !path.isEmpty else { + return nil + } + return path } -} - -func browserOmnibarNormalizedModifierFlags(_ flags: NSEvent.ModifierFlags) -> NSEvent.ModifierFlags { - flags - .intersection(.deviceIndependentFlagsMask) - .subtracting([.numericPad, .function, .capsLock]) -} - -func browserOmnibarShouldSubmitOnReturn(flags: NSEvent.ModifierFlags) -> Bool { - let normalizedFlags = browserOmnibarNormalizedModifierFlags(flags) - return normalizedFlags == [] || normalizedFlags == [.shift] -} - -func browserResponderHasMarkedText(_ responder: NSResponder?) -> Bool { - guard let responder else { return false } - // During IME composition, Return/Enter belongs to the text system so the - // candidate list can commit or confirm the marked text. - if let textInputClient = responder as? NSTextInputClient { - if textInputClient.hasMarkedText() { return true } + private func childExitKeyboardProbeHex(_ value: String?) -> String { + guard let value else { return "" } + return value.unicodeScalars + .map { String(format: "%04X", $0.value) } + .joined(separator: ",") } - if let textField = responder as? NSTextField, - let editor = textField.currentEditor() as? NSTextView { - if editor.hasMarkedText() { return true } + private func writeChildExitKeyboardProbe(_ updates: [String: String], increments: [String: Int] = [:]) { + guard let path = childExitKeyboardProbePath() else { return } + var payload: [String: String] = { + guard let data = try? Data(contentsOf: URL(fileURLWithPath: path)), + let object = try? JSONSerialization.jsonObject(with: data) as? [String: String] else { + return [:] + } + return object + }() + for (key, by) in increments { + let current = Int(payload[key] ?? "") ?? 0 + payload[key] = String(current + by) + } + for (key, value) in updates { + payload[key] = value + } + guard let data = try? JSONSerialization.data(withJSONObject: payload) else { return } + try? data.write(to: URL(fileURLWithPath: path), options: .atomic) } +#endif - // WKWebView clears marked text before performKeyEquivalent fires, so the - // synchronous hasMarkedText() check above can return false even though an IME - // composition just ended on the same Enter keystroke. Check the JS bridge's - // composition timestamp to detect this race condition (#2626). - if let webView = responder.programaEnclosingProgramaWebView { - if webView.webViewIsComposing { return true } - let age = ProcessInfo.processInfo.systemUptime - webView.recentCompositionEndTimestamp - if age >= 0 && age < 0.15 { return true } - } + var mainWindowContexts: [ObjectIdentifier: MainWindowContext] = [:] + private var mainWindowControllers: [MainWindowController] = [] - return false -} - -private extension NSResponder { - /// Walk the responder chain to find the enclosing ProgramaWebView. - var programaEnclosingProgramaWebView: ProgramaWebView? { - var current: NSResponder? = self - while let responder = current { - if let webView = responder as? ProgramaWebView { return webView } - current = responder.nextResponder - } - return nil + /// Tracks the cascade point for new windows, matching Ghostty's upstream algorithm. + /// Reset to `.zero` so the first window seeds the point from its own position. + private var lastCascadePoint = NSPoint.zero + private var startupSessionSnapshot: AppSessionSnapshot? + private var didPrepareStartupSessionSnapshot = false + private var didAttemptStartupSessionRestore = false + private var isApplyingStartupSessionRestore = false + private var sessionAutosaveTimer: DispatchSourceTimer? + private var sessionAutosaveTickInFlight = false + private var sessionAutosaveDeferredRetryPending = false + private let sessionPersistenceQueue = DispatchQueue( + label: "com.cmuxterm.app.sessionPersistence", + qos: .utility + ) + private nonisolated static let launchServicesRegistrationQueue = DispatchQueue( + label: "com.cmuxterm.app.launchServicesRegistration", + qos: .utility + ) + private nonisolated static func enqueueLaunchServicesRegistrationWork(_ work: @escaping @Sendable () -> Void) { + launchServicesRegistrationQueue.async(execute: work) } -} - -func shouldDispatchBrowserReturnViaFirstResponderKeyDown( - keyCode: UInt16, - firstResponderIsBrowser: Bool, - firstResponderHasMarkedText: Bool = false, - flags: NSEvent.ModifierFlags -) -> Bool { - guard firstResponderIsBrowser else { return false } - guard !firstResponderHasMarkedText else { return false } - guard keyCode == 36 || keyCode == 76 else { return false } - // Keep browser Return forwarding narrow: only plain/Shift Return should be - // treated as submit-intent. Command-modified Return is reserved for app shortcuts - // like Toggle Pane Zoom (Cmd+Shift+Enter). - return browserOmnibarShouldSubmitOnReturn(flags: flags) -} + private var lastSessionAutosaveFingerprint: Int? + private var lastSessionAutosavePersistedAt: Date = .distantPast + private var lastTypingActivityAt: TimeInterval = 0 + private var didHandleExplicitOpenIntentAtStartup = false + private var isTerminatingApp = false + // Set to true when the user has already confirmed quit via the warning dialog, + // so applicationShouldTerminate does not show a second alert. + private var isQuitWarningConfirmed = false + private var didInstallLifecycleSnapshotObservers = false + private var didDisableSuddenTermination = false + private var commandPaletteVisibilityByWindowId: [UUID: Bool] = [:] + private var commandPalettePendingOpenByWindowId: [UUID: Bool] = [:] + private var commandPaletteRecentRequestAtByWindowId: [UUID: TimeInterval] = [:] + private var commandPaletteEscapeSuppressionByWindowId: Set = [] + private var commandPaletteEscapeSuppressionStartedAtByWindowId: [UUID: TimeInterval] = [:] + private var commandPaletteSelectionByWindowId: [UUID: Int] = [:] + private var commandPaletteSnapshotByWindowId: [UUID: CommandPaletteDebugSnapshot] = [:] + private static let commandPaletteRequestGraceInterval: TimeInterval = 1.25 + private static let commandPalettePendingOpenMaxAge: TimeInterval = 8.0 + private static let sessionAutosaveTypingQuietPeriod: TimeInterval = 0.65 -func shouldToggleMainWindowFullScreenForCommandControlFShortcut( - flags: NSEvent.ModifierFlags, - chars: String, - keyCode: UInt16, - layoutCharacterProvider: (UInt16, NSEvent.ModifierFlags) -> String? = KeyboardLayout.character(forKeyCode:modifierFlags:) -) -> Bool { - let normalizedFlags = flags - .intersection(.deviceIndependentFlagsMask) - .subtracting([.numericPad, .function, .capsLock]) - guard normalizedFlags == [.command, .control] else { return false } - let normalizedChars = chars.lowercased() - if normalizedChars == "f" { - return true - } - let charsAreControlSequence = !normalizedChars.isEmpty - && normalizedChars.unicodeScalars.allSatisfy { CharacterSet.controlCharacters.contains($0) } - if !normalizedChars.isEmpty && !charsAreControlSequence { - return false + var updateViewModel: UpdateViewModel { + updateController.viewModel } - // Fallback to layout translation only when characters are unavailable (for - // synthetic/key-equivalent paths that can report an empty string). - if let translatedCharacter = layoutCharacterProvider(keyCode, flags), !translatedCharacter.isEmpty { - return translatedCharacter == "f" +#if DEBUG + private func pointerString(_ object: AnyObject?) -> String { + guard let object else { return "nil" } + return String(describing: Unmanaged.passUnretained(object).toOpaque()) } - // Keep ANSI fallback as a final safety net when layout translation is unavailable. - return keyCode == 3 -} - -func commandPaletteSelectionDeltaForKeyboardNavigation( - flags: NSEvent.ModifierFlags, - chars: String, - keyCode: UInt16 -) -> Int? { - let normalizedFlags = flags - .intersection(.deviceIndependentFlagsMask) - .subtracting([.numericPad, .function]) - let normalizedChars = chars.lowercased() - - if normalizedFlags == [] { - switch keyCode { - case 125: return 1 // Down arrow - case 126: return -1 // Up arrow - default: break - } + private func summarizeContextForWorkspaceRouting(_ context: MainWindowContext?) -> String { + guard let context else { return "nil" } + let window = context.window ?? windowForMainWindowId(context.windowId) + let windowNumber = window?.windowNumber ?? -1 + let key = window?.isKeyWindow == true ? 1 : 0 + let main = window?.isMainWindow == true ? 1 : 0 + let visible = window?.isVisible == true ? 1 : 0 + let selected = context.tabManager.selectedTabId.map { String($0.uuidString.prefix(8)) } ?? "nil" + return "wid=\(context.windowId.uuidString.prefix(8)) win=\(windowNumber) key=\(key) main=\(main) vis=\(visible) tabs=\(context.tabManager.tabs.count) sel=\(selected) tm=\(pointerString(context.tabManager))" } - if normalizedFlags == [.control] { - // Control modifiers can surface as either printable chars or ASCII control chars. - // Keep Emacs-style next/previous navigation, but leave other control bindings - // (for example Ctrl+K text editing in the palette search field) to AppKit. - if keyCode == 45 || normalizedChars == "n" || normalizedChars == "\u{0e}" { return 1 } // Ctrl+N - if keyCode == 35 || normalizedChars == "p" || normalizedChars == "\u{10}" { return -1 } // Ctrl+P + private func summarizeAllContextsForWorkspaceRouting() -> String { + guard !mainWindowContexts.isEmpty else { return "" } + return mainWindowContexts.values + .map { summarizeContextForWorkspaceRouting($0) } + .joined(separator: " | ") } - return nil -} + private func logWorkspaceCreationRouting( + phase: String, + source: String, + reason: String, + event: NSEvent?, + chosenContext: MainWindowContext?, + workspaceId: UUID? = nil, + workingDirectory: String? = nil + ) { + let eventWindowNumber = event?.window?.windowNumber ?? -1 + let eventNumber = event?.windowNumber ?? -1 + let eventChars = event?.charactersIgnoringModifiers ?? "" + let eventKeyCode = event.map { String($0.keyCode) } ?? "nil" + let keyWindowNumber = NSApp.keyWindow?.windowNumber ?? -1 + let mainWindowNumber = NSApp.mainWindow?.windowNumber ?? -1 + let ws = workspaceId.map { String($0.uuidString.prefix(8)) } ?? "nil" + let wd = workingDirectory.map { String($0.prefix(120)) } ?? "-" + FocusLogStore.shared.append( + "cmdn.route phase=\(phase) src=\(source) reason=\(reason) eventWin=\(eventWindowNumber) eventNum=\(eventNumber) keyCode=\(eventKeyCode) chars=\(eventChars) keyWin=\(keyWindowNumber) mainWin=\(mainWindowNumber) activeTM=\(pointerString(tabManager)) chosen={\(summarizeContextForWorkspaceRouting(chosenContext))} ws=\(ws) wd=\(wd) contexts=[\(summarizeAllContextsForWorkspaceRouting())]" + ) + } +#endif -func shouldRouteCommandPaletteSelectionNavigation( - delta: Int?, - isInteractive: Bool, - usesInlineTextHandling: Bool -) -> Bool { - guard delta != nil, isInteractive else { return false } - return !usesInlineTextHandling -} + override init() { + super.init() + Self.shared = self + } -func shouldConsumeShortcutWhileCommandPaletteVisible( - isCommandPaletteVisible: Bool, - normalizedFlags: NSEvent.ModifierFlags, - chars: String, - keyCode: UInt16 -) -> Bool { - guard isCommandPaletteVisible else { return false } + func application(_ application: NSApplication, open urls: [URL]) { + let directories = externalOpenDirectories(from: urls) + guard !directories.isEmpty else { return } - // Escape dismisses the palette, and must not leak through to the - // underlying terminal or browser content. - if normalizedFlags.isEmpty, keyCode == 53 { - return true + prepareForExplicitOpenIntentAtStartup() + for directory in directories { + openWorkspaceForExternalDirectory( + workingDirectory: directory, + debugSource: "application.openURLs" + ) + } } - guard normalizedFlags.contains(.command) else { return false } + func applicationDidFinishLaunching(_ notification: Notification) { + let env = ProcessInfo.processInfo.environment + let isRunningUnderXCTest = isRunningUnderXCTest(env) - let normalizedChars = chars.lowercased() + DistributedNotificationCenter.default().addObserver( + self, + selector: #selector(handleThemesReloadNotification(_:)), + name: ProgramaThemeNotifications.reloadConfig, + object: nil, + suspensionBehavior: .deliverImmediately + ) + NotificationCenter.default.addObserver( + self, + selector: #selector(handleReactGrabDidCopySelection(_:)), + name: .reactGrabDidCopySelection, + object: nil + ) - if normalizedFlags == [.command] { - if normalizedChars == "a" - || normalizedChars == "c" - || normalizedChars == "v" - || normalizedChars == "x" - || normalizedChars == "z" - || normalizedChars == "y" { - return false +#if DEBUG + // UI tests run on a shared VM user profile, so persisted shortcuts can drift and make + // key-equivalent routing flaky. Force defaults for deterministic tests. + if isRunningUnderXCTest { + KeyboardShortcutSettings.resetAll() } +#endif - switch keyCode { - case 51, 117, 123, 124: - return false - default: - break +#if DEBUG + writeUITestDiagnosticsIfNeeded(stage: "didFinishLaunching") + ProgramaMainRunLoopStallMonitor.shared.installIfNeeded() + ProgramaMainThreadTurnProfiler.shared.installIfNeeded() + DispatchQueue.main.asyncAfter(deadline: .now() + 1.0) { [weak self] in + self?.writeUITestDiagnosticsIfNeeded(stage: "after1s") } - } - - if normalizedFlags == [.command, .shift], normalizedChars == "z" { - return false - } +#endif - return true -} + let forceDuplicateLaunchObserver = env["PROGRAMA_UI_TEST_ENABLE_DUPLICATE_LAUNCH_OBSERVER"] == "1" -func shouldSubmitCommandPaletteWithReturn( - keyCode: UInt16, - flags: NSEvent.ModifierFlags, - mode: String -) -> Bool { - guard keyCode == 36 || keyCode == 76 else { return false } - let normalizedFlags = flags - .intersection(.deviceIndependentFlagsMask) - .subtracting([.numericPad, .function, .capsLock]) - if normalizedFlags.isEmpty { - return true - } - if normalizedFlags == [.shift] { - return mode != "workspace_description_input" - } - return false -} - -func commandPaletteFieldEditorHasMarkedText(in window: NSWindow) -> Bool { - if let editor = window.firstResponder as? NSTextView { - return editor.hasMarkedText() - } - if let textField = window.firstResponder as? NSTextField, - let editor = textField.currentEditor() as? NSTextView { - return editor.hasMarkedText() - } - return false -} + // UI tests frequently time out waiting for the main window if we do heavyweight + // LaunchServices registration / single-instance enforcement synchronously at startup. + // Skip these during XCTest (the app-under-test) so the window can appear quickly. + if !isRunningUnderXCTest { + DispatchQueue.main.async { [weak self] in + guard let self else { return } + self.scheduleLaunchServicesBundleRegistration() + self.enforceSingleInstance() + self.observeDuplicateLaunches() + } + } else if forceDuplicateLaunchObserver { + // Some UI regressions specifically exercise launch-observer behavior while still + // running under XCTest. Allow an explicit opt-in for those cases only. + DispatchQueue.main.async { [weak self] in + self?.observeDuplicateLaunches() + } + } + NSWindow.allowsAutomaticWindowTabbing = false + disableNativeTabbingShortcut() + ensureApplicationIcon() + if !isRunningUnderXCTest { + configureUserNotifications() + installMenuBarVisibilityObserver() + syncMenuBarExtraVisibility() + updateController.startUpdaterIfNeeded() + } + titlebarAccessoryController.start() + windowDecorationsController.start() + installMainWindowKeyObserver() + refreshGhosttyGotoSplitShortcuts() + installGhosttyConfigObserver() + installWindowResponderSwizzles() + installBrowserAddressBarFocusObservers() + installShortcutMonitor() + installShortcutDefaultsObserver() + NSApp.servicesProvider = self +#if DEBUG + UpdateTestSupport.applyIfNeeded(to: updateController.viewModel) + if env["PROGRAMA_UI_TEST_MODE"] == "1" { + let trigger = env["PROGRAMA_UI_TEST_TRIGGER_UPDATE_CHECK"] ?? "" + let feed = env["PROGRAMA_UI_TEST_FEED_URL"] ?? "" + UpdateLogStore.shared.append("ui test env: trigger=\(trigger) feed=\(feed)") + } + if env["PROGRAMA_UI_TEST_TRIGGER_UPDATE_CHECK"] == "1" { + UpdateLogStore.shared.append("ui test trigger update check detected") + DispatchQueue.main.asyncAfter(deadline: .now() + 0.25) { [weak self] in + guard let self else { return } + let windowIds = NSApp.windows.map { $0.identifier?.rawValue ?? "" } + UpdateLogStore.shared.append("ui test windows: count=\(NSApp.windows.count) ids=\(windowIds.joined(separator: ","))") + if UpdateTestSupport.performMockFeedCheckIfNeeded(on: self.updateController.viewModel) { + return + } + self.checkForUpdates(nil) + } + } -func shouldHandleCommandPaletteShortcutEvent( - _ event: NSEvent, - paletteWindow: NSWindow? -) -> Bool { - guard let paletteWindow else { return false } - if let eventWindow = event.window { - return eventWindow === paletteWindow - } - let eventWindowNumber = event.windowNumber - if eventWindowNumber > 0 { - return eventWindowNumber == paletteWindow.windowNumber - } - if let keyWindow = NSApp.keyWindow { - return keyWindow === paletteWindow + // In UI tests, `WindowGroup` occasionally fails to materialize a window quickly on the VM. + // If there are no windows shortly after launch, force-create one so XCUITest can proceed. + if isRunningUnderXCTest { + if let rawShow = env["PROGRAMA_UI_TEST_BROWSER_IMPORT_HINT_SHOW"] { + UserDefaults.standard.set( + rawShow == "1", + forKey: BrowserImportHintSettings.showOnBlankTabsKey + ) + } + if let rawDismissed = env["PROGRAMA_UI_TEST_BROWSER_IMPORT_HINT_DISMISSED"] { + UserDefaults.standard.set( + rawDismissed == "1", + forKey: BrowserImportHintSettings.dismissedKey + ) + } + DispatchQueue.main.asyncAfter(deadline: .now() + 0.25) { [weak self] in + guard let self else { return } + if NSApp.windows.isEmpty { + self.openNewMainWindow(nil) + } + self.moveUITestWindowToTargetDisplayIfNeeded() + NSRunningApplication.current.activate(options: [.activateAllWindows, .activateIgnoringOtherApps]) + // On headless CI runners, activate() silently fails (no GUI session). + // Force windows visible so the terminal surface starts rendering. + for window in NSApp.windows { + window.orderFrontRegardless() + } + self.writeUITestDiagnosticsIfNeeded(stage: "afterForceWindow") + } + if env["PROGRAMA_UI_TEST_BROWSER_IMPORT_HINT_OPEN_BLANK_BROWSER"] == "1" { + DispatchQueue.main.asyncAfter(deadline: .now() + 0.45) { [weak self] in + guard let self else { return } + _ = self.openBrowserAndFocusAddressBar(insertAtEnd: true) + } + } + if env["PROGRAMA_UI_TEST_BROWSER_IMPORT_HINT_OPEN_SETTINGS"] == "1" { + DispatchQueue.main.asyncAfter(deadline: .now() + 0.55) { [weak self] in + self?.openPreferencesWindow( + debugSource: "uiTest.browserImportHint", + navigationTarget: .browser + ) + } + } + if env["PROGRAMA_UI_TEST_BROWSER_IMPORT_AUTO_OPEN"] == "1" { + DispatchQueue.main.asyncAfter(deadline: .now() + 0.4) { + BrowserDataImportCoordinator.shared.presentImportDialog() + } + } + } +#endif } - return false -} - -enum BrowserZoomShortcutAction: Equatable { - case zoomIn - case zoomOut - case reset -} -struct CommandPaletteDebugResultRow { - let commandId: String - let title: String - let shortcutHint: String? - let trailingLabel: String? - let score: Int -} - -struct CommandPaletteDebugSnapshot { - let query: String - let mode: String - let results: [CommandPaletteDebugResultRow] +#if DEBUG + func writeUITestDiagnosticsIfNeeded(stage: String) { + let env = ProcessInfo.processInfo.environment + guard let path = env["PROGRAMA_UI_TEST_DIAGNOSTICS_PATH"], !path.isEmpty else { return } - static let empty = CommandPaletteDebugSnapshot(query: "", mode: "commands", results: []) -} + var payload = loadUITestDiagnostics(at: path) + let isRunningUnderXCTest = isRunningUnderXCTest(env) -func browserZoomShortcutAction( - flags: NSEvent.ModifierFlags, - chars: String, - keyCode: UInt16, - literalChars: String? = nil -) -> BrowserZoomShortcutAction? { - let normalizedFlags = flags - .intersection(.deviceIndependentFlagsMask) - .subtracting([.numericPad, .function]) - let hasCommand = normalizedFlags.contains(.command) - let hasOnlyCommandAndOptionalShift = hasCommand && normalizedFlags.isDisjoint(with: [.control, .option]) + let windows = NSApp.windows + let ids = windows.map { $0.identifier?.rawValue ?? "" }.joined(separator: ",") + let vis = windows.map { $0.isVisible ? "1" : "0" }.joined(separator: ",") + let screenIDs = windows.map { $0.screen?.programaDisplayID.map(String.init) ?? "" }.joined(separator: ",") + let targetDisplayID = env["PROGRAMA_UI_TEST_TARGET_DISPLAY_ID"] ?? "" - guard hasOnlyCommandAndOptionalShift else { return nil } - let keys = browserZoomShortcutKeyCandidates( - chars: chars, - literalChars: literalChars, - keyCode: keyCode - ) + payload["stage"] = stage + payload["pid"] = String(ProcessInfo.processInfo.processIdentifier) + payload["bundleId"] = Bundle.main.bundleIdentifier ?? "" + payload["isRunningUnderXCTest"] = isRunningUnderXCTest ? "1" : "0" + payload["windowsCount"] = String(windows.count) + payload["windowIdentifiers"] = ids + payload["windowVisibleFlags"] = vis + payload["windowScreenDisplayIDs"] = screenIDs + payload["uiTestTargetDisplayID"] = targetDisplayID + if let rawDisplayID = UInt32(targetDisplayID) { + let screenPresent = NSScreen.screens.contains(where: { $0.programaDisplayID == rawDisplayID }) + let movedWindow = windows.contains(where: { $0.screen?.programaDisplayID == rawDisplayID }) + payload["targetDisplayPresent"] = screenPresent ? "1" : "0" + payload["targetDisplayMoveSucceeded"] = movedWindow ? "1" : "0" + } + appendUITestRenderDiagnosticsIfNeeded(&payload, environment: env) + appendUITestSocketDiagnosticsIfNeeded(&payload, environment: env) - if keys.contains("=") || keys.contains("+") || keyCode == 24 || keyCode == 69 { // kVK_ANSI_Equal / kVK_ANSI_KeypadPlus - return .zoomIn + guard let data = try? JSONSerialization.data(withJSONObject: payload) else { return } + try? data.write(to: URL(fileURLWithPath: path), options: .atomic) } - if keys.contains("-") || keys.contains("_") || keyCode == 27 || keyCode == 78 { // kVK_ANSI_Minus / kVK_ANSI_KeypadMinus - return .zoomOut + private func loadUITestDiagnostics(at path: String) -> [String: String] { + guard let data = try? Data(contentsOf: URL(fileURLWithPath: path)), + let object = try? JSONSerialization.jsonObject(with: data) as? [String: String] else { + return [:] + } + return object } - if keys.contains("0") || keyCode == 29 || keyCode == 82 { // kVK_ANSI_0 / kVK_ANSI_Keypad0 - return .reset - } + private func appendUITestSocketDiagnosticsIfNeeded( + _ payload: inout [String: String], + environment env: [String: String] + ) { + guard env["PROGRAMA_UI_TEST_SOCKET_SANITY"] == "1" else { return } - return nil -} + guard let config = socketListenerConfigurationIfEnabled() else { + payload["socketExpectedPath"] = env["PROGRAMA_SOCKET_PATH"] ?? "" + payload["socketMode"] = "off" + payload["socketReady"] = "0" + payload["socketPingResponse"] = "" + payload["socketIsRunning"] = "0" + payload["socketAcceptLoopAlive"] = "0" + payload["socketPathMatches"] = "0" + payload["socketPathExists"] = "0" + payload["socketFailureSignals"] = "socket_disabled" + return + } -func browserZoomShortcutKeyCandidates( - chars: String, - literalChars: String?, - keyCode: UInt16 -) -> Set { - var keys: Set = [chars.lowercased()] + let socketPath = TerminalController.shared.activeSocketPath(preferredPath: config.path) + let health = TerminalController.shared.socketListenerHealth(expectedSocketPath: socketPath) + let pingResponse = health.isHealthy + ? TerminalController.probeSocketCommand("ping", at: socketPath, timeout: 1.0) + : nil + let isReady = health.isHealthy && pingResponse == "PONG" + var failureSignals = health.failureSignals + if health.isHealthy && pingResponse != "PONG" { + failureSignals.append("ping_timeout") + } - if let literalChars, !literalChars.isEmpty { - keys.insert(literalChars.lowercased()) - } - - if let layoutChar = KeyboardLayout.character(forKeyCode: keyCode), !layoutChar.isEmpty { - keys.insert(layoutChar) + payload["socketExpectedPath"] = socketPath + payload["socketMode"] = config.mode.rawValue + payload["socketReady"] = isReady ? "1" : "0" + payload["socketPingResponse"] = pingResponse ?? "" + payload["socketIsRunning"] = health.isRunning ? "1" : "0" + payload["socketAcceptLoopAlive"] = health.acceptLoopAlive ? "1" : "0" + payload["socketPathMatches"] = health.socketPathMatches ? "1" : "0" + payload["socketPathExists"] = health.socketPathExists ? "1" : "0" + payload["socketFailureSignals"] = failureSignals.joined(separator: ",") } - return keys -} - -func shouldSuppressSplitShortcutForTransientTerminalFocusInputs( - firstResponderIsWindow: Bool, - hostedSize: CGSize, - hostedHiddenInHierarchy: Bool, - hostedAttachedToWindow: Bool -) -> Bool { - guard firstResponderIsWindow else { return false } - let tinyGeometry = hostedSize.width <= 1 || hostedSize.height <= 1 - return tinyGeometry || hostedHiddenInHierarchy || !hostedAttachedToWindow -} + private func appendUITestRenderDiagnosticsIfNeeded( + _ payload: inout [String: String], + environment env: [String: String] + ) { + guard env["PROGRAMA_UI_TEST_DISPLAY_RENDER_STATS"] == "1" else { return } -func shouldRouteTerminalFontZoomShortcutToGhostty( - firstResponderIsGhostty: Bool, - flags: NSEvent.ModifierFlags, - chars: String, - keyCode: UInt16, - literalChars: String? = nil -) -> Bool { - guard firstResponderIsGhostty else { return false } - return browserZoomShortcutAction( - flags: flags, - chars: chars, - keyCode: keyCode, - literalChars: literalChars - ) != nil -} + guard let renderState = currentUITestRenderDiagnostics() else { + payload["renderStatsAvailable"] = "0" + payload["renderPanelId"] = "" + payload["renderDrawCount"] = "" + payload["renderPresentCount"] = "" + payload["renderLastPresentTime"] = "" + payload["renderWindowVisible"] = "" + payload["renderAppIsActive"] = "" + payload["renderDesiredFocus"] = "" + payload["renderIsFirstResponder"] = "" + payload["renderDiagnosticsUpdatedAt"] = String(format: "%.6f", ProcessInfo.processInfo.systemUptime) + return + } -@discardableResult -func startOrFocusTerminalSearch( - _ terminalSurface: TerminalSurface, - searchFocusNotifier: @escaping (TerminalSurface) -> Void = { - NotificationCenter.default.post(name: .ghosttySearchFocus, object: $0) - } -) -> Bool { - if terminalSurface.searchState != nil { - searchFocusNotifier(terminalSurface) - return true + payload["renderStatsAvailable"] = "1" + payload["renderPanelId"] = renderState.panelId.uuidString + payload["renderDrawCount"] = String(renderState.drawCount) + payload["renderPresentCount"] = String(renderState.presentCount) + payload["renderLastPresentTime"] = String(format: "%.6f", renderState.lastPresentTime) + payload["renderWindowVisible"] = renderState.windowVisible ? "1" : "0" + payload["renderAppIsActive"] = renderState.appIsActive ? "1" : "0" + payload["renderDesiredFocus"] = renderState.desiredFocus ? "1" : "0" + payload["renderIsFirstResponder"] = renderState.isFirstResponder ? "1" : "0" + payload["renderDiagnosticsUpdatedAt"] = String(format: "%.6f", ProcessInfo.processInfo.systemUptime) } - if terminalSurface.performBindingAction("start_search") { - DispatchQueue.main.async { [weak terminalSurface] in - guard let terminalSurface, terminalSurface.searchState == nil else { return } - terminalSurface.searchState = TerminalSurface.SearchState() - searchFocusNotifier(terminalSurface) + private func currentUITestRenderDiagnostics() -> UITestRenderDiagnosticsSnapshot? { + guard let tabManager, + let tabId = tabManager.selectedTabId, + let workspace = tabManager.tabs.first(where: { $0.id == tabId }) else { + return nil } - return true - } - - terminalSurface.searchState = TerminalSurface.SearchState() - searchFocusNotifier(terminalSurface) - return true -} -/// Let AppKit own native Cmd+` window cycling so key-window changes do not -/// re-enter our direct-to-menu shortcut path. -func shouldRouteCommandEquivalentDirectlyToMainMenu(_ event: NSEvent) -> Bool { - let flags = event.modifierFlags.intersection(.deviceIndependentFlagsMask) - guard flags.contains(.command) else { return false } + let terminalPanel: TerminalPanel? = { + if let focusedPanelId = workspace.focusedPanelId, + let terminalPanel = workspace.terminalPanel(for: focusedPanelId) { + return terminalPanel + } + if let focusedTerminalPanel = workspace.focusedTerminalPanel { + return focusedTerminalPanel + } + return workspace.panels.values.compactMap { $0 as? TerminalPanel }.first + }() - let normalizedFlags = flags.subtracting([.numericPad, .function, .capsLock]) - if event.keyCode == 50, - normalizedFlags == [.command] || normalizedFlags == [.command, .shift] { - return false + guard let terminalPanel else { return nil } + let stats = terminalPanel.hostedView.debugRenderStats() + return UITestRenderDiagnosticsSnapshot( + panelId: terminalPanel.id, + drawCount: stats.drawCount, + presentCount: stats.presentCount, + lastPresentTime: stats.lastPresentTime, + windowVisible: stats.windowOcclusionVisible, + appIsActive: stats.appIsActive, + desiredFocus: stats.desiredFocus, + isFirstResponder: stats.isFirstResponder + ) } - return true -} + private func moveUITestWindowToTargetDisplayIfNeeded(attempt: Int = 0) { + let env = ProcessInfo.processInfo.environment + guard let rawDisplayID = env["PROGRAMA_UI_TEST_TARGET_DISPLAY_ID"], + let targetDisplayID = UInt32(rawDisplayID) else { + return + } -private enum BrowserFindCommandEquivalent { - case find - case findNext - case findPrevious - case hideFind - case useSelection + guard let screen = NSScreen.screens.first(where: { $0.programaDisplayID == targetDisplayID }) else { + if attempt < 20 { + DispatchQueue.main.asyncAfter(deadline: .now() + 0.25) { [weak self] in + self?.moveUITestWindowToTargetDisplayIfNeeded(attempt: attempt + 1) + } + } + self.writeUITestDiagnosticsIfNeeded(stage: "targetDisplayMissing") + return + } - var keepsProgramaBrowserFindBarOwnershipWhenVisible: Bool { - switch self { - case .find, .findNext, .findPrevious, .hideFind: - return true - case .useSelection: - return false + guard let window = NSApp.windows.first else { + if attempt < 20 { + DispatchQueue.main.asyncAfter(deadline: .now() + 0.25) { [weak self] in + self?.moveUITestWindowToTargetDisplayIfNeeded(attempt: attempt + 1) + } + } + self.writeUITestDiagnosticsIfNeeded(stage: "targetDisplayNoWindow") + return } - } -} -private func programaIsLikelyWebInspectorResponder(_ responder: NSResponder?) -> Bool { - guard let responder else { return false } - let responderType = String(describing: type(of: responder)) - if responderType.contains("WKInspector") { - return true - } - guard let view = responder as? NSView else { return false } - var node: NSView? = view - var hops = 0 - while let current = node, hops < 64 { - if String(describing: type(of: current)).contains("WKInspector") { - return true + let visibleFrame = screen.visibleFrame + let width = min(window.frame.width, max(visibleFrame.width - 80, 480)) + let height = min(window.frame.height, max(visibleFrame.height - 80, 360)) + let frame = NSRect( + x: visibleFrame.midX - (width / 2), + y: visibleFrame.midY - (height / 2), + width: width, + height: height + ).integral + + window.setFrame(frame, display: true, animate: false) + window.makeKeyAndOrderFront(nil) + window.orderFrontRegardless() + if window.screen?.programaDisplayID != targetDisplayID, attempt < 20 { + DispatchQueue.main.asyncAfter(deadline: .now() + 0.25) { [weak self] in + self?.moveUITestWindowToTargetDisplayIfNeeded(attempt: attempt + 1) + } + return } - node = current.superview - hops += 1 + self.writeUITestDiagnosticsIfNeeded(stage: "afterMoveToTargetDisplay") } - return false -} +#endif -private func browserFindCommandEquivalent(for event: NSEvent) -> BrowserFindCommandEquivalent? { - let flags = event.modifierFlags - .intersection(.deviceIndependentFlagsMask) - .subtracting([.numericPad, .function, .capsLock]) + func applicationDidBecomeActive(_ notification: Notification) { + guard let notificationStore else { return } + notificationStore.handleApplicationDidBecomeActive() + guard let tabManager else { return } + guard let tabId = tabManager.selectedTabId else { return } + let surfaceId = tabManager.focusedSurfaceId(for: tabId) + guard notificationStore.hasUnreadNotification(forTabId: tabId, surfaceId: surfaceId) else { return } - let normalizedChars = KeyboardLayout.normalizedCharacters(for: event).lowercased() - let hasSingleASCIIShortcutChar = - normalizedChars.count == 1 && normalizedChars.allSatisfy(\.isASCII) - let producedAnyASCIIShortcutChar = normalizedChars.contains(where: \.isASCII) - func matches(_ chars: String, keyCode: UInt16) -> Bool { - if hasSingleASCIIShortcutChar { - return normalizedChars == chars - } - if !producedAnyASCIIShortcutChar { - return event.keyCode == keyCode + if let surfaceId, + let tab = tabManager.tabs.first(where: { $0.id == tabId }) { + tab.triggerNotificationFocusFlash(panelId: surfaceId, requiresSplit: false, shouldFocus: false) } - return false + notificationStore.markRead(forTabId: tabId, surfaceId: surfaceId) } - switch flags { - case [.command]: - if matches("e", keyCode: 14) { // kVK_ANSI_E - return .useSelection + func applicationShouldTerminate(_ sender: NSApplication) -> NSApplication.TerminateReply { + isTerminatingApp = true + _ = saveSessionSnapshot(includeScrollback: true, removeWhenEmpty: false) + + // Tagged DEV builds are ephemeral, skip quit confirmation entirely. + if SocketControlSettings.isTaggedDevBuild() { + return .terminateNow } - if matches("f", keyCode: 3) { // kVK_ANSI_F - return .find + + // If the user already confirmed via the Cmd+Q shortcut warning dialog + // (handleQuitShortcutWarning), skip the check to avoid a second alert. + if isQuitWarningConfirmed { + return .terminateNow } - if matches("g", keyCode: 5) { // kVK_ANSI_G - return .findNext + + // Respect the "Warn Before Quit" setting even when Cmd+Q arrives via + // the Cmd+Tab app switcher, bypassing handleCustomShortcut. + guard QuitWarningSettings.isEnabled() else { + return .terminateNow } - return nil - case [.command, .shift]: - if matches("f", keyCode: 3) { // kVK_ANSI_F - return .hideFind - } - if matches("g", keyCode: 5) { // kVK_ANSI_G - return .findPrevious + + // Show the same confirmation dialog used by the Cmd+Q shortcut path, + // then reply asynchronously so we can return .terminateLater now. + DispatchQueue.main.async { + let alert = NSAlert() + alert.alertStyle = .warning + alert.messageText = String(localized: "dialog.quitPrograma.title", defaultValue: "Quit Programa?") + alert.informativeText = String(localized: "dialog.quitPrograma.message", defaultValue: "This will close all windows and workspaces.") + alert.addButton(withTitle: String(localized: "dialog.quitPrograma.quit", defaultValue: "Quit")) + alert.addButton(withTitle: String(localized: "common.cancel", defaultValue: "Cancel")) + alert.showsSuppressionButton = true + alert.suppressionButton?.title = String(localized: "dialog.dontWarnCmdQ", defaultValue: "Don't warn again for Cmd+Q") + + let response = alert.runModal() + if alert.suppressionButton?.state == .on { + QuitWarningSettings.setEnabled(false) + } + + let shouldQuit = response == .alertFirstButtonReturn + if shouldQuit { + self.isQuitWarningConfirmed = true + } else { + // Reset so that the next quit attempt can show the dialog again. + self.isTerminatingApp = false + } + NSApp.reply(toApplicationShouldTerminate: shouldQuit) } - return nil - default: - return nil + return .terminateLater } -} -/// For browser content, let the page try the Find command family before cmux's menu fallback. -/// This preserves native web-app shortcuts like VS Code's Cmd+F while still allowing cmux's -/// browser find overlay to keep owning its visible Find UI shortcuts. -func shouldRouteBrowserFindCommandEquivalentThroughWebContentFirst( - _ event: NSEvent, - responder: NSResponder? = nil, - owningWebView: ProgramaWebView? = nil -) -> Bool { - guard let shortcut = browserFindCommandEquivalent(for: event) else { - return false + func applicationWillTerminate(_ notification: Notification) { + isTerminatingApp = true + _ = saveSessionSnapshot(includeScrollback: true, removeWhenEmpty: false) + stopSessionAutosaveTimer() + TerminalController.shared.stop() + VSCodeServeWebController.shared.stop() + BrowserProfileStore.shared.flushPendingSaves() + notificationStore?.clearAll() + SurfacePool.shared.teardownAll() + enableSuddenTerminationIfNeeded() } - if programaIsLikelyWebInspectorResponder(responder) { - return false + func applicationWillResignActive(_ notification: Notification) { + guard !isTerminatingApp else { return } + clearConfiguredShortcutChordState() + _ = saveSessionSnapshot(includeScrollback: false) } - if shortcut.keepsProgramaBrowserFindBarOwnershipWhenVisible, - let owningWebView { - let browserFindBarIsVisible = MainActor.assumeIsolated { - AppDelegate.shared?.browserFindBarIsVisible(for: owningWebView) == true - } - if browserFindBarIsVisible { - return false - } + func persistSessionForUpdateRelaunch() { + isTerminatingApp = true + _ = saveSessionSnapshot(includeScrollback: true, removeWhenEmpty: false) } - return true -} + func configure(tabManager: TabManager, notificationStore: TerminalNotificationStore, sidebarState: SidebarState) { + self.tabManager = tabManager + self.notificationStore = notificationStore + self.sidebarState = sidebarState + disableSuddenTerminationIfNeeded() + installLifecycleSnapshotObserversIfNeeded() + prepareStartupSessionSnapshotIfNeeded() + startSessionAutosaveTimerIfNeeded() +#if DEBUG + setupJumpUnreadUITestIfNeeded() + setupTerminalCmdClickUITestIfNeeded() + setupGotoSplitUITestIfNeeded() + setupBonsplitTabDragUITestIfNeeded() + setupMultiWindowNotificationsUITestIfNeeded() + setupDisplayResolutionUITestDiagnosticsIfNeeded() -func cmuxOwningGhosttyView(for responder: NSResponder?) -> GhosttyNSView? { - guard let responder else { return nil } - if let ghosttyView = responder as? GhosttyNSView { - return ghosttyView + // UI tests sometimes don't run SwiftUI `.onAppear` soon enough (or at all) on the VM. + // The automation socket is a core testing primitive, so ensure it's started here when + // we detect XCTest, even if the main view lifecycle is flaky. + let env = ProcessInfo.processInfo.environment + if isRunningUnderXCTest(env) { + let raw = UserDefaults.standard.string(forKey: SocketControlSettings.appStorageKey) + ?? SocketControlSettings.defaultMode.rawValue + let userMode = SocketControlSettings.migrateMode(raw) + let mode = SocketControlSettings.effectiveMode(userMode: userMode) + if mode != .off { + TerminalController.shared.start( + tabManager: tabManager, + socketPath: SocketControlSettings.socketPath(), + accessMode: mode + ) + scheduleUITestSocketSanityCheckIfNeeded() + } + } +#endif } - if let view = responder as? NSView, - let ghosttyView = cmuxOwningGhosttyView(for: view) { - return ghosttyView + + private func prepareStartupSessionSnapshotIfNeeded() { + guard !didPrepareStartupSessionSnapshot else { return } + didPrepareStartupSessionSnapshot = true + guard SessionRestorePolicy.shouldAttemptRestore() else { return } + Self.removeLegacyPersistedWindowGeometry() + startupSessionSnapshot = SessionPersistenceStore.load() } - if let textView = responder as? NSTextView { - if textView.isFieldEditor, - let ownerView = programaFieldEditorOwnerView(textView), - let ghosttyView = cmuxOwningGhosttyView(for: ownerView) { - return ghosttyView + private func persistedWindowGeometry( + defaults: UserDefaults = .standard + ) -> PersistedWindowGeometry? { + Self.removeLegacyPersistedWindowGeometry(defaults: defaults) + guard let data = defaults.data(forKey: Self.persistedWindowGeometryDefaultsKey) else { + return nil } - - if !textView.isFieldEditor, - let delegateView = textView.delegate as? NSView, - let ghosttyView = cmuxOwningGhosttyView(for: delegateView) { - return ghosttyView + guard let payload = Self.decodedPersistedWindowGeometryData(data) else { + defaults.removeObject(forKey: Self.persistedWindowGeometryDefaultsKey) + return nil } + return payload } - var current = responder.nextResponder - while let next = current { - if let ghosttyView = next as? GhosttyNSView { - return ghosttyView - } - if let view = next as? NSView, - let ghosttyView = cmuxOwningGhosttyView(for: view) { - return ghosttyView + private func persistWindowGeometry( + frame: SessionRectSnapshot?, + display: SessionDisplaySnapshot?, + defaults: UserDefaults = .standard + ) { + Self.removeLegacyPersistedWindowGeometry(defaults: defaults) + guard let data = Self.encodedPersistedWindowGeometryData(frame: frame, display: display) else { + return } - current = next.nextResponder + defaults.set(data, forKey: Self.persistedWindowGeometryDefaultsKey) } - return nil -} - -private func programaFieldEditorOwnerView(_ editor: NSTextView) -> NSView? { - guard editor.isFieldEditor else { return nil } + private nonisolated static func encodedPersistedWindowGeometryData( + frame: SessionRectSnapshot?, + display: SessionDisplaySnapshot? + ) -> Data? { + guard let frame else { return nil } + let payload = PersistedWindowGeometry( + version: persistedWindowGeometrySchemaVersion, + frame: frame, + display: display + ) + return try? JSONEncoder().encode(payload) + } - var current = editor.nextResponder - while let next = current { - if let view = next as? NSView { - return view + nonisolated static func decodedPersistedWindowGeometryData(_ data: Data) -> PersistedWindowGeometry? { + guard let payload = try? JSONDecoder().decode(PersistedWindowGeometry.self, from: data), + payload.version == persistedWindowGeometrySchemaVersion else { + return nil } - current = next.nextResponder + return payload } - return editor.superview -} + private nonisolated static func removeLegacyPersistedWindowGeometry( + defaults: UserDefaults = .standard + ) { + legacyPersistedWindowGeometryDefaultsKeys.forEach { defaults.removeObject(forKey: $0) } + } -private func cmuxOwningGhosttyView(for view: NSView) -> GhosttyNSView? { - if let ghosttyView = view as? GhosttyNSView { - return ghosttyView + private func persistWindowGeometry(from window: NSWindow?) { + guard let window else { return } + persistWindowGeometry( + frame: SessionRectSnapshot(window.frame), + display: displaySnapshot(for: window) + ) } - var current: NSView? = view.superview - while let candidate = current { - if let ghosttyView = candidate as? GhosttyNSView { - return ghosttyView + private func currentDisplayGeometries() -> ( + available: [SessionDisplayGeometry], + fallback: SessionDisplayGeometry? + ) { + let available = NSScreen.screens.map { screen in + SessionDisplayGeometry( + displayID: screen.programaDisplayID, + frame: screen.frame, + visibleFrame: screen.visibleFrame + ) } - current = candidate.superview + let fallback = (NSScreen.main ?? NSScreen.screens.first).map { screen in + SessionDisplayGeometry( + displayID: screen.programaDisplayID, + frame: screen.frame, + visibleFrame: screen.visibleFrame + ) + } + return (available, fallback) } - return nil -} + private func attemptStartupSessionRestoreIfNeeded(primaryWindow: NSWindow) { + guard !didAttemptStartupSessionRestore else { return } + didAttemptStartupSessionRestore = true + guard !didHandleExplicitOpenIntentAtStartup else { return } + guard let primaryContext = contextForMainTerminalWindow(primaryWindow) else { return } + let startupSnapshot = startupSessionSnapshot + let primaryWindowSnapshot = startupSnapshot?.windows.first + if let primaryWindowSnapshot { + isApplyingStartupSessionRestore = true #if DEBUG -func browserZoomShortcutTraceCandidate( - flags: NSEvent.ModifierFlags, - chars: String, - keyCode: UInt16, - literalChars: String? = nil -) -> Bool { - let normalizedFlags = flags - .intersection(.deviceIndependentFlagsMask) - .subtracting([.numericPad, .function]) - guard normalizedFlags.contains(.command) else { return false } - - let keys = browserZoomShortcutKeyCandidates( - chars: chars, - literalChars: literalChars, - keyCode: keyCode - ) - if keys.contains("=") || keys.contains("+") || keys.contains("-") || keys.contains("_") || keys.contains("0") { - return true - } - switch keyCode { - case 24, 27, 29, 69, 78, 82: // ANSI and keypad zoom keys - return true - default: - return false - } -} - -func browserZoomShortcutTraceFlagsString(_ flags: NSEvent.ModifierFlags) -> String { - let normalizedFlags = flags - .intersection(.deviceIndependentFlagsMask) - .subtracting([.numericPad, .function]) - var parts: [String] = [] - if normalizedFlags.contains(.command) { parts.append("Cmd") } - if normalizedFlags.contains(.shift) { parts.append("Shift") } - if normalizedFlags.contains(.option) { parts.append("Opt") } - if normalizedFlags.contains(.control) { parts.append("Ctrl") } - return parts.isEmpty ? "none" : parts.joined(separator: "+") -} - -func browserZoomShortcutTraceActionString(_ action: BrowserZoomShortcutAction?) -> String { - guard let action else { return "none" } - switch action { - case .zoomIn: return "zoomIn" - case .zoomOut: return "zoomOut" - case .reset: return "reset" - } -} + dlog( + "session.restore.start windows=\(startupSnapshot?.windows.count ?? 0) " + + "primaryFrame={\(debugSessionRectDescription(primaryWindowSnapshot.frame))} " + + "primaryDisplay={\(debugSessionDisplayDescription(primaryWindowSnapshot.display))}" + ) #endif + applySessionWindowSnapshot( + primaryWindowSnapshot, + to: primaryContext, + window: primaryWindow + ) + } else { + let displays = currentDisplayGeometries() + let fallbackGeometry = persistedWindowGeometry() + if let restoredFrame = Self.resolvedStartupPrimaryWindowFrame( + primarySnapshot: nil, + fallbackFrame: fallbackGeometry?.frame, + fallbackDisplaySnapshot: fallbackGeometry?.display, + availableDisplays: displays.available, + fallbackDisplay: displays.fallback + ) { + primaryWindow.setFrame(restoredFrame, display: true) + } + } -func shouldSuppressWindowMoveForFolderDrag(hitView: NSView?) -> Bool { - var candidate = hitView - while let view = candidate { - if view is DraggableFolderNSView { - return true + if let startupSnapshot { + let additionalWindows = Array(startupSnapshot + .windows + .dropFirst() + .prefix(max(0, SessionPersistencePolicy.maxWindowsPerSnapshot - 1))) +#if DEBUG + for (index, windowSnapshot) in additionalWindows.enumerated() { + dlog( + "session.restore.enqueueAdditional idx=\(index + 1) " + + "frame={\(debugSessionRectDescription(windowSnapshot.frame))} " + + "display={\(debugSessionDisplayDescription(windowSnapshot.display))}" + ) + } +#endif + if !additionalWindows.isEmpty { + DispatchQueue.main.async { [weak self] in + guard let self else { return } + for windowSnapshot in additionalWindows { + _ = self.createMainWindow(sessionWindowSnapshot: windowSnapshot) + } + self.completeStartupSessionRestore() + } + } else { + completeStartupSessionRestore() + } } - candidate = view.superview } - return false -} -func shouldSuppressWindowMoveForFolderDrag(window: NSWindow, event: NSEvent) -> Bool { - guard event.type == .leftMouseDown, - window.isMovable, - let contentView = window.contentView else { - return false + private func completeStartupSessionRestore() { + startupSessionSnapshot = nil + isApplyingStartupSessionRestore = false + _ = saveSessionSnapshot(includeScrollback: false) } - let contentPoint = contentView.convert(event.locationInWindow, from: nil) - let hitView = contentView.hitTest(contentPoint) - return shouldSuppressWindowMoveForFolderDrag(hitView: hitView) -} - -@MainActor -final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCenterDelegate, NSMenuItemValidation { - nonisolated(unsafe) static var shared: AppDelegate? - - private static let cachedIsRunningUnderXCTest = detectRunningUnderXCTest(ProcessInfo.processInfo.environment) - - private var isRunningUnderXCTestCached: Bool { - Self.cachedIsRunningUnderXCTest - } + private func applySessionWindowSnapshot( + _ snapshot: SessionWindowSnapshot, + to context: MainWindowContext, + window: NSWindow? + ) { +#if DEBUG + dlog( + "session.restore.apply window=\(context.windowId.uuidString.prefix(8)) " + + "liveWin=\(window?.windowNumber ?? -1) " + + "snapshotFrame={\(debugSessionRectDescription(snapshot.frame))} " + + "snapshotDisplay={\(debugSessionDisplayDescription(snapshot.display))}" + ) +#endif + context.tabManager.restoreSessionSnapshot(snapshot.tabManager) + context.sidebarState.isVisible = snapshot.sidebar.isVisible + context.sidebarState.persistedWidth = CGFloat( + SessionPersistencePolicy.sanitizedSidebarWidth(snapshot.sidebar.width) + ) + context.sidebarSelectionState.selection = snapshot.sidebar.selection.sidebarSelection - private static func detectRunningUnderXCTest(_ env: [String: String]) -> Bool { - if env["XCTestConfigurationFilePath"] != nil { return true } - if env["XCTestBundlePath"] != nil { return true } - if env["XCTestSessionIdentifier"] != nil { return true } - if env["XCInjectBundle"] != nil { return true } - if env["XCInjectBundleInto"] != nil { return true } - if env["DYLD_INSERT_LIBRARIES"]?.contains("libXCTest") == true { return true } - if env.keys.contains(where: { $0.hasPrefix("PROGRAMA_UI_TEST_") }) { return true } - return false + if let restoredFrame = resolvedWindowFrame(from: snapshot), let window { + window.setFrame(restoredFrame, display: true) +#if DEBUG + dlog( + "session.restore.frameApplied window=\(context.windowId.uuidString.prefix(8)) " + + "applied={\(debugNSRectDescription(window.frame))}" + ) +#endif + } } - private func isRunningUnderXCTest(_ env: [String: String]) -> Bool { - // On some macOS/Xcode setups, the app-under-test process doesn't get - // `XCTestConfigurationFilePath`. Use a broader set of signals so UI tests - // can reliably skip heavyweight startup work and bring up a window. - Self.detectRunningUnderXCTest(env) + private func resolvedWindowFrame(from snapshot: SessionWindowSnapshot?) -> NSRect? { + let displays = currentDisplayGeometries() + return Self.resolvedWindowFrame( + from: snapshot?.frame, + display: snapshot?.display, + availableDisplays: displays.available, + fallbackDisplay: displays.fallback + ) } - final class MainWindowContext { - let windowId: UUID - let tabManager: TabManager - let sidebarState: SidebarState - let sidebarSelectionState: SidebarSelectionState - weak var window: NSWindow? - - init( - windowId: UUID, - tabManager: TabManager, - sidebarState: SidebarState, - sidebarSelectionState: SidebarSelectionState, - window: NSWindow? + nonisolated static func resolvedStartupPrimaryWindowFrame( + primarySnapshot: SessionWindowSnapshot?, + fallbackFrame: SessionRectSnapshot?, + fallbackDisplaySnapshot: SessionDisplaySnapshot?, + availableDisplays: [SessionDisplayGeometry], + fallbackDisplay: SessionDisplayGeometry? + ) -> CGRect? { + if let primary = resolvedWindowFrame( + from: primarySnapshot?.frame, + display: primarySnapshot?.display, + availableDisplays: availableDisplays, + fallbackDisplay: fallbackDisplay ) { - self.windowId = windowId - self.tabManager = tabManager - self.sidebarState = sidebarState - self.sidebarSelectionState = sidebarSelectionState - self.window = window + return primary } - } - private final class MainWindowController: NSWindowController, NSWindowDelegate { - var onClose: (() -> Void)? + return resolvedWindowFrame( + from: fallbackFrame, + display: fallbackDisplaySnapshot, + availableDisplays: availableDisplays, + fallbackDisplay: fallbackDisplay + ) + } - func windowWillClose(_ notification: Notification) { - onClose?() + nonisolated static func resolvedWindowFrame( + from frameSnapshot: SessionRectSnapshot?, + display displaySnapshot: SessionDisplaySnapshot?, + availableDisplays: [SessionDisplayGeometry], + fallbackDisplay: SessionDisplayGeometry? + ) -> CGRect? { + guard let frameSnapshot else { return nil } + let frame = frameSnapshot.cgRect + guard frame.width.isFinite, + frame.height.isFinite, + frame.origin.x.isFinite, + frame.origin.y.isFinite else { + return nil } - } - struct ScriptableMainWindowState { - let windowId: UUID - let tabManager: TabManager - let window: NSWindow? - } + let minWidth = CGFloat(SessionPersistencePolicy.minimumWindowWidth) + let minHeight = CGFloat(SessionPersistencePolicy.minimumWindowHeight) + guard frame.width >= minWidth, + frame.height >= minHeight else { + return nil + } - struct SessionDisplayGeometry { - let displayID: UInt32? - let frame: CGRect - let visibleFrame: CGRect - } + guard !availableDisplays.isEmpty else { return frame } - struct PersistedWindowGeometry: Codable, Sendable { - let version: Int - let frame: SessionRectSnapshot - let display: SessionDisplaySnapshot? - } + if let targetDisplay = display(for: displaySnapshot, in: availableDisplays) { + if shouldPreserveExactFrame( + frame: frame, + displaySnapshot: displaySnapshot, + targetDisplay: targetDisplay + ) { + return frame + } + return resolvedWindowFrame( + frame: frame, + displaySnapshot: displaySnapshot, + targetDisplay: targetDisplay, + minWidth: minWidth, + minHeight: minHeight + ) + } - nonisolated static let persistedWindowGeometrySchemaVersion = 2 - private nonisolated static let persistedWindowGeometryDefaultsKey = "programa.session.lastWindowGeometry.v2" - private nonisolated static let legacyPersistedWindowGeometryDefaultsKeys = [ - "programa.session.lastWindowGeometry.v1" - ] - - weak var tabManager: TabManager? - weak var notificationStore: TerminalNotificationStore? - weak var sidebarState: SidebarState? - weak var fullscreenControlsViewModel: TitlebarControlsViewModel? - weak var sidebarSelectionState: SidebarSelectionState? - var shortcutLayoutCharacterProvider: (UInt16, NSEvent.ModifierFlags) -> String? = KeyboardLayout.character(forKeyCode:modifierFlags:) - private var workspaceObserver: NSObjectProtocol? - private var lifecycleSnapshotObservers: [NSObjectProtocol] = [] - private var windowKeyObserver: NSObjectProtocol? - private var shortcutMonitor: Any? - private var shortcutDefaultsObserver: NSObjectProtocol? - private var menuBarVisibilityObserver: NSObjectProtocol? - private var splitButtonTooltipRefreshScheduled = false - private struct PendingConfiguredShortcutChord { - let firstStroke: ShortcutStroke - let windowNumber: Int? - } - private var pendingConfiguredShortcutChord: PendingConfiguredShortcutChord? - private var activeConfiguredShortcutChordPrefixForCurrentEvent: ShortcutStroke? - private var configuredShortcutChordActions: [KeyboardShortcutSettings.Action] = [] - private var ghosttyConfigObserver: NSObjectProtocol? - var ghosttyGotoSplitLeftShortcut: StoredShortcut? - var ghosttyGotoSplitRightShortcut: StoredShortcut? - var ghosttyGotoSplitUpShortcut: StoredShortcut? - var ghosttyGotoSplitDownShortcut: StoredShortcut? - private var browserAddressBarFocusedPanelId: UUID? - private var browserOmnibarRepeatStartWorkItem: DispatchWorkItem? - private var browserOmnibarRepeatTickWorkItem: DispatchWorkItem? - private var browserOmnibarRepeatKeyCode: UInt16? - private var browserOmnibarRepeatDelta: Int = 0 - private var browserAddressBarFocusObserver: NSObjectProtocol? - private var browserAddressBarBlurObserver: NSObjectProtocol? - private let updateController = UpdateController() - private lazy var titlebarAccessoryController = UpdateTitlebarAccessoryController(viewModel: updateViewModel) - private let windowDecorationsController = WindowDecorationsController() - private var menuBarExtraController: MenuBarExtraController? - private static let serviceErrorNoPath = NSString(string: String(localized: "error.clipboardFolderPath", defaultValue: "Could not load any folder path from the clipboard.")) - private static let didInstallWindowKeyEquivalentSwizzle: Void = { - let targetClass: AnyClass = NSWindow.self - let originalSelector = #selector(NSWindow.performKeyEquivalent(with:)) - let swizzledSelector = #selector(NSWindow.programa_performKeyEquivalent(with:)) - guard let originalMethod = class_getInstanceMethod(targetClass, originalSelector), - let swizzledMethod = class_getInstanceMethod(targetClass, swizzledSelector) else { - return + if let intersectingDisplay = availableDisplays.first(where: { $0.visibleFrame.intersects(frame) }) { + return clampFrame( + frame, + within: intersectingDisplay.visibleFrame, + minWidth: minWidth, + minHeight: minHeight + ) } - method_exchangeImplementations(originalMethod, swizzledMethod) - }() - private static let didInstallWindowFirstResponderSwizzle: Void = { - let targetClass: AnyClass = NSWindow.self - let originalSelector = #selector(NSWindow.makeFirstResponder(_:)) - let swizzledSelector = #selector(NSWindow.programa_makeFirstResponder(_:)) - guard let originalMethod = class_getInstanceMethod(targetClass, originalSelector), - let swizzledMethod = class_getInstanceMethod(targetClass, swizzledSelector) else { - return + + guard let fallbackDisplay else { return frame } + if let sourceReference = displaySnapshot?.visibleFrame?.cgRect ?? displaySnapshot?.frame?.cgRect { + return remappedFrame( + frame, + from: sourceReference, + to: fallbackDisplay.visibleFrame, + minWidth: minWidth, + minHeight: minHeight + ) } - method_exchangeImplementations(originalMethod, swizzledMethod) - }() - private static let didInstallWindowSendEventSwizzle: Void = { - let targetClass: AnyClass = NSWindow.self - let originalSelector = #selector(NSWindow.sendEvent(_:)) - let swizzledSelector = #selector(NSWindow.programa_sendEvent(_:)) - guard let originalMethod = class_getInstanceMethod(targetClass, originalSelector), - let swizzledMethod = class_getInstanceMethod(targetClass, swizzledSelector) else { - return + + return centeredFrame( + frame, + in: fallbackDisplay.visibleFrame, + minWidth: minWidth, + minHeight: minHeight + ) + } + + private nonisolated static func resolvedWindowFrame( + frame: CGRect, + displaySnapshot: SessionDisplaySnapshot?, + targetDisplay: SessionDisplayGeometry, + minWidth: CGFloat, + minHeight: CGFloat + ) -> CGRect { + if targetDisplay.visibleFrame.intersects(frame) { + // Preserve the user's exact frame when enough of the top of the window + // remains reachable on-screen; only clamp when the saved frame would + // reopen with an inaccessible titlebar/top strip. + if shouldPreserveAccessibleFrame( + frame: frame, + targetDisplay: targetDisplay + ) { + return frame + } + return clampFrame( + frame, + within: targetDisplay.visibleFrame, + minWidth: minWidth, + minHeight: minHeight + ) } - method_exchangeImplementations(originalMethod, swizzledMethod) - }() - private static let didInstallApplicationSendEventSwizzle: Void = { - let targetClass: AnyClass = NSApplication.self - let originalSelector = #selector(NSApplication.sendEvent(_:)) - let swizzledSelector = #selector(NSApplication.programa_applicationSendEvent(_:)) - guard let originalMethod = class_getInstanceMethod(targetClass, originalSelector), - let swizzledMethod = class_getInstanceMethod(targetClass, swizzledSelector) else { - return + + if let sourceReference = displaySnapshot?.visibleFrame?.cgRect ?? displaySnapshot?.frame?.cgRect { + return remappedFrame( + frame, + from: sourceReference, + to: targetDisplay.visibleFrame, + minWidth: minWidth, + minHeight: minHeight + ) } - method_exchangeImplementations(originalMethod, swizzledMethod) - }() -#if DEBUG - var didSetupJumpUnreadUITest = false - var jumpUnreadFocusExpectation: (tabId: UUID, surfaceId: UUID)? - var jumpUnreadFocusObserver: NSObjectProtocol? - var didSetupTerminalCmdClickUITest = false - var didSetupGotoSplitUITest = false - var didSetupBonsplitTabDragUITest = false - var terminalCmdClickUITestPoller: DispatchSourceTimer? - var bonsplitTabDragUITestRecorder: DispatchSourceTimer? - var gotoSplitUITestRecorder: DispatchSourceTimer? - var gotoSplitUITestObservers: [NSObjectProtocol] = [] - var didSetupMultiWindowNotificationsUITest = false - var didSetupDisplayResolutionUITestDiagnostics = false - var displayResolutionUITestObservers: [NSObjectProtocol] = [] - private struct UITestRenderDiagnosticsSnapshot { - let panelId: UUID - let drawCount: Int - let presentCount: Int - let lastPresentTime: Double - let windowVisible: Bool - let appIsActive: Bool - let desiredFocus: Bool - let isFirstResponder: Bool + return centeredFrame( + frame, + in: targetDisplay.visibleFrame, + minWidth: minWidth, + minHeight: minHeight + ) } - var debugCloseMainWindowConfirmationHandler: ((NSWindow) -> Bool)? - var debugCreateMainWindowSourceIsNativeFullScreenOverride: Bool? - // Keep debug-only windows alive when tests intentionally inject key mismatches. - private var debugDetachedContextWindows: [NSWindow] = [] - private func childExitKeyboardProbePath() -> String? { - let env = ProcessInfo.processInfo.environment - guard env["PROGRAMA_UI_TEST_CHILD_EXIT_KEYBOARD_SETUP"] == "1", - let path = env["PROGRAMA_UI_TEST_CHILD_EXIT_KEYBOARD_PATH"], - !path.isEmpty else { - return nil + private nonisolated static func shouldPreserveAccessibleFrame( + frame: CGRect, + targetDisplay: SessionDisplayGeometry, + minimumVisibleTopStripWidth: CGFloat = 120, + topStripHeight: CGFloat = 64, + minimumVisibleTopStripHeight: CGFloat = 24 + ) -> Bool { + let standardizedFrame = frame.standardized + guard standardizedFrame.width.isFinite, + standardizedFrame.height.isFinite, + standardizedFrame.width > 0, + standardizedFrame.height > 0, + standardizedFrame.intersects(targetDisplay.frame) else { + return false } - return path - } - private func childExitKeyboardProbeHex(_ value: String?) -> String { - guard let value else { return "" } - return value.unicodeScalars - .map { String(format: "%04X", $0.value) } - .joined(separator: ",") + let stripHeight = min(topStripHeight, standardizedFrame.height) + let topStrip = CGRect( + x: standardizedFrame.minX, + y: standardizedFrame.maxY - stripHeight, + width: standardizedFrame.width, + height: stripHeight + ) + let visibleTopStrip = topStrip.intersection(targetDisplay.visibleFrame) + guard !visibleTopStrip.isNull else { return false } + + let requiredWidth = min(minimumVisibleTopStripWidth, standardizedFrame.width) + let requiredHeight = min(minimumVisibleTopStripHeight, stripHeight) + return visibleTopStrip.width >= requiredWidth + && visibleTopStrip.height >= requiredHeight } - private func writeChildExitKeyboardProbe(_ updates: [String: String], increments: [String: Int] = [:]) { - guard let path = childExitKeyboardProbePath() else { return } - var payload: [String: String] = { - guard let data = try? Data(contentsOf: URL(fileURLWithPath: path)), - let object = try? JSONSerialization.jsonObject(with: data) as? [String: String] else { - return [:] - } - return object - }() - for (key, by) in increments { - let current = Int(payload[key] ?? "") ?? 0 - payload[key] = String(current + by) + private nonisolated static func display( + for snapshot: SessionDisplaySnapshot?, + in displays: [SessionDisplayGeometry] + ) -> SessionDisplayGeometry? { + guard let snapshot else { return nil } + if let displayID = snapshot.displayID, + let exact = displays.first(where: { $0.displayID == displayID }) { + return exact } - for (key, value) in updates { - payload[key] = value + + guard let referenceRect = (snapshot.visibleFrame ?? snapshot.frame)?.cgRect else { + return nil } - guard let data = try? JSONSerialization.data(withJSONObject: payload) else { return } - try? data.write(to: URL(fileURLWithPath: path), options: .atomic) - } -#endif - var mainWindowContexts: [ObjectIdentifier: MainWindowContext] = [:] - private var mainWindowControllers: [MainWindowController] = [] + let overlaps = displays.map { display -> (display: SessionDisplayGeometry, area: CGFloat) in + (display, intersectionArea(referenceRect, display.visibleFrame)) + } + if let bestOverlap = overlaps.max(by: { $0.area < $1.area }), bestOverlap.area > 0 { + return bestOverlap.display + } - /// Tracks the cascade point for new windows, matching Ghostty's upstream algorithm. - /// Reset to `.zero` so the first window seeds the point from its own position. - private var lastCascadePoint = NSPoint.zero - private var startupSessionSnapshot: AppSessionSnapshot? - private var didPrepareStartupSessionSnapshot = false - private var didAttemptStartupSessionRestore = false - private var isApplyingStartupSessionRestore = false - private var sessionAutosaveTimer: DispatchSourceTimer? - private var sessionAutosaveTickInFlight = false - private var sessionAutosaveDeferredRetryPending = false - private let sessionPersistenceQueue = DispatchQueue( - label: "com.cmuxterm.app.sessionPersistence", - qos: .utility - ) - private nonisolated static let launchServicesRegistrationQueue = DispatchQueue( - label: "com.cmuxterm.app.launchServicesRegistration", - qos: .utility - ) - private nonisolated static func enqueueLaunchServicesRegistrationWork(_ work: @escaping @Sendable () -> Void) { - launchServicesRegistrationQueue.async(execute: work) + let referenceCenter = CGPoint(x: referenceRect.midX, y: referenceRect.midY) + return displays.min { lhs, rhs in + let lhsDistance = distanceSquared(lhs.visibleFrame, referenceCenter) + let rhsDistance = distanceSquared(rhs.visibleFrame, referenceCenter) + return lhsDistance < rhsDistance + } } - private var lastSessionAutosaveFingerprint: Int? - private var lastSessionAutosavePersistedAt: Date = .distantPast - private var lastTypingActivityAt: TimeInterval = 0 - private var didHandleExplicitOpenIntentAtStartup = false - private var isTerminatingApp = false - // Set to true when the user has already confirmed quit via the warning dialog, - // so applicationShouldTerminate does not show a second alert. - private var isQuitWarningConfirmed = false - private var didInstallLifecycleSnapshotObservers = false - private var didDisableSuddenTermination = false - private var commandPaletteVisibilityByWindowId: [UUID: Bool] = [:] - private var commandPalettePendingOpenByWindowId: [UUID: Bool] = [:] - private var commandPaletteRecentRequestAtByWindowId: [UUID: TimeInterval] = [:] - private var commandPaletteEscapeSuppressionByWindowId: Set = [] - private var commandPaletteEscapeSuppressionStartedAtByWindowId: [UUID: TimeInterval] = [:] - private var commandPaletteSelectionByWindowId: [UUID: Int] = [:] - private var commandPaletteSnapshotByWindowId: [UUID: CommandPaletteDebugSnapshot] = [:] - private static let commandPaletteRequestGraceInterval: TimeInterval = 1.25 - private static let commandPalettePendingOpenMaxAge: TimeInterval = 8.0 - private static let sessionAutosaveTypingQuietPeriod: TimeInterval = 0.65 - var updateViewModel: UpdateViewModel { - updateController.viewModel - } + private nonisolated static func remappedFrame( + _ frame: CGRect, + from sourceRect: CGRect, + to targetRect: CGRect, + minWidth: CGFloat, + minHeight: CGFloat + ) -> CGRect { + let source = sourceRect.standardized + let target = targetRect.standardized + guard source.width.isFinite, + source.height.isFinite, + source.width > 1, + source.height > 1, + target.width.isFinite, + target.height.isFinite, + target.width > 0, + target.height > 0 else { + return centeredFrame(frame, in: targetRect, minWidth: minWidth, minHeight: minHeight) + } -#if DEBUG - private func pointerString(_ object: AnyObject?) -> String { - guard let object else { return "nil" } - return String(describing: Unmanaged.passUnretained(object).toOpaque()) - } + let relativeX = (frame.minX - source.minX) / source.width + let relativeY = (frame.minY - source.minY) / source.height + let relativeWidth = frame.width / source.width + let relativeHeight = frame.height / source.height - private func summarizeContextForWorkspaceRouting(_ context: MainWindowContext?) -> String { - guard let context else { return "nil" } - let window = context.window ?? windowForMainWindowId(context.windowId) - let windowNumber = window?.windowNumber ?? -1 - let key = window?.isKeyWindow == true ? 1 : 0 - let main = window?.isMainWindow == true ? 1 : 0 - let visible = window?.isVisible == true ? 1 : 0 - let selected = context.tabManager.selectedTabId.map { String($0.uuidString.prefix(8)) } ?? "nil" - return "wid=\(context.windowId.uuidString.prefix(8)) win=\(windowNumber) key=\(key) main=\(main) vis=\(visible) tabs=\(context.tabManager.tabs.count) sel=\(selected) tm=\(pointerString(context.tabManager))" + let remapped = CGRect( + x: target.minX + (relativeX * target.width), + y: target.minY + (relativeY * target.height), + width: target.width * relativeWidth, + height: target.height * relativeHeight + ) + return clampFrame(remapped, within: target, minWidth: minWidth, minHeight: minHeight) } - private func summarizeAllContextsForWorkspaceRouting() -> String { - guard !mainWindowContexts.isEmpty else { return "" } - return mainWindowContexts.values - .map { summarizeContextForWorkspaceRouting($0) } - .joined(separator: " | ") + private nonisolated static func centeredFrame( + _ frame: CGRect, + in visibleFrame: CGRect, + minWidth: CGFloat, + minHeight: CGFloat + ) -> CGRect { + let centered = CGRect( + x: visibleFrame.midX - (frame.width / 2), + y: visibleFrame.midY - (frame.height / 2), + width: frame.width, + height: frame.height + ) + return clampFrame(centered, within: visibleFrame, minWidth: minWidth, minHeight: minHeight) } - private func logWorkspaceCreationRouting( - phase: String, - source: String, - reason: String, - event: NSEvent?, - chosenContext: MainWindowContext?, - workspaceId: UUID? = nil, - workingDirectory: String? = nil - ) { - let eventWindowNumber = event?.window?.windowNumber ?? -1 - let eventNumber = event?.windowNumber ?? -1 - let eventChars = event?.charactersIgnoringModifiers ?? "" - let eventKeyCode = event.map { String($0.keyCode) } ?? "nil" - let keyWindowNumber = NSApp.keyWindow?.windowNumber ?? -1 - let mainWindowNumber = NSApp.mainWindow?.windowNumber ?? -1 - let ws = workspaceId.map { String($0.uuidString.prefix(8)) } ?? "nil" - let wd = workingDirectory.map { String($0.prefix(120)) } ?? "-" - FocusLogStore.shared.append( - "cmdn.route phase=\(phase) src=\(source) reason=\(reason) eventWin=\(eventWindowNumber) eventNum=\(eventNumber) keyCode=\(eventKeyCode) chars=\(eventChars) keyWin=\(keyWindowNumber) mainWin=\(mainWindowNumber) activeTM=\(pointerString(tabManager)) chosen={\(summarizeContextForWorkspaceRouting(chosenContext))} ws=\(ws) wd=\(wd) contexts=[\(summarizeAllContextsForWorkspaceRouting())]" - ) + private nonisolated static func clampFrame( + _ frame: CGRect, + within visibleFrame: CGRect, + minWidth: CGFloat, + minHeight: CGFloat + ) -> CGRect { + guard visibleFrame.width.isFinite, + visibleFrame.height.isFinite, + visibleFrame.width > 0, + visibleFrame.height > 0 else { + return frame + } + + let maxWidth = max(visibleFrame.width, 1) + let maxHeight = max(visibleFrame.height, 1) + let widthFloor = min(minWidth, maxWidth) + let heightFloor = min(minHeight, maxHeight) + + let width = min(max(frame.width, widthFloor), maxWidth) + let height = min(max(frame.height, heightFloor), maxHeight) + let maxX = visibleFrame.maxX - width + let maxY = visibleFrame.maxY - height + let x = min(max(frame.minX, visibleFrame.minX), maxX) + let y = min(max(frame.minY, visibleFrame.minY), maxY) + + return CGRect(x: x, y: y, width: width, height: height) } -#endif - override init() { - super.init() - Self.shared = self + private nonisolated static func intersectionArea(_ lhs: CGRect, _ rhs: CGRect) -> CGFloat { + let intersection = lhs.intersection(rhs) + guard !intersection.isNull else { return 0 } + return max(0, intersection.width) * max(0, intersection.height) } - func application(_ application: NSApplication, open urls: [URL]) { - let directories = externalOpenDirectories(from: urls) - guard !directories.isEmpty else { return } + private nonisolated static func distanceSquared(_ rect: CGRect, _ point: CGPoint) -> CGFloat { + let dx = rect.midX - point.x + let dy = rect.midY - point.y + return (dx * dx) + (dy * dy) + } - prepareForExplicitOpenIntentAtStartup() - for directory in directories { - openWorkspaceForExternalDirectory( - workingDirectory: directory, - debugSource: "application.openURLs" - ) + private nonisolated static func shouldPreserveExactFrame( + frame: CGRect, + displaySnapshot: SessionDisplaySnapshot?, + targetDisplay: SessionDisplayGeometry + ) -> Bool { + guard let displaySnapshot else { return false } + guard let snapshotDisplayID = displaySnapshot.displayID, + let targetDisplayID = targetDisplay.displayID, + snapshotDisplayID == targetDisplayID else { + return false } + + let visibleMatches = displaySnapshot.visibleFrame.map { + rectApproximatelyEqual($0.cgRect, targetDisplay.visibleFrame) + } ?? false + let frameMatches = displaySnapshot.frame.map { + rectApproximatelyEqual($0.cgRect, targetDisplay.frame) + } ?? false + guard visibleMatches || frameMatches else { return false } + + return frame.width.isFinite + && frame.height.isFinite + && frame.origin.x.isFinite + && frame.origin.y.isFinite } - func applicationDidFinishLaunching(_ notification: Notification) { - let env = ProcessInfo.processInfo.environment - let isRunningUnderXCTest = isRunningUnderXCTest(env) + private nonisolated static func rectApproximatelyEqual( + _ lhs: CGRect, + _ rhs: CGRect, + tolerance: CGFloat = 1 + ) -> Bool { + let lhsStd = lhs.standardized + let rhsStd = rhs.standardized + return abs(lhsStd.origin.x - rhsStd.origin.x) <= tolerance + && abs(lhsStd.origin.y - rhsStd.origin.y) <= tolerance + && abs(lhsStd.size.width - rhsStd.size.width) <= tolerance + && abs(lhsStd.size.height - rhsStd.size.height) <= tolerance + } - DistributedNotificationCenter.default().addObserver( - self, - selector: #selector(handleThemesReloadNotification(_:)), - name: ProgramaThemeNotifications.reloadConfig, - object: nil, - suspensionBehavior: .deliverImmediately - ) - NotificationCenter.default.addObserver( - self, - selector: #selector(handleReactGrabDidCopySelection(_:)), - name: .reactGrabDidCopySelection, - object: nil + private func displaySnapshot(for window: NSWindow?) -> SessionDisplaySnapshot? { + guard let window else { return nil } + let screen = window.screen + ?? NSScreen.screens.first(where: { $0.frame.intersects(window.frame) }) + guard let screen else { return nil } + + return SessionDisplaySnapshot( + displayID: screen.programaDisplayID, + frame: SessionRectSnapshot(screen.frame), + visibleFrame: SessionRectSnapshot(screen.visibleFrame) ) + } -#if DEBUG - // UI tests run on a shared VM user profile, so persisted shortcuts can drift and make - // key-equivalent routing flaky. Force defaults for deterministic tests. - if isRunningUnderXCTest { - KeyboardShortcutSettings.resetAll() - } -#endif + private func startSessionAutosaveTimerIfNeeded() { + guard sessionAutosaveTimer == nil else { return } + let env = ProcessInfo.processInfo.environment + guard !isRunningUnderXCTest(env) else { return } -#if DEBUG - writeUITestDiagnosticsIfNeeded(stage: "didFinishLaunching") - ProgramaMainRunLoopStallMonitor.shared.installIfNeeded() - ProgramaMainThreadTurnProfiler.shared.installIfNeeded() - DispatchQueue.main.asyncAfter(deadline: .now() + 1.0) { [weak self] in - self?.writeUITestDiagnosticsIfNeeded(stage: "after1s") + let timer = DispatchSource.makeTimerSource(queue: .main) + let interval = SessionPersistencePolicy.autosaveInterval + timer.schedule(deadline: .now() + interval, repeating: interval, leeway: .seconds(1)) + timer.setEventHandler { [weak self] in + guard let self, + Self.shouldRunSessionAutosaveTick(isTerminatingApp: self.isTerminatingApp) else { + return + } + self.runSessionAutosaveTick(source: "timer") } -#endif - - let forceDuplicateLaunchObserver = env["PROGRAMA_UI_TEST_ENABLE_DUPLICATE_LAUNCH_OBSERVER"] == "1" + sessionAutosaveTimer = timer + timer.resume() + } - // UI tests frequently time out waiting for the main window if we do heavyweight - // LaunchServices registration / single-instance enforcement synchronously at startup. - // Skip these during XCTest (the app-under-test) so the window can appear quickly. - if !isRunningUnderXCTest { - DispatchQueue.main.async { [weak self] in + private func stopSessionAutosaveTimer() { + sessionAutosaveTimer?.cancel() + sessionAutosaveTimer = nil + sessionAutosaveTickInFlight = false + sessionAutosaveDeferredRetryPending = false + } + + private func installLifecycleSnapshotObserversIfNeeded() { + guard !didInstallLifecycleSnapshotObservers else { return } + didInstallLifecycleSnapshotObservers = true + + let workspaceCenter = NSWorkspace.shared.notificationCenter + let powerOffObserver = workspaceCenter.addObserver( + forName: NSWorkspace.willPowerOffNotification, + object: nil, + queue: .main + ) { [weak self] _ in + Task { @MainActor [weak self] in guard let self else { return } - self.scheduleLaunchServicesBundleRegistration() - self.enforceSingleInstance() - self.observeDuplicateLaunches() - } - } else if forceDuplicateLaunchObserver { - // Some UI regressions specifically exercise launch-observer behavior while still - // running under XCTest. Allow an explicit opt-in for those cases only. - DispatchQueue.main.async { [weak self] in - self?.observeDuplicateLaunches() + self.isTerminatingApp = true + _ = self.saveSessionSnapshot(includeScrollback: true, removeWhenEmpty: false) } } - NSWindow.allowsAutomaticWindowTabbing = false - disableNativeTabbingShortcut() - ensureApplicationIcon() - if !isRunningUnderXCTest { - configureUserNotifications() - installMenuBarVisibilityObserver() - syncMenuBarExtraVisibility() - updateController.startUpdaterIfNeeded() - } - titlebarAccessoryController.start() - windowDecorationsController.start() - installMainWindowKeyObserver() - refreshGhosttyGotoSplitShortcuts() - installGhosttyConfigObserver() - installWindowResponderSwizzles() - installBrowserAddressBarFocusObservers() - installShortcutMonitor() - installShortcutDefaultsObserver() - NSApp.servicesProvider = self -#if DEBUG - UpdateTestSupport.applyIfNeeded(to: updateController.viewModel) - if env["PROGRAMA_UI_TEST_MODE"] == "1" { - let trigger = env["PROGRAMA_UI_TEST_TRIGGER_UPDATE_CHECK"] ?? "" - let feed = env["PROGRAMA_UI_TEST_FEED_URL"] ?? "" - UpdateLogStore.shared.append("ui test env: trigger=\(trigger) feed=\(feed)") - } - if env["PROGRAMA_UI_TEST_TRIGGER_UPDATE_CHECK"] == "1" { - UpdateLogStore.shared.append("ui test trigger update check detected") - DispatchQueue.main.asyncAfter(deadline: .now() + 0.25) { [weak self] in + lifecycleSnapshotObservers.append(powerOffObserver) + + let sessionResignObserver = workspaceCenter.addObserver( + forName: NSWorkspace.sessionDidResignActiveNotification, + object: nil, + queue: .main + ) { [weak self] _ in + Task { @MainActor [weak self] in guard let self else { return } - let windowIds = NSApp.windows.map { $0.identifier?.rawValue ?? "" } - UpdateLogStore.shared.append("ui test windows: count=\(NSApp.windows.count) ids=\(windowIds.joined(separator: ","))") - if UpdateTestSupport.performMockFeedCheckIfNeeded(on: self.updateController.viewModel) { - return + if self.isTerminatingApp { + _ = self.saveSessionSnapshot(includeScrollback: true, removeWhenEmpty: false) + } else { + _ = self.saveSessionSnapshot(includeScrollback: false) } - self.checkForUpdates(nil) } } + lifecycleSnapshotObservers.append(sessionResignObserver) - // In UI tests, `WindowGroup` occasionally fails to materialize a window quickly on the VM. - // If there are no windows shortly after launch, force-create one so XCUITest can proceed. - if isRunningUnderXCTest { - if let rawShow = env["PROGRAMA_UI_TEST_BROWSER_IMPORT_HINT_SHOW"] { - UserDefaults.standard.set( - rawShow == "1", - forKey: BrowserImportHintSettings.showOnBlankTabsKey - ) - } - if let rawDismissed = env["PROGRAMA_UI_TEST_BROWSER_IMPORT_HINT_DISMISSED"] { - UserDefaults.standard.set( - rawDismissed == "1", - forKey: BrowserImportHintSettings.dismissedKey - ) - } - DispatchQueue.main.asyncAfter(deadline: .now() + 0.25) { [weak self] in - guard let self else { return } - if NSApp.windows.isEmpty { - self.openNewMainWindow(nil) - } - self.moveUITestWindowToTargetDisplayIfNeeded() - NSRunningApplication.current.activate(options: [.activateAllWindows, .activateIgnoringOtherApps]) - // On headless CI runners, activate() silently fails (no GUI session). - // Force windows visible so the terminal surface starts rendering. - for window in NSApp.windows { - window.orderFrontRegardless() - } - self.writeUITestDiagnosticsIfNeeded(stage: "afterForceWindow") - } - if env["PROGRAMA_UI_TEST_BROWSER_IMPORT_HINT_OPEN_BLANK_BROWSER"] == "1" { - DispatchQueue.main.asyncAfter(deadline: .now() + 0.45) { [weak self] in - guard let self else { return } - _ = self.openBrowserAndFocusAddressBar(insertAtEnd: true) - } - } - if env["PROGRAMA_UI_TEST_BROWSER_IMPORT_HINT_OPEN_SETTINGS"] == "1" { - DispatchQueue.main.asyncAfter(deadline: .now() + 0.55) { [weak self] in - self?.openPreferencesWindow( - debugSource: "uiTest.browserImportHint", - navigationTarget: .browser - ) - } - } - if env["PROGRAMA_UI_TEST_BROWSER_IMPORT_AUTO_OPEN"] == "1" { - DispatchQueue.main.asyncAfter(deadline: .now() + 0.4) { - BrowserDataImportCoordinator.shared.presentImportDialog() - } + let didWakeObserver = workspaceCenter.addObserver( + forName: NSWorkspace.didWakeNotification, + object: nil, + queue: .main + ) { [weak self] _ in + Task { @MainActor [weak self] in + self?.restartSocketListenerIfEnabled(source: "workspace.didWake") } } -#endif + lifecycleSnapshotObservers.append(didWakeObserver) } -#if DEBUG - func writeUITestDiagnosticsIfNeeded(stage: String) { - let env = ProcessInfo.processInfo.environment - guard let path = env["PROGRAMA_UI_TEST_DIAGNOSTICS_PATH"], !path.isEmpty else { return } - - var payload = loadUITestDiagnostics(at: path) - let isRunningUnderXCTest = isRunningUnderXCTest(env) - - let windows = NSApp.windows - let ids = windows.map { $0.identifier?.rawValue ?? "" }.joined(separator: ",") - let vis = windows.map { $0.isVisible ? "1" : "0" }.joined(separator: ",") - let screenIDs = windows.map { $0.screen?.programaDisplayID.map(String.init) ?? "" }.joined(separator: ",") - let targetDisplayID = env["PROGRAMA_UI_TEST_TARGET_DISPLAY_ID"] ?? "" + func socketListenerConfigurationIfEnabled() -> (mode: SocketControlMode, path: String)? { + let raw = UserDefaults.standard.string(forKey: SocketControlSettings.appStorageKey) + ?? SocketControlSettings.defaultMode.rawValue + let userMode = SocketControlSettings.migrateMode(raw) + let mode = SocketControlSettings.effectiveMode(userMode: userMode) + guard mode != .off else { return nil } + return (mode: mode, path: SocketControlSettings.socketPath()) + } - payload["stage"] = stage - payload["pid"] = String(ProcessInfo.processInfo.processIdentifier) - payload["bundleId"] = Bundle.main.bundleIdentifier ?? "" - payload["isRunningUnderXCTest"] = isRunningUnderXCTest ? "1" : "0" - payload["windowsCount"] = String(windows.count) - payload["windowIdentifiers"] = ids - payload["windowVisibleFlags"] = vis - payload["windowScreenDisplayIDs"] = screenIDs - payload["uiTestTargetDisplayID"] = targetDisplayID - if let rawDisplayID = UInt32(targetDisplayID) { - let screenPresent = NSScreen.screens.contains(where: { $0.programaDisplayID == rawDisplayID }) - let movedWindow = windows.contains(where: { $0.screen?.programaDisplayID == rawDisplayID }) - payload["targetDisplayPresent"] = screenPresent ? "1" : "0" - payload["targetDisplayMoveSucceeded"] = movedWindow ? "1" : "0" - } - appendUITestRenderDiagnosticsIfNeeded(&payload, environment: env) - appendUITestSocketDiagnosticsIfNeeded(&payload, environment: env) + func restartSocketListenerIfEnabled(source: String) { + guard let tabManager, + let config = socketListenerConfigurationIfEnabled() else { return } + let restartPath = TerminalController.shared.activeSocketPath(preferredPath: config.path) + TerminalController.shared.stop() + TerminalController.shared.start(tabManager: tabManager, socketPath: restartPath, accessMode: config.mode) + } - guard let data = try? JSONSerialization.data(withJSONObject: payload) else { return } - try? data.write(to: URL(fileURLWithPath: path), options: .atomic) + private func disableSuddenTerminationIfNeeded() { + guard !didDisableSuddenTermination else { return } + ProcessInfo.processInfo.disableSuddenTermination() + didDisableSuddenTermination = true } - private func loadUITestDiagnostics(at path: String) -> [String: String] { - guard let data = try? Data(contentsOf: URL(fileURLWithPath: path)), - let object = try? JSONSerialization.jsonObject(with: data) as? [String: String] else { - return [:] - } - return object + private func enableSuddenTerminationIfNeeded() { + guard didDisableSuddenTermination else { return } + ProcessInfo.processInfo.enableSuddenTermination() + didDisableSuddenTermination = false } - private func appendUITestSocketDiagnosticsIfNeeded( - _ payload: inout [String: String], - environment env: [String: String] - ) { - guard env["PROGRAMA_UI_TEST_SOCKET_SANITY"] == "1" else { return } + private func sessionAutosaveFingerprint(includeScrollback: Bool) -> Int? { + guard !includeScrollback else { return nil } - guard let config = socketListenerConfigurationIfEnabled() else { - payload["socketExpectedPath"] = env["PROGRAMA_SOCKET_PATH"] ?? "" - payload["socketMode"] = "off" - payload["socketReady"] = "0" - payload["socketPingResponse"] = "" - payload["socketIsRunning"] = "0" - payload["socketAcceptLoopAlive"] = "0" - payload["socketPathMatches"] = "0" - payload["socketPathExists"] = "0" - payload["socketFailureSignals"] = "socket_disabled" - return + var hasher = Hasher() + let contexts = mainWindowContexts.values.sorted { lhs, rhs in + lhs.windowId.uuidString < rhs.windowId.uuidString } + hasher.combine(contexts.count) - let socketPath = TerminalController.shared.activeSocketPath(preferredPath: config.path) - let health = TerminalController.shared.socketListenerHealth(expectedSocketPath: socketPath) - let pingResponse = health.isHealthy - ? TerminalController.probeSocketCommand("ping", at: socketPath, timeout: 1.0) - : nil - let isReady = health.isHealthy && pingResponse == "PONG" - var failureSignals = health.failureSignals - if health.isHealthy && pingResponse != "PONG" { - failureSignals.append("ping_timeout") - } + for context in contexts.prefix(SessionPersistencePolicy.maxWindowsPerSnapshot) { + hasher.combine(context.windowId) + hasher.combine(context.tabManager.sessionAutosaveFingerprint()) + hasher.combine(context.sidebarState.isVisible) + hasher.combine( + Int(SessionPersistencePolicy.sanitizedSidebarWidth(Double(context.sidebarState.persistedWidth)).rounded()) + ) - payload["socketExpectedPath"] = socketPath - payload["socketMode"] = config.mode.rawValue - payload["socketReady"] = isReady ? "1" : "0" - payload["socketPingResponse"] = pingResponse ?? "" - payload["socketIsRunning"] = health.isRunning ? "1" : "0" - payload["socketAcceptLoopAlive"] = health.acceptLoopAlive ? "1" : "0" - payload["socketPathMatches"] = health.socketPathMatches ? "1" : "0" - payload["socketPathExists"] = health.socketPathExists ? "1" : "0" - payload["socketFailureSignals"] = failureSignals.joined(separator: ",") - } + switch context.sidebarSelectionState.selection { + case .tabs: + hasher.combine(0) + case .notifications: + hasher.combine(1) + } - private func appendUITestRenderDiagnosticsIfNeeded( - _ payload: inout [String: String], - environment env: [String: String] - ) { - guard env["PROGRAMA_UI_TEST_DISPLAY_RENDER_STATS"] == "1" else { return } - - guard let renderState = currentUITestRenderDiagnostics() else { - payload["renderStatsAvailable"] = "0" - payload["renderPanelId"] = "" - payload["renderDrawCount"] = "" - payload["renderPresentCount"] = "" - payload["renderLastPresentTime"] = "" - payload["renderWindowVisible"] = "" - payload["renderAppIsActive"] = "" - payload["renderDesiredFocus"] = "" - payload["renderIsFirstResponder"] = "" - payload["renderDiagnosticsUpdatedAt"] = String(format: "%.6f", ProcessInfo.processInfo.systemUptime) - return + if let window = context.window ?? windowForMainWindowId(context.windowId) { + Self.hashFrame(window.frame, into: &hasher) + } else { + hasher.combine(-1) + } } - payload["renderStatsAvailable"] = "1" - payload["renderPanelId"] = renderState.panelId.uuidString - payload["renderDrawCount"] = String(renderState.drawCount) - payload["renderPresentCount"] = String(renderState.presentCount) - payload["renderLastPresentTime"] = String(format: "%.6f", renderState.lastPresentTime) - payload["renderWindowVisible"] = renderState.windowVisible ? "1" : "0" - payload["renderAppIsActive"] = renderState.appIsActive ? "1" : "0" - payload["renderDesiredFocus"] = renderState.desiredFocus ? "1" : "0" - payload["renderIsFirstResponder"] = renderState.isFirstResponder ? "1" : "0" - payload["renderDiagnosticsUpdatedAt"] = String(format: "%.6f", ProcessInfo.processInfo.systemUptime) + return hasher.finalize() } - private func currentUITestRenderDiagnostics() -> UITestRenderDiagnosticsSnapshot? { - guard let tabManager, - let tabId = tabManager.selectedTabId, - let workspace = tabManager.tabs.first(where: { $0.id == tabId }) else { - return nil + @discardableResult + private func saveSessionSnapshot(includeScrollback: Bool, removeWhenEmpty: Bool = false) -> Bool { + if Self.shouldSkipSessionSaveDuringStartupRestore( + isApplyingStartupSessionRestore: isApplyingStartupSessionRestore, + includeScrollback: includeScrollback + ) { +#if DEBUG + dlog("session.save.skipped reason=startup_restore_in_progress includeScrollback=0") +#endif + return false } - let terminalPanel: TerminalPanel? = { - if let focusedPanelId = workspace.focusedPanelId, - let terminalPanel = workspace.terminalPanel(for: focusedPanelId) { - return terminalPanel - } - if let focusedTerminalPanel = workspace.focusedTerminalPanel { - return focusedTerminalPanel - } - return workspace.panels.values.compactMap { $0 as? TerminalPanel }.first - }() - - guard let terminalPanel else { return nil } - let stats = terminalPanel.hostedView.debugRenderStats() - return UITestRenderDiagnosticsSnapshot( - panelId: terminalPanel.id, - drawCount: stats.drawCount, - presentCount: stats.presentCount, - lastPresentTime: stats.lastPresentTime, - windowVisible: stats.windowOcclusionVisible, - appIsActive: stats.appIsActive, - desiredFocus: stats.desiredFocus, - isFirstResponder: stats.isFirstResponder + let writeSynchronously = Self.shouldWriteSessionSnapshotSynchronously( + isTerminatingApp: isTerminatingApp, + includeScrollback: includeScrollback ) - } - - private func moveUITestWindowToTargetDisplayIfNeeded(attempt: Int = 0) { - let env = ProcessInfo.processInfo.environment - guard let rawDisplayID = env["PROGRAMA_UI_TEST_TARGET_DISPLAY_ID"], - let targetDisplayID = UInt32(rawDisplayID) else { - return +#if DEBUG + let timingStart = ProgramaTypingTiming.start() + defer { + ProgramaTypingTiming.logDuration( + path: "session.saveSnapshot", + startedAt: timingStart, + extra: "includeScrollback=\(includeScrollback ? 1 : 0) removeWhenEmpty=\(removeWhenEmpty ? 1 : 0) sync=\(writeSynchronously ? 1 : 0)" + ) } +#endif - guard let screen = NSScreen.screens.first(where: { $0.programaDisplayID == targetDisplayID }) else { - if attempt < 20 { - DispatchQueue.main.asyncAfter(deadline: .now() + 0.25) { [weak self] in - self?.moveUITestWindowToTargetDisplayIfNeeded(attempt: attempt + 1) - } - } - self.writeUITestDiagnosticsIfNeeded(stage: "targetDisplayMissing") - return + guard let snapshot = buildSessionSnapshot(includeScrollback: includeScrollback) else { + persistSessionSnapshot( + nil, + removeWhenEmpty: removeWhenEmpty, + persistedGeometryData: nil, + synchronously: writeSynchronously + ) + return false } - guard let window = NSApp.windows.first else { - if attempt < 20 { - DispatchQueue.main.asyncAfter(deadline: .now() + 0.25) { [weak self] in - self?.moveUITestWindowToTargetDisplayIfNeeded(attempt: attempt + 1) - } - } - self.writeUITestDiagnosticsIfNeeded(stage: "targetDisplayNoWindow") - return + let persistedGeometryData = snapshot.windows.first.flatMap { primaryWindow in + Self.encodedPersistedWindowGeometryData( + frame: primaryWindow.frame, + display: primaryWindow.display + ) } - let visibleFrame = screen.visibleFrame - let width = min(window.frame.width, max(visibleFrame.width - 80, 480)) - let height = min(window.frame.height, max(visibleFrame.height - 80, 360)) - let frame = NSRect( - x: visibleFrame.midX - (width / 2), - y: visibleFrame.midY - (height / 2), - width: width, - height: height - ).integral +#if DEBUG + debugLogSessionSaveSnapshot(snapshot, includeScrollback: includeScrollback) +#endif + persistSessionSnapshot( + snapshot, + removeWhenEmpty: false, + persistedGeometryData: persistedGeometryData, + synchronously: writeSynchronously + ) + return true + } - window.setFrame(frame, display: true, animate: false) - window.makeKeyAndOrderFront(nil) - window.orderFrontRegardless() - if window.screen?.programaDisplayID != targetDisplayID, attempt < 20 { - DispatchQueue.main.asyncAfter(deadline: .now() + 0.25) { [weak self] in - self?.moveUITestWindowToTargetDisplayIfNeeded(attempt: attempt + 1) - } - return - } - self.writeUITestDiagnosticsIfNeeded(stage: "afterMoveToTargetDisplay") + nonisolated static func shouldPersistSnapshotOnWindowUnregister(isTerminatingApp: Bool) -> Bool { + !isTerminatingApp } -#endif - func applicationDidBecomeActive(_ notification: Notification) { - guard let notificationStore else { return } - notificationStore.handleApplicationDidBecomeActive() - guard let tabManager else { return } - guard let tabId = tabManager.selectedTabId else { return } - let surfaceId = tabManager.focusedSurfaceId(for: tabId) - guard notificationStore.hasUnreadNotification(forTabId: tabId, surfaceId: surfaceId) else { return } + nonisolated static func shouldRemoveSnapshotWhenNoWindowsRemainOnWindowUnregister( + isTerminatingApp: Bool + ) -> Bool { + !isTerminatingApp + } - if let surfaceId, - let tab = tabManager.tabs.first(where: { $0.id == tabId }) { - tab.triggerNotificationFocusFlash(panelId: surfaceId, requiresSplit: false, shouldFocus: false) - } - notificationStore.markRead(forTabId: tabId, surfaceId: surfaceId) + nonisolated static func shouldSkipSessionSaveDuringStartupRestore( + isApplyingStartupSessionRestore: Bool, + includeScrollback: Bool + ) -> Bool { + isApplyingStartupSessionRestore && !includeScrollback } - func applicationShouldTerminate(_ sender: NSApplication) -> NSApplication.TerminateReply { - isTerminatingApp = true - _ = saveSessionSnapshot(includeScrollback: true, removeWhenEmpty: false) + nonisolated static func shouldRunSessionAutosaveTick(isTerminatingApp: Bool) -> Bool { + !isTerminatingApp + } - // Tagged DEV builds are ephemeral, skip quit confirmation entirely. - if SocketControlSettings.isTaggedDevBuild() { - return .terminateNow + private func remainingSessionAutosaveTypingQuietPeriod( + nowUptime: TimeInterval = ProcessInfo.processInfo.systemUptime + ) -> TimeInterval? { + guard lastTypingActivityAt > 0 else { return nil } + let elapsed = nowUptime - lastTypingActivityAt + guard elapsed < Self.sessionAutosaveTypingQuietPeriod else { return nil } + return Self.sessionAutosaveTypingQuietPeriod - elapsed + } + + private func scheduleDeferredSessionAutosaveRetry(after delay: TimeInterval) { + guard delay.isFinite, delay > 0 else { return } + guard !sessionAutosaveDeferredRetryPending else { return } + sessionAutosaveDeferredRetryPending = true + sessionPersistenceQueue.asyncAfter(deadline: .now() + delay) { [weak self] in + Task { @MainActor [weak self] in + guard let self else { return } + self.sessionAutosaveDeferredRetryPending = false + self.runSessionAutosaveTick(source: "typingQuietRetry") + } } + } - // If the user already confirmed via the Cmd+Q shortcut warning dialog - // (handleQuitShortcutWarning), skip the check to avoid a second alert. - if isQuitWarningConfirmed { - return .terminateNow + private func runSessionAutosaveTick(source: String) { + guard Self.shouldRunSessionAutosaveTick(isTerminatingApp: isTerminatingApp) else { return } + guard !sessionAutosaveTickInFlight else { return } + if let remainingQuietPeriod = remainingSessionAutosaveTypingQuietPeriod() { +#if DEBUG + dlog( + "session.save.skipped reason=typing_recent includeScrollback=0 source=\(source) " + + "retryMs=\(Int((remainingQuietPeriod * 1000).rounded()))" + ) +#endif + scheduleDeferredSessionAutosaveRetry(after: remainingQuietPeriod) + return } - // Respect the "Warn Before Quit" setting even when Cmd+Q arrives via - // the Cmd+Tab app switcher, bypassing handleCustomShortcut. - guard QuitWarningSettings.isEnabled() else { - return .terminateNow + sessionAutosaveTickInFlight = true +#if DEBUG + let timingStart = ProgramaTypingTiming.start() + let phaseStart = ProcessInfo.processInfo.systemUptime + var fingerprintMs: Double = 0 + var saveMs: Double = 0 + defer { + sessionAutosaveTickInFlight = false + let totalMs = (ProcessInfo.processInfo.systemUptime - phaseStart) * 1000.0 + ProgramaTypingTiming.logBreakdown( + path: "session.autosaveTick.phase", + totalMs: totalMs, + thresholdMs: 2.0, + parts: [ + ("fingerprintMs", fingerprintMs), + ("saveMs", saveMs), + ], + extra: "source=\(source)" + ) + ProgramaTypingTiming.logDuration( + path: "session.autosaveTick", + startedAt: timingStart, + extra: "source=\(source)" + ) } +#else + defer { sessionAutosaveTickInFlight = false } +#endif - // Show the same confirmation dialog used by the Cmd+Q shortcut path, - // then reply asynchronously so we can return .terminateLater now. - DispatchQueue.main.async { - let alert = NSAlert() - alert.alertStyle = .warning - alert.messageText = String(localized: "dialog.quitPrograma.title", defaultValue: "Quit Programa?") - alert.informativeText = String(localized: "dialog.quitPrograma.message", defaultValue: "This will close all windows and workspaces.") - alert.addButton(withTitle: String(localized: "dialog.quitPrograma.quit", defaultValue: "Quit")) - alert.addButton(withTitle: String(localized: "common.cancel", defaultValue: "Cancel")) - alert.showsSuppressionButton = true - alert.suppressionButton?.title = String(localized: "dialog.dontWarnCmdQ", defaultValue: "Don't warn again for Cmd+Q") - - let response = alert.runModal() - if alert.suppressionButton?.state == .on { - QuitWarningSettings.setEnabled(false) - } - - let shouldQuit = response == .alertFirstButtonReturn - if shouldQuit { - self.isQuitWarningConfirmed = true - } else { - // Reset so that the next quit attempt can show the dialog again. - self.isTerminatingApp = false - } - NSApp.reply(toApplicationShouldTerminate: shouldQuit) + let now = Date() +#if DEBUG + let fingerprintStart = ProcessInfo.processInfo.systemUptime +#endif + let autosaveFingerprint = sessionAutosaveFingerprint(includeScrollback: false) +#if DEBUG + fingerprintMs = (ProcessInfo.processInfo.systemUptime - fingerprintStart) * 1000.0 +#endif + if Self.shouldSkipSessionAutosaveForUnchangedFingerprint( + isTerminatingApp: isTerminatingApp, + includeScrollback: false, + previousFingerprint: lastSessionAutosaveFingerprint, + currentFingerprint: autosaveFingerprint, + lastPersistedAt: lastSessionAutosavePersistedAt, + now: now + ) { +#if DEBUG + dlog( + "session.save.skipped reason=unchanged_autosave_fingerprint includeScrollback=0 source=\(source)" + ) +#endif + return } - return .terminateLater - } - - func applicationWillTerminate(_ notification: Notification) { - isTerminatingApp = true - _ = saveSessionSnapshot(includeScrollback: true, removeWhenEmpty: false) - stopSessionAutosaveTimer() - TerminalController.shared.stop() - VSCodeServeWebController.shared.stop() - BrowserProfileStore.shared.flushPendingSaves() - notificationStore?.clearAll() - SurfacePool.shared.teardownAll() - enableSuddenTerminationIfNeeded() - } - func applicationWillResignActive(_ notification: Notification) { - guard !isTerminatingApp else { return } - clearConfiguredShortcutChordState() +#if DEBUG + let saveStart = ProcessInfo.processInfo.systemUptime +#endif _ = saveSessionSnapshot(includeScrollback: false) - } - - func persistSessionForUpdateRelaunch() { - isTerminatingApp = true - _ = saveSessionSnapshot(includeScrollback: true, removeWhenEmpty: false) - } - - func configure(tabManager: TabManager, notificationStore: TerminalNotificationStore, sidebarState: SidebarState) { - self.tabManager = tabManager - self.notificationStore = notificationStore - self.sidebarState = sidebarState - disableSuddenTerminationIfNeeded() - installLifecycleSnapshotObserversIfNeeded() - prepareStartupSessionSnapshotIfNeeded() - startSessionAutosaveTimerIfNeeded() #if DEBUG - setupJumpUnreadUITestIfNeeded() - setupTerminalCmdClickUITestIfNeeded() - setupGotoSplitUITestIfNeeded() - setupBonsplitTabDragUITestIfNeeded() - setupMultiWindowNotificationsUITestIfNeeded() - setupDisplayResolutionUITestDiagnosticsIfNeeded() - - // UI tests sometimes don't run SwiftUI `.onAppear` soon enough (or at all) on the VM. - // The automation socket is a core testing primitive, so ensure it's started here when - // we detect XCTest, even if the main view lifecycle is flaky. - let env = ProcessInfo.processInfo.environment - if isRunningUnderXCTest(env) { - let raw = UserDefaults.standard.string(forKey: SocketControlSettings.appStorageKey) - ?? SocketControlSettings.defaultMode.rawValue - let userMode = SocketControlSettings.migrateMode(raw) - let mode = SocketControlSettings.effectiveMode(userMode: userMode) - if mode != .off { - TerminalController.shared.start( - tabManager: tabManager, - socketPath: SocketControlSettings.socketPath(), - accessMode: mode - ) - scheduleUITestSocketSanityCheckIfNeeded() - } - } + saveMs = (ProcessInfo.processInfo.systemUptime - saveStart) * 1000.0 #endif + updateSessionAutosaveSaveState( + includeScrollback: false, + persistedAt: now, + fingerprint: autosaveFingerprint + ) } - - private func prepareStartupSessionSnapshotIfNeeded() { - guard !didPrepareStartupSessionSnapshot else { return } - didPrepareStartupSessionSnapshot = true - guard SessionRestorePolicy.shouldAttemptRestore() else { return } - Self.removeLegacyPersistedWindowGeometry() - startupSessionSnapshot = SessionPersistenceStore.load() + // Widened from `fileprivate` to `internal`: NSWindow.programa_sendEvent(_:) (now in + // WindowSwizzles.swift) calls AppDelegate.shared?.recordTypingActivity() from a different file. Refs #95. + func recordTypingActivity() { + lastTypingActivityAt = ProcessInfo.processInfo.systemUptime } - private func persistedWindowGeometry( - defaults: UserDefaults = .standard - ) -> PersistedWindowGeometry? { - Self.removeLegacyPersistedWindowGeometry(defaults: defaults) - guard let data = defaults.data(forKey: Self.persistedWindowGeometryDefaultsKey) else { - return nil - } - guard let payload = Self.decodedPersistedWindowGeometryData(data) else { - defaults.removeObject(forKey: Self.persistedWindowGeometryDefaultsKey) - return nil - } - return payload + nonisolated static func shouldWriteSessionSnapshotSynchronously( + isTerminatingApp: Bool, + includeScrollback: Bool + ) -> Bool { + isTerminatingApp && includeScrollback } - private func persistWindowGeometry( - frame: SessionRectSnapshot?, - display: SessionDisplaySnapshot?, - defaults: UserDefaults = .standard - ) { - Self.removeLegacyPersistedWindowGeometry(defaults: defaults) - guard let data = Self.encodedPersistedWindowGeometryData(frame: frame, display: display) else { - return + nonisolated static func shouldSkipSessionAutosaveForUnchangedFingerprint( + isTerminatingApp: Bool, + includeScrollback: Bool, + previousFingerprint: Int?, + currentFingerprint: Int?, + lastPersistedAt: Date, + now: Date, + maximumAutosaveSkippableInterval: TimeInterval = 60 + ) -> Bool { + guard !isTerminatingApp, + !includeScrollback, + let previousFingerprint, + let currentFingerprint, + previousFingerprint == currentFingerprint else { + return false } - defaults.set(data, forKey: Self.persistedWindowGeometryDefaultsKey) - } - - private nonisolated static func encodedPersistedWindowGeometryData( - frame: SessionRectSnapshot?, - display: SessionDisplaySnapshot? - ) -> Data? { - guard let frame else { return nil } - let payload = PersistedWindowGeometry( - version: persistedWindowGeometrySchemaVersion, - frame: frame, - display: display - ) - return try? JSONEncoder().encode(payload) - } - nonisolated static func decodedPersistedWindowGeometryData(_ data: Data) -> PersistedWindowGeometry? { - guard let payload = try? JSONDecoder().decode(PersistedWindowGeometry.self, from: data), - payload.version == persistedWindowGeometrySchemaVersion else { - return nil - } - return payload + return now.timeIntervalSince(lastPersistedAt) < maximumAutosaveSkippableInterval } - private nonisolated static func removeLegacyPersistedWindowGeometry( - defaults: UserDefaults = .standard + private func updateSessionAutosaveSaveState( + includeScrollback: Bool, + persistedAt: Date, + fingerprint: Int? ) { - legacyPersistedWindowGeometryDefaultsKeys.forEach { defaults.removeObject(forKey: $0) } + guard !isTerminatingApp, !includeScrollback else { return } + lastSessionAutosaveFingerprint = fingerprint + lastSessionAutosavePersistedAt = persistedAt } - private func persistWindowGeometry(from window: NSWindow?) { - guard let window else { return } - persistWindowGeometry( - frame: SessionRectSnapshot(window.frame), - display: displaySnapshot(for: window) - ) + private nonisolated static func hashFrame(_ frame: NSRect, into hasher: inout Hasher) { + let standardized = frame.standardized + let quantized = [ + standardized.origin.x, + standardized.origin.y, + standardized.size.width, + standardized.size.height, + ].map { Int(($0 * 2).rounded()) } + quantized.forEach { hasher.combine($0) } } - private func currentDisplayGeometries() -> ( - available: [SessionDisplayGeometry], - fallback: SessionDisplayGeometry? + private func persistSessionSnapshot( + _ snapshot: AppSessionSnapshot?, + removeWhenEmpty: Bool, + persistedGeometryData: Data?, + synchronously: Bool ) { - let available = NSScreen.screens.map { screen in - SessionDisplayGeometry( - displayID: screen.programaDisplayID, - frame: screen.frame, - visibleFrame: screen.visibleFrame - ) + guard snapshot != nil || removeWhenEmpty || persistedGeometryData != nil else { return } + + let writeBlock = { + Self.removeLegacyPersistedWindowGeometry() + if let persistedGeometryData { + UserDefaults.standard.set( + persistedGeometryData, + forKey: Self.persistedWindowGeometryDefaultsKey + ) + } + if let snapshot { + _ = SessionPersistenceStore.save(snapshot) + } else if removeWhenEmpty { + SessionPersistenceStore.removeSnapshot() + } } - let fallback = (NSScreen.main ?? NSScreen.screens.first).map { screen in - SessionDisplayGeometry( - displayID: screen.programaDisplayID, - frame: screen.frame, - visibleFrame: screen.visibleFrame - ) + + if synchronously { + writeBlock() + } else { + sessionPersistenceQueue.async(execute: writeBlock) } - return (available, fallback) } - private func attemptStartupSessionRestoreIfNeeded(primaryWindow: NSWindow) { - guard !didAttemptStartupSessionRestore else { return } - didAttemptStartupSessionRestore = true - guard !didHandleExplicitOpenIntentAtStartup else { return } - guard let primaryContext = contextForMainTerminalWindow(primaryWindow) else { return } - - let startupSnapshot = startupSessionSnapshot - let primaryWindowSnapshot = startupSnapshot?.windows.first - if let primaryWindowSnapshot { - isApplyingStartupSessionRestore = true -#if DEBUG - dlog( - "session.restore.start windows=\(startupSnapshot?.windows.count ?? 0) " + - "primaryFrame={\(debugSessionRectDescription(primaryWindowSnapshot.frame))} " + - "primaryDisplay={\(debugSessionDisplayDescription(primaryWindowSnapshot.display))}" - ) -#endif - applySessionWindowSnapshot( - primaryWindowSnapshot, - to: primaryContext, - window: primaryWindow - ) - } else { - let displays = currentDisplayGeometries() - let fallbackGeometry = persistedWindowGeometry() - if let restoredFrame = Self.resolvedStartupPrimaryWindowFrame( - primarySnapshot: nil, - fallbackFrame: fallbackGeometry?.frame, - fallbackDisplaySnapshot: fallbackGeometry?.display, - availableDisplays: displays.available, - fallbackDisplay: displays.fallback - ) { - primaryWindow.setFrame(restoredFrame, display: true) + private func buildSessionSnapshot(includeScrollback: Bool) -> AppSessionSnapshot? { + let contexts = mainWindowContexts.values.sorted { lhs, rhs in + let lhsWindow = lhs.window ?? windowForMainWindowId(lhs.windowId) + let rhsWindow = rhs.window ?? windowForMainWindowId(rhs.windowId) + let lhsIsKey = lhsWindow?.isKeyWindow ?? false + let rhsIsKey = rhsWindow?.isKeyWindow ?? false + if lhsIsKey != rhsIsKey { + return lhsIsKey && !rhsIsKey } + return lhs.windowId.uuidString < rhs.windowId.uuidString } - if let startupSnapshot { - let additionalWindows = Array(startupSnapshot - .windows - .dropFirst() - .prefix(max(0, SessionPersistencePolicy.maxWindowsPerSnapshot - 1))) -#if DEBUG - for (index, windowSnapshot) in additionalWindows.enumerated() { - dlog( - "session.restore.enqueueAdditional idx=\(index + 1) " + - "frame={\(debugSessionRectDescription(windowSnapshot.frame))} " + - "display={\(debugSessionDisplayDescription(windowSnapshot.display))}" + guard !contexts.isEmpty else { return nil } + + let windows: [SessionWindowSnapshot] = contexts + .prefix(SessionPersistencePolicy.maxWindowsPerSnapshot) + .map { context in + let window = context.window ?? windowForMainWindowId(context.windowId) + return SessionWindowSnapshot( + frame: window.map { SessionRectSnapshot($0.frame) }, + display: displaySnapshot(for: window), + tabManager: context.tabManager.sessionSnapshot(includeScrollback: includeScrollback), + sidebar: SessionSidebarSnapshot( + isVisible: context.sidebarState.isVisible, + selection: SessionSidebarSelection(selection: context.sidebarSelectionState.selection), + width: SessionPersistencePolicy.sanitizedSidebarWidth(Double(context.sidebarState.persistedWidth)) + ) ) } -#endif - if !additionalWindows.isEmpty { - DispatchQueue.main.async { [weak self] in - guard let self else { return } - for windowSnapshot in additionalWindows { - _ = self.createMainWindow(sessionWindowSnapshot: windowSnapshot) - } - self.completeStartupSessionRestore() - } - } else { - completeStartupSessionRestore() - } - } - } - private func completeStartupSessionRestore() { - startupSessionSnapshot = nil - isApplyingStartupSessionRestore = false - _ = saveSessionSnapshot(includeScrollback: false) + guard !windows.isEmpty else { return nil } + return AppSessionSnapshot( + version: SessionSnapshotSchema.currentVersion, + createdAt: Date().timeIntervalSince1970, + windows: windows + ) } - private func applySessionWindowSnapshot( - _ snapshot: SessionWindowSnapshot, - to context: MainWindowContext, - window: NSWindow? - ) { #if DEBUG + private func debugLogSessionSaveSnapshot( + _ snapshot: AppSessionSnapshot, + includeScrollback: Bool + ) { dlog( - "session.restore.apply window=\(context.windowId.uuidString.prefix(8)) " + - "liveWin=\(window?.windowNumber ?? -1) " + - "snapshotFrame={\(debugSessionRectDescription(snapshot.frame))} " + - "snapshotDisplay={\(debugSessionDisplayDescription(snapshot.display))}" - ) -#endif - context.tabManager.restoreSessionSnapshot(snapshot.tabManager) - context.sidebarState.isVisible = snapshot.sidebar.isVisible - context.sidebarState.persistedWidth = CGFloat( - SessionPersistencePolicy.sanitizedSidebarWidth(snapshot.sidebar.width) + "session.save includeScrollback=\(includeScrollback ? 1 : 0) " + + "windows=\(snapshot.windows.count)" ) - context.sidebarSelectionState.selection = snapshot.sidebar.selection.sidebarSelection - - if let restoredFrame = resolvedWindowFrame(from: snapshot), let window { - window.setFrame(restoredFrame, display: true) -#if DEBUG + for (index, windowSnapshot) in snapshot.windows.enumerated() { + let workspaceCount = windowSnapshot.tabManager.workspaces.count + let selectedWorkspace = windowSnapshot.tabManager.selectedWorkspaceIndex.map(String.init) ?? "nil" dlog( - "session.restore.frameApplied window=\(context.windowId.uuidString.prefix(8)) " + - "applied={\(debugNSRectDescription(window.frame))}" + "session.save.window idx=\(index) " + + "frame={\(debugSessionRectDescription(windowSnapshot.frame))} " + + "display={\(debugSessionDisplayDescription(windowSnapshot.display))} " + + "workspaces=\(workspaceCount) selected=\(selectedWorkspace)" ) -#endif } } - private func resolvedWindowFrame(from snapshot: SessionWindowSnapshot?) -> NSRect? { - let displays = currentDisplayGeometries() - return Self.resolvedWindowFrame( - from: snapshot?.frame, - display: snapshot?.display, - availableDisplays: displays.available, - fallbackDisplay: displays.fallback - ) + private func debugSessionRectDescription(_ rect: SessionRectSnapshot?) -> String { + guard let rect else { return "nil" } + return "x=\(debugSessionNumber(rect.x)) y=\(debugSessionNumber(rect.y)) " + + "w=\(debugSessionNumber(rect.width)) h=\(debugSessionNumber(rect.height))" } - nonisolated static func resolvedStartupPrimaryWindowFrame( - primarySnapshot: SessionWindowSnapshot?, - fallbackFrame: SessionRectSnapshot?, - fallbackDisplaySnapshot: SessionDisplaySnapshot?, - availableDisplays: [SessionDisplayGeometry], - fallbackDisplay: SessionDisplayGeometry? - ) -> CGRect? { - if let primary = resolvedWindowFrame( - from: primarySnapshot?.frame, - display: primarySnapshot?.display, - availableDisplays: availableDisplays, - fallbackDisplay: fallbackDisplay - ) { - return primary - } + private func debugNSRectDescription(_ rect: NSRect?) -> String { + guard let rect else { return "nil" } + return "x=\(debugSessionNumber(Double(rect.origin.x))) " + + "y=\(debugSessionNumber(Double(rect.origin.y))) " + + "w=\(debugSessionNumber(Double(rect.size.width))) " + + "h=\(debugSessionNumber(Double(rect.size.height)))" + } - return resolvedWindowFrame( - from: fallbackFrame, - display: fallbackDisplaySnapshot, - availableDisplays: availableDisplays, - fallbackDisplay: fallbackDisplay - ) + private func debugSessionDisplayDescription(_ display: SessionDisplaySnapshot?) -> String { + guard let display else { return "nil" } + let displayIdText = display.displayID.map(String.init) ?? "nil" + return "id=\(displayIdText) " + + "frame={\(debugSessionRectDescription(display.frame))} " + + "visible={\(debugSessionRectDescription(display.visibleFrame))}" } - nonisolated static func resolvedWindowFrame( - from frameSnapshot: SessionRectSnapshot?, - display displaySnapshot: SessionDisplaySnapshot?, - availableDisplays: [SessionDisplayGeometry], - fallbackDisplay: SessionDisplayGeometry? - ) -> CGRect? { - guard let frameSnapshot else { return nil } - let frame = frameSnapshot.cgRect - guard frame.width.isFinite, - frame.height.isFinite, - frame.origin.x.isFinite, - frame.origin.y.isFinite else { - return nil - } + private func debugSessionNumber(_ value: Double) -> String { + String(format: "%.1f", value) + } +#endif - let minWidth = CGFloat(SessionPersistencePolicy.minimumWindowWidth) - let minHeight = CGFloat(SessionPersistencePolicy.minimumWindowHeight) - guard frame.width >= minWidth, - frame.height >= minHeight else { - return nil - } + private func notifyMainWindowContextsDidChange() { + NotificationCenter.default.post(name: .mainWindowContextsDidChange, object: self) + } - guard !availableDisplays.isEmpty else { return frame } + /// Register a terminal window with the AppDelegate so menu commands and socket control + /// can target whichever window is currently active. + func registerMainWindow( + _ window: NSWindow, + windowId: UUID, + tabManager: TabManager, + sidebarState: SidebarState, + sidebarSelectionState: SidebarSelectionState + ) { + tabManager.window = window - if let targetDisplay = display(for: displaySnapshot, in: availableDisplays) { - if shouldPreserveExactFrame( - frame: frame, - displaySnapshot: displaySnapshot, - targetDisplay: targetDisplay - ) { - return frame - } - return resolvedWindowFrame( - frame: frame, - displaySnapshot: displaySnapshot, - targetDisplay: targetDisplay, - minWidth: minWidth, - minHeight: minHeight + let key = ObjectIdentifier(window) + #if DEBUG + let priorManagerToken = debugManagerToken(self.tabManager) + #endif + if let existing = mainWindowContexts[key] { + existing.window = window + } else if let existing = mainWindowContexts.values.first(where: { $0.windowId == windowId }) { + existing.window = window + reindexMainWindowContextIfNeeded(existing, for: window) + } else { + mainWindowContexts[key] = MainWindowContext( + windowId: windowId, + tabManager: tabManager, + sidebarState: sidebarState, + sidebarSelectionState: sidebarSelectionState, + window: window ) + NotificationCenter.default.addObserver( + forName: NSWindow.willCloseNotification, + object: window, + queue: .main + ) { [weak self] note in + guard let self, let closing = note.object as? NSWindow else { return } + self.unregisterMainWindow(closing) + } } + commandPaletteVisibilityByWindowId[windowId] = false + commandPaletteSelectionByWindowId[windowId] = 0 + commandPaletteSnapshotByWindowId[windowId] = .empty - if let intersectingDisplay = availableDisplays.first(where: { $0.visibleFrame.intersects(frame) }) { - return clampFrame( - frame, - within: intersectingDisplay.visibleFrame, - minWidth: minWidth, - minHeight: minHeight - ) +#if DEBUG + dlog( + "mainWindow.register windowId=\(String(windowId.uuidString.prefix(8))) window={\(debugWindowToken(window))} manager=\(debugManagerToken(tabManager)) priorActiveMgr=\(priorManagerToken) \(debugShortcutRouteSnapshot())" + ) +#endif + notifyMainWindowContextsDidChange() + if window.isKeyWindow { + setActiveMainWindow(window) } - guard let fallbackDisplay else { return frame } - if let sourceReference = displaySnapshot?.visibleFrame?.cgRect ?? displaySnapshot?.frame?.cgRect { - return remappedFrame( - frame, - from: sourceReference, - to: fallbackDisplay.visibleFrame, - minWidth: minWidth, - minHeight: minHeight - ) + attemptStartupSessionRestoreIfNeeded(primaryWindow: window) + if !isTerminatingApp { + _ = saveSessionSnapshot(includeScrollback: false) } + } - return centeredFrame( - frame, - in: fallbackDisplay.visibleFrame, - minWidth: minWidth, - minHeight: minHeight - ) + struct MainWindowSummary { + let windowId: UUID + let isKeyWindow: Bool + let isVisible: Bool + let workspaceCount: Int + let selectedWorkspaceId: UUID? } - private nonisolated static func resolvedWindowFrame( - frame: CGRect, - displaySnapshot: SessionDisplaySnapshot?, - targetDisplay: SessionDisplayGeometry, - minWidth: CGFloat, - minHeight: CGFloat - ) -> CGRect { - if targetDisplay.visibleFrame.intersects(frame) { - // Preserve the user's exact frame when enough of the top of the window - // remains reachable on-screen; only clamp when the saved frame would - // reopen with an inaccessible titlebar/top strip. - if shouldPreserveAccessibleFrame( - frame: frame, - targetDisplay: targetDisplay - ) { - return frame - } - return clampFrame( - frame, - within: targetDisplay.visibleFrame, - minWidth: minWidth, - minHeight: minHeight - ) + struct WindowMoveTarget: Identifiable { + let windowId: UUID + let label: String + let tabManager: TabManager + let isCurrentWindow: Bool + + var id: UUID { windowId } + } + + struct WorkspaceMoveTarget: Identifiable { + let windowId: UUID + let workspaceId: UUID + let windowLabel: String + let workspaceTitle: String + let tabManager: TabManager + let isCurrentWindow: Bool + + var id: String { "\(windowId.uuidString):\(workspaceId.uuidString)" } + var label: String { + isCurrentWindow ? workspaceTitle : "\(workspaceTitle) (\(windowLabel))" } + } - if let sourceReference = displaySnapshot?.visibleFrame?.cgRect ?? displaySnapshot?.frame?.cgRect { - return remappedFrame( - frame, - from: sourceReference, - to: targetDisplay.visibleFrame, - minWidth: minWidth, - minHeight: minHeight + func listMainWindowSummaries() -> [MainWindowSummary] { + let contexts = Array(mainWindowContexts.values) + return contexts.map { ctx in + let window = ctx.window ?? windowForMainWindowId(ctx.windowId) + return MainWindowSummary( + windowId: ctx.windowId, + isKeyWindow: window?.isKeyWindow ?? false, + isVisible: window?.isVisible ?? false, + workspaceCount: ctx.tabManager.tabs.count, + selectedWorkspaceId: ctx.tabManager.selectedTabId ) } - - return centeredFrame( - frame, - in: targetDisplay.visibleFrame, - minWidth: minWidth, - minHeight: minHeight - ) } - private nonisolated static func shouldPreserveAccessibleFrame( - frame: CGRect, - targetDisplay: SessionDisplayGeometry, - minimumVisibleTopStripWidth: CGFloat = 120, - topStripHeight: CGFloat = 64, - minimumVisibleTopStripHeight: CGFloat = 24 - ) -> Bool { - let standardizedFrame = frame.standardized - guard standardizedFrame.width.isFinite, - standardizedFrame.height.isFinite, - standardizedFrame.width > 0, - standardizedFrame.height > 0, - standardizedFrame.intersects(targetDisplay.frame) else { - return false + func windowMoveTargets(referenceWindowId: UUID?) -> [WindowMoveTarget] { + let orderedSummaries = orderedMainWindowSummaries(referenceWindowId: referenceWindowId) + let labels = windowLabelsById(orderedSummaries: orderedSummaries, referenceWindowId: referenceWindowId) + return orderedSummaries.compactMap { summary in + guard let manager = tabManagerFor(windowId: summary.windowId) else { return nil } + let label = labels[summary.windowId] ?? "Window" + return WindowMoveTarget( + windowId: summary.windowId, + label: label, + tabManager: manager, + isCurrentWindow: summary.windowId == referenceWindowId + ) } + } - let stripHeight = min(topStripHeight, standardizedFrame.height) - let topStrip = CGRect( - x: standardizedFrame.minX, - y: standardizedFrame.maxY - stripHeight, - width: standardizedFrame.width, - height: stripHeight - ) - let visibleTopStrip = topStrip.intersection(targetDisplay.visibleFrame) - guard !visibleTopStrip.isNull else { return false } + func workspaceMoveTargets(excludingWorkspaceId: UUID? = nil, referenceWindowId: UUID?) -> [WorkspaceMoveTarget] { + let orderedSummaries = orderedMainWindowSummaries(referenceWindowId: referenceWindowId) + let labels = windowLabelsById(orderedSummaries: orderedSummaries, referenceWindowId: referenceWindowId) - let requiredWidth = min(minimumVisibleTopStripWidth, standardizedFrame.width) - let requiredHeight = min(minimumVisibleTopStripHeight, stripHeight) - return visibleTopStrip.width >= requiredWidth - && visibleTopStrip.height >= requiredHeight - } + var targets: [WorkspaceMoveTarget] = [] + targets.reserveCapacity(orderedSummaries.reduce(0) { partial, summary in + partial + summary.workspaceCount + }) - private nonisolated static func display( - for snapshot: SessionDisplaySnapshot?, - in displays: [SessionDisplayGeometry] - ) -> SessionDisplayGeometry? { - guard let snapshot else { return nil } - if let displayID = snapshot.displayID, - let exact = displays.first(where: { $0.displayID == displayID }) { - return exact + for summary in orderedSummaries { + guard let manager = tabManagerFor(windowId: summary.windowId) else { continue } + let windowLabel = labels[summary.windowId] ?? "Window" + let isCurrentWindow = summary.windowId == referenceWindowId + for workspace in manager.tabs { + if workspace.id == excludingWorkspaceId { + continue + } + targets.append( + WorkspaceMoveTarget( + windowId: summary.windowId, + workspaceId: workspace.id, + windowLabel: windowLabel, + workspaceTitle: workspaceDisplayName(workspace), + tabManager: manager, + isCurrentWindow: isCurrentWindow + ) + ) + } } - guard let referenceRect = (snapshot.visibleFrame ?? snapshot.frame)?.cgRect else { - return nil - } + return targets + } - let overlaps = displays.map { display -> (display: SessionDisplayGeometry, area: CGFloat) in - (display, intersectionArea(referenceRect, display.visibleFrame)) + @discardableResult + func moveWorkspaceToWindow(workspaceId: UUID, windowId: UUID, focus: Bool = true) -> Bool { + guard let sourceManager = tabManagerFor(tabId: workspaceId), + let destinationManager = tabManagerFor(windowId: windowId) else { + return false } - if let bestOverlap = overlaps.max(by: { $0.area < $1.area }), bestOverlap.area > 0 { - return bestOverlap.display + + if sourceManager === destinationManager { + if focus { + destinationManager.focusTab(workspaceId, suppressFlash: true) + _ = focusMainWindow(windowId: windowId) + TerminalController.shared.setActiveTabManager(destinationManager) + } + return true } - let referenceCenter = CGPoint(x: referenceRect.midX, y: referenceRect.midY) - return displays.min { lhs, rhs in - let lhsDistance = distanceSquared(lhs.visibleFrame, referenceCenter) - let rhsDistance = distanceSquared(rhs.visibleFrame, referenceCenter) - return lhsDistance < rhsDistance + guard let workspace = sourceManager.detachWorkspace(tabId: workspaceId) else { return false } + destinationManager.attachWorkspace(workspace, select: focus) + + if focus { + _ = focusMainWindow(windowId: windowId) + TerminalController.shared.setActiveTabManager(destinationManager) } + return true } - private nonisolated static func remappedFrame( - _ frame: CGRect, - from sourceRect: CGRect, - to targetRect: CGRect, - minWidth: CGFloat, - minHeight: CGFloat - ) -> CGRect { - let source = sourceRect.standardized - let target = targetRect.standardized - guard source.width.isFinite, - source.height.isFinite, - source.width > 1, - source.height > 1, - target.width.isFinite, - target.height.isFinite, - target.width > 0, - target.height > 0 else { - return centeredFrame(frame, in: targetRect, minWidth: minWidth, minHeight: minHeight) - } + @discardableResult + func moveWorkspaceToNewWindow(workspaceId: UUID, focus: Bool = true) -> UUID? { + let windowId = createMainWindow() + guard let destinationManager = tabManagerFor(windowId: windowId) else { return nil } + let bootstrapWorkspaceId = destinationManager.tabs.first?.id - let relativeX = (frame.minX - source.minX) / source.width - let relativeY = (frame.minY - source.minY) / source.height - let relativeWidth = frame.width / source.width - let relativeHeight = frame.height / source.height + guard moveWorkspaceToWindow(workspaceId: workspaceId, windowId: windowId, focus: focus) else { + _ = closeMainWindow(windowId: windowId) + return nil + } - let remapped = CGRect( - x: target.minX + (relativeX * target.width), - y: target.minY + (relativeY * target.height), - width: target.width * relativeWidth, - height: target.height * relativeHeight - ) - return clampFrame(remapped, within: target, minWidth: minWidth, minHeight: minHeight) + // Remove the bootstrap workspace from the new window once the moved workspace arrives. + if let bootstrapWorkspaceId, + bootstrapWorkspaceId != workspaceId, + let bootstrapWorkspace = destinationManager.tabs.first(where: { $0.id == bootstrapWorkspaceId }), + destinationManager.tabs.count > 1 { + destinationManager.closeWorkspace(bootstrapWorkspace) + } + return windowId } - private nonisolated static func centeredFrame( - _ frame: CGRect, - in visibleFrame: CGRect, - minWidth: CGFloat, - minHeight: CGFloat - ) -> CGRect { - let centered = CGRect( - x: visibleFrame.midX - (frame.width / 2), - y: visibleFrame.midY - (frame.height / 2), - width: frame.width, - height: frame.height - ) - return clampFrame(centered, within: visibleFrame, minWidth: minWidth, minHeight: minHeight) - } - - private nonisolated static func clampFrame( - _ frame: CGRect, - within visibleFrame: CGRect, - minWidth: CGFloat, - minHeight: CGFloat - ) -> CGRect { - guard visibleFrame.width.isFinite, - visibleFrame.height.isFinite, - visibleFrame.width > 0, - visibleFrame.height > 0 else { - return frame + func locateBonsplitSurface(tabId: UUID) -> (windowId: UUID, workspaceId: UUID, panelId: UUID, tabManager: TabManager)? { + let bonsplitTabId = TabID(uuid: tabId) + for context in mainWindowContexts.values { + for workspace in context.tabManager.tabs { + if let panelId = workspace.panelIdFromSurfaceId(bonsplitTabId) { + return (context.windowId, workspace.id, panelId, context.tabManager) + } + } } - - let maxWidth = max(visibleFrame.width, 1) - let maxHeight = max(visibleFrame.height, 1) - let widthFloor = min(minWidth, maxWidth) - let heightFloor = min(minHeight, maxHeight) - - let width = min(max(frame.width, widthFloor), maxWidth) - let height = min(max(frame.height, heightFloor), maxHeight) - let maxX = visibleFrame.maxX - width - let maxY = visibleFrame.maxY - height - let x = min(max(frame.minX, visibleFrame.minX), maxX) - let y = min(max(frame.minY, visibleFrame.minY), maxY) - - return CGRect(x: x, y: y, width: width, height: height) - } - - private nonisolated static func intersectionArea(_ lhs: CGRect, _ rhs: CGRect) -> CGFloat { - let intersection = lhs.intersection(rhs) - guard !intersection.isNull else { return 0 } - return max(0, intersection.width) * max(0, intersection.height) - } - - private nonisolated static func distanceSquared(_ rect: CGRect, _ point: CGPoint) -> CGFloat { - let dx = rect.midX - point.x - let dy = rect.midY - point.y - return (dx * dx) + (dy * dy) + return nil } - private nonisolated static func shouldPreserveExactFrame( - frame: CGRect, - displaySnapshot: SessionDisplaySnapshot?, - targetDisplay: SessionDisplayGeometry + @discardableResult + func moveSurface( + panelId: UUID, + toWorkspace targetWorkspaceId: UUID, + targetPane: PaneID? = nil, + targetIndex: Int? = nil, + splitTarget: (orientation: SplitOrientation, insertFirst: Bool)? = nil, + focus: Bool = true, + focusWindow: Bool = true ) -> Bool { - guard let displaySnapshot else { return false } - guard let snapshotDisplayID = displaySnapshot.displayID, - let targetDisplayID = targetDisplay.displayID, - snapshotDisplayID == targetDisplayID else { - return false +#if DEBUG + let moveStart = ProcessInfo.processInfo.systemUptime + let splitLabel = splitTarget.map { split in + "\(split.orientation.rawValue):\(split.insertFirst ? 1 : 0)" + } ?? "none" + func elapsedMs(since start: TimeInterval) -> String { + let ms = (ProcessInfo.processInfo.systemUptime - start) * 1000 + return String(format: "%.2f", ms) } - - let visibleMatches = displaySnapshot.visibleFrame.map { - rectApproximatelyEqual($0.cgRect, targetDisplay.visibleFrame) - } ?? false - let frameMatches = displaySnapshot.frame.map { - rectApproximatelyEqual($0.cgRect, targetDisplay.frame) - } ?? false - guard visibleMatches || frameMatches else { return false } - - return frame.width.isFinite - && frame.height.isFinite - && frame.origin.x.isFinite - && frame.origin.y.isFinite - } - - private nonisolated static func rectApproximatelyEqual( - _ lhs: CGRect, - _ rhs: CGRect, - tolerance: CGFloat = 1 - ) -> Bool { - let lhsStd = lhs.standardized - let rhsStd = rhs.standardized - return abs(lhsStd.origin.x - rhsStd.origin.x) <= tolerance - && abs(lhsStd.origin.y - rhsStd.origin.y) <= tolerance - && abs(lhsStd.size.width - rhsStd.size.width) <= tolerance - && abs(lhsStd.size.height - rhsStd.size.height) <= tolerance - } - - private func displaySnapshot(for window: NSWindow?) -> SessionDisplaySnapshot? { - guard let window else { return nil } - let screen = window.screen - ?? NSScreen.screens.first(where: { $0.frame.intersects(window.frame) }) - guard let screen else { return nil } - - return SessionDisplaySnapshot( - displayID: screen.programaDisplayID, - frame: SessionRectSnapshot(screen.frame), - visibleFrame: SessionRectSnapshot(screen.visibleFrame) + dlog( + "surface.move.begin panel=\(panelId.uuidString.prefix(5)) targetWs=\(targetWorkspaceId.uuidString.prefix(5)) " + + "targetPane=\(targetPane?.id.uuidString.prefix(5) ?? "auto") targetIndex=\(targetIndex.map(String.init) ?? "nil") " + + "split=\(splitLabel) focus=\(focus ? 1 : 0) focusWindow=\(focusWindow ? 1 : 0)" ) - } - - private func startSessionAutosaveTimerIfNeeded() { - guard sessionAutosaveTimer == nil else { return } - let env = ProcessInfo.processInfo.environment - guard !isRunningUnderXCTest(env) else { return } - - let timer = DispatchSource.makeTimerSource(queue: .main) - let interval = SessionPersistencePolicy.autosaveInterval - timer.schedule(deadline: .now() + interval, repeating: interval, leeway: .seconds(1)) - timer.setEventHandler { [weak self] in - guard let self, - Self.shouldRunSessionAutosaveTick(isTerminatingApp: self.isTerminatingApp) else { - return - } - self.runSessionAutosaveTick(source: "timer") +#endif + guard let source = locateSurface(surfaceId: panelId) else { +#if DEBUG + dlog("surface.move.fail panel=\(panelId.uuidString.prefix(5)) reason=sourcePanelNotFound elapsedMs=\(elapsedMs(since: moveStart))") +#endif + return false } - sessionAutosaveTimer = timer - timer.resume() - } - - private func stopSessionAutosaveTimer() { - sessionAutosaveTimer?.cancel() - sessionAutosaveTimer = nil - sessionAutosaveTickInFlight = false - sessionAutosaveDeferredRetryPending = false - } + guard let sourceWorkspace = source.tabManager.tabs.first(where: { $0.id == source.workspaceId }) else { +#if DEBUG + dlog("surface.move.fail panel=\(panelId.uuidString.prefix(5)) reason=sourceWorkspaceMissing elapsedMs=\(elapsedMs(since: moveStart))") +#endif + return false + } + guard let destinationManager = tabManagerFor(tabId: targetWorkspaceId) else { +#if DEBUG + dlog("surface.move.fail panel=\(panelId.uuidString.prefix(5)) reason=destinationManagerMissing elapsedMs=\(elapsedMs(since: moveStart))") +#endif + return false + } + guard let destinationWorkspace = destinationManager.tabs.first(where: { $0.id == targetWorkspaceId }) else { +#if DEBUG + dlog("surface.move.fail panel=\(panelId.uuidString.prefix(5)) reason=destinationWorkspaceMissing elapsedMs=\(elapsedMs(since: moveStart))") +#endif + return false + } +#if DEBUG + dlog( + "surface.move.route panel=\(panelId.uuidString.prefix(5)) sourceWs=\(sourceWorkspace.id.uuidString.prefix(5)) " + + "sourceWin=\(source.windowId.uuidString.prefix(5)) destinationWs=\(destinationWorkspace.id.uuidString.prefix(5)) " + + "sameWorkspace=\(destinationWorkspace.id == sourceWorkspace.id ? 1 : 0)" + ) +#endif - private func installLifecycleSnapshotObserversIfNeeded() { - guard !didInstallLifecycleSnapshotObservers else { return } - didInstallLifecycleSnapshotObservers = true + let resolvedTargetPane = targetPane.flatMap { pane in + destinationWorkspace.bonsplitController.allPaneIds.first(where: { $0 == pane }) + } ?? destinationWorkspace.bonsplitController.focusedPaneId + ?? destinationWorkspace.bonsplitController.allPaneIds.first - let workspaceCenter = NSWorkspace.shared.notificationCenter - let powerOffObserver = workspaceCenter.addObserver( - forName: NSWorkspace.willPowerOffNotification, - object: nil, - queue: .main - ) { [weak self] _ in - Task { @MainActor [weak self] in - guard let self else { return } - self.isTerminatingApp = true - _ = self.saveSessionSnapshot(includeScrollback: true, removeWhenEmpty: false) - } + guard let resolvedTargetPane else { +#if DEBUG + dlog( + "surface.move.fail panel=\(panelId.uuidString.prefix(5)) reason=targetPaneMissing " + + "destinationWs=\(destinationWorkspace.id.uuidString.prefix(5)) elapsedMs=\(elapsedMs(since: moveStart))" + ) +#endif + return false } - lifecycleSnapshotObservers.append(powerOffObserver) - let sessionResignObserver = workspaceCenter.addObserver( - forName: NSWorkspace.sessionDidResignActiveNotification, - object: nil, - queue: .main - ) { [weak self] _ in - Task { @MainActor [weak self] in - guard let self else { return } - if self.isTerminatingApp { - _ = self.saveSessionSnapshot(includeScrollback: true, removeWhenEmpty: false) - } else { - _ = self.saveSessionSnapshot(includeScrollback: false) + if destinationWorkspace.id == sourceWorkspace.id { + if let splitTarget { + guard let sourceTabId = sourceWorkspace.surfaceIdFromPanelId(panelId), + sourceWorkspace.bonsplitController.splitPane( + resolvedTargetPane, + orientation: splitTarget.orientation, + movingTab: sourceTabId, + insertFirst: splitTarget.insertFirst + ) != nil else { +#if DEBUG + dlog( + "surface.move.fail panel=\(panelId.uuidString.prefix(5)) reason=sameWorkspaceSplitFailed " + + "targetPane=\(resolvedTargetPane.id.uuidString.prefix(5)) split=\(splitLabel) " + + "elapsedMs=\(elapsedMs(since: moveStart))" + ) +#endif + return false + } + if focus { + source.tabManager.focusTab(sourceWorkspace.id, surfaceId: panelId, suppressFlash: true) } +#if DEBUG + dlog( + "surface.move.end panel=\(panelId.uuidString.prefix(5)) path=sameWorkspaceSplit moved=1 " + + "targetPane=\(resolvedTargetPane.id.uuidString.prefix(5)) elapsedMs=\(elapsedMs(since: moveStart))" + ) +#endif + return true } - } - lifecycleSnapshotObservers.append(sessionResignObserver) - let didWakeObserver = workspaceCenter.addObserver( - forName: NSWorkspace.didWakeNotification, - object: nil, - queue: .main - ) { [weak self] _ in - Task { @MainActor [weak self] in - self?.restartSocketListenerIfEnabled(source: "workspace.didWake") - } - } - lifecycleSnapshotObservers.append(didWakeObserver) - } - - func socketListenerConfigurationIfEnabled() -> (mode: SocketControlMode, path: String)? { - let raw = UserDefaults.standard.string(forKey: SocketControlSettings.appStorageKey) - ?? SocketControlSettings.defaultMode.rawValue - let userMode = SocketControlSettings.migrateMode(raw) - let mode = SocketControlSettings.effectiveMode(userMode: userMode) - guard mode != .off else { return nil } - return (mode: mode, path: SocketControlSettings.socketPath()) - } - - func restartSocketListenerIfEnabled(source: String) { - guard let tabManager, - let config = socketListenerConfigurationIfEnabled() else { return } - let restartPath = TerminalController.shared.activeSocketPath(preferredPath: config.path) - TerminalController.shared.stop() - TerminalController.shared.start(tabManager: tabManager, socketPath: restartPath, accessMode: config.mode) - } - - private func disableSuddenTerminationIfNeeded() { - guard !didDisableSuddenTermination else { return } - ProcessInfo.processInfo.disableSuddenTermination() - didDisableSuddenTermination = true - } - - private func enableSuddenTerminationIfNeeded() { - guard didDisableSuddenTermination else { return } - ProcessInfo.processInfo.enableSuddenTermination() - didDisableSuddenTermination = false - } - - private func sessionAutosaveFingerprint(includeScrollback: Bool) -> Int? { - guard !includeScrollback else { return nil } - - var hasher = Hasher() - let contexts = mainWindowContexts.values.sorted { lhs, rhs in - lhs.windowId.uuidString < rhs.windowId.uuidString - } - hasher.combine(contexts.count) - - for context in contexts.prefix(SessionPersistencePolicy.maxWindowsPerSnapshot) { - hasher.combine(context.windowId) - hasher.combine(context.tabManager.sessionAutosaveFingerprint()) - hasher.combine(context.sidebarState.isVisible) - hasher.combine( - Int(SessionPersistencePolicy.sanitizedSidebarWidth(Double(context.sidebarState.persistedWidth)).rounded()) + let moved = sourceWorkspace.moveSurface( + panelId: panelId, + toPane: resolvedTargetPane, + atIndex: targetIndex, + focus: focus ) - - switch context.sidebarSelectionState.selection { - case .tabs: - hasher.combine(0) - case .notifications: - hasher.combine(1) - } - - if let window = context.window ?? windowForMainWindowId(context.windowId) { - Self.hashFrame(window.frame, into: &hasher) - } else { - hasher.combine(-1) - } - } - - return hasher.finalize() - } - - @discardableResult - private func saveSessionSnapshot(includeScrollback: Bool, removeWhenEmpty: Bool = false) -> Bool { - if Self.shouldSkipSessionSaveDuringStartupRestore( - isApplyingStartupSessionRestore: isApplyingStartupSessionRestore, - includeScrollback: includeScrollback - ) { -#if DEBUG - dlog("session.save.skipped reason=startup_restore_in_progress includeScrollback=0") -#endif - return false - } - - let writeSynchronously = Self.shouldWriteSessionSnapshotSynchronously( - isTerminatingApp: isTerminatingApp, - includeScrollback: includeScrollback - ) #if DEBUG - let timingStart = ProgramaTypingTiming.start() - defer { - ProgramaTypingTiming.logDuration( - path: "session.saveSnapshot", - startedAt: timingStart, - extra: "includeScrollback=\(includeScrollback ? 1 : 0) removeWhenEmpty=\(removeWhenEmpty ? 1 : 0) sync=\(writeSynchronously ? 1 : 0)" + dlog( + "surface.move.end panel=\(panelId.uuidString.prefix(5)) path=sameWorkspaceMove moved=\(moved ? 1 : 0) " + + "targetPane=\(resolvedTargetPane.id.uuidString.prefix(5)) targetIndex=\(targetIndex.map(String.init) ?? "nil") " + + "elapsedMs=\(elapsedMs(since: moveStart))" ) - } #endif - - guard let snapshot = buildSessionSnapshot(includeScrollback: includeScrollback) else { - persistSessionSnapshot( - nil, - removeWhenEmpty: removeWhenEmpty, - persistedGeometryData: nil, - synchronously: writeSynchronously - ) - return false - } - - let persistedGeometryData = snapshot.windows.first.flatMap { primaryWindow in - Self.encodedPersistedWindowGeometryData( - frame: primaryWindow.frame, - display: primaryWindow.display - ) + return moved } + let sourcePane = sourceWorkspace.paneId(forPanelId: panelId) + let sourceIndex = sourceWorkspace.indexInPane(forPanelId: panelId) #if DEBUG - debugLogSessionSaveSnapshot(snapshot, includeScrollback: includeScrollback) + let detachStart = ProcessInfo.processInfo.systemUptime #endif - persistSessionSnapshot( - snapshot, - removeWhenEmpty: false, - persistedGeometryData: persistedGeometryData, - synchronously: writeSynchronously - ) - return true - } - - nonisolated static func shouldPersistSnapshotOnWindowUnregister(isTerminatingApp: Bool) -> Bool { - !isTerminatingApp - } - nonisolated static func shouldRemoveSnapshotWhenNoWindowsRemainOnWindowUnregister( - isTerminatingApp: Bool - ) -> Bool { - !isTerminatingApp - } - - nonisolated static func shouldSkipSessionSaveDuringStartupRestore( - isApplyingStartupSessionRestore: Bool, - includeScrollback: Bool - ) -> Bool { - isApplyingStartupSessionRestore && !includeScrollback - } - - nonisolated static func shouldRunSessionAutosaveTick(isTerminatingApp: Bool) -> Bool { - !isTerminatingApp - } - - private func remainingSessionAutosaveTypingQuietPeriod( - nowUptime: TimeInterval = ProcessInfo.processInfo.systemUptime - ) -> TimeInterval? { - guard lastTypingActivityAt > 0 else { return nil } - let elapsed = nowUptime - lastTypingActivityAt - guard elapsed < Self.sessionAutosaveTypingQuietPeriod else { return nil } - return Self.sessionAutosaveTypingQuietPeriod - elapsed - } - - private func scheduleDeferredSessionAutosaveRetry(after delay: TimeInterval) { - guard delay.isFinite, delay > 0 else { return } - guard !sessionAutosaveDeferredRetryPending else { return } - sessionAutosaveDeferredRetryPending = true - sessionPersistenceQueue.asyncAfter(deadline: .now() + delay) { [weak self] in - Task { @MainActor [weak self] in - guard let self else { return } - self.sessionAutosaveDeferredRetryPending = false - self.runSessionAutosaveTick(source: "typingQuietRetry") - } - } - } - - private func runSessionAutosaveTick(source: String) { - guard Self.shouldRunSessionAutosaveTick(isTerminatingApp: isTerminatingApp) else { return } - guard !sessionAutosaveTickInFlight else { return } - if let remainingQuietPeriod = remainingSessionAutosaveTypingQuietPeriod() { + guard let detached = sourceWorkspace.detachSurface(panelId: panelId) else { #if DEBUG dlog( - "session.save.skipped reason=typing_recent includeScrollback=0 source=\(source) " + - "retryMs=\(Int((remainingQuietPeriod * 1000).rounded()))" + "surface.move.fail panel=\(panelId.uuidString.prefix(5)) reason=detachFailed " + + "elapsedMs=\(elapsedMs(since: moveStart))" ) #endif - scheduleDeferredSessionAutosaveRetry(after: remainingQuietPeriod) - return - } - - sessionAutosaveTickInFlight = true -#if DEBUG - let timingStart = ProgramaTypingTiming.start() - let phaseStart = ProcessInfo.processInfo.systemUptime - var fingerprintMs: Double = 0 - var saveMs: Double = 0 - defer { - sessionAutosaveTickInFlight = false - let totalMs = (ProcessInfo.processInfo.systemUptime - phaseStart) * 1000.0 - ProgramaTypingTiming.logBreakdown( - path: "session.autosaveTick.phase", - totalMs: totalMs, - thresholdMs: 2.0, - parts: [ - ("fingerprintMs", fingerprintMs), - ("saveMs", saveMs), - ], - extra: "source=\(source)" - ) - ProgramaTypingTiming.logDuration( - path: "session.autosaveTick", - startedAt: timingStart, - extra: "source=\(source)" - ) + return false } -#else - defer { sessionAutosaveTickInFlight = false } -#endif - - let now = Date() -#if DEBUG - let fingerprintStart = ProcessInfo.processInfo.systemUptime -#endif - let autosaveFingerprint = sessionAutosaveFingerprint(includeScrollback: false) #if DEBUG - fingerprintMs = (ProcessInfo.processInfo.systemUptime - fingerprintStart) * 1000.0 + let detachMs = elapsedMs(since: detachStart) + let attachStart = ProcessInfo.processInfo.systemUptime #endif - if Self.shouldSkipSessionAutosaveForUnchangedFingerprint( - isTerminatingApp: isTerminatingApp, - includeScrollback: false, - previousFingerprint: lastSessionAutosaveFingerprint, - currentFingerprint: autosaveFingerprint, - lastPersistedAt: lastSessionAutosavePersistedAt, - now: now - ) { + guard destinationWorkspace.attachDetachedSurface( + detached, + inPane: resolvedTargetPane, + atIndex: targetIndex, + focus: focus + ) != nil else { + rollbackDetachedSurface( + detached, + to: sourceWorkspace, + sourcePane: sourcePane, + sourceIndex: sourceIndex, + focus: focus + ) #if DEBUG dlog( - "session.save.skipped reason=unchanged_autosave_fingerprint includeScrollback=0 source=\(source)" + "surface.move.fail panel=\(panelId.uuidString.prefix(5)) reason=attachFailed " + + "detachMs=\(detachMs) elapsedMs=\(elapsedMs(since: moveStart))" ) #endif - return + return false } - #if DEBUG - let saveStart = ProcessInfo.processInfo.systemUptime + let attachMs = elapsedMs(since: attachStart) + var splitMs = "0.00" #endif - _ = saveSessionSnapshot(includeScrollback: false) + + if let splitTarget { #if DEBUG - saveMs = (ProcessInfo.processInfo.systemUptime - saveStart) * 1000.0 + let splitStart = ProcessInfo.processInfo.systemUptime #endif - updateSessionAutosaveSaveState( - includeScrollback: false, - persistedAt: now, - fingerprint: autosaveFingerprint + guard let movedTabId = destinationWorkspace.surfaceIdFromPanelId(panelId), + destinationWorkspace.bonsplitController.splitPane( + resolvedTargetPane, + orientation: splitTarget.orientation, + movingTab: movedTabId, + insertFirst: splitTarget.insertFirst + ) != nil else { + if let detachedFromDestination = destinationWorkspace.detachSurface(panelId: panelId) { + rollbackDetachedSurface( + detachedFromDestination, + to: sourceWorkspace, + sourcePane: sourcePane, + sourceIndex: sourceIndex, + focus: focus + ) + } +#if DEBUG + dlog( + "surface.move.fail panel=\(panelId.uuidString.prefix(5)) reason=postAttachSplitFailed " + + "detachMs=\(detachMs) attachMs=\(attachMs) elapsedMs=\(elapsedMs(since: moveStart))" + ) +#endif + return false + } +#if DEBUG + splitMs = elapsedMs(since: splitStart) +#endif + } + +#if DEBUG + let cleanupStart = ProcessInfo.processInfo.systemUptime +#endif + cleanupEmptySourceWorkspaceAfterSurfaceMove( + sourceWorkspace: sourceWorkspace, + sourceManager: source.tabManager, + sourceWindowId: source.windowId ) - } +#if DEBUG + let cleanupMs = elapsedMs(since: cleanupStart) + let focusStart = ProcessInfo.processInfo.systemUptime +#endif - fileprivate func recordTypingActivity() { - lastTypingActivityAt = ProcessInfo.processInfo.systemUptime - } + if focus { + let destinationWindowId = focusWindow ? windowId(for: destinationManager) : nil + if let destinationWindowId { + _ = focusMainWindow(windowId: destinationWindowId) + } + destinationManager.focusTab(targetWorkspaceId, surfaceId: panelId, suppressFlash: true) + if let destinationWindowId { + reassertCrossWindowSurfaceMoveFocusIfNeeded( + destinationWindowId: destinationWindowId, + sourceWindowId: source.windowId, + destinationWorkspaceId: targetWorkspaceId, + destinationPanelId: panelId, + destinationManager: destinationManager + ) + } + } +#if DEBUG + let focusMs = elapsedMs(since: focusStart) + dlog( + "surface.move.end panel=\(panelId.uuidString.prefix(5)) path=crossWorkspace moved=1 " + + "sourceWs=\(sourceWorkspace.id.uuidString.prefix(5)) destinationWs=\(destinationWorkspace.id.uuidString.prefix(5)) " + + "targetPane=\(resolvedTargetPane.id.uuidString.prefix(5)) targetIndex=\(targetIndex.map(String.init) ?? "nil") " + + "split=\(splitLabel) detachMs=\(detachMs) attachMs=\(attachMs) splitMs=\(splitMs) " + + "cleanupMs=\(cleanupMs) focusMs=\(focusMs) elapsedMs=\(elapsedMs(since: moveStart))" + ) +#endif - nonisolated static func shouldWriteSessionSnapshotSynchronously( - isTerminatingApp: Bool, - includeScrollback: Bool - ) -> Bool { - isTerminatingApp && includeScrollback + return true } - nonisolated static func shouldSkipSessionAutosaveForUnchangedFingerprint( - isTerminatingApp: Bool, - includeScrollback: Bool, - previousFingerprint: Int?, - currentFingerprint: Int?, - lastPersistedAt: Date, - now: Date, - maximumAutosaveSkippableInterval: TimeInterval = 60 + @discardableResult + func moveBonsplitTab( + tabId: UUID, + toWorkspace targetWorkspaceId: UUID, + targetPane: PaneID? = nil, + targetIndex: Int? = nil, + splitTarget: (orientation: SplitOrientation, insertFirst: Bool)? = nil, + focus: Bool = true, + focusWindow: Bool = true ) -> Bool { - guard !isTerminatingApp, - !includeScrollback, - let previousFingerprint, - let currentFingerprint, - previousFingerprint == currentFingerprint else { +#if DEBUG + let moveStart = ProcessInfo.processInfo.systemUptime + func elapsedMs(since start: TimeInterval) -> String { + let ms = (ProcessInfo.processInfo.systemUptime - start) * 1000 + return String(format: "%.2f", ms) + } + dlog( + "surface.moveBonsplit.begin tab=\(tabId.uuidString.prefix(5)) targetWs=\(targetWorkspaceId.uuidString.prefix(5)) " + + "targetPane=\(targetPane?.id.uuidString.prefix(5) ?? "auto") targetIndex=\(targetIndex.map(String.init) ?? "nil")" + ) +#endif + guard let located = locateBonsplitSurface(tabId: tabId) else { +#if DEBUG + dlog( + "surface.moveBonsplit.fail tab=\(tabId.uuidString.prefix(5)) reason=tabNotFound " + + "targetWs=\(targetWorkspaceId.uuidString.prefix(5)) elapsedMs=\(elapsedMs(since: moveStart))" + ) +#endif return false } - - return now.timeIntervalSince(lastPersistedAt) < maximumAutosaveSkippableInterval +#if DEBUG + dlog( + "surface.moveBonsplit.located tab=\(tabId.uuidString.prefix(5)) panel=\(located.panelId.uuidString.prefix(5)) " + + "sourceWs=\(located.workspaceId.uuidString.prefix(5)) sourceWin=\(located.windowId.uuidString.prefix(5))" + ) +#endif + let moved = moveSurface( + panelId: located.panelId, + toWorkspace: targetWorkspaceId, + targetPane: targetPane, + targetIndex: targetIndex, + splitTarget: splitTarget, + focus: focus, + focusWindow: focusWindow + ) +#if DEBUG + dlog( + "surface.moveBonsplit.end tab=\(tabId.uuidString.prefix(5)) panel=\(located.panelId.uuidString.prefix(5)) " + + "moved=\(moved ? 1 : 0) elapsedMs=\(elapsedMs(since: moveStart))" + ) +#endif + return moved } - private func updateSessionAutosaveSaveState( - includeScrollback: Bool, - persistedAt: Date, - fingerprint: Int? - ) { - guard !isTerminatingApp, !includeScrollback else { return } - lastSessionAutosaveFingerprint = fingerprint - lastSessionAutosavePersistedAt = persistedAt + func tabManagerFor(windowId: UUID) -> TabManager? { + mainWindowContexts.values.first(where: { $0.windowId == windowId })?.tabManager } - private nonisolated static func hashFrame(_ frame: NSRect, into hasher: inout Hasher) { - let standardized = frame.standardized - let quantized = [ - standardized.origin.x, - standardized.origin.y, - standardized.size.width, - standardized.size.height, - ].map { Int(($0 * 2).rounded()) } - quantized.forEach { hasher.combine($0) } + func windowId(for tabManager: TabManager) -> UUID? { + mainWindowContexts.values.first(where: { $0.tabManager === tabManager })?.windowId } - private func persistSessionSnapshot( - _ snapshot: AppSessionSnapshot?, - removeWhenEmpty: Bool, - persistedGeometryData: Data?, - synchronously: Bool - ) { - guard snapshot != nil || removeWhenEmpty || persistedGeometryData != nil else { return } + func mainWindow(for windowId: UUID) -> NSWindow? { + windowForMainWindowId(windowId) + } - let writeBlock = { - Self.removeLegacyPersistedWindowGeometry() - if let persistedGeometryData { - UserDefaults.standard.set( - persistedGeometryData, - forKey: Self.persistedWindowGeometryDefaultsKey - ) - } - if let snapshot { - _ = SessionPersistenceStore.save(snapshot) - } else if removeWhenEmpty { - SessionPersistenceStore.removeSnapshot() + func mainWindowContainingWorkspace(_ workspaceId: UUID) -> NSWindow? { + for context in mainWindowContexts.values where context.tabManager.tabs.contains(where: { $0.id == workspaceId }) { + if let window = context.window ?? windowForMainWindowId(context.windowId) { + return window } } - - if synchronously { - writeBlock() - } else { - sessionPersistenceQueue.async(execute: writeBlock) - } + return nil } - private func buildSessionSnapshot(includeScrollback: Bool) -> AppSessionSnapshot? { - let contexts = mainWindowContexts.values.sorted { lhs, rhs in - let lhsWindow = lhs.window ?? windowForMainWindowId(lhs.windowId) - let rhsWindow = rhs.window ?? windowForMainWindowId(rhs.windowId) - let lhsIsKey = lhsWindow?.isKeyWindow ?? false - let rhsIsKey = rhsWindow?.isKeyWindow ?? false - if lhsIsKey != rhsIsKey { - return lhsIsKey && !rhsIsKey - } - return lhs.windowId.uuidString < rhs.windowId.uuidString + func scriptableMainWindows() -> [ScriptableMainWindowState] { + var results: [ScriptableMainWindowState] = [] + var seen: Set = [] + + for window in NSApp.orderedWindows { + guard let context = contextForMainTerminalWindow(window, reindex: false) else { continue } + guard seen.insert(context.windowId).inserted else { continue } + results.append( + ScriptableMainWindowState( + windowId: context.windowId, + tabManager: context.tabManager, + window: context.window ?? windowForMainWindowId(context.windowId) + ) + ) } - guard !contexts.isEmpty else { return nil } + let remaining = mainWindowContexts.values + .sorted { $0.windowId.uuidString < $1.windowId.uuidString } + .filter { seen.insert($0.windowId).inserted } - let windows: [SessionWindowSnapshot] = contexts - .prefix(SessionPersistencePolicy.maxWindowsPerSnapshot) - .map { context in - let window = context.window ?? windowForMainWindowId(context.windowId) - return SessionWindowSnapshot( - frame: window.map { SessionRectSnapshot($0.frame) }, - display: displaySnapshot(for: window), - tabManager: context.tabManager.sessionSnapshot(includeScrollback: includeScrollback), - sidebar: SessionSidebarSnapshot( - isVisible: context.sidebarState.isVisible, - selection: SessionSidebarSelection(selection: context.sidebarSelectionState.selection), - width: SessionPersistencePolicy.sanitizedSidebarWidth(Double(context.sidebarState.persistedWidth)) - ) + for context in remaining { + results.append( + ScriptableMainWindowState( + windowId: context.windowId, + tabManager: context.tabManager, + window: context.window ?? windowForMainWindowId(context.windowId) ) - } + ) + } - guard !windows.isEmpty else { return nil } - return AppSessionSnapshot( - version: SessionSnapshotSchema.currentVersion, - createdAt: Date().timeIntervalSince1970, - windows: windows - ) + return results } -#if DEBUG - private func debugLogSessionSaveSnapshot( - _ snapshot: AppSessionSnapshot, - includeScrollback: Bool - ) { - dlog( - "session.save includeScrollback=\(includeScrollback ? 1 : 0) " + - "windows=\(snapshot.windows.count)" - ) - for (index, windowSnapshot) in snapshot.windows.enumerated() { - let workspaceCount = windowSnapshot.tabManager.workspaces.count - let selectedWorkspace = windowSnapshot.tabManager.selectedWorkspaceIndex.map(String.init) ?? "nil" - dlog( - "session.save.window idx=\(index) " + - "frame={\(debugSessionRectDescription(windowSnapshot.frame))} " + - "display={\(debugSessionDisplayDescription(windowSnapshot.display))} " + - "workspaces=\(workspaceCount) selected=\(selectedWorkspace)" - ) + func scriptableMainWindow(windowId: UUID) -> ScriptableMainWindowState? { + guard let context = mainWindowContexts.values.first(where: { $0.windowId == windowId }) else { + return nil } + return ScriptableMainWindowState( + windowId: context.windowId, + tabManager: context.tabManager, + window: context.window ?? windowForMainWindowId(context.windowId) + ) } - private func debugSessionRectDescription(_ rect: SessionRectSnapshot?) -> String { - guard let rect else { return "nil" } - return "x=\(debugSessionNumber(rect.x)) y=\(debugSessionNumber(rect.y)) " + - "w=\(debugSessionNumber(rect.width)) h=\(debugSessionNumber(rect.height))" - } - - private func debugNSRectDescription(_ rect: NSRect?) -> String { - guard let rect else { return "nil" } - return "x=\(debugSessionNumber(Double(rect.origin.x))) " + - "y=\(debugSessionNumber(Double(rect.origin.y))) " + - "w=\(debugSessionNumber(Double(rect.size.width))) " + - "h=\(debugSessionNumber(Double(rect.size.height)))" + func scriptableMainWindowForTab(_ tabId: UUID) -> ScriptableMainWindowState? { + guard let context = contextContainingTabId(tabId) else { return nil } + return ScriptableMainWindowState( + windowId: context.windowId, + tabManager: context.tabManager, + window: context.window ?? windowForMainWindowId(context.windowId) + ) } - private func debugSessionDisplayDescription(_ display: SessionDisplaySnapshot?) -> String { - guard let display else { return "nil" } - let displayIdText = display.displayID.map(String.init) ?? "nil" - return "id=\(displayIdText) " + - "frame={\(debugSessionRectDescription(display.frame))} " + - "visible={\(debugSessionRectDescription(display.visibleFrame))}" + @discardableResult + func focusScriptableMainWindow(windowId: UUID, bringToFront shouldBringToFront: Bool) -> Bool { + guard let state = scriptableMainWindow(windowId: windowId), + let window = state.window else { + return false + } + setActiveMainWindow(window) + if shouldBringToFront { + bringToFront(window) + } + return true } - private func debugSessionNumber(_ value: Double) -> String { - String(format: "%.1f", value) + @discardableResult + func addWorkspace(windowId: UUID, workingDirectory: String? = nil, bringToFront shouldBringToFront: Bool = false) -> UUID? { + guard let state = scriptableMainWindow(windowId: windowId) else { return nil } + if shouldBringToFront, let window = state.window { + setActiveMainWindow(window) + bringToFront(window) + } + let workspace = state.tabManager.addWorkspace( + workingDirectory: workingDirectory, + select: shouldBringToFront + ) + return workspace.id } -#endif - private func notifyMainWindowContextsDidChange() { - NotificationCenter.default.post(name: .mainWindowContextsDidChange, object: self) + private func markCommandPaletteOpenRequested(for window: NSWindow?) { + guard let window, + let windowId = mainWindowId(for: window) else { return } + commandPalettePendingOpenByWindowId[windowId] = true + commandPaletteRecentRequestAtByWindowId[windowId] = ProcessInfo.processInfo.systemUptime } - /// Register a terminal window with the AppDelegate so menu commands and socket control - /// can target whichever window is currently active. - func registerMainWindow( - _ window: NSWindow, - windowId: UUID, - tabManager: TabManager, - sidebarState: SidebarState, - sidebarSelectionState: SidebarSelectionState + private func postCommandPaletteRequest( + name: Notification.Name, + preferredWindow: NSWindow?, + source: String, + markPending: Bool ) { - tabManager.window = window - - let key = ObjectIdentifier(window) - #if DEBUG - let priorManagerToken = debugManagerToken(self.tabManager) - #endif - if let existing = mainWindowContexts[key] { - existing.window = window - } else if let existing = mainWindowContexts.values.first(where: { $0.windowId == windowId }) { - existing.window = window - reindexMainWindowContextIfNeeded(existing, for: window) - } else { - mainWindowContexts[key] = MainWindowContext( - windowId: windowId, - tabManager: tabManager, - sidebarState: sidebarState, - sidebarSelectionState: sidebarSelectionState, - window: window - ) - NotificationCenter.default.addObserver( - forName: NSWindow.willCloseNotification, - object: window, - queue: .main - ) { [weak self] note in - guard let self, let closing = note.object as? NSWindow else { return } - self.unregisterMainWindow(closing) - } + let targetWindow = preferredWindow ?? NSApp.keyWindow ?? NSApp.mainWindow + if markPending { + markCommandPaletteOpenRequested(for: targetWindow) } - commandPaletteVisibilityByWindowId[windowId] = false - commandPaletteSelectionByWindowId[windowId] = 0 - commandPaletteSnapshotByWindowId[windowId] = .empty - + NotificationCenter.default.post(name: name, object: targetWindow) #if DEBUG dlog( - "mainWindow.register windowId=\(String(windowId.uuidString.prefix(8))) window={\(debugWindowToken(window))} manager=\(debugManagerToken(tabManager)) priorActiveMgr=\(priorManagerToken) \(debugShortcutRouteSnapshot())" + "shortcut.palette.request source=\(source) " + + "target={\(debugWindowToken(targetWindow))} " + + "pendingMarked=\(markPending ? 1 : 0)" ) #endif - notifyMainWindowContextsDidChange() - if window.isKeyWindow { - setActiveMainWindow(window) - } - - attemptStartupSessionRestoreIfNeeded(primaryWindow: window) - if !isTerminatingApp { - _ = saveSessionSnapshot(includeScrollback: false) - } } - struct MainWindowSummary { - let windowId: UUID - let isKeyWindow: Bool - let isVisible: Bool - let workspaceCount: Int - let selectedWorkspaceId: UUID? + func requestCommandPaletteCommands(preferredWindow: NSWindow? = nil, source: String = "api.commandPalette") { + postCommandPaletteRequest( + name: .commandPaletteRequested, + preferredWindow: preferredWindow, + source: source, + markPending: true + ) } - struct WindowMoveTarget: Identifiable { - let windowId: UUID - let label: String - let tabManager: TabManager - let isCurrentWindow: Bool + func requestCommandPaletteSwitcher(preferredWindow: NSWindow? = nil, source: String = "api.commandPaletteSwitcher") { + postCommandPaletteRequest( + name: .commandPaletteSwitcherRequested, + preferredWindow: preferredWindow, + source: source, + markPending: true + ) + } - var id: UUID { windowId } + func requestCommandPaletteRenameTab(preferredWindow: NSWindow? = nil, source: String = "api.commandPaletteRenameTab") { + postCommandPaletteRequest( + name: .commandPaletteRenameTabRequested, + preferredWindow: preferredWindow, + source: source, + markPending: true + ) } - struct WorkspaceMoveTarget: Identifiable { - let windowId: UUID - let workspaceId: UUID - let windowLabel: String - let workspaceTitle: String - let tabManager: TabManager - let isCurrentWindow: Bool + func requestCommandPaletteRenameWorkspace( + preferredWindow: NSWindow? = nil, + source: String = "api.commandPaletteRenameWorkspace" + ) { + postCommandPaletteRequest( + name: .commandPaletteRenameWorkspaceRequested, + preferredWindow: preferredWindow, + source: source, + markPending: true + ) + } - var id: String { "\(windowId.uuidString):\(workspaceId.uuidString)" } - var label: String { - isCurrentWindow ? workspaceTitle : "\(workspaceTitle) (\(windowLabel))" - } + func requestCommandPaletteEditWorkspaceDescription( + preferredWindow: NSWindow? = nil, + source: String = "api.commandPaletteEditWorkspaceDescription" + ) { + postCommandPaletteRequest( + name: .commandPaletteEditWorkspaceDescriptionRequested, + preferredWindow: preferredWindow, + source: source, + markPending: true + ) } - func listMainWindowSummaries() -> [MainWindowSummary] { - let contexts = Array(mainWindowContexts.values) - return contexts.map { ctx in - let window = ctx.window ?? windowForMainWindowId(ctx.windowId) - return MainWindowSummary( - windowId: ctx.windowId, - isKeyWindow: window?.isKeyWindow ?? false, - isVisible: window?.isVisible ?? false, - workspaceCount: ctx.tabManager.tabs.count, - selectedWorkspaceId: ctx.tabManager.selectedTabId - ) - } + private func clearCommandPalettePendingOpen(for window: NSWindow?) { + guard let window, + let windowId = mainWindowId(for: window) else { return } + commandPalettePendingOpenByWindowId.removeValue(forKey: windowId) + commandPaletteRecentRequestAtByWindowId.removeValue(forKey: windowId) } - func windowMoveTargets(referenceWindowId: UUID?) -> [WindowMoveTarget] { - let orderedSummaries = orderedMainWindowSummaries(referenceWindowId: referenceWindowId) - let labels = windowLabelsById(orderedSummaries: orderedSummaries, referenceWindowId: referenceWindowId) - return orderedSummaries.compactMap { summary in - guard let manager = tabManagerFor(windowId: summary.windowId) else { return nil } - let label = labels[summary.windowId] ?? "Window" - return WindowMoveTarget( - windowId: summary.windowId, - label: label, - tabManager: manager, - isCurrentWindow: summary.windowId == referenceWindowId + private func pruneExpiredCommandPalettePendingOpenStates( + now: TimeInterval = ProcessInfo.processInfo.systemUptime + ) { + for windowId in Array(commandPalettePendingOpenByWindowId.keys) { + guard commandPalettePendingOpenByWindowId[windowId] == true else { continue } + guard let requestedAt = commandPaletteRecentRequestAtByWindowId[windowId] else { + commandPalettePendingOpenByWindowId.removeValue(forKey: windowId) +#if DEBUG + dlog("shortcut.palette.pendingPrune windowId=\(windowId.uuidString.prefix(8)) reason=missingTimestamp") +#endif + continue + } + let age = now - requestedAt + guard age > Self.commandPalettePendingOpenMaxAge else { continue } + commandPalettePendingOpenByWindowId.removeValue(forKey: windowId) + commandPaletteRecentRequestAtByWindowId.removeValue(forKey: windowId) +#if DEBUG + dlog( + "shortcut.palette.pendingPrune windowId=\(windowId.uuidString.prefix(8)) " + + "reason=stale ageMs=\(Int(age * 1000))" ) +#endif } } - func workspaceMoveTargets(excludingWorkspaceId: UUID? = nil, referenceWindowId: UUID?) -> [WorkspaceMoveTarget] { - let orderedSummaries = orderedMainWindowSummaries(referenceWindowId: referenceWindowId) - let labels = windowLabelsById(orderedSummaries: orderedSummaries, referenceWindowId: referenceWindowId) - - var targets: [WorkspaceMoveTarget] = [] - targets.reserveCapacity(orderedSummaries.reduce(0) { partial, summary in - partial + summary.workspaceCount - }) + private func isCommandPalettePendingOpen(for window: NSWindow) -> Bool { + guard let windowId = mainWindowId(for: window) else { return false } + pruneExpiredCommandPalettePendingOpenStates() + return commandPalettePendingOpenByWindowId[windowId] == true + } - for summary in orderedSummaries { - guard let manager = tabManagerFor(windowId: summary.windowId) else { continue } - let windowLabel = labels[summary.windowId] ?? "Window" - let isCurrentWindow = summary.windowId == referenceWindowId - for workspace in manager.tabs { - if workspace.id == excludingWorkspaceId { - continue - } - targets.append( - WorkspaceMoveTarget( - windowId: summary.windowId, - workspaceId: workspace.id, - windowLabel: windowLabel, - workspaceTitle: workspaceDisplayName(workspace), - tabManager: manager, - isCurrentWindow: isCurrentWindow - ) - ) - } - } + private func beginCommandPaletteEscapeSuppression(for window: NSWindow?) { + guard let window, + let windowId = mainWindowId(for: window) else { return } + commandPaletteEscapeSuppressionByWindowId.insert(windowId) + commandPaletteEscapeSuppressionStartedAtByWindowId[windowId] = ProcessInfo.processInfo.systemUptime + } - return targets + private func endCommandPaletteEscapeSuppression(for window: NSWindow?) { + guard let window, + let windowId = mainWindowId(for: window) else { return } + commandPaletteEscapeSuppressionByWindowId.remove(windowId) + commandPaletteEscapeSuppressionStartedAtByWindowId.removeValue(forKey: windowId) } - @discardableResult - func moveWorkspaceToWindow(workspaceId: UUID, windowId: UUID, focus: Bool = true) -> Bool { - guard let sourceManager = tabManagerFor(tabId: workspaceId), - let destinationManager = tabManagerFor(windowId: windowId) else { + private func shouldConsumeSuppressedEscape(event: NSEvent, window: NSWindow?) -> Bool { + guard let window, + let windowId = mainWindowId(for: window), + commandPaletteEscapeSuppressionByWindowId.contains(windowId) else { return false } - - if sourceManager === destinationManager { - if focus { - destinationManager.focusTab(workspaceId, suppressFlash: true) - _ = focusMainWindow(windowId: windowId) - TerminalController.shared.setActiveTabManager(destinationManager) - } + let startedAt = commandPaletteEscapeSuppressionStartedAtByWindowId[windowId] ?? 0 + if ProcessInfo.processInfo.systemUptime - startedAt <= 0.35 { return true } - - guard let workspace = sourceManager.detachWorkspace(tabId: workspaceId) else { return false } - destinationManager.attachWorkspace(workspace, select: focus) - - if focus { - _ = focusMainWindow(windowId: windowId) - TerminalController.shared.setActiveTabManager(destinationManager) - } - return true + // Fallback cleanup when keyUp is lost for any reason. + endCommandPaletteEscapeSuppression(for: window) + return false } - @discardableResult - func moveWorkspaceToNewWindow(workspaceId: UUID, focus: Bool = true) -> UUID? { - let windowId = createMainWindow() - guard let destinationManager = tabManagerFor(windowId: windowId) else { return nil } - let bootstrapWorkspaceId = destinationManager.tabs.first?.id - - guard moveWorkspaceToWindow(workspaceId: workspaceId, windowId: windowId, focus: focus) else { - _ = closeMainWindow(windowId: windowId) + private func recentCommandPaletteRequestAge(for window: NSWindow?) -> TimeInterval? { + guard let window, + let windowId = mainWindowId(for: window) else { return nil } - - // Remove the bootstrap workspace from the new window once the moved workspace arrives. - if let bootstrapWorkspaceId, - bootstrapWorkspaceId != workspaceId, - let bootstrapWorkspace = destinationManager.tabs.first(where: { $0.id == bootstrapWorkspaceId }), - destinationManager.tabs.count > 1 { - destinationManager.closeWorkspace(bootstrapWorkspace) + let now = ProcessInfo.processInfo.systemUptime + pruneExpiredCommandPalettePendingOpenStates(now: now) + guard commandPalettePendingOpenByWindowId[windowId] == true else { + commandPaletteRecentRequestAtByWindowId.removeValue(forKey: windowId) + return nil } - return windowId - } - - func locateBonsplitSurface(tabId: UUID) -> (windowId: UUID, workspaceId: UUID, panelId: UUID, tabManager: TabManager)? { - let bonsplitTabId = TabID(uuid: tabId) - for context in mainWindowContexts.values { - for workspace in context.tabManager.tabs { - if let panelId = workspace.panelIdFromSurfaceId(bonsplitTabId) { - return (context.windowId, workspace.id, panelId, context.tabManager) - } - } + guard let startedAt = commandPaletteRecentRequestAtByWindowId[windowId] else { + commandPalettePendingOpenByWindowId.removeValue(forKey: windowId) + return nil + } + let age = now - startedAt + if age <= Self.commandPaletteRequestGraceInterval { + return age } return nil } - @discardableResult - func moveSurface( - panelId: UUID, - toWorkspace targetWorkspaceId: UUID, - targetPane: PaneID? = nil, - targetIndex: Int? = nil, - splitTarget: (orientation: SplitOrientation, insertFirst: Bool)? = nil, - focus: Bool = true, - focusWindow: Bool = true - ) -> Bool { -#if DEBUG - let moveStart = ProcessInfo.processInfo.systemUptime - let splitLabel = splitTarget.map { split in - "\(split.orientation.rawValue):\(split.insertFirst ? 1 : 0)" - } ?? "none" - func elapsedMs(since start: TimeInterval) -> String { - let ms = (ProcessInfo.processInfo.systemUptime - start) * 1000 - return String(format: "%.2f", ms) - } - dlog( - "surface.move.begin panel=\(panelId.uuidString.prefix(5)) targetWs=\(targetWorkspaceId.uuidString.prefix(5)) " + - "targetPane=\(targetPane?.id.uuidString.prefix(5) ?? "auto") targetIndex=\(targetIndex.map(String.init) ?? "nil") " + - "split=\(splitLabel) focus=\(focus ? 1 : 0) focusWindow=\(focusWindow ? 1 : 0)" - ) -#endif - guard let source = locateSurface(surfaceId: panelId) else { -#if DEBUG - dlog("surface.move.fail panel=\(panelId.uuidString.prefix(5)) reason=sourcePanelNotFound elapsedMs=\(elapsedMs(since: moveStart))") -#endif - return false - } - guard let sourceWorkspace = source.tabManager.tabs.first(where: { $0.id == source.workspaceId }) else { -#if DEBUG - dlog("surface.move.fail panel=\(panelId.uuidString.prefix(5)) reason=sourceWorkspaceMissing elapsedMs=\(elapsedMs(since: moveStart))") -#endif - return false - } - guard let destinationManager = tabManagerFor(tabId: targetWorkspaceId) else { -#if DEBUG - dlog("surface.move.fail panel=\(panelId.uuidString.prefix(5)) reason=destinationManagerMissing elapsedMs=\(elapsedMs(since: moveStart))") -#endif - return false - } - guard let destinationWorkspace = destinationManager.tabs.first(where: { $0.id == targetWorkspaceId }) else { -#if DEBUG - dlog("surface.move.fail panel=\(panelId.uuidString.prefix(5)) reason=destinationWorkspaceMissing elapsedMs=\(elapsedMs(since: moveStart))") -#endif - return false - } -#if DEBUG - dlog( - "surface.move.route panel=\(panelId.uuidString.prefix(5)) sourceWs=\(sourceWorkspace.id.uuidString.prefix(5)) " + - "sourceWin=\(source.windowId.uuidString.prefix(5)) destinationWs=\(destinationWorkspace.id.uuidString.prefix(5)) " + - "sameWorkspace=\(destinationWorkspace.id == sourceWorkspace.id ? 1 : 0)" - ) -#endif - - let resolvedTargetPane = targetPane.flatMap { pane in - destinationWorkspace.bonsplitController.allPaneIds.first(where: { $0 == pane }) - } ?? destinationWorkspace.bonsplitController.focusedPaneId - ?? destinationWorkspace.bonsplitController.allPaneIds.first + private func escapeSuppressionWindow(for event: NSEvent) -> NSWindow? { + commandPaletteWindowForShortcutEvent(event) ?? event.window ?? NSApp.keyWindow ?? NSApp.mainWindow + } - guard let resolvedTargetPane else { + @discardableResult + private func clearEscapeSuppressionForKeyUp(event: NSEvent, consumeIfSuppressed: Bool = false) -> Bool { + guard event.type == .keyUp, event.keyCode == 53 else { return false } + let suppressionWindow = escapeSuppressionWindow(for: event) + let didConsume = consumeIfSuppressed && shouldConsumeSuppressedEscape(event: event, window: suppressionWindow) + if let window = suppressionWindow { + endCommandPaletteEscapeSuppression(for: window) #if DEBUG dlog( - "surface.move.fail panel=\(panelId.uuidString.prefix(5)) reason=targetPaneMissing " + - "destinationWs=\(destinationWorkspace.id.uuidString.prefix(5)) elapsedMs=\(elapsedMs(since: moveStart))" + "shortcut.escape suppressionClear target={\(debugWindowToken(window))} " + + "keyUpConsumed=\(didConsume ? 1 : 0)" ) #endif - return false + return didConsume } - - if destinationWorkspace.id == sourceWorkspace.id { - if let splitTarget { - guard let sourceTabId = sourceWorkspace.surfaceIdFromPanelId(panelId), - sourceWorkspace.bonsplitController.splitPane( - resolvedTargetPane, - orientation: splitTarget.orientation, - movingTab: sourceTabId, - insertFirst: splitTarget.insertFirst - ) != nil else { -#if DEBUG - dlog( - "surface.move.fail panel=\(panelId.uuidString.prefix(5)) reason=sameWorkspaceSplitFailed " + - "targetPane=\(resolvedTargetPane.id.uuidString.prefix(5)) split=\(splitLabel) " + - "elapsedMs=\(elapsedMs(since: moveStart))" - ) -#endif - return false - } - if focus { - source.tabManager.focusTab(sourceWorkspace.id, surfaceId: panelId, suppressFlash: true) - } + commandPaletteEscapeSuppressionByWindowId.removeAll() + commandPaletteEscapeSuppressionStartedAtByWindowId.removeAll() #if DEBUG - dlog( - "surface.move.end panel=\(panelId.uuidString.prefix(5)) path=sameWorkspaceSplit moved=1 " + - "targetPane=\(resolvedTargetPane.id.uuidString.prefix(5)) elapsedMs=\(elapsedMs(since: moveStart))" - ) + dlog("shortcut.escape suppressionClear target={nil} clearedAll=1 keyUpConsumed=\(didConsume ? 1 : 0)") #endif - return true - } + return didConsume + } - let moved = sourceWorkspace.moveSurface( - panelId: panelId, - toPane: resolvedTargetPane, - atIndex: targetIndex, - focus: focus - ) + func setCommandPaletteVisible(_ visible: Bool, for window: NSWindow) { + guard let windowId = mainWindowId(for: window) else { return } + let wasVisible = commandPaletteVisibilityByWindowId[windowId] ?? false + commandPaletteVisibilityByWindowId[windowId] = visible + // Opening (false -> true) always resolves pending-open. + // Closing (true -> false) also clears stale pending state. + // Ignore repeated false updates so a stale sync cannot erase an in-flight open request. + if visible || wasVisible { + commandPalettePendingOpenByWindowId.removeValue(forKey: windowId) + commandPaletteRecentRequestAtByWindowId.removeValue(forKey: windowId) + } #if DEBUG + if !visible, + !wasVisible, + commandPalettePendingOpenByWindowId[windowId] == true { dlog( - "surface.move.end panel=\(panelId.uuidString.prefix(5)) path=sameWorkspaceMove moved=\(moved ? 1 : 0) " + - "targetPane=\(resolvedTargetPane.id.uuidString.prefix(5)) targetIndex=\(targetIndex.map(String.init) ?? "nil") " + - "elapsedMs=\(elapsedMs(since: moveStart))" + "palette.visibility.retainPending " + + "window={\(debugWindowToken(window))} visible=0 wasVisible=0 pending=1" ) -#endif - return moved } - - let sourcePane = sourceWorkspace.paneId(forPanelId: panelId) - let sourceIndex = sourceWorkspace.indexInPane(forPanelId: panelId) -#if DEBUG - let detachStart = ProcessInfo.processInfo.systemUptime -#endif - - guard let detached = sourceWorkspace.detachSurface(panelId: panelId) else { -#if DEBUG - dlog( - "surface.move.fail panel=\(panelId.uuidString.prefix(5)) reason=detachFailed " + - "elapsedMs=\(elapsedMs(since: moveStart))" - ) -#endif - return false - } -#if DEBUG - let detachMs = elapsedMs(since: detachStart) - let attachStart = ProcessInfo.processInfo.systemUptime -#endif - guard destinationWorkspace.attachDetachedSurface( - detached, - inPane: resolvedTargetPane, - atIndex: targetIndex, - focus: focus - ) != nil else { - rollbackDetachedSurface( - detached, - to: sourceWorkspace, - sourcePane: sourcePane, - sourceIndex: sourceIndex, - focus: focus - ) -#if DEBUG - dlog( - "surface.move.fail panel=\(panelId.uuidString.prefix(5)) reason=attachFailed " + - "detachMs=\(detachMs) elapsedMs=\(elapsedMs(since: moveStart))" - ) -#endif - return false - } -#if DEBUG - let attachMs = elapsedMs(since: attachStart) - var splitMs = "0.00" #endif + } - if let splitTarget { -#if DEBUG - let splitStart = ProcessInfo.processInfo.systemUptime -#endif - guard let movedTabId = destinationWorkspace.surfaceIdFromPanelId(panelId), - destinationWorkspace.bonsplitController.splitPane( - resolvedTargetPane, - orientation: splitTarget.orientation, - movingTab: movedTabId, - insertFirst: splitTarget.insertFirst - ) != nil else { - if let detachedFromDestination = destinationWorkspace.detachSurface(panelId: panelId) { - rollbackDetachedSurface( - detachedFromDestination, - to: sourceWorkspace, - sourcePane: sourcePane, - sourceIndex: sourceIndex, - focus: focus - ) - } -#if DEBUG - dlog( - "surface.move.fail panel=\(panelId.uuidString.prefix(5)) reason=postAttachSplitFailed " + - "detachMs=\(detachMs) attachMs=\(attachMs) elapsedMs=\(elapsedMs(since: moveStart))" - ) -#endif - return false - } -#if DEBUG - splitMs = elapsedMs(since: splitStart) -#endif - } + func isCommandPaletteVisible(windowId: UUID) -> Bool { + commandPaletteVisibilityByWindowId[windowId] ?? false + } -#if DEBUG - let cleanupStart = ProcessInfo.processInfo.systemUptime -#endif - cleanupEmptySourceWorkspaceAfterSurfaceMove( - sourceWorkspace: sourceWorkspace, - sourceManager: source.tabManager, - sourceWindowId: source.windowId - ) -#if DEBUG - let cleanupMs = elapsedMs(since: cleanupStart) - let focusStart = ProcessInfo.processInfo.systemUptime -#endif + func setCommandPaletteSelectionIndex(_ index: Int, for window: NSWindow) { + guard let windowId = mainWindowId(for: window) else { return } + commandPaletteSelectionByWindowId[windowId] = max(0, index) + } - if focus { - let destinationWindowId = focusWindow ? windowId(for: destinationManager) : nil - if let destinationWindowId { - _ = focusMainWindow(windowId: destinationWindowId) - } - destinationManager.focusTab(targetWorkspaceId, surfaceId: panelId, suppressFlash: true) - if let destinationWindowId { - reassertCrossWindowSurfaceMoveFocusIfNeeded( - destinationWindowId: destinationWindowId, - sourceWindowId: source.windowId, - destinationWorkspaceId: targetWorkspaceId, - destinationPanelId: panelId, - destinationManager: destinationManager - ) - } - } -#if DEBUG - let focusMs = elapsedMs(since: focusStart) - dlog( - "surface.move.end panel=\(panelId.uuidString.prefix(5)) path=crossWorkspace moved=1 " + - "sourceWs=\(sourceWorkspace.id.uuidString.prefix(5)) destinationWs=\(destinationWorkspace.id.uuidString.prefix(5)) " + - "targetPane=\(resolvedTargetPane.id.uuidString.prefix(5)) targetIndex=\(targetIndex.map(String.init) ?? "nil") " + - "split=\(splitLabel) detachMs=\(detachMs) attachMs=\(attachMs) splitMs=\(splitMs) " + - "cleanupMs=\(cleanupMs) focusMs=\(focusMs) elapsedMs=\(elapsedMs(since: moveStart))" - ) -#endif + func commandPaletteSelectionIndex(windowId: UUID) -> Int { + commandPaletteSelectionByWindowId[windowId] ?? 0 + } - return true + func setCommandPaletteSnapshot(_ snapshot: CommandPaletteDebugSnapshot, for window: NSWindow) { + guard let windowId = mainWindowId(for: window) else { return } + commandPaletteSnapshotByWindowId[windowId] = snapshot } - @discardableResult - func moveBonsplitTab( - tabId: UUID, - toWorkspace targetWorkspaceId: UUID, - targetPane: PaneID? = nil, - targetIndex: Int? = nil, - splitTarget: (orientation: SplitOrientation, insertFirst: Bool)? = nil, - focus: Bool = true, - focusWindow: Bool = true - ) -> Bool { -#if DEBUG - let moveStart = ProcessInfo.processInfo.systemUptime - func elapsedMs(since start: TimeInterval) -> String { - let ms = (ProcessInfo.processInfo.systemUptime - start) * 1000 - return String(format: "%.2f", ms) - } - dlog( - "surface.moveBonsplit.begin tab=\(tabId.uuidString.prefix(5)) targetWs=\(targetWorkspaceId.uuidString.prefix(5)) " + - "targetPane=\(targetPane?.id.uuidString.prefix(5) ?? "auto") targetIndex=\(targetIndex.map(String.init) ?? "nil")" - ) -#endif - guard let located = locateBonsplitSurface(tabId: tabId) else { -#if DEBUG - dlog( - "surface.moveBonsplit.fail tab=\(tabId.uuidString.prefix(5)) reason=tabNotFound " + - "targetWs=\(targetWorkspaceId.uuidString.prefix(5)) elapsedMs=\(elapsedMs(since: moveStart))" - ) -#endif - return false - } -#if DEBUG - dlog( - "surface.moveBonsplit.located tab=\(tabId.uuidString.prefix(5)) panel=\(located.panelId.uuidString.prefix(5)) " + - "sourceWs=\(located.workspaceId.uuidString.prefix(5)) sourceWin=\(located.windowId.uuidString.prefix(5))" - ) -#endif - let moved = moveSurface( - panelId: located.panelId, - toWorkspace: targetWorkspaceId, - targetPane: targetPane, - targetIndex: targetIndex, - splitTarget: splitTarget, - focus: focus, - focusWindow: focusWindow - ) -#if DEBUG - dlog( - "surface.moveBonsplit.end tab=\(tabId.uuidString.prefix(5)) panel=\(located.panelId.uuidString.prefix(5)) " + - "moved=\(moved ? 1 : 0) elapsedMs=\(elapsedMs(since: moveStart))" - ) -#endif - return moved + func commandPaletteSnapshot(windowId: UUID) -> CommandPaletteDebugSnapshot { + commandPaletteSnapshotByWindowId[windowId] ?? .empty } - func tabManagerFor(windowId: UUID) -> TabManager? { - mainWindowContexts.values.first(where: { $0.windowId == windowId })?.tabManager + func isCommandPaletteVisible(for window: NSWindow) -> Bool { + guard let windowId = mainWindowId(for: window) else { return false } + return commandPaletteVisibilityByWindowId[windowId] ?? false } - func windowId(for tabManager: TabManager) -> UUID? { - mainWindowContexts.values.first(where: { $0.tabManager === tabManager })?.windowId + func isCommandPaletteEffectivelyVisible(for window: NSWindow) -> Bool { + isCommandPaletteEffectivelyVisible(in: window) } - func mainWindow(for windowId: UUID) -> NSWindow? { - windowForMainWindowId(windowId) + func shouldBlockFirstResponderChangeWhileCommandPaletteVisible( + window: NSWindow, + responder: NSResponder? + ) -> Bool { + guard isCommandPaletteVisible(for: window) else { return false } + guard let responder else { return false } + guard !isCommandPaletteResponder(responder) else { return false } + return isFocusStealingResponderWhileCommandPaletteVisible(responder) } - func mainWindowContainingWorkspace(_ workspaceId: UUID) -> NSWindow? { - for context in mainWindowContexts.values where context.tabManager.tabs.contains(where: { $0.id == workspaceId }) { - if let window = context.window ?? windowForMainWindowId(context.windowId) { - return window + private func isCommandPaletteResponder(_ responder: NSResponder) -> Bool { + if let textView = responder as? NSTextView, textView.isFieldEditor { + if let delegateView = textView.delegate as? NSView { + return isInsideCommandPaletteOverlay(delegateView) } + // SwiftUI can attach a non-view delegate to TextField editors. + // When command palette is visible, its search/rename editor is the + // only expected field editor inside the main window. + return true } - return nil - } - - func scriptableMainWindows() -> [ScriptableMainWindowState] { - var results: [ScriptableMainWindowState] = [] - var seen: Set = [] - - for window in NSApp.orderedWindows { - guard let context = contextForMainTerminalWindow(window, reindex: false) else { continue } - guard seen.insert(context.windowId).inserted else { continue } - results.append( - ScriptableMainWindowState( - windowId: context.windowId, - tabManager: context.tabManager, - window: context.window ?? windowForMainWindowId(context.windowId) - ) - ) + if let view = responder as? NSView { + return isInsideCommandPaletteOverlay(view) } + return false + } - let remaining = mainWindowContexts.values - .sorted { $0.windowId.uuidString < $1.windowId.uuidString } - .filter { seen.insert($0.windowId).inserted } + private func isFocusStealingResponderWhileCommandPaletteVisible(_ responder: NSResponder) -> Bool { + isCommandPaletteFocusStealingTerminalOrBrowserResponder(responder) + } - for context in remaining { - results.append( - ScriptableMainWindowState( - windowId: context.windowId, - tabManager: context.tabManager, - window: context.window ?? windowForMainWindowId(context.windowId) - ) - ) + private func isInsideCommandPaletteOverlay(_ view: NSView) -> Bool { + var current: NSView? = view + while let candidate = current { + if candidate.identifier == commandPaletteOverlayContainerIdentifier { + return true + } + current = candidate.superview } - - return results + return false } - func scriptableMainWindow(windowId: UUID) -> ScriptableMainWindowState? { - guard let context = mainWindowContexts.values.first(where: { $0.windowId == windowId }) else { - return nil + private func keyRoutingOwnerView(for responder: NSResponder?) -> NSView? { + guard let responder else { return nil } + if let editor = responder as? NSTextView, + editor.isFieldEditor { + return programaFieldEditorOwnerView(editor) ?? editor } - return ScriptableMainWindowState( - windowId: context.windowId, - tabManager: context.tabManager, - window: context.window ?? windowForMainWindowId(context.windowId) - ) + return responder as? NSView } - func scriptableMainWindowForTab(_ tabId: UUID) -> ScriptableMainWindowState? { - guard let context = contextContainingTabId(tabId) else { return nil } - return ScriptableMainWindowState( - windowId: context.windowId, - tabManager: context.tabManager, - window: context.window ?? windowForMainWindowId(context.windowId) - ) - } + private func responderHasViableKeyRoutingOwner( + _ responder: NSResponder, + in window: NSWindow + ) -> Bool { + if let ghosttyView = cmuxOwningGhosttyView(for: responder) { + if ghosttyView.window !== window { + return false + } + if ghosttyView.isHiddenOrHasHiddenAncestor { + return false + } + return ghosttyView === window.contentView || ghosttyView.superview != nil + } - @discardableResult - func focusScriptableMainWindow(windowId: UUID, bringToFront shouldBringToFront: Bool) -> Bool { - guard let state = scriptableMainWindow(windowId: windowId), - let window = state.window else { + guard let ownerView = keyRoutingOwnerView(for: responder) else { return false } - setActiveMainWindow(window) - if shouldBringToFront { - bringToFront(window) + + if ownerView.window !== window { + return false } - return true - } - @discardableResult - func addWorkspace(windowId: UUID, workingDirectory: String? = nil, bringToFront shouldBringToFront: Bool = false) -> UUID? { - guard let state = scriptableMainWindow(windowId: windowId) else { return nil } - if shouldBringToFront, let window = state.window { - setActiveMainWindow(window) - bringToFront(window) + if ownerView.isHiddenOrHasHiddenAncestor { + return false } - let workspace = state.tabManager.addWorkspace( - workingDirectory: workingDirectory, - select: shouldBringToFront - ) - return workspace.id + + if ownerView !== window.contentView, ownerView.superview == nil { + return false + } + + return true } - private func markCommandPaletteOpenRequested(for window: NSWindow?) { - guard let window, - let windowId = mainWindowId(for: window) else { return } - commandPalettePendingOpenByWindowId[windowId] = true - commandPaletteRecentRequestAtByWindowId[windowId] = ProcessInfo.processInfo.systemUptime + private func responderNeedsFocusedTerminalKeyRepair( + _ responder: NSResponder?, + in window: NSWindow, + hostedView: GhosttySurfaceScrollView + ) -> Bool { + guard let responder else { return true } + if responder is NSWindow { return true } + guard responderHasViableKeyRoutingOwner(responder, in: window) else { + return true + } + if hostedView.responderMatchesPreferredKeyboardFocus(responder) { + return false + } + return true } - private func postCommandPaletteRequest( - name: Notification.Name, - preferredWindow: NSWindow?, - source: String, - markPending: Bool + func repairFocusedTerminalKeyboardRoutingIfNeeded( + window: NSWindow, + event: NSEvent ) { - let targetWindow = preferredWindow ?? NSApp.keyWindow ?? NSApp.mainWindow - if markPending { - markCommandPaletteOpenRequested(for: targetWindow) + guard event.type == .keyDown else { return } + let normalizedFlags = event.modifierFlags.intersection(.deviceIndependentFlagsMask) + guard !normalizedFlags.contains(.command) else { return } + guard isMainTerminalWindow(window) else { return } + guard window.attachedSheet == nil else { return } + guard !isCommandPaletteEffectivelyVisible(in: window) else { return } + guard let context = contextForMainWindow(window) ?? contextForMainTerminalWindow(window), + let workspace = context.tabManager.selectedWorkspace, + let panelId = workspace.focusedPanelId, + let terminalPanel = workspace.terminalPanel(for: panelId) else { + return } - NotificationCenter.default.post(name: name, object: targetWindow) + guard responderNeedsFocusedTerminalKeyRepair( + window.firstResponder, + in: window, + hostedView: terminalPanel.hostedView + ) else { return } + #if DEBUG + let before = window.firstResponder.map { String(describing: type(of: $0)) } ?? "nil" + let target = terminalPanel.hostedView.preferredPanelFocusIntentForActivation() dlog( - "shortcut.palette.request source=\(source) " + - "target={\(debugWindowToken(targetWindow))} " + - "pendingMarked=\(markPending ? 1 : 0)" + "focus.keyRepair attempt window=\(ObjectIdentifier(window)) " + + "workspace=\(String(workspace.id.uuidString.prefix(5))) " + + "panel=\(String(panelId.uuidString.prefix(5))) " + + "target=\(target == .findField ? "searchField" : "surface") " + + "fr=\(before) keyCode=\(event.keyCode) mods=\(event.modifierFlags.rawValue)" ) #endif - } - func requestCommandPaletteCommands(preferredWindow: NSWindow? = nil, source: String = "api.commandPalette") { - postCommandPaletteRequest( - name: .commandPaletteRequested, - preferredWindow: preferredWindow, - source: source, - markPending: true - ) - } + terminalPanel.hostedView.ensureFocus(for: workspace.id, surfaceId: panelId) - func requestCommandPaletteSwitcher(preferredWindow: NSWindow? = nil, source: String = "api.commandPaletteSwitcher") { - postCommandPaletteRequest( - name: .commandPaletteSwitcherRequested, - preferredWindow: preferredWindow, - source: source, - markPending: true +#if DEBUG + let after = window.firstResponder.map { String(describing: type(of: $0)) } ?? "nil" + dlog( + "focus.keyRepair result window=\(ObjectIdentifier(window)) " + + "panel=\(String(panelId.uuidString.prefix(5))) " + + "isSurfaceResponder=\(terminalPanel.hostedView.isSurfaceViewFirstResponder() ? 1 : 0) " + + "fr=\(after)" ) +#endif } - func requestCommandPaletteRenameTab(preferredWindow: NSWindow? = nil, source: String = "api.commandPaletteRenameTab") { - postCommandPaletteRequest( - name: .commandPaletteRenameTabRequested, - preferredWindow: preferredWindow, - source: source, - markPending: true - ) + func locateSurface(surfaceId: UUID) -> (windowId: UUID, workspaceId: UUID, tabManager: TabManager)? { + for ctx in mainWindowContexts.values { + for ws in ctx.tabManager.tabs { + if ws.panels[surfaceId] != nil { + return (ctx.windowId, ws.id, ctx.tabManager) + } + } + } + return nil } - func requestCommandPaletteRenameWorkspace( - preferredWindow: NSWindow? = nil, - source: String = "api.commandPaletteRenameWorkspace" - ) { - postCommandPaletteRequest( - name: .commandPaletteRenameWorkspaceRequested, - preferredWindow: preferredWindow, - source: source, - markPending: true - ) - } + /// Resolve the workspace that currently owns a panel/surface ID. + /// Prefer the provided workspace when available, then fall back to global lookup. + func workspaceContainingPanel( + panelId: UUID, + preferredWorkspaceId: UUID? = nil + ) -> (workspace: Workspace, tabManager: TabManager)? { + if let preferredWorkspaceId, + let manager = tabManagerFor(tabId: preferredWorkspaceId), + let workspace = manager.tabs.first(where: { $0.id == preferredWorkspaceId }), + workspace.panels[panelId] != nil { + return (workspace, manager) + } - func requestCommandPaletteEditWorkspaceDescription( - preferredWindow: NSWindow? = nil, - source: String = "api.commandPaletteEditWorkspaceDescription" - ) { - postCommandPaletteRequest( - name: .commandPaletteEditWorkspaceDescriptionRequested, - preferredWindow: preferredWindow, - source: source, - markPending: true - ) - } + if let located = locateSurface(surfaceId: panelId), + let workspace = located.tabManager.tabs.first(where: { $0.id == located.workspaceId }), + workspace.panels[panelId] != nil { + return (workspace, located.tabManager) + } - private func clearCommandPalettePendingOpen(for window: NSWindow?) { - guard let window, - let windowId = mainWindowId(for: window) else { return } - commandPalettePendingOpenByWindowId.removeValue(forKey: windowId) - commandPaletteRecentRequestAtByWindowId.removeValue(forKey: windowId) + if let preferredWorkspaceId, + let manager = tabManagerFor(tabId: preferredWorkspaceId) ?? tabManager, + let workspace = manager.tabs.first(where: { $0.id == preferredWorkspaceId }), + workspace.panels[panelId] != nil { + return (workspace, manager) + } + + if let manager = tabManager, + let workspace = manager.tabs.first(where: { $0.panels[panelId] != nil }) { + return (workspace, manager) + } + + return nil } - private func pruneExpiredCommandPalettePendingOpenStates( - now: TimeInterval = ProcessInfo.processInfo.systemUptime - ) { - for windowId in Array(commandPalettePendingOpenByWindowId.keys) { - guard commandPalettePendingOpenByWindowId[windowId] == true else { continue } - guard let requestedAt = commandPaletteRecentRequestAtByWindowId[windowId] else { - commandPalettePendingOpenByWindowId.removeValue(forKey: windowId) -#if DEBUG - dlog("shortcut.palette.pendingPrune windowId=\(windowId.uuidString.prefix(8)) reason=missingTimestamp") -#endif - continue + func locateGhosttySurface(_ surface: ghostty_surface_t?) -> (windowId: UUID, workspaceId: UUID, panelId: UUID, tabManager: TabManager)? { + guard let surface else { return nil } + for ctx in mainWindowContexts.values { + for ws in ctx.tabManager.tabs { + for (panelId, panel) in ws.panels { + guard let terminal = panel as? TerminalPanel else { continue } + if terminal.surface.surface == surface { + return (ctx.windowId, ws.id, panelId, ctx.tabManager) + } + } } - let age = now - requestedAt - guard age > Self.commandPalettePendingOpenMaxAge else { continue } - commandPalettePendingOpenByWindowId.removeValue(forKey: windowId) - commandPaletteRecentRequestAtByWindowId.removeValue(forKey: windowId) -#if DEBUG - dlog( - "shortcut.palette.pendingPrune windowId=\(windowId.uuidString.prefix(8)) " + - "reason=stale ageMs=\(Int(age * 1000))" - ) -#endif } + return nil } - private func isCommandPalettePendingOpen(for window: NSWindow) -> Bool { - guard let windowId = mainWindowId(for: window) else { return false } - pruneExpiredCommandPalettePendingOpenStates() - return commandPalettePendingOpenByWindowId[windowId] == true - } - - private func beginCommandPaletteEscapeSuppression(for window: NSWindow?) { - guard let window, - let windowId = mainWindowId(for: window) else { return } - commandPaletteEscapeSuppressionByWindowId.insert(windowId) - commandPaletteEscapeSuppressionStartedAtByWindowId[windowId] = ProcessInfo.processInfo.systemUptime + func refreshTerminalSurfacesAfterGhosttyConfigReload(source: String) { + var refreshedCount = 0 + forEachTerminalPanel { terminalPanel in + terminalPanel.hostedView.reconcileGeometryNow() + terminalPanel.hostedView.refreshHostBackgroundAfterGhosttyConfigReload() + terminalPanel.surface.forceRefresh(reason: "appDelegate.refreshAfterGhosttyConfigReload") + refreshedCount += 1 + } +#if DEBUG + dlog("reload.config.surfaceRefresh source=\(source) count=\(refreshedCount)") +#endif } - private func endCommandPaletteEscapeSuppression(for window: NSWindow?) { - guard let window, - let windowId = mainWindowId(for: window) else { return } - commandPaletteEscapeSuppressionByWindowId.remove(windowId) - commandPaletteEscapeSuppressionStartedAtByWindowId.removeValue(forKey: windowId) - } + private func forEachTerminalPanel(_ body: (TerminalPanel) -> Void) { + var seenManagers: Set = [] - private func shouldConsumeSuppressedEscape(event: NSEvent, window: NSWindow?) -> Bool { - guard let window, - let windowId = mainWindowId(for: window), - commandPaletteEscapeSuppressionByWindowId.contains(windowId) else { - return false + func visitManager(_ manager: TabManager?) { + guard let manager else { return } + let managerId = ObjectIdentifier(manager) + guard seenManagers.insert(managerId).inserted else { return } + for workspace in manager.tabs { + for panel in workspace.panels.values { + guard let terminalPanel = panel as? TerminalPanel else { continue } + body(terminalPanel) + } + } } - let startedAt = commandPaletteEscapeSuppressionStartedAtByWindowId[windowId] ?? 0 - if ProcessInfo.processInfo.systemUptime - startedAt <= 0.35 { - return true + + visitManager(tabManager) + for context in mainWindowContexts.values { + visitManager(context.tabManager) } - // Fallback cleanup when keyUp is lost for any reason. - endCommandPaletteEscapeSuppression(for: window) - return false } - private func recentCommandPaletteRequestAge(for window: NSWindow?) -> TimeInterval? { - guard let window, - let windowId = mainWindowId(for: window) else { - return nil - } - let now = ProcessInfo.processInfo.systemUptime - pruneExpiredCommandPalettePendingOpenStates(now: now) - guard commandPalettePendingOpenByWindowId[windowId] == true else { - commandPaletteRecentRequestAtByWindowId.removeValue(forKey: windowId) - return nil - } - guard let startedAt = commandPaletteRecentRequestAtByWindowId[windowId] else { - commandPalettePendingOpenByWindowId.removeValue(forKey: windowId) - return nil - } - let age = now - startedAt - if age <= Self.commandPaletteRequestGraceInterval { - return age + func focusMainWindow(windowId: UUID) -> Bool { + guard let window = windowForMainWindowId(windowId) else { return false } + if TerminalController.shouldSuppressSocketCommandActivation(), + !TerminalController.socketCommandAllowsInAppFocusMutations() { + setActiveMainWindow(window) + return true } - return nil + bringToFront(window) + return true } - private func escapeSuppressionWindow(for event: NSEvent) -> NSWindow? { - commandPaletteWindowForShortcutEvent(event) ?? event.window ?? NSApp.keyWindow ?? NSApp.mainWindow + func closeMainWindow(windowId: UUID) -> Bool { + guard let window = windowForMainWindowId(windowId) else { return false } + window.performClose(nil) + return true } - @discardableResult - private func clearEscapeSuppressionForKeyUp(event: NSEvent, consumeIfSuppressed: Bool = false) -> Bool { - guard event.type == .keyUp, event.keyCode == 53 else { return false } - let suppressionWindow = escapeSuppressionWindow(for: event) - let didConsume = consumeIfSuppressed && shouldConsumeSuppressedEscape(event: event, window: suppressionWindow) - if let window = suppressionWindow { - endCommandPaletteEscapeSuppression(for: window) + private func confirmCloseMainWindow(_ window: NSWindow) -> Bool { #if DEBUG - dlog( - "shortcut.escape suppressionClear target={\(debugWindowToken(window))} " + - "keyUpConsumed=\(didConsume ? 1 : 0)" - ) -#endif - return didConsume + if let debugCloseMainWindowConfirmationHandler { + return debugCloseMainWindowConfirmationHandler(window) } - commandPaletteEscapeSuppressionByWindowId.removeAll() - commandPaletteEscapeSuppressionStartedAtByWindowId.removeAll() -#if DEBUG - dlog("shortcut.escape suppressionClear target={nil} clearedAll=1 keyUpConsumed=\(didConsume ? 1 : 0)") #endif - return didConsume - } - func setCommandPaletteVisible(_ visible: Bool, for window: NSWindow) { - guard let windowId = mainWindowId(for: window) else { return } - let wasVisible = commandPaletteVisibilityByWindowId[windowId] ?? false - commandPaletteVisibilityByWindowId[windowId] = visible - // Opening (false -> true) always resolves pending-open. - // Closing (true -> false) also clears stale pending state. - // Ignore repeated false updates so a stale sync cannot erase an in-flight open request. - if visible || wasVisible { - commandPalettePendingOpenByWindowId.removeValue(forKey: windowId) - commandPaletteRecentRequestAtByWindowId.removeValue(forKey: windowId) - } -#if DEBUG - if !visible, - !wasVisible, - commandPalettePendingOpenByWindowId[windowId] == true { - dlog( - "palette.visibility.retainPending " + - "window={\(debugWindowToken(window))} visible=0 wasVisible=0 pending=1" - ) - } -#endif - } + let alert = NSAlert() + alert.alertStyle = .warning + alert.messageText = String(localized: "dialog.closeWindow.title", defaultValue: "Close window?") + alert.informativeText = String( + localized: "dialog.closeWindow.message", + defaultValue: "This will close the current window and all of its workspaces." + ) + alert.addButton(withTitle: String(localized: "common.close", defaultValue: "Close")) + alert.addButton(withTitle: String(localized: "common.cancel", defaultValue: "Cancel")) - func isCommandPaletteVisible(windowId: UUID) -> Bool { - commandPaletteVisibilityByWindowId[windowId] ?? false - } + let alertWindow = alert.window + if let closeButton = alert.buttons.first { + alertWindow.defaultButtonCell = closeButton.cell as? NSButtonCell + alertWindow.initialFirstResponder = closeButton + DispatchQueue.main.async { + _ = alertWindow.makeFirstResponder(closeButton) + } + } - func setCommandPaletteSelectionIndex(_ index: Int, for window: NSWindow) { - guard let windowId = mainWindowId(for: window) else { return } - commandPaletteSelectionByWindowId[windowId] = max(0, index) + return alert.runModal() == .alertFirstButtonReturn } - func commandPaletteSelectionIndex(windowId: UUID) -> Int { - commandPaletteSelectionByWindowId[windowId] ?? 0 + @discardableResult + func closeWindowWithConfirmation(_ window: NSWindow) -> Bool { + guard isMainTerminalWindow(window) else { + window.performClose(nil) + return true + } + guard confirmCloseMainWindow(window) else { return true } + window.performClose(nil) + return true } - func setCommandPaletteSnapshot(_ snapshot: CommandPaletteDebugSnapshot, for window: NSWindow) { - guard let windowId = mainWindowId(for: window) else { return } - commandPaletteSnapshotByWindowId[windowId] = snapshot + private func orderedMainWindowSummaries(referenceWindowId: UUID?) -> [MainWindowSummary] { + let summaries = listMainWindowSummaries() + return summaries.sorted { lhs, rhs in + let lhsIsReference = lhs.windowId == referenceWindowId + let rhsIsReference = rhs.windowId == referenceWindowId + if lhsIsReference != rhsIsReference { return lhsIsReference } + if lhs.isKeyWindow != rhs.isKeyWindow { return lhs.isKeyWindow } + if lhs.isVisible != rhs.isVisible { return lhs.isVisible } + return lhs.windowId.uuidString < rhs.windowId.uuidString + } } - func commandPaletteSnapshot(windowId: UUID) -> CommandPaletteDebugSnapshot { - commandPaletteSnapshotByWindowId[windowId] ?? .empty + private func windowLabelsById(orderedSummaries: [MainWindowSummary], referenceWindowId: UUID?) -> [UUID: String] { + var labels: [UUID: String] = [:] + for (index, summary) in orderedSummaries.enumerated() { + if summary.windowId == referenceWindowId { + labels[summary.windowId] = String(localized: "menu.currentWindow", defaultValue: "Current Window") + } else { + let number = index + 1 + labels[summary.windowId] = String(localized: "menu.windowNumber", defaultValue: "Window \(number)") + } + } + return labels } - func isCommandPaletteVisible(for window: NSWindow) -> Bool { - guard let windowId = mainWindowId(for: window) else { return false } - return commandPaletteVisibilityByWindowId[windowId] ?? false + private func workspaceDisplayName(_ workspace: Workspace) -> String { + let trimmed = workspace.title.trimmingCharacters(in: .whitespacesAndNewlines) + return trimmed.isEmpty ? String(localized: "workspace.displayName.fallback", defaultValue: "Workspace") : trimmed } - func isCommandPaletteEffectivelyVisible(for window: NSWindow) -> Bool { - isCommandPaletteEffectivelyVisible(in: window) + private func rollbackDetachedSurface( + _ detached: Workspace.DetachedSurfaceTransfer, + to workspace: Workspace, + sourcePane: PaneID?, + sourceIndex: Int?, + focus: Bool + ) { + let rollbackPane = sourcePane.flatMap { pane in + workspace.bonsplitController.allPaneIds.first(where: { $0 == pane }) + } ?? workspace.bonsplitController.focusedPaneId + ?? workspace.bonsplitController.allPaneIds.first + guard let rollbackPane else { return } + _ = workspace.attachDetachedSurface( + detached, + inPane: rollbackPane, + atIndex: sourceIndex, + focus: focus + ) } - func shouldBlockFirstResponderChangeWhileCommandPaletteVisible( - window: NSWindow, - responder: NSResponder? - ) -> Bool { - guard isCommandPaletteVisible(for: window) else { return false } - guard let responder else { return false } - guard !isCommandPaletteResponder(responder) else { return false } - return isFocusStealingResponderWhileCommandPaletteVisible(responder) - } + private func cleanupEmptySourceWorkspaceAfterSurfaceMove( + sourceWorkspace: Workspace, + sourceManager: TabManager, + sourceWindowId: UUID + ) { + guard sourceWorkspace.panels.isEmpty else { return } + guard sourceManager.tabs.contains(where: { $0.id == sourceWorkspace.id }) else { return } - private func isCommandPaletteResponder(_ responder: NSResponder) -> Bool { - if let textView = responder as? NSTextView, textView.isFieldEditor { - if let delegateView = textView.delegate as? NSView { - return isInsideCommandPaletteOverlay(delegateView) - } - // SwiftUI can attach a non-view delegate to TextField editors. - // When command palette is visible, its search/rename editor is the - // only expected field editor inside the main window. - return true - } - if let view = responder as? NSView { - return isInsideCommandPaletteOverlay(view) + if sourceManager.tabs.count > 1 { + sourceManager.closeWorkspace(sourceWorkspace) + } else { + _ = closeMainWindow(windowId: sourceWindowId) } - return false } - private func isFocusStealingResponderWhileCommandPaletteVisible(_ responder: NSResponder) -> Bool { - isCommandPaletteFocusStealingTerminalOrBrowserResponder(responder) - } - - private func isInsideCommandPaletteOverlay(_ view: NSView) -> Bool { - var current: NSView? = view - while let candidate = current { - if candidate.identifier == commandPaletteOverlayContainerIdentifier { - return true + private func reassertCrossWindowSurfaceMoveFocusIfNeeded( + destinationWindowId: UUID, + sourceWindowId: UUID, + destinationWorkspaceId: UUID, + destinationPanelId: UUID, + destinationManager: TabManager + ) { + let reassert: () -> Void = { [weak self, weak destinationManager] in + guard let self, let destinationManager else { return } + guard let workspace = destinationManager.tabs.first(where: { $0.id == destinationWorkspaceId }), + workspace.panels[destinationPanelId] != nil else { + return + } + guard let destinationWindow = self.mainWindow(for: destinationWindowId) else { return } + guard let keyWindow = NSApp.keyWindow, + let keyWindowId = self.mainWindowId(for: keyWindow), + keyWindowId == sourceWindowId, + keyWindow !== destinationWindow else { + return } - current = candidate.superview - } - return false - } - private func keyRoutingOwnerView(for responder: NSResponder?) -> NSView? { - guard let responder else { return nil } - if let editor = responder as? NSTextView, - editor.isFieldEditor { - return programaFieldEditorOwnerView(editor) ?? editor + self.bringToFront(destinationWindow) + destinationManager.focusTab( + destinationWorkspaceId, + surfaceId: destinationPanelId, + suppressFlash: true + ) } - return responder as? NSView + + DispatchQueue.main.asyncAfter(deadline: .now() + 0.05, execute: reassert) + DispatchQueue.main.asyncAfter(deadline: .now() + 0.16, execute: reassert) } - private func responderHasViableKeyRoutingOwner( - _ responder: NSResponder, - in window: NSWindow - ) -> Bool { - if let ghosttyView = cmuxOwningGhosttyView(for: responder) { - if ghosttyView.window !== window { - return false - } - if ghosttyView.isHiddenOrHasHiddenAncestor { - return false - } - return ghosttyView === window.contentView || ghosttyView.superview != nil + private func windowForMainWindowId(_ windowId: UUID) -> NSWindow? { + if let ctx = mainWindowContexts.values.first(where: { $0.windowId == windowId }), + let window = ctx.window { + return window } + let expectedIdentifier = "cmux.main.\(windowId.uuidString)" + return NSApp.windows.first(where: { $0.identifier?.rawValue == expectedIdentifier }) + } - guard let ownerView = keyRoutingOwnerView(for: responder) else { - return false + private func resolvedWindow(for context: MainWindowContext) -> NSWindow? { + if let window = context.window { + return window } - - if ownerView.window !== window { - return false + guard let window = windowForMainWindowId(context.windowId) else { + return nil } + reindexMainWindowContextIfNeeded(context, for: window) + return window + } - if ownerView.isHiddenOrHasHiddenAncestor { - return false - } + private func mainWindowId(from window: NSWindow) -> UUID? { + guard let raw = window.identifier?.rawValue else { return nil } + let prefix = "cmux.main." + guard raw.hasPrefix(prefix) else { return nil } + let suffix = String(raw.dropFirst(prefix.count)) + return UUID(uuidString: suffix) + } - if ownerView !== window.contentView, ownerView.superview == nil { - return false + private func reindexMainWindowContextIfNeeded(_ context: MainWindowContext, for window: NSWindow) { + let desiredKey = ObjectIdentifier(window) + if mainWindowContexts[desiredKey] === context { + context.window = window + return } - return true - } - - private func responderNeedsFocusedTerminalKeyRepair( - _ responder: NSResponder?, - in window: NSWindow, - hostedView: GhosttySurfaceScrollView - ) -> Bool { - guard let responder else { return true } - if responder is NSWindow { return true } - guard responderHasViableKeyRoutingOwner(responder, in: window) else { - return true + let contextKeys = mainWindowContexts.compactMap { key, value in + value === context ? key : nil } - if hostedView.responderMatchesPreferredKeyboardFocus(responder) { - return false + for key in contextKeys { + mainWindowContexts.removeValue(forKey: key) } - return true - } - func repairFocusedTerminalKeyboardRoutingIfNeeded( - window: NSWindow, - event: NSEvent - ) { - guard event.type == .keyDown else { return } - let normalizedFlags = event.modifierFlags.intersection(.deviceIndependentFlagsMask) - guard !normalizedFlags.contains(.command) else { return } - guard isMainTerminalWindow(window) else { return } - guard window.attachedSheet == nil else { return } - guard !isCommandPaletteEffectivelyVisible(in: window) else { return } - guard let context = contextForMainWindow(window) ?? contextForMainTerminalWindow(window), - let workspace = context.tabManager.selectedWorkspace, - let panelId = workspace.focusedPanelId, - let terminalPanel = workspace.terminalPanel(for: panelId) else { + if let conflicting = mainWindowContexts[desiredKey], conflicting !== context { + context.window = window return } - guard responderNeedsFocusedTerminalKeyRepair( - window.firstResponder, - in: window, - hostedView: terminalPanel.hostedView - ) else { return } - -#if DEBUG - let before = window.firstResponder.map { String(describing: type(of: $0)) } ?? "nil" - let target = terminalPanel.hostedView.preferredPanelFocusIntentForActivation() - dlog( - "focus.keyRepair attempt window=\(ObjectIdentifier(window)) " + - "workspace=\(String(workspace.id.uuidString.prefix(5))) " + - "panel=\(String(panelId.uuidString.prefix(5))) " + - "target=\(target == .findField ? "searchField" : "surface") " + - "fr=\(before) keyCode=\(event.keyCode) mods=\(event.modifierFlags.rawValue)" - ) -#endif - - terminalPanel.hostedView.ensureFocus(for: workspace.id, surfaceId: panelId) - -#if DEBUG - let after = window.firstResponder.map { String(describing: type(of: $0)) } ?? "nil" - dlog( - "focus.keyRepair result window=\(ObjectIdentifier(window)) " + - "panel=\(String(panelId.uuidString.prefix(5))) " + - "isSurfaceResponder=\(terminalPanel.hostedView.isSurfaceViewFirstResponder() ? 1 : 0) " + - "fr=\(after)" - ) -#endif - } - func locateSurface(surfaceId: UUID) -> (windowId: UUID, workspaceId: UUID, tabManager: TabManager)? { - for ctx in mainWindowContexts.values { - for ws in ctx.tabManager.tabs { - if ws.panels[surfaceId] != nil { - return (ctx.windowId, ws.id, ctx.tabManager) - } - } - } - return nil + mainWindowContexts[desiredKey] = context + context.window = window + notifyMainWindowContextsDidChange() } - /// Resolve the workspace that currently owns a panel/surface ID. - /// Prefer the provided workspace when available, then fall back to global lookup. - func workspaceContainingPanel( - panelId: UUID, - preferredWorkspaceId: UUID? = nil - ) -> (workspace: Workspace, tabManager: TabManager)? { - if let preferredWorkspaceId, - let manager = tabManagerFor(tabId: preferredWorkspaceId), - let workspace = manager.tabs.first(where: { $0.id == preferredWorkspaceId }), - workspace.panels[panelId] != nil { - return (workspace, manager) - } + private func contextForMainTerminalWindow(_ window: NSWindow, reindex: Bool = true) -> MainWindowContext? { + guard isMainTerminalWindow(window) else { return nil } - if let located = locateSurface(surfaceId: panelId), - let workspace = located.tabManager.tabs.first(where: { $0.id == located.workspaceId }), - workspace.panels[panelId] != nil { - return (workspace, located.tabManager) + if let context = mainWindowContexts[ObjectIdentifier(window)] { + context.window = window + return context } - if let preferredWorkspaceId, - let manager = tabManagerFor(tabId: preferredWorkspaceId) ?? tabManager, - let workspace = manager.tabs.first(where: { $0.id == preferredWorkspaceId }), - workspace.panels[panelId] != nil { - return (workspace, manager) + if let windowId = mainWindowId(from: window), + let context = mainWindowContexts.values.first(where: { $0.windowId == windowId }) { + if reindex { + reindexMainWindowContextIfNeeded(context, for: window) + } else { + context.window = window + } + return context } - if let manager = tabManager, - let workspace = manager.tabs.first(where: { $0.panels[panelId] != nil }) { - return (workspace, manager) + let windowNumber = window.windowNumber + if windowNumber >= 0, + let context = mainWindowContexts.values.first(where: { candidate in + let candidateWindow = candidate.window ?? windowForMainWindowId(candidate.windowId) + return candidateWindow?.windowNumber == windowNumber + }) { + if reindex { + reindexMainWindowContextIfNeeded(context, for: window) + } else { + context.window = window + } + return context } return nil } - func locateGhosttySurface(_ surface: ghostty_surface_t?) -> (windowId: UUID, workspaceId: UUID, panelId: UUID, tabManager: TabManager)? { - guard let surface else { return nil } - for ctx in mainWindowContexts.values { - for ws in ctx.tabManager.tabs { - for (panelId, panel) in ws.panels { - guard let terminal = panel as? TerminalPanel else { continue } - if terminal.surface.surface == surface { - return (ctx.windowId, ws.id, panelId, ctx.tabManager) - } - } - } + private func unregisterMainWindowContext(for window: NSWindow) -> MainWindowContext? { + guard let removed = contextForMainTerminalWindow(window, reindex: false) else { return nil } + let removedKeys = mainWindowContexts.compactMap { key, value in + value === removed ? key : nil } - return nil + for key in removedKeys { + mainWindowContexts.removeValue(forKey: key) + } + notifyMainWindowContextsDidChange() + return removed } - func refreshTerminalSurfacesAfterGhosttyConfigReload(source: String) { - var refreshedCount = 0 - forEachTerminalPanel { terminalPanel in - terminalPanel.hostedView.reconcileGeometryNow() - terminalPanel.hostedView.refreshHostBackgroundAfterGhosttyConfigReload() - terminalPanel.surface.forceRefresh(reason: "appDelegate.refreshAfterGhosttyConfigReload") - refreshedCount += 1 + private func discardOrphanedMainWindowContext(_ context: MainWindowContext) { + let contextKeys = mainWindowContexts.compactMap { key, value in + value === context ? key : nil } -#if DEBUG - dlog("reload.config.surfaceRefresh source=\(source) count=\(refreshedCount)") -#endif - } + for key in contextKeys { + mainWindowContexts.removeValue(forKey: key) + } + notifyMainWindowContextsDidChange() - private func forEachTerminalPanel(_ body: (TerminalPanel) -> Void) { - var seenManagers: Set = [] + commandPaletteVisibilityByWindowId.removeValue(forKey: context.windowId) + commandPalettePendingOpenByWindowId.removeValue(forKey: context.windowId) + commandPaletteRecentRequestAtByWindowId.removeValue(forKey: context.windowId) + commandPaletteEscapeSuppressionByWindowId.remove(context.windowId) + commandPaletteEscapeSuppressionStartedAtByWindowId.removeValue(forKey: context.windowId) + commandPaletteSelectionByWindowId.removeValue(forKey: context.windowId) + commandPaletteSnapshotByWindowId.removeValue(forKey: context.windowId) - func visitManager(_ manager: TabManager?) { - guard let manager else { return } - let managerId = ObjectIdentifier(manager) - guard seenManagers.insert(managerId).inserted else { return } - for workspace in manager.tabs { - for panel in workspace.panels.values { - guard let terminalPanel = panel as? TerminalPanel else { continue } - body(terminalPanel) - } + if tabManager === context.tabManager { + if let nextContext = mainWindowContexts.values.first(where: { resolvedWindow(for: $0) != nil }) { + tabManager = nextContext.tabManager + sidebarState = nextContext.sidebarState + sidebarSelectionState = nextContext.sidebarSelectionState + TerminalController.shared.setActiveTabManager(nextContext.tabManager) + } else { + tabManager = nil + sidebarState = nil + sidebarSelectionState = nil + TerminalController.shared.setActiveTabManager(nil) } } - visitManager(tabManager) - for context in mainWindowContexts.values { - visitManager(context.tabManager) + if let store = notificationStore { + for tab in context.tabManager.tabs { + store.clearNotifications(forTabId: tab.id) + } } } - func focusMainWindow(windowId: UUID) -> Bool { - guard let window = windowForMainWindowId(windowId) else { return false } - if TerminalController.shouldSuppressSocketCommandActivation(), - !TerminalController.socketCommandAllowsInAppFocusMutations() { - setActiveMainWindow(window) - return true + private func mainWindowId(for window: NSWindow) -> UUID? { + if let context = mainWindowContexts[ObjectIdentifier(window)] { + return context.windowId } - bringToFront(window) - return true - } - - func closeMainWindow(windowId: UUID) -> Bool { - guard let window = windowForMainWindowId(windowId) else { return false } - window.performClose(nil) - return true + guard let rawIdentifier = window.identifier?.rawValue, + rawIdentifier.hasPrefix("cmux.main.") else { return nil } + let idPart = String(rawIdentifier.dropFirst("cmux.main.".count)) + return UUID(uuidString: idPart) } - private func confirmCloseMainWindow(_ window: NSWindow) -> Bool { -#if DEBUG - if let debugCloseMainWindowConfirmationHandler { - return debugCloseMainWindowConfirmationHandler(window) - } -#endif - - let alert = NSAlert() - alert.alertStyle = .warning - alert.messageText = String(localized: "dialog.closeWindow.title", defaultValue: "Close window?") - alert.informativeText = String( - localized: "dialog.closeWindow.message", - defaultValue: "This will close the current window and all of its workspaces." - ) - alert.addButton(withTitle: String(localized: "common.close", defaultValue: "Close")) - alert.addButton(withTitle: String(localized: "common.cancel", defaultValue: "Cancel")) - - let alertWindow = alert.window - if let closeButton = alert.buttons.first { - alertWindow.defaultButtonCell = closeButton.cell as? NSButtonCell - alertWindow.initialFirstResponder = closeButton - DispatchQueue.main.async { - _ = alertWindow.makeFirstResponder(closeButton) + private func commandPaletteOverlayContainer(in window: NSWindow) -> NSView? { + guard let searchRoot = window.contentView?.superview ?? window.contentView else { return nil } + var stack: [NSView] = [searchRoot] + while let candidate = stack.popLast() { + if candidate.identifier == commandPaletteOverlayContainerIdentifier { + return candidate } + stack.append(contentsOf: candidate.subviews) } + return nil + } - return alert.runModal() == .alertFirstButtonReturn + private func isCommandPaletteOverlayPresented(in window: NSWindow) -> Bool { + guard let container = commandPaletteOverlayContainer(in: window) else { return false } + return !container.isHidden && container.alphaValue > 0.001 } - @discardableResult - func closeWindowWithConfirmation(_ window: NSWindow) -> Bool { - guard isMainTerminalWindow(window) else { - window.performClose(nil) - return true + private func isCommandPaletteResponderActive(in window: NSWindow) -> Bool { + guard let responder = window.firstResponder else { return false } + if let textView = responder as? NSTextView, + textView.isFieldEditor, + !(textView.delegate is NSView) { + // Field-editor delegates can be non-view responders. Confirm the overlay is + // mounted and visible to avoid treating unrelated editors as palette input. + return isCommandPaletteOverlayPresented(in: window) } - guard confirmCloseMainWindow(window) else { return true } - window.performClose(nil) - return true + return isCommandPaletteResponder(responder) } - private func orderedMainWindowSummaries(referenceWindowId: UUID?) -> [MainWindowSummary] { - let summaries = listMainWindowSummaries() - return summaries.sorted { lhs, rhs in - let lhsIsReference = lhs.windowId == referenceWindowId - let rhsIsReference = rhs.windowId == referenceWindowId - if lhsIsReference != rhsIsReference { return lhsIsReference } - if lhs.isKeyWindow != rhs.isKeyWindow { return lhs.isKeyWindow } - if lhs.isVisible != rhs.isVisible { return lhs.isVisible } - return lhs.windowId.uuidString < rhs.windowId.uuidString + private func isCommandPaletteMultilineTextResponderActive(in window: NSWindow) -> Bool { + guard let textView = window.firstResponder as? NSTextView, + !textView.isFieldEditor else { + return false } + return isCommandPaletteResponder(textView) } - private func windowLabelsById(orderedSummaries: [MainWindowSummary], referenceWindowId: UUID?) -> [UUID: String] { - var labels: [UUID: String] = [:] - for (index, summary) in orderedSummaries.enumerated() { - if summary.windowId == referenceWindowId { - labels[summary.windowId] = String(localized: "menu.currentWindow", defaultValue: "Current Window") - } else { - let number = index + 1 - labels[summary.windowId] = String(localized: "menu.windowNumber", defaultValue: "Window \(number)") - } + private func commandPaletteMarkedTextInput(in window: NSWindow) -> NSTextView? { + if let textView = window.firstResponder as? NSTextView, + isCommandPaletteResponder(textView), + textView.hasMarkedText() { + return textView } - return labels - } - private func workspaceDisplayName(_ workspace: Workspace) -> String { - let trimmed = workspace.title.trimmingCharacters(in: .whitespacesAndNewlines) - return trimmed.isEmpty ? String(localized: "workspace.displayName.fallback", defaultValue: "Workspace") : trimmed - } + if let textField = window.firstResponder as? NSTextField, + let editor = textField.currentEditor() as? NSTextView, + isCommandPaletteResponder(editor), + editor.hasMarkedText() { + return editor + } - private func rollbackDetachedSurface( - _ detached: Workspace.DetachedSurfaceTransfer, - to workspace: Workspace, - sourcePane: PaneID?, - sourceIndex: Int?, - focus: Bool - ) { - let rollbackPane = sourcePane.flatMap { pane in - workspace.bonsplitController.allPaneIds.first(where: { $0 == pane }) - } ?? workspace.bonsplitController.focusedPaneId - ?? workspace.bonsplitController.allPaneIds.first - guard let rollbackPane else { return } - _ = workspace.attachDetachedSurface( - detached, - inPane: rollbackPane, - atIndex: sourceIndex, - focus: focus - ) + return nil } - private func cleanupEmptySourceWorkspaceAfterSurfaceMove( - sourceWorkspace: Workspace, - sourceManager: TabManager, - sourceWindowId: UUID - ) { - guard sourceWorkspace.panels.isEmpty else { return } - guard sourceManager.tabs.contains(where: { $0.id == sourceWorkspace.id }) else { return } + private func isCommandPaletteEffectivelyVisible(in window: NSWindow) -> Bool { + isCommandPaletteVisible(for: window) + || isCommandPalettePendingOpen(for: window) + || isCommandPaletteOverlayPresented(in: window) + || isCommandPaletteResponderActive(in: window) + } - if sourceManager.tabs.count > 1 { - sourceManager.closeWorkspace(sourceWorkspace) - } else { - _ = closeMainWindow(windowId: sourceWindowId) + private func activeCommandPaletteWindow() -> NSWindow? { + pruneExpiredCommandPalettePendingOpenStates() + if let keyWindow = NSApp.keyWindow, + isMainTerminalWindow(keyWindow), + isCommandPaletteEffectivelyVisible(in: keyWindow) { + return keyWindow + } + if let mainWindow = NSApp.mainWindow, + isMainTerminalWindow(mainWindow), + isCommandPaletteEffectivelyVisible(in: mainWindow) { + return mainWindow + } + if let orderedWindow = NSApp.orderedWindows.first(where: { window in + isMainTerminalWindow(window) && isCommandPaletteEffectivelyVisible(in: window) + }) { + return orderedWindow } + if let visibleWindowId = commandPaletteVisibilityByWindowId.first(where: { $0.value })?.key { + return windowForMainWindowId(visibleWindowId) + } + if let pendingWindowId = commandPalettePendingOpenByWindowId.first(where: { $0.value })?.key { + return windowForMainWindowId(pendingWindowId) + } + return nil } - private func reassertCrossWindowSurfaceMoveFocusIfNeeded( - destinationWindowId: UUID, - sourceWindowId: UUID, - destinationWorkspaceId: UUID, - destinationPanelId: UUID, - destinationManager: TabManager - ) { - let reassert: () -> Void = { [weak self, weak destinationManager] in - guard let self, let destinationManager else { return } - guard let workspace = destinationManager.tabs.first(where: { $0.id == destinationWorkspaceId }), - workspace.panels[destinationPanelId] != nil else { - return - } - guard let destinationWindow = self.mainWindow(for: destinationWindowId) else { return } - guard let keyWindow = NSApp.keyWindow, - let keyWindowId = self.mainWindowId(for: keyWindow), - keyWindowId == sourceWindowId, - keyWindow !== destinationWindow else { - return - } - - self.bringToFront(destinationWindow) - destinationManager.focusTab( - destinationWorkspaceId, - surfaceId: destinationPanelId, - suppressFlash: true - ) + private func commandPaletteWindowForShortcutEvent(_ event: NSEvent) -> NSWindow? { + if let scopedWindow = mainWindowForShortcutEvent(event) { + return scopedWindow } + return activeCommandPaletteWindow() + } - DispatchQueue.main.asyncAfter(deadline: .now() + 0.05, execute: reassert) - DispatchQueue.main.asyncAfter(deadline: .now() + 0.16, execute: reassert) + private func contextForMainWindow(_ window: NSWindow?) -> MainWindowContext? { + guard let window, isMainTerminalWindow(window) else { return nil } + return mainWindowContexts[ObjectIdentifier(window)] } - private func windowForMainWindowId(_ windowId: UUID) -> NSWindow? { - if let ctx = mainWindowContexts.values.first(where: { $0.windowId == windowId }), - let window = ctx.window { - return window - } - let expectedIdentifier = "cmux.main.\(windowId.uuidString)" - return NSApp.windows.first(where: { $0.identifier?.rawValue == expectedIdentifier }) - } - - private func resolvedWindow(for context: MainWindowContext) -> NSWindow? { - if let window = context.window { - return window - } - guard let window = windowForMainWindowId(context.windowId) else { - return nil - } - reindexMainWindowContextIfNeeded(context, for: window) - return window - } - - private func mainWindowId(from window: NSWindow) -> UUID? { - guard let raw = window.identifier?.rawValue else { return nil } - let prefix = "cmux.main." - guard raw.hasPrefix(prefix) else { return nil } - let suffix = String(raw.dropFirst(prefix.count)) - return UUID(uuidString: suffix) - } - - private func reindexMainWindowContextIfNeeded(_ context: MainWindowContext, for window: NSWindow) { - let desiredKey = ObjectIdentifier(window) - if mainWindowContexts[desiredKey] === context { - context.window = window - return - } - - let contextKeys = mainWindowContexts.compactMap { key, value in - value === context ? key : nil - } - for key in contextKeys { - mainWindowContexts.removeValue(forKey: key) - } - - if let conflicting = mainWindowContexts[desiredKey], conflicting !== context { - context.window = window - return - } - - mainWindowContexts[desiredKey] = context - context.window = window - notifyMainWindowContextsDidChange() - } - - private func contextForMainTerminalWindow(_ window: NSWindow, reindex: Bool = true) -> MainWindowContext? { - guard isMainTerminalWindow(window) else { return nil } - - if let context = mainWindowContexts[ObjectIdentifier(window)] { - context.window = window - return context - } - - if let windowId = mainWindowId(from: window), - let context = mainWindowContexts.values.first(where: { $0.windowId == windowId }) { - if reindex { - reindexMainWindowContextIfNeeded(context, for: window) - } else { - context.window = window - } - return context - } - - let windowNumber = window.windowNumber - if windowNumber >= 0, - let context = mainWindowContexts.values.first(where: { candidate in - let candidateWindow = candidate.window ?? windowForMainWindowId(candidate.windowId) - return candidateWindow?.windowNumber == windowNumber - }) { - if reindex { - reindexMainWindowContextIfNeeded(context, for: window) - } else { - context.window = window - } - return context - } - - return nil - } - - private func unregisterMainWindowContext(for window: NSWindow) -> MainWindowContext? { - guard let removed = contextForMainTerminalWindow(window, reindex: false) else { return nil } - let removedKeys = mainWindowContexts.compactMap { key, value in - value === removed ? key : nil - } - for key in removedKeys { - mainWindowContexts.removeValue(forKey: key) - } - notifyMainWindowContextsDidChange() - return removed - } - - private func discardOrphanedMainWindowContext(_ context: MainWindowContext) { - let contextKeys = mainWindowContexts.compactMap { key, value in - value === context ? key : nil - } - for key in contextKeys { - mainWindowContexts.removeValue(forKey: key) - } - notifyMainWindowContextsDidChange() - - commandPaletteVisibilityByWindowId.removeValue(forKey: context.windowId) - commandPalettePendingOpenByWindowId.removeValue(forKey: context.windowId) - commandPaletteRecentRequestAtByWindowId.removeValue(forKey: context.windowId) - commandPaletteEscapeSuppressionByWindowId.remove(context.windowId) - commandPaletteEscapeSuppressionStartedAtByWindowId.removeValue(forKey: context.windowId) - commandPaletteSelectionByWindowId.removeValue(forKey: context.windowId) - commandPaletteSnapshotByWindowId.removeValue(forKey: context.windowId) - - if tabManager === context.tabManager { - if let nextContext = mainWindowContexts.values.first(where: { resolvedWindow(for: $0) != nil }) { - tabManager = nextContext.tabManager - sidebarState = nextContext.sidebarState - sidebarSelectionState = nextContext.sidebarSelectionState - TerminalController.shared.setActiveTabManager(nextContext.tabManager) - } else { - tabManager = nil - sidebarState = nil - sidebarSelectionState = nil - TerminalController.shared.setActiveTabManager(nil) - } - } - - if let store = notificationStore { - for tab in context.tabManager.tabs { - store.clearNotifications(forTabId: tab.id) - } - } - } - - private func mainWindowId(for window: NSWindow) -> UUID? { - if let context = mainWindowContexts[ObjectIdentifier(window)] { - return context.windowId - } - guard let rawIdentifier = window.identifier?.rawValue, - rawIdentifier.hasPrefix("cmux.main.") else { return nil } - let idPart = String(rawIdentifier.dropFirst("cmux.main.".count)) - return UUID(uuidString: idPart) - } - - private func commandPaletteOverlayContainer(in window: NSWindow) -> NSView? { - guard let searchRoot = window.contentView?.superview ?? window.contentView else { return nil } - var stack: [NSView] = [searchRoot] - while let candidate = stack.popLast() { - if candidate.identifier == commandPaletteOverlayContainerIdentifier { - return candidate - } - stack.append(contentsOf: candidate.subviews) - } - return nil - } - - private func isCommandPaletteOverlayPresented(in window: NSWindow) -> Bool { - guard let container = commandPaletteOverlayContainer(in: window) else { return false } - return !container.isHidden && container.alphaValue > 0.001 - } - - private func isCommandPaletteResponderActive(in window: NSWindow) -> Bool { - guard let responder = window.firstResponder else { return false } - if let textView = responder as? NSTextView, - textView.isFieldEditor, - !(textView.delegate is NSView) { - // Field-editor delegates can be non-view responders. Confirm the overlay is - // mounted and visible to avoid treating unrelated editors as palette input. - return isCommandPaletteOverlayPresented(in: window) - } - return isCommandPaletteResponder(responder) - } - - private func isCommandPaletteMultilineTextResponderActive(in window: NSWindow) -> Bool { - guard let textView = window.firstResponder as? NSTextView, - !textView.isFieldEditor else { - return false - } - return isCommandPaletteResponder(textView) - } - - private func commandPaletteMarkedTextInput(in window: NSWindow) -> NSTextView? { - if let textView = window.firstResponder as? NSTextView, - isCommandPaletteResponder(textView), - textView.hasMarkedText() { - return textView - } - - if let textField = window.firstResponder as? NSTextField, - let editor = textField.currentEditor() as? NSTextView, - isCommandPaletteResponder(editor), - editor.hasMarkedText() { - return editor - } - - return nil - } - - private func isCommandPaletteEffectivelyVisible(in window: NSWindow) -> Bool { - isCommandPaletteVisible(for: window) - || isCommandPalettePendingOpen(for: window) - || isCommandPaletteOverlayPresented(in: window) - || isCommandPaletteResponderActive(in: window) - } - - private func activeCommandPaletteWindow() -> NSWindow? { - pruneExpiredCommandPalettePendingOpenStates() - if let keyWindow = NSApp.keyWindow, - isMainTerminalWindow(keyWindow), - isCommandPaletteEffectivelyVisible(in: keyWindow) { - return keyWindow - } - if let mainWindow = NSApp.mainWindow, - isMainTerminalWindow(mainWindow), - isCommandPaletteEffectivelyVisible(in: mainWindow) { - return mainWindow - } - if let orderedWindow = NSApp.orderedWindows.first(where: { window in - isMainTerminalWindow(window) && isCommandPaletteEffectivelyVisible(in: window) - }) { - return orderedWindow - } - if let visibleWindowId = commandPaletteVisibilityByWindowId.first(where: { $0.value })?.key { - return windowForMainWindowId(visibleWindowId) - } - if let pendingWindowId = commandPalettePendingOpenByWindowId.first(where: { $0.value })?.key { - return windowForMainWindowId(pendingWindowId) - } - return nil - } - - private func commandPaletteWindowForShortcutEvent(_ event: NSEvent) -> NSWindow? { - if let scopedWindow = mainWindowForShortcutEvent(event) { - return scopedWindow - } - return activeCommandPaletteWindow() - } - - private func contextForMainWindow(_ window: NSWindow?) -> MainWindowContext? { - guard let window, isMainTerminalWindow(window) else { return nil } - return mainWindowContexts[ObjectIdentifier(window)] - } - -#if DEBUG - func debugManagerToken(_ manager: TabManager?) -> String { - guard let manager else { return "nil" } - return String(describing: Unmanaged.passUnretained(manager).toOpaque()) +#if DEBUG + func debugManagerToken(_ manager: TabManager?) -> String { + guard let manager else { return "nil" } + return String(describing: Unmanaged.passUnretained(manager).toOpaque()) } private func debugWindowToken(_ window: NSWindow?) -> String { @@ -6150,5722 +4576,4343 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent setActiveMainWindow(window) if shouldBringToFront { bringToFront(window) - } - - let workspace: Workspace - if let workingDirectory { - workspace = context.tabManager.addWorkspace(workingDirectory: workingDirectory, select: true) - } else { - workspace = context.tabManager.addTab(select: true) - } - #if DEBUG - logWorkspaceCreationRouting( - phase: "created", - source: debugSource, - reason: "workspace_created", - event: event, - chosenContext: context, - workspaceId: workspace.id, - workingDirectory: workingDirectory - ) - #endif - return workspace.id - } - - private func preferredMainWindowContextForWorkspaceCreation( - event: NSEvent? = nil, - debugSource: String = "unspecified" - ) -> MainWindowContext? { - if let context = mainWindowContext(forShortcutEvent: event, debugSource: debugSource) { - return context - } - - // If a keyboard event identifies a specific window but that context - // can't be resolved, do not fall back to another window. - if shortcutEventHasAddressableWindow(event) { -#if DEBUG - logWorkspaceCreationRouting( - phase: "choose", - source: debugSource, - reason: "event_context_required_no_fallback", - event: event, - chosenContext: nil - ) -#endif - return nil - } - - if let keyWindow = NSApp.keyWindow, - let context = contextForMainTerminalWindow(keyWindow) { -#if DEBUG - logWorkspaceCreationRouting( - phase: "choose", - source: debugSource, - reason: "key_window", - event: event, - chosenContext: context - ) - #endif - return context - } - - if let mainWindow = NSApp.mainWindow, - let context = contextForMainTerminalWindow(mainWindow) { - #if DEBUG - logWorkspaceCreationRouting( - phase: "choose", - source: debugSource, - reason: "main_window", - event: event, - chosenContext: context - ) - #endif - return context - } - - for window in NSApp.orderedWindows where isMainTerminalWindow(window) { - if let context = contextForMainTerminalWindow(window) { - #if DEBUG - logWorkspaceCreationRouting( - phase: "choose", - source: debugSource, - reason: "ordered_windows", - event: event, - chosenContext: context - ) - #endif - return context - } - } - - let fallback = mainWindowContexts.values.first(where: { resolvedWindow(for: $0) != nil }) - #if DEBUG - logWorkspaceCreationRouting( - phase: "choose", - source: debugSource, - reason: "fallback_first_context", - event: event, - chosenContext: fallback - ) -#endif - return fallback - } - - private func shortcutEventHasAddressableWindow(_ event: NSEvent?) -> Bool { - guard let event else { return false } - // NSEvent.windowNumber can be 0 for responder-chain events that are not - // actually bound to an NSWindow (notably some WebKit key paths). - return event.window != nil || event.windowNumber > 0 - } - - private func mainWindowContext( - forShortcutEvent event: NSEvent?, - debugSource: String = "unspecified" - ) -> MainWindowContext? { - guard let event else { return nil } - - if let eventWindow = event.window, - let context = contextForMainTerminalWindow(eventWindow) { - #if DEBUG - logWorkspaceCreationRouting( - phase: "choose", - source: debugSource, - reason: "event_window", - event: event, - chosenContext: context - ) - #endif - return context - } - - if event.windowNumber > 0, - let numberedWindow = NSApp.window(withWindowNumber: event.windowNumber), - let context = contextForMainTerminalWindow(numberedWindow) { - #if DEBUG - logWorkspaceCreationRouting( - phase: "choose", - source: debugSource, - reason: "event_window_number", - event: event, - chosenContext: context - ) - #endif - return context - } - - if event.windowNumber > 0, - let context = mainWindowContexts.values.first(where: { candidate in - let window = candidate.window ?? windowForMainWindowId(candidate.windowId) - return window?.windowNumber == event.windowNumber - }) { - #if DEBUG - logWorkspaceCreationRouting( - phase: "choose", - source: debugSource, - reason: "event_window_number_scan", - event: event, - chosenContext: context - ) - #endif - return context - } - - #if DEBUG - logWorkspaceCreationRouting( - phase: "choose", - source: debugSource, - reason: "event_context_not_found", - event: event, - chosenContext: nil - ) - #endif - return nil - } - - private func preferredMainWindowContextForShortcutRouting(event: NSEvent) -> MainWindowContext? { - if let context = mainWindowContext(forShortcutEvent: event, debugSource: "shortcut.routing") { - return context - } - - if shortcutEventHasAddressableWindow(event) { - if let eventWindow = resolvedShortcutEventWindow(event), - programaWindowShouldOwnCloseShortcut(eventWindow) { - // Auxiliary cmux windows do not own a terminal tab manager. Let them fall back - // to the active main terminal window so app shortcuts like Cmd+W still route. - } else { -#if DEBUG - logWorkspaceCreationRouting( - phase: "choose", - source: "shortcut.routing", - reason: "event_context_required_no_fallback", - event: event, - chosenContext: nil - ) -#endif - return nil - } - } - - if let keyWindow = NSApp.keyWindow, - let context = contextForMainTerminalWindow(keyWindow) { - return context - } - - if let mainWindow = NSApp.mainWindow, - let context = contextForMainTerminalWindow(mainWindow) { - return context - } - - if let activeManager = tabManager, - let context = mainWindowContexts.values.first(where: { $0.tabManager === activeManager }) { - return context - } - - return mainWindowContexts.values.first - } - - @discardableResult - private func synchronizeShortcutRoutingContext(event: NSEvent) -> Bool { - guard let context = preferredMainWindowContextForShortcutRouting(event: event) else { -#if DEBUG - FocusLogStore.shared.append( - "shortcut.route reason=no_context_no_fallback eventWin=\(event.windowNumber) keyCode=\(event.keyCode)" - ) -#endif - return false - } - - let alreadyActive = - tabManager === context.tabManager - && sidebarState === context.sidebarState - && sidebarSelectionState === context.sidebarSelectionState - if alreadyActive { return true } - - if let window = context.window ?? windowForMainWindowId(context.windowId) { - setActiveMainWindow(window) - } else { - tabManager = context.tabManager - sidebarState = context.sidebarState - sidebarSelectionState = context.sidebarSelectionState - TerminalController.shared.setActiveTabManager(context.tabManager) - } - -#if DEBUG - FocusLogStore.shared.append( - "shortcut.route reason=sync activeTM=\(pointerString(tabManager)) chosen={\(summarizeContextForWorkspaceRouting(context))}" - ) -#endif - return true - } - - @discardableResult - func createMainWindow( - initialWorkingDirectory: String? = nil, - sessionWindowSnapshot: SessionWindowSnapshot? = nil - ) -> UUID { - let windowId = UUID() - let tabManager = TabManager(initialWorkingDirectory: initialWorkingDirectory) - if let tabManagerSnapshot = sessionWindowSnapshot?.tabManager { - tabManager.restoreSessionSnapshot(tabManagerSnapshot) - } - - let sidebarWidth = sessionWindowSnapshot?.sidebar.width - .map(SessionPersistencePolicy.sanitizedSidebarWidth) - ?? SessionPersistencePolicy.defaultSidebarWidth - let sidebarState = SidebarState( - isVisible: sessionWindowSnapshot?.sidebar.isVisible ?? true, - persistedWidth: CGFloat(sidebarWidth) - ) - let sidebarSelectionState = SidebarSelectionState( - selection: sessionWindowSnapshot?.sidebar.selection.sidebarSelection ?? .tabs - ) - let notificationStore = TerminalNotificationStore.shared - - let programaConfigStore = ProgramaConfigStore() - programaConfigStore.wireDirectoryTracking(tabManager: tabManager) - programaConfigStore.loadAll() - - let root = ContentView(updateViewModel: updateViewModel, windowId: windowId) - .environmentObject(tabManager) - .environmentObject(notificationStore) - .environmentObject(sidebarState) - .environmentObject(sidebarSelectionState) - .environmentObject(programaConfigStore) - - // Use the current key window's size for new windows so Cmd+Shift+N - // creates a window matching the previous one's dimensions. - let styleMask: NSWindow.StyleMask = [.titled, .closable, .miniaturizable, .resizable, .fullSizeContentView] - let sourceContext = preferredMainWindowContextForWorkspaceCreation( - debugSource: "createMainWindow.initialGeometry" - ) - let sourceWindow = sourceContext.flatMap { resolvedWindow(for: $0) } - let existingFrame = sourceWindow?.frame - let sourceWindowIsNativeFullScreen: Bool = { -#if DEBUG - if let debugCreateMainWindowSourceIsNativeFullScreenOverride { - return debugCreateMainWindowSourceIsNativeFullScreenOverride - } -#endif - return sourceWindow?.styleMask.contains(.fullScreen) == true - }() - let shouldTemporarilyDisallowFullScreenTiling = - sessionWindowSnapshot == nil && sourceWindowIsNativeFullScreen - let initialRect: NSRect - if sessionWindowSnapshot == nil, let existingFrame { - // Convert frame rect to content rect so the new window matches the - // source window's actual size (frame includes titlebar insets). - initialRect = NSWindow.contentRect(forFrameRect: existingFrame, styleMask: styleMask) - } else { - initialRect = NSRect(x: 0, y: 0, width: 460, height: 360) - } - - let window = NSWindow( - contentRect: initialRect, - styleMask: styleMask, - backing: .buffered, - defer: false - ) - // When creating a new window from an existing native fullscreen window, - // temporarily opt out of fullscreen tiling so AppKit doesn't place the - // new window into the active fullscreen Space. - if shouldTemporarilyDisallowFullScreenTiling { - window.collectionBehavior.insert(.fullScreenDisallowsTiling) - } - window.title = "" - window.titleVisibility = .hidden - window.titlebarAppearsTransparent = true - window.isMovableByWindowBackground = false - window.isMovable = false - let restoredFrame = resolvedWindowFrame(from: sessionWindowSnapshot) - if let restoredFrame { - window.setFrame(restoredFrame, display: false) - } else { - window.center() - // Cascade using the same algorithm as upstream Ghostty: seed from - // the window's own top-left on the first call, then advance the - // cascade point for each subsequent window. - if mainWindowContexts.count >= 1 { - lastCascadePoint = window.cascadeTopLeft(from: lastCascadePoint) - } else { - lastCascadePoint = window.cascadeTopLeft(from: NSPoint(x: window.frame.minX, y: window.frame.maxY)) - } - } - window.contentView = MainWindowHostingView(rootView: root) - - // Apply shared window styling. - attachUpdateAccessory(to: window) - applyWindowDecorations(to: window) - - // Keep a strong reference so the window isn't deallocated. - let controller = MainWindowController(window: window) - controller.onClose = { [weak self, weak controller] in - guard let self, let controller else { return } - self.mainWindowControllers.removeAll(where: { $0 === controller }) - } - window.delegate = controller - mainWindowControllers.append(controller) - - registerMainWindow( - window, - windowId: windowId, - tabManager: tabManager, - sidebarState: sidebarState, - sidebarSelectionState: sidebarSelectionState - ) - installFileDropOverlay(on: window, tabManager: tabManager) - if TerminalController.shouldSuppressSocketCommandActivation() { - window.orderFront(nil) - if TerminalController.socketCommandAllowsInAppFocusMutations() { - setActiveMainWindow(window) - } - } else { - window.makeKeyAndOrderFront(nil) - setActiveMainWindow(window) - NSApp.activate(ignoringOtherApps: true) - } - if shouldTemporarilyDisallowFullScreenTiling { - DispatchQueue.main.async { [weak window] in - window?.collectionBehavior.remove(.fullScreenDisallowsTiling) - } - } - if let restoredFrame { - window.setFrame(restoredFrame, display: true) -#if DEBUG - dlog( - "session.restore.frameApplied window=\(windowId.uuidString.prefix(8)) " + - "applied={\(debugNSRectDescription(window.frame))}" - ) -#endif - } - return windowId - } - - @objc func checkForUpdates(_ sender: Any?) { - updateViewModel.overrideState = nil - updateController.checkForUpdates() - } - - func checkForUpdatesInCustomUI() { - updateViewModel.overrideState = nil - updateController.checkForUpdatesInCustomUI() - } - - func openWelcomeWorkspace() { - guard let context = preferredMainWindowContextForWorkspaceCreation(event: nil, debugSource: "welcome") else { - return - } - if let window = context.window ?? windowForMainWindowId(context.windowId) { - setActiveMainWindow(window) - bringToFront(window) - } - let workspace = context.tabManager.addWorkspace(select: true, autoWelcomeIfNeeded: false) - sendWelcomeCommandWhenReady(to: workspace) - } - - func sendWelcomeCommandWhenReady(to workspace: Workspace, markShownOnSend: Bool = false) { - sendTextWhenReady("programa welcome\n", to: workspace) { - if markShownOnSend { - UserDefaults.standard.set(true, forKey: WelcomeSettings.shownKey) - } - } - } - - @objc func applyUpdateIfAvailable(_ sender: Any?) { - updateViewModel.overrideState = nil - updateController.installUpdate() - } - - @objc func attemptUpdate(_ sender: Any?) { - updateViewModel.overrideState = nil - updateController.attemptUpdate() - } - - func isProgramaCLIInstalledInPATH() -> Bool { - ProgramaCLIPathInstaller().isInstalled() - } - - @objc func installProgramaCLIInPath(_ sender: Any?) { - let installer = ProgramaCLIPathInstaller() - do { - let outcome = try installer.install() - var informativeText = String(localized: "cli.install.symlinkCreated", defaultValue: "Created symlink:\n\n\(outcome.destinationURL.path) -> \(outcome.sourceURL.path)") - if outcome.usedAdministratorPrivileges { - informativeText += "\n\n" + String(localized: "cli.install.adminRequired", defaultValue: "Administrator privileges were required to write to /usr/local/bin.") - } - presentCLIPathAlert( - title: String(localized: "cli.installed", defaultValue: "Programa CLI Installed"), - informativeText: informativeText, - style: .informational - ) - } catch { - presentCLIPathAlert( - title: String(localized: "cli.installFailed", defaultValue: "Couldn't Install Programa CLI"), - informativeText: error.localizedDescription, - style: .warning - ) - } - } - - @objc func uninstallProgramaCLIInPath(_ sender: Any?) { - let installer = ProgramaCLIPathInstaller() - do { - let outcome = try installer.uninstall() - let prefix = outcome.removedExistingEntry - ? String(localized: "cli.uninstall.removed", defaultValue: "Removed \(outcome.destinationURL.path).") - : String(localized: "cli.uninstall.notFound", defaultValue: "No Programa CLI symlink was found at \(outcome.destinationURL.path).") - var informativeText = prefix - if outcome.usedAdministratorPrivileges { - informativeText += "\n\n" + String(localized: "cli.uninstall.adminRequired", defaultValue: "Administrator privileges were required to modify /usr/local/bin.") - } - presentCLIPathAlert( - title: String(localized: "cli.uninstalled", defaultValue: "Programa CLI Uninstalled"), - informativeText: informativeText, - style: .informational - ) - } catch { - presentCLIPathAlert( - title: String(localized: "cli.uninstallFailed", defaultValue: "Couldn't Uninstall Programa CLI"), - informativeText: error.localizedDescription, - style: .warning - ) - } - } - - private func presentCLIPathAlert( - title: String, - informativeText: String, - style: NSAlert.Style - ) { - let alert = NSAlert() - alert.alertStyle = style - alert.messageText = title - alert.informativeText = informativeText - alert.addButton(withTitle: String(localized: "common.ok", defaultValue: "OK")) - - if let window = NSApp.keyWindow ?? NSApp.mainWindow { - alert.beginSheetModal(for: window, completionHandler: nil) - } else { - _ = alert.runModal() - } - } - - @objc func restartSocketListener(_ sender: Any?) { - guard tabManager != nil else { - NSSound.beep() - return - } - - guard socketListenerConfigurationIfEnabled() != nil else { - TerminalController.shared.stop() - NSSound.beep() - return - } - restartSocketListenerIfEnabled(source: "menu.command") - } - - private func setupMenuBarExtra() { - guard menuBarExtraController == nil else { return } - let store = TerminalNotificationStore.shared - menuBarExtraController = MenuBarExtraController( - notificationStore: store, - onShowNotifications: { [weak self] in - self?.showNotificationsPopoverFromMenuBar() - }, - onOpenNotification: { [weak self] notification in - _ = self?.openNotification( - tabId: notification.tabId, - surfaceId: notification.surfaceId, - notificationId: notification.id - ) - }, - onJumpToLatestUnread: { [weak self] in - self?.jumpToLatestUnread() - }, - onCheckForUpdates: { [weak self] in - self?.checkForUpdates(nil) - }, - onOpenPreferences: { [weak self] in - self?.openPreferencesWindow(debugSource: "menuBarExtra") - }, - onQuitApp: { - NSApp.terminate(nil) - } - ) - } - - private func installMenuBarVisibilityObserver() { - guard menuBarVisibilityObserver == nil else { return } - menuBarVisibilityObserver = NotificationCenter.default.addObserver( - forName: UserDefaults.didChangeNotification, - object: nil, - queue: .main - ) { [weak self] _ in - Task { @MainActor [weak self] in - self?.syncMenuBarExtraVisibility() - } - } - } - - private func syncMenuBarExtraVisibility(defaults: UserDefaults = .standard) { - if MenuBarExtraSettings.showsMenuBarExtra(defaults: defaults) { - setupMenuBarExtra() - return - } - - menuBarExtraController?.removeFromMenuBar() - menuBarExtraController = nil - } - - @MainActor - static func presentPreferencesWindow( - navigationTarget: SettingsNavigationTarget? = nil, - showFallbackSettingsWindow: @MainActor (SettingsNavigationTarget?) -> Void = { target in - SettingsWindowController.shared.show(navigationTarget: target) - }, - activateApplication: @MainActor () -> Void = { - NSRunningApplication.current.activate(options: [.activateAllWindows, .activateIgnoringOtherApps]) - } - ) { -#if DEBUG - dlog("settings.open.present path=customWindowDirect") -#endif - showFallbackSettingsWindow(navigationTarget) - activateApplication() - if let window = SettingsWindowController.shared.window { - window.orderFrontRegardless() - window.makeKeyAndOrderFront(nil) - DispatchQueue.main.async { - window.orderFrontRegardless() - window.makeKeyAndOrderFront(nil) - } - } -#if DEBUG - dlog("settings.open.present activate=1") -#endif - } - - @MainActor - func openPreferencesWindow(debugSource: String, navigationTarget: SettingsNavigationTarget? = nil) { -#if DEBUG - dlog("settings.open.request source=\(debugSource)") -#endif - Self.presentPreferencesWindow(navigationTarget: navigationTarget) - } - - @objc func openPreferencesWindow() { - openPreferencesWindow(debugSource: "appDelegate") - } - - func refreshMenuBarExtraForDebug() { - menuBarExtraController?.refreshForDebugControls() - } - - func showNotificationsPopoverFromMenuBar() { - let context: MainWindowContext? = { - if let keyWindow = NSApp.keyWindow, - let keyContext = contextForMainTerminalWindow(keyWindow) { - return keyContext - } - if let first = mainWindowContexts.values.first { - return first - } - let windowId = createMainWindow() - return mainWindowContexts.values.first(where: { $0.windowId == windowId }) - }() - - if let context, - let window = context.window ?? windowForMainWindowId(context.windowId) { - setActiveMainWindow(window) - bringToFront(window) - } - - DispatchQueue.main.asyncAfter(deadline: .now() + 0.05) { [weak self] in - self?.titlebarAccessoryController.showNotificationsPopover(animated: false) - } - } - - #if DEBUG - @objc func showUpdatePill(_ sender: Any?) { - updateViewModel.debugOverrideText = nil - updateViewModel.overrideState = .installing(.init(isAutoUpdate: true, retryTerminatingApplication: {}, dismiss: {})) - } - - @objc func showUpdatePillLongNightly(_ sender: Any?) { - updateViewModel.debugOverrideText = "Update Available: 0.32.0-nightly+20260216.abc1234" - updateViewModel.overrideState = .notFound(.init(acknowledgement: {})) - } - - @objc func showUpdatePillLoading(_ sender: Any?) { - updateViewModel.debugOverrideText = nil - updateViewModel.overrideState = .checking(.init(cancel: {})) - } - - @objc func hideUpdatePill(_ sender: Any?) { - updateViewModel.debugOverrideText = nil - updateViewModel.overrideState = .idle - } - - @objc func clearUpdatePillOverride(_ sender: Any?) { - updateViewModel.debugOverrideText = nil - updateViewModel.overrideState = nil - } -#endif - - @objc func copyUpdateLogs(_ sender: Any?) { - let logText = UpdateLogStore.shared.snapshot() - let payload: String - if logText.isEmpty { - payload = "No update logs captured.\nLog file: \(UpdateLogStore.shared.logPath())" - } else { - payload = logText + "\nLog file: \(UpdateLogStore.shared.logPath())" - } - let pasteboard = NSPasteboard.general - pasteboard.clearContents() - pasteboard.setString(payload, forType: .string) - } - @objc func copyFocusLogs(_ sender: Any?) { - let logText = FocusLogStore.shared.snapshot() - let payload: String - if logText.isEmpty { - payload = "No focus logs captured.\nLog file: \(FocusLogStore.shared.logPath())" - } else { - payload = logText + "\nLog file: \(FocusLogStore.shared.logPath())" - } - let pasteboard = NSPasteboard.general - pasteboard.clearContents() - pasteboard.setString(payload, forType: .string) - } - - @objc private func handleReactGrabDidCopySelection(_ notification: Notification) { - let browserPanelId = notification.userInfo?[ReactGrabPastebackNotificationKey.browserPanelId] as? UUID - guard let workspaceId = notification.userInfo?[ReactGrabPastebackNotificationKey.workspaceId] as? UUID, - let returnPanelId = notification.userInfo?[ReactGrabPastebackNotificationKey.returnPanelId] as? UUID, - let content = notification.userInfo?[ReactGrabPastebackNotificationKey.content] as? String else { -#if DEBUG - dlog( - "reactGrab.pasteback h3.didCopy.drop " + - "reason=missingNotificationFields " + - "workspace=\(Self.debugShortId(notification.userInfo?[ReactGrabPastebackNotificationKey.workspaceId] as? UUID)) " + - "browser=\(Self.debugShortId(browserPanelId)) " + - "return=\(Self.debugShortId(notification.userInfo?[ReactGrabPastebackNotificationKey.returnPanelId] as? UUID)) " + - "hasContent=\((notification.userInfo?[ReactGrabPastebackNotificationKey.content] as? String) != nil ? 1 : 0)" - ) -#endif - return - } - - guard let manager = tabManagerFor(tabId: workspaceId), - let workspace = manager.tabs.first(where: { $0.id == workspaceId }) else { -#if DEBUG - dlog( - "reactGrab.pasteback h3.didCopy.drop " + - "reason=missingWorkspace workspace=\(Self.debugShortId(workspaceId)) " + - "browser=\(Self.debugShortId(browserPanelId)) return=\(Self.debugShortId(returnPanelId))" - ) -#endif - return - } - - guard workspace.terminalPanel(for: returnPanelId) != nil else { -#if DEBUG - dlog( - "reactGrab.pasteback h3.didCopy.drop " + - "reason=missingReturnTerminal workspace=\(Self.debugShortId(workspaceId)) " + - "browser=\(Self.debugShortId(browserPanelId)) return=\(Self.debugShortId(returnPanelId)) " + - "focused=\(Self.debugShortId(workspace.focusedPanelId))" - ) -#endif - return - } - -#if DEBUG - dlog( - "reactGrab.pasteback h3.didCopy " + - "workspace=\(Self.debugShortId(workspaceId)) " + - "browser=\(Self.debugShortId(browserPanelId)) " + - "return=\(Self.debugShortId(returnPanelId)) " + - "focusedBefore=\(Self.debugShortId(workspace.focusedPanelId)) len=\(content.count)" - ) -#endif - manager.focusTab(workspaceId, surfaceId: returnPanelId, suppressFlash: true) -#if DEBUG - dlog( - "reactGrab.pasteback h1.focusRequested " + - "workspace=\(Self.debugShortId(workspaceId)) " + - "return=\(Self.debugShortId(returnPanelId)) " + - "focusedAfterRequest=\(Self.debugShortId(workspace.focusedPanelId))" - ) -#endif - sendTextWhenReady(content, to: workspace, preferredPanelId: returnPanelId) - } - - nonisolated private static func debugShortId(_ id: UUID?) -> String { - id.map { String($0.uuidString.prefix(5)) } ?? "nil" - } - - static func resolveTerminalPanelForTextSend(in tab: Tab, preferredPanelId: UUID? = nil) -> TerminalPanel? { - if let preferredPanelId { - return tab.terminalPanel(for: preferredPanelId) - } - return tab.focusedTerminalPanel - } - - func sendTextWhenReady( - _ text: String, - to tab: Tab, - preferredPanelId: UUID? = nil, - beforeSend: (() -> Void)? = nil - ) { - let isReactGrabPasteback = preferredPanelId != nil -#if DEBUG - let initialTargetPanel = Self.resolveTerminalPanelForTextSend( - in: tab, - preferredPanelId: preferredPanelId - ) - if isReactGrabPasteback { - dlog( - "reactGrab.pasteback h2.send.start " + - "workspace=\(Self.debugShortId(tab.id)) " + - "preferred=\(Self.debugShortId(preferredPanelId)) " + - "focused=\(Self.debugShortId(tab.focusedPanelId)) " + - "focusedTerminal=\(Self.debugShortId(tab.focusedTerminalPanel?.id)) " + - "resolved=\(Self.debugShortId(initialTargetPanel?.id)) " + - "surfaceReady=\(initialTargetPanel?.surface.surface != nil ? 1 : 0) len=\(text.count)" - ) - } -#endif - if let terminalPanel = Self.resolveTerminalPanelForTextSend( - in: tab, - preferredPanelId: preferredPanelId - ), - terminalPanel.surface.surface != nil { -#if DEBUG - if isReactGrabPasteback { - dlog( - "reactGrab.pasteback h2.send.immediate " + - "workspace=\(Self.debugShortId(tab.id)) " + - "target=\(Self.debugShortId(terminalPanel.id)) len=\(text.count)" - ) - } -#endif - beforeSend?() - terminalPanel.sendText(text) -#if DEBUG - if isReactGrabPasteback { - dlog( - "reactGrab.pasteback h2.send.sent " + - "workspace=\(Self.debugShortId(tab.id)) " + - "target=\(Self.debugShortId(terminalPanel.id)) mode=immediate len=\(text.count)" - ) - } -#endif - return - } - - var resolved = false - var readyObserver: NSObjectProtocol? - var focusObserver: NSObjectProtocol? - var firstResponderObserver: NSObjectProtocol? - var panelsCancellable: AnyCancellable? - - func cleanupObservers() { - if let readyObserver { - NotificationCenter.default.removeObserver(readyObserver) - } - if let focusObserver { - NotificationCenter.default.removeObserver(focusObserver) - } - if let firstResponderObserver { - NotificationCenter.default.removeObserver(firstResponderObserver) - } - panelsCancellable?.cancel() - } - - func finishIfReady() { - let terminalPanel = Self.resolveTerminalPanelForTextSend( - in: tab, - preferredPanelId: preferredPanelId - ) -#if DEBUG - if isReactGrabPasteback { - dlog( - "reactGrab.pasteback h2.finishIfReady " + - "workspace=\(Self.debugShortId(tab.id)) " + - "preferred=\(Self.debugShortId(preferredPanelId)) " + - "focused=\(Self.debugShortId(tab.focusedPanelId)) " + - "resolved=\(Self.debugShortId(terminalPanel?.id)) " + - "surfaceReady=\(terminalPanel?.surface.surface != nil ? 1 : 0) alreadyResolved=\(resolved ? 1 : 0)" - ) - } -#endif - guard !resolved, - let terminalPanel, - terminalPanel.surface.surface != nil else { return } - resolved = true - cleanupObservers() - beforeSend?() - terminalPanel.sendText(text) -#if DEBUG - if isReactGrabPasteback { - dlog( - "reactGrab.pasteback h2.send.sent " + - "workspace=\(Self.debugShortId(tab.id)) " + - "target=\(Self.debugShortId(terminalPanel.id)) mode=delayed len=\(text.count)" - ) - } -#endif - } - - panelsCancellable = tab.$panels - .map { _ in () } - .sink { _ in -#if DEBUG - if isReactGrabPasteback { - dlog( - "reactGrab.pasteback h2.panelsChanged " + - "workspace=\(Self.debugShortId(tab.id)) " + - "focused=\(Self.debugShortId(tab.focusedPanelId))" - ) - } -#endif - finishIfReady() - } - if isReactGrabPasteback { - focusObserver = NotificationCenter.default.addObserver( - forName: .ghosttyDidFocusSurface, - object: nil, - queue: .main - ) { note in - guard let candidateTabId = note.userInfo?[GhosttyNotificationKey.tabId] as? UUID, - candidateTabId == tab.id, - let candidateSurfaceId = note.userInfo?[GhosttyNotificationKey.surfaceId] as? UUID else { - return - } -#if DEBUG - dlog( - "reactGrab.pasteback h1.focusEvent " + - "workspace=\(Self.debugShortId(candidateTabId)) " + - "surface=\(Self.debugShortId(candidateSurfaceId)) " + - "target=\(Self.debugShortId(preferredPanelId)) " + - "match=\(candidateSurfaceId == preferredPanelId ? 1 : 0)" - ) -#endif - } - firstResponderObserver = NotificationCenter.default.addObserver( - forName: .ghosttyDidBecomeFirstResponderSurface, - object: nil, - queue: .main - ) { note in - guard let candidateTabId = note.userInfo?[GhosttyNotificationKey.tabId] as? UUID, - candidateTabId == tab.id, - let candidateSurfaceId = note.userInfo?[GhosttyNotificationKey.surfaceId] as? UUID else { - return - } -#if DEBUG - dlog( - "reactGrab.pasteback h1.firstResponderEvent " + - "workspace=\(Self.debugShortId(candidateTabId)) " + - "surface=\(Self.debugShortId(candidateSurfaceId)) " + - "target=\(Self.debugShortId(preferredPanelId)) " + - "match=\(candidateSurfaceId == preferredPanelId ? 1 : 0)" - ) -#endif - } - } - readyObserver = NotificationCenter.default.addObserver( - forName: .terminalSurfaceDidBecomeReady, - object: nil, - queue: .main - ) { note in - guard let workspaceId = note.userInfo?["workspaceId"] as? UUID, - workspaceId == tab.id else { return } - let surfaceId = note.userInfo?["surfaceId"] as? UUID -#if DEBUG - if isReactGrabPasteback { - dlog( - "reactGrab.pasteback h2.surfaceReadyEvent " + - "workspace=\(Self.debugShortId(workspaceId)) " + - "surface=\(Self.debugShortId(surfaceId)) " + - "target=\(Self.debugShortId(preferredPanelId)) " + - "match=\(surfaceId == preferredPanelId ? 1 : 0)" - ) - } -#endif - if let preferredPanelId, - let surfaceId, - surfaceId != preferredPanelId { - return - } - finishIfReady() - } - DispatchQueue.main.asyncAfter(deadline: .now() + 3.0) { - if !resolved { -#if DEBUG - if isReactGrabPasteback { - dlog( - "reactGrab.pasteback h2.send.timeout " + - "workspace=\(Self.debugShortId(tab.id)) " + - "preferred=\(Self.debugShortId(preferredPanelId)) " + - "focused=\(Self.debugShortId(tab.focusedPanelId)) " + - "focusedTerminal=\(Self.debugShortId(tab.focusedTerminalPanel?.id))" - ) - } -#endif - cleanupObservers() - NSLog("Command send: surface not ready after 3.0s") - } - } - } - -#if DEBUG - private let debugColorWorkspaceTitlePrefix = "Debug Color - " - let debugPerfWorkspaceTitlePrefix = "Debug Perf - " - var debugStressWorkspaceCreationInProgress = false - var debugStressLagProbeEnabled = false - let debugStressWorkspaceCount = 20 - let debugStressPaneCount = 4 - let debugStressTabsPerPane = 4 - let debugStressYieldInterval = 4 - let debugStressSurfaceLoadTimeoutSeconds: TimeInterval = 10.0 - - @objc func openDebugScrollbackTab(_ sender: Any?) { - guard let tabManager else { return } - let tab = tabManager.addTab() - let config = GhosttyConfig.load() - let lineCount = min(max(config.scrollbackLimit * 2, 2000), 60000) - let command = "for i in {1..\(lineCount)}; do printf \"scrollback %06d\\n\" $i; done\n" - sendTextWhenReady(command, to: tab) - } - - @objc func openDebugLoremTab(_ sender: Any?) { - guard let tabManager else { return } - let tab = tabManager.addTab() - let lineCount = 2000 - let base = "Lorem ipsum dolor sit amet, consectetur adipiscing elit. Sed do eiusmod tempor incididunt ut labore." - var lines: [String] = [] - lines.reserveCapacity(lineCount) - for index in 1...lineCount { - lines.append(String(format: "%04d %@", index, base)) - } - let payload = lines.joined(separator: "\n") + "\n" - sendTextWhenReady(payload, to: tab) - } - - @objc func openDebugColorComparisonWorkspaces(_ sender: Any?) { - guard let tabManager else { return } - - let palette = WorkspaceTabColorSettings.palette() - guard !palette.isEmpty else { return } - - var existingByTitle: [String: Workspace] = [:] - for tab in tabManager.tabs { - guard let title = tab.customTitle, - title.hasPrefix(debugColorWorkspaceTitlePrefix) else { continue } - existingByTitle[title] = tab - } - - for entry in palette { - let title = "\(debugColorWorkspaceTitlePrefix)\(entry.name)" - let targetTab: Workspace - if let existing = existingByTitle[title] { - targetTab = existing - } else { - targetTab = tabManager.addTab() - } - tabManager.setCustomTitle(tabId: targetTab.id, title: title) - tabManager.setTabColor(tabId: targetTab.id, color: entry.hex) - } - } - - -#endif - - - func attachUpdateAccessory(to window: NSWindow) { - titlebarAccessoryController.start() - titlebarAccessoryController.attach(to: window) - } - - func applyWindowDecorations(to window: NSWindow) { - windowDecorationsController.apply(to: window) - } - - func toggleNotificationsPopover(animated: Bool = true, anchorView: NSView? = nil) { - titlebarAccessoryController.toggleNotificationsPopover(animated: animated, anchorView: anchorView) - } - - @discardableResult - func dismissNotificationsPopoverIfShown() -> Bool { - titlebarAccessoryController.dismissNotificationsPopoverIfShown() - } - - func isNotificationsPopoverShown() -> Bool { - titlebarAccessoryController.isNotificationsPopoverShown() - } - - func jumpToLatestUnread() { - guard let notificationStore else { return } -#if DEBUG - if ProcessInfo.processInfo.environment["PROGRAMA_UI_TEST_JUMP_UNREAD_SETUP"] == "1" { - writeJumpUnreadTestData([ - "jumpUnreadInvoked": "1", - "jumpUnreadNotificationCount": String(notificationStore.notifications.count), - ]) - } -#endif - // Prefer the latest unread that we can actually open. In early startup (especially on the VM), - // the window-context registry can lag behind model initialization, so fall back to whatever - // tab manager currently owns the tab. - for notification in notificationStore.notifications where !notification.isRead { - if openNotification(tabId: notification.tabId, surfaceId: notification.surfaceId, notificationId: notification.id) { - return - } - } - } - - static func installWindowResponderSwizzlesForTesting() { - _ = didInstallWindowKeyEquivalentSwizzle - _ = didInstallWindowFirstResponderSwizzle - _ = didInstallWindowSendEventSwizzle - } - -#if DEBUG - static func setWindowFirstResponderGuardTesting(currentEvent: NSEvent?, hitView: NSView?) { - programaFirstResponderGuardCurrentEventOverride = currentEvent - programaFirstResponderGuardHitViewOverride = hitView - } - - static func clearWindowFirstResponderGuardTesting() { - programaFirstResponderGuardCurrentEventOverride = nil - programaFirstResponderGuardHitViewOverride = nil - } -#endif - - private func installWindowResponderSwizzles() { - _ = Self.didInstallApplicationSendEventSwizzle - _ = Self.didInstallWindowKeyEquivalentSwizzle - _ = Self.didInstallWindowFirstResponderSwizzle - _ = Self.didInstallWindowSendEventSwizzle - } - - private func installShortcutMonitor() { - // Local monitor only receives events when app is active (not global) - shortcutMonitor = NSEvent.addLocalMonitorForEvents(matching: [.keyDown, .keyUp, .flagsChanged]) { [weak self] event in - guard let self else { return event } - if event.type == .keyDown { -#if DEBUG - let phaseTotalStart = ProcessInfo.processInfo.systemUptime - let preludeStart = ProcessInfo.processInfo.systemUptime - var preludeMs: Double = 0 - var shortcutMs: Double = 0 - ProgramaTypingTiming.logEventDelay(path: "appMonitor", event: event) - let shortcutMonitorTraceEnabled = - ProcessInfo.processInfo.environment["PROGRAMA_SHORTCUT_MONITOR_TRACE"] == "1" - || UserDefaults.standard.bool(forKey: "programaShortcutMonitorTrace") - if shortcutMonitorTraceEnabled { - let frType = NSApp.keyWindow?.firstResponder.map { String(describing: type(of: $0)) } ?? "nil" - dlog( - "monitor.keyDown: \(NSWindow.keyDescription(event)) fr=\(frType) addrBarId=\(self.browserAddressBarFocusedPanelId?.uuidString.prefix(8) ?? "nil") \(self.debugShortcutRouteSnapshot(event: event))" - ) - } - if let probeKind = self.developerToolsShortcutProbeKind(event: event) { - self.logDeveloperToolsShortcutSnapshot(phase: "monitor.pre.\(probeKind)", event: event) - } - preludeMs = (ProcessInfo.processInfo.systemUptime - preludeStart) * 1000.0 - let shortcutTimingStart = ProgramaTypingTiming.start() -#endif - let shortcutStart = ProcessInfo.processInfo.systemUptime - let handledByShortcut = self.handleCustomShortcut(event: event) -#if DEBUG - shortcutMs = (ProcessInfo.processInfo.systemUptime - shortcutStart) * 1000.0 - ProgramaTypingTiming.logDuration( - path: "appMonitor.handleCustomShortcut", - startedAt: shortcutTimingStart, - event: event, - extra: "handled=\(handledByShortcut ? 1 : 0)" - ) - let shortcutElapsedMs = (ProcessInfo.processInfo.systemUptime - shortcutStart) * 1000.0 - self.logSlowShortcutMonitorLatencyIfNeeded( - event: event, - handledByShortcut: handledByShortcut, - elapsedMs: shortcutElapsedMs - ) - let totalMs = (ProcessInfo.processInfo.systemUptime - phaseTotalStart) * 1000.0 - ProgramaTypingTiming.logBreakdown( - path: "appMonitor.phase", - totalMs: totalMs, - event: event, - thresholdMs: 0.75, - parts: [ - ("preludeMs", preludeMs), - ("shortcutMs", shortcutMs), - ], - extra: "handled=\(handledByShortcut ? 1 : 0)" - ) -#endif - if handledByShortcut { -#if DEBUG - dlog(" → consumed by handleCustomShortcut") -#endif - return nil // Consume the event - } - return event // Pass through - } - self.handleBrowserOmnibarSelectionRepeatLifecycleEvent(event) - if self.clearEscapeSuppressionForKeyUp(event: event, consumeIfSuppressed: true) { - return nil - } - return event - } - } - - private func installShortcutDefaultsObserver() { - guard shortcutDefaultsObserver == nil else { return } - refreshConfiguredShortcutChordActions() - shortcutDefaultsObserver = NotificationCenter.default.addObserver( - forName: KeyboardShortcutSettings.didChangeNotification, - object: nil, - queue: .main - ) { [weak self] _ in - self?.refreshConfiguredShortcutChordActions() - self?.clearConfiguredShortcutChordState() - self?.scheduleSplitButtonTooltipRefreshAcrossWorkspaces() - } - } - - private func refreshConfiguredShortcutChordActions() { - configuredShortcutChordActions = KeyboardShortcutSettings.Action.allCases.filter { - KeyboardShortcutSettings.shortcut(for: $0).hasChord - } - } - - private func clearConfiguredShortcutChordState() { - pendingConfiguredShortcutChord = nil - activeConfiguredShortcutChordPrefixForCurrentEvent = nil - } - - /// Coalesce shortcut-default changes and refresh on the next runloop turn to - /// avoid mutating Bonsplit/SwiftUI-observed state during an active update pass. - private func scheduleSplitButtonTooltipRefreshAcrossWorkspaces() { - guard !splitButtonTooltipRefreshScheduled else { return } - splitButtonTooltipRefreshScheduled = true - DispatchQueue.main.async { [weak self] in - guard let self else { return } - self.splitButtonTooltipRefreshScheduled = false - self.refreshSplitButtonTooltipsAcrossWorkspaces() - } - } - - private func refreshSplitButtonTooltipsAcrossWorkspaces() { - var refreshedManagers: Set = [] - if let manager = tabManager { - manager.refreshSplitButtonTooltips() - refreshedManagers.insert(ObjectIdentifier(manager)) - } - for context in mainWindowContexts.values { - let manager = context.tabManager - let identifier = ObjectIdentifier(manager) - guard refreshedManagers.insert(identifier).inserted else { continue } - manager.refreshSplitButtonTooltips() - } - } - - private func installGhosttyConfigObserver() { - guard ghosttyConfigObserver == nil else { return } - ghosttyConfigObserver = NotificationCenter.default.addObserver( - forName: .ghosttyConfigDidReload, - object: nil, - queue: .main - ) { [weak self] _ in - self?.refreshGhosttyGotoSplitShortcuts() - } - } - - private func refreshGhosttyGotoSplitShortcuts() { - guard let config = GhosttyApp.shared.config else { - ghosttyGotoSplitLeftShortcut = nil - ghosttyGotoSplitRightShortcut = nil - ghosttyGotoSplitUpShortcut = nil - ghosttyGotoSplitDownShortcut = nil - return - } - - ghosttyGotoSplitLeftShortcut = storedShortcutFromGhosttyTrigger( - ghostty_config_trigger(config, "goto_split:left", UInt("goto_split:left".utf8.count)) - ) - ghosttyGotoSplitRightShortcut = storedShortcutFromGhosttyTrigger( - ghostty_config_trigger(config, "goto_split:right", UInt("goto_split:right".utf8.count)) - ) - ghosttyGotoSplitUpShortcut = storedShortcutFromGhosttyTrigger( - ghostty_config_trigger(config, "goto_split:up", UInt("goto_split:up".utf8.count)) - ) - ghosttyGotoSplitDownShortcut = storedShortcutFromGhosttyTrigger( - ghostty_config_trigger(config, "goto_split:down", UInt("goto_split:down".utf8.count)) - ) - } - - private func storedShortcutFromGhosttyTrigger(_ trigger: ghostty_input_trigger_s) -> StoredShortcut? { - let key: String - switch trigger.tag { - case GHOSTTY_TRIGGER_PHYSICAL: - switch trigger.key.physical { - case GHOSTTY_KEY_ARROW_LEFT: - key = "←" - case GHOSTTY_KEY_ARROW_RIGHT: - key = "→" - case GHOSTTY_KEY_ARROW_UP: - key = "↑" - case GHOSTTY_KEY_ARROW_DOWN: - key = "↓" - case GHOSTTY_KEY_A: key = "a" - case GHOSTTY_KEY_B: key = "b" - case GHOSTTY_KEY_C: key = "c" - case GHOSTTY_KEY_D: key = "d" - case GHOSTTY_KEY_E: key = "e" - case GHOSTTY_KEY_F: key = "f" - case GHOSTTY_KEY_G: key = "g" - case GHOSTTY_KEY_H: key = "h" - case GHOSTTY_KEY_I: key = "i" - case GHOSTTY_KEY_J: key = "j" - case GHOSTTY_KEY_K: key = "k" - case GHOSTTY_KEY_L: key = "l" - case GHOSTTY_KEY_M: key = "m" - case GHOSTTY_KEY_N: key = "n" - case GHOSTTY_KEY_O: key = "o" - case GHOSTTY_KEY_P: key = "p" - case GHOSTTY_KEY_Q: key = "q" - case GHOSTTY_KEY_R: key = "r" - case GHOSTTY_KEY_S: key = "s" - case GHOSTTY_KEY_T: key = "t" - case GHOSTTY_KEY_U: key = "u" - case GHOSTTY_KEY_V: key = "v" - case GHOSTTY_KEY_W: key = "w" - case GHOSTTY_KEY_X: key = "x" - case GHOSTTY_KEY_Y: key = "y" - case GHOSTTY_KEY_Z: key = "z" - case GHOSTTY_KEY_DIGIT_0: key = "0" - case GHOSTTY_KEY_DIGIT_1: key = "1" - case GHOSTTY_KEY_DIGIT_2: key = "2" - case GHOSTTY_KEY_DIGIT_3: key = "3" - case GHOSTTY_KEY_DIGIT_4: key = "4" - case GHOSTTY_KEY_DIGIT_5: key = "5" - case GHOSTTY_KEY_DIGIT_6: key = "6" - case GHOSTTY_KEY_DIGIT_7: key = "7" - case GHOSTTY_KEY_DIGIT_8: key = "8" - case GHOSTTY_KEY_DIGIT_9: key = "9" - case GHOSTTY_KEY_BRACKET_LEFT: key = "[" - case GHOSTTY_KEY_BRACKET_RIGHT: key = "]" - case GHOSTTY_KEY_MINUS: key = "-" - case GHOSTTY_KEY_EQUAL: key = "=" - case GHOSTTY_KEY_COMMA: key = "," - case GHOSTTY_KEY_PERIOD: key = "." - case GHOSTTY_KEY_SLASH: key = "/" - case GHOSTTY_KEY_SEMICOLON: key = ";" - case GHOSTTY_KEY_QUOTE: key = "'" - case GHOSTTY_KEY_BACKQUOTE: key = "`" - case GHOSTTY_KEY_BACKSLASH: key = "\\" - default: - return nil - } - case GHOSTTY_TRIGGER_UNICODE: - guard let scalar = UnicodeScalar(trigger.key.unicode) else { return nil } - key = String(Character(scalar)).lowercased() - case GHOSTTY_TRIGGER_CATCH_ALL: - return nil - default: - return nil - } - - let mods = trigger.mods.rawValue - let command = (mods & GHOSTTY_MODS_SUPER.rawValue) != 0 - let shift = (mods & GHOSTTY_MODS_SHIFT.rawValue) != 0 - let option = (mods & GHOSTTY_MODS_ALT.rawValue) != 0 - let control = (mods & GHOSTTY_MODS_CTRL.rawValue) != 0 - - // Ignore bogus empty triggers. - if key.isEmpty || (!command && !shift && !option && !control) { - return nil - } - - return StoredShortcut(key: key, command: command, shift: shift, option: option, control: control) - } - - private func handleQuitShortcutWarning() -> Bool { - // Tagged DEV builds are ephemeral, skip quit confirmation entirely. - if SocketControlSettings.isTaggedDevBuild() { - NSApp.terminate(nil) - return true - } - - if !QuitWarningSettings.isEnabled() { - NSApp.terminate(nil) - return true - } - - let alert = NSAlert() - alert.alertStyle = .warning - alert.messageText = String(localized: "dialog.quitPrograma.title", defaultValue: "Quit Programa?") - alert.informativeText = String(localized: "dialog.quitPrograma.message", defaultValue: "This will close all windows and workspaces.") - alert.addButton(withTitle: String(localized: "dialog.quitPrograma.quit", defaultValue: "Quit")) - alert.addButton(withTitle: String(localized: "common.cancel", defaultValue: "Cancel")) - alert.showsSuppressionButton = true - alert.suppressionButton?.title = String(localized: "dialog.dontWarnCmdQ", defaultValue: "Don't warn again for Cmd+Q") - - let response = alert.runModal() - if alert.suppressionButton?.state == .on { - QuitWarningSettings.setEnabled(false) - } - - if response == .alertFirstButtonReturn { - // Mark as confirmed so applicationShouldTerminate does not show a - // second alert when NSApp.terminate re-enters the delegate callback. - isQuitWarningConfirmed = true - NSApp.terminate(nil) - } - return true - } - - func promptRenameSelectedWorkspace() -> Bool { - guard let tabManager, - let tabId = tabManager.selectedTabId, - let tab = tabManager.tabs.first(where: { $0.id == tabId }) else { - NSSound.beep() - return false - } - - let alert = NSAlert() - alert.messageText = String(localized: "dialog.renameWorkspace.title", defaultValue: "Rename Workspace") - alert.informativeText = String(localized: "dialog.renameWorkspace.message", defaultValue: "Enter a custom name for this workspace.") - let input = NSTextField(string: tab.customTitle ?? tab.title) - input.placeholderString = String(localized: "dialog.renameWorkspace.placeholder", defaultValue: "Workspace 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")) - let alertWindow = alert.window - alertWindow.initialFirstResponder = input - DispatchQueue.main.async { - alertWindow.makeFirstResponder(input) - input.selectText(nil) - } - - let response = alert.runModal() - guard response == .alertFirstButtonReturn else { return true } - tabManager.setCustomTitle(tabId: tab.id, title: input.stringValue) - return true - } - - private func handleCustomShortcut(event: NSEvent) -> Bool { - // `charactersIgnoringModifiers` can be nil for some synthetic NSEvents and certain special keys. - // Treat nil as "" and rely on keyCode/layout-aware fallback logic where needed. - // When a non-Latin input source is active (Korean, Chinese, Japanese, etc.), - // charactersIgnoringModifiers returns non-ASCII characters that never match - // Latin shortcut keys. Normalize via KeyboardLayout so downstream comparisons - // (Cmd+1-9, Ctrl+1-9, omnibar N/P, command palette, etc.) work correctly. - let chars = KeyboardLayout.normalizedCharacters(for: event) - let flags = event.modifierFlags.intersection(.deviceIndependentFlagsMask) - let hasControl = flags.contains(.control) - let hasCommand = flags.contains(.command) - let hasOption = flags.contains(.option) - let isControlOnly = hasControl && !hasCommand && !hasOption - let controlDChar = chars == "d" || event.characters == "\u{04}" - let isControlD = isControlOnly && (controlDChar || event.keyCode == 2) - let configuredShortcutEventWindowNumber = configuredShortcutChordWindowNumber(for: event) - if let pendingConfiguredShortcutChord, - pendingConfiguredShortcutChord.windowNumber == configuredShortcutEventWindowNumber { - activeConfiguredShortcutChordPrefixForCurrentEvent = pendingConfiguredShortcutChord.firstStroke - } else { - activeConfiguredShortcutChordPrefixForCurrentEvent = nil - } - pendingConfiguredShortcutChord = nil - defer { activeConfiguredShortcutChordPrefixForCurrentEvent = nil } -#if DEBUG - if isControlD { - writeChildExitKeyboardProbe( - [ - "probeAppShortcutCharsHex": childExitKeyboardProbeHex(event.characters), - "probeAppShortcutCharsIgnoringHex": childExitKeyboardProbeHex(event.charactersIgnoringModifiers), - "probeAppShortcutKeyCode": String(event.keyCode), - "probeAppShortcutModsRaw": String(event.modifierFlags.rawValue), - ], - increments: ["probeAppShortcutCtrlDSeenCount": 1] - ) - } -#endif - - // Don't steal shortcuts from close-confirmation alerts. Keep standard alert key - // equivalents working and avoid surprising actions while the confirmation is up. - let closeConfirmationTitles = [ - String(localized: "dialog.closeWorkspace.title", defaultValue: "Close workspace?"), - String(localized: "dialog.closeWorkspaces.title", defaultValue: "Close workspaces?"), - String(localized: "dialog.closeTab.title", defaultValue: "Close tab?"), - String(localized: "dialog.closeOtherTabs.title", defaultValue: "Close other tabs?"), - String(localized: "dialog.closeWindow.title", defaultValue: "Close window?"), - ] - let closeConfirmationPanel = NSApp.windows - .compactMap { $0 as? NSPanel } - .first { panel in - guard panel.isVisible, let root = panel.contentView else { return false } - return closeConfirmationTitles.contains { title in - findStaticText(in: root, equals: title) - } - } - if let closeConfirmationPanel { - // Special-case: Cmd+D should confirm destructive close on alerts. - // XCUITest key events often hit the app-level local monitor first, so forward the key - // equivalent to the alert panel explicitly. - if matchShortcut( - event: event, - shortcut: StoredShortcut(key: "d", command: true, shift: false, option: false, control: false) - ), - let root = closeConfirmationPanel.contentView, - let closeButton = findButton( - in: root, - titled: String(localized: "common.close", defaultValue: "Close") - ) { - closeButton.performClick(nil) - return true - } - return false - } - - if NSApp.modalWindow != nil || NSApp.keyWindow?.attachedSheet != nil { - return false - } - - let normalizedFlags = flags.subtracting([.numericPad, .function, .capsLock]) - let commandPaletteTargetWindow = commandPaletteWindowForShortcutEvent(event) - let commandPaletteShortcutWindow = shouldHandleCommandPaletteShortcutEvent( - event, - paletteWindow: commandPaletteTargetWindow - ) ? commandPaletteTargetWindow : nil - let commandPaletteVisibleInTargetWindow = commandPaletteShortcutWindow.map { - isCommandPaletteVisible(for: $0) - } ?? false - let commandPalettePendingOpenInTargetWindow = commandPaletteTargetWindow.map { - isCommandPalettePendingOpen(for: $0) - } ?? false - let commandPaletteOverlayVisibleInTargetWindow = commandPaletteTargetWindow.map { - isCommandPaletteOverlayPresented(in: $0) - } ?? false - let commandPaletteResponderActiveInTargetWindow = commandPaletteTargetWindow.map { - isCommandPaletteResponderActive(in: $0) - } ?? false - let commandPaletteInteractiveInTargetWindow = - commandPaletteVisibleInTargetWindow - || commandPaletteOverlayVisibleInTargetWindow - || commandPaletteResponderActiveInTargetWindow - let commandPaletteEffectiveInTargetWindow = - commandPaletteInteractiveInTargetWindow - || commandPalettePendingOpenInTargetWindow - -#if DEBUG - if event.keyCode == 36 || event.keyCode == 76 { - dlog( - "shortcut.return.raw " + - "interactive=\(commandPaletteInteractiveInTargetWindow ? 1 : 0) " + - "effective=\(commandPaletteEffectiveInTargetWindow ? 1 : 0) " + - "target={\(debugWindowToken(commandPaletteTargetWindow))} " + - "shortcutWindow={\(debugWindowToken(commandPaletteShortcutWindow))} " + - "responderTarget=\(commandPaletteResponderActiveInTargetWindow ? 1 : 0) " + - "overlayTarget=\(commandPaletteOverlayVisibleInTargetWindow ? 1 : 0) " + - "pendingTarget=\(commandPalettePendingOpenInTargetWindow ? 1 : 0) " + - "\(debugShortcutRouteSnapshot(event: event))" - ) - } -#endif - - if normalizedFlags.isEmpty, event.keyCode == 53 { - let activePaletteWindow = activeCommandPaletteWindow() - let escapePaletteWindow: NSWindow? = { - if let targetWindow = commandPaletteTargetWindow { - guard commandPaletteEffectiveInTargetWindow else { - return nil - } - return targetWindow - } - return activePaletteWindow - }() -#if DEBUG - dlog( - "shortcut.escape route target={\(debugWindowToken(commandPaletteTargetWindow))} " + - "active={\(debugWindowToken(activePaletteWindow))} " + - "visibleTarget=\(commandPaletteVisibleInTargetWindow ? 1 : 0) " + - "pendingTarget=\(commandPalettePendingOpenInTargetWindow ? 1 : 0) " + - "overlayTarget=\(commandPaletteOverlayVisibleInTargetWindow ? 1 : 0) " + - "responderTarget=\(commandPaletteResponderActiveInTargetWindow ? 1 : 0) " + - "effectiveTarget=\(commandPaletteEffectiveInTargetWindow ? 1 : 0) " + - "\(debugShortcutRouteSnapshot(event: event))" - ) - if commandPaletteTargetWindow != nil, - !commandPaletteVisibleInTargetWindow, - !commandPalettePendingOpenInTargetWindow, - (commandPaletteOverlayVisibleInTargetWindow || commandPaletteResponderActiveInTargetWindow) { - dlog( - "shortcut.escape stateMismatch target={\(debugWindowToken(commandPaletteTargetWindow))} " + - "overlayTarget=\(commandPaletteOverlayVisibleInTargetWindow ? 1 : 0) " + - "responderTarget=\(commandPaletteResponderActiveInTargetWindow ? 1 : 0)" - ) - } -#endif - if let paletteWindow = escapePaletteWindow, - isCommandPaletteEffectivelyVisible(in: paletteWindow) { - if commandPaletteMarkedTextInput(in: paletteWindow) != nil { -#if DEBUG - dlog( - "shortcut.escape imeMarkedTextBypass consumed=0 target={\(debugWindowToken(paletteWindow))}" - ) -#endif - return false - } - clearCommandPalettePendingOpen(for: paletteWindow) - beginCommandPaletteEscapeSuppression(for: paletteWindow) - NotificationCenter.default.post(name: .commandPaletteToggleRequested, object: paletteWindow) -#if DEBUG - dlog("shortcut.escape paletteDismiss consumed=1 target={\(debugWindowToken(paletteWindow))}") -#endif - return true - } - let suppressionWindow = commandPaletteTargetWindow - ?? event.window - ?? NSApp.keyWindow - ?? NSApp.mainWindow - if shouldConsumeSuppressedEscape(event: event, window: suppressionWindow) { -#if DEBUG - dlog( - "shortcut.escape suppressionConsume consumed=1 target={\(debugWindowToken(suppressionWindow))} " + - "repeat=\(event.isARepeat ? 1 : 0)" - ) -#endif - return true - } - if let requestAge = recentCommandPaletteRequestAge(for: suppressionWindow) { - beginCommandPaletteEscapeSuppression(for: suppressionWindow) + } + + let workspace: Workspace + if let workingDirectory { + workspace = context.tabManager.addWorkspace(workingDirectory: workingDirectory, select: true) + } else { + workspace = context.tabManager.addTab(select: true) + } + #if DEBUG + logWorkspaceCreationRouting( + phase: "created", + source: debugSource, + reason: "workspace_created", + event: event, + chosenContext: context, + workspaceId: workspace.id, + workingDirectory: workingDirectory + ) + #endif + return workspace.id + } + + private func preferredMainWindowContextForWorkspaceCreation( + event: NSEvent? = nil, + debugSource: String = "unspecified" + ) -> MainWindowContext? { + if let context = mainWindowContext(forShortcutEvent: event, debugSource: debugSource) { + return context + } + + // If a keyboard event identifies a specific window but that context + // can't be resolved, do not fall back to another window. + if shortcutEventHasAddressableWindow(event) { #if DEBUG - dlog( - "shortcut.escape requestGraceConsume consumed=1 target={\(debugWindowToken(suppressionWindow))} " + - "ageMs=\(Int(requestAge * 1000)) repeat=\(event.isARepeat ? 1 : 0)" - ) + logWorkspaceCreationRouting( + phase: "choose", + source: debugSource, + reason: "event_context_required_no_fallback", + event: event, + chosenContext: nil + ) #endif - return true - } + return nil + } + + if let keyWindow = NSApp.keyWindow, + let context = contextForMainTerminalWindow(keyWindow) { #if DEBUG - dlog( - "shortcut.escape paletteDismiss consumed=0 target={\(debugWindowToken(commandPaletteTargetWindow))} " + - "active={\(debugWindowToken(activePaletteWindow))}" + logWorkspaceCreationRouting( + phase: "choose", + source: debugSource, + reason: "key_window", + event: event, + chosenContext: context ) -#endif + #endif + return context } - let paletteUsesInlineTextHandling = commandPaletteShortcutWindow.map { - isCommandPaletteMultilineTextResponderActive(in: $0) - } ?? false + if let mainWindow = NSApp.mainWindow, + let context = contextForMainTerminalWindow(mainWindow) { + #if DEBUG + logWorkspaceCreationRouting( + phase: "choose", + source: debugSource, + reason: "main_window", + event: event, + chosenContext: context + ) + #endif + return context + } - let paletteSelectionDelta = commandPaletteSelectionDeltaForKeyboardNavigation( - flags: event.modifierFlags, - chars: chars, - keyCode: event.keyCode + for window in NSApp.orderedWindows where isMainTerminalWindow(window) { + if let context = contextForMainTerminalWindow(window) { + #if DEBUG + logWorkspaceCreationRouting( + phase: "choose", + source: debugSource, + reason: "ordered_windows", + event: event, + chosenContext: context + ) + #endif + return context + } + } + + let fallback = mainWindowContexts.values.first(where: { resolvedWindow(for: $0) != nil }) + #if DEBUG + logWorkspaceCreationRouting( + phase: "choose", + source: debugSource, + reason: "fallback_first_context", + event: event, + chosenContext: fallback ) +#endif + return fallback + } - if shouldRouteCommandPaletteSelectionNavigation( - delta: paletteSelectionDelta, - isInteractive: commandPaletteInteractiveInTargetWindow, - usesInlineTextHandling: paletteUsesInlineTextHandling - ), - let delta = paletteSelectionDelta, - let paletteWindow = commandPaletteShortcutWindow { - NotificationCenter.default.post( - name: .commandPaletteMoveSelection, - object: paletteWindow, - userInfo: ["delta": delta] + private func shortcutEventHasAddressableWindow(_ event: NSEvent?) -> Bool { + guard let event else { return false } + // NSEvent.windowNumber can be 0 for responder-chain events that are not + // actually bound to an NSWindow (notably some WebKit key paths). + return event.window != nil || event.windowNumber > 0 + } + + private func mainWindowContext( + forShortcutEvent event: NSEvent?, + debugSource: String = "unspecified" + ) -> MainWindowContext? { + guard let event else { return nil } + + if let eventWindow = event.window, + let context = contextForMainTerminalWindow(eventWindow) { + #if DEBUG + logWorkspaceCreationRouting( + phase: "choose", + source: debugSource, + reason: "event_window", + event: event, + chosenContext: context ) - return true + #endif + return context } - if commandPaletteInteractiveInTargetWindow, - let paletteWindow = commandPaletteShortcutWindow { - let paletteFieldEditorHasMarkedText = commandPaletteFieldEditorHasMarkedText(in: paletteWindow) - let paletteSnapshot = mainWindowId(for: paletteWindow).map(commandPaletteSnapshot(windowId:)) ?? .empty - let paletteUsesInlineReturnHandling = paletteUsesInlineTextHandling - if normalizedFlags.isEmpty, event.keyCode == 53 { - if paletteFieldEditorHasMarkedText { - return false - } - NotificationCenter.default.post(name: .commandPaletteDismissRequested, object: paletteWindow) - return true - } + if event.windowNumber > 0, + let numberedWindow = NSApp.window(withWindowNumber: event.windowNumber), + let context = contextForMainTerminalWindow(numberedWindow) { + #if DEBUG + logWorkspaceCreationRouting( + phase: "choose", + source: debugSource, + reason: "event_window_number", + event: event, + chosenContext: context + ) + #endif + return context + } - let shouldSubmitPalette = shouldSubmitCommandPaletteWithReturn( - keyCode: event.keyCode, - flags: event.modifierFlags, - mode: paletteSnapshot.mode + if event.windowNumber > 0, + let context = mainWindowContexts.values.first(where: { candidate in + let window = candidate.window ?? windowForMainWindowId(candidate.windowId) + return window?.windowNumber == event.windowNumber + }) { + #if DEBUG + logWorkspaceCreationRouting( + phase: "choose", + source: debugSource, + reason: "event_window_number_scan", + event: event, + chosenContext: context ) + #endif + return context + } + + #if DEBUG + logWorkspaceCreationRouting( + phase: "choose", + source: debugSource, + reason: "event_context_not_found", + event: event, + chosenContext: nil + ) + #endif + return nil + } + + private func preferredMainWindowContextForShortcutRouting(event: NSEvent) -> MainWindowContext? { + if let context = mainWindowContext(forShortcutEvent: event, debugSource: "shortcut.routing") { + return context + } + + if shortcutEventHasAddressableWindow(event) { + if let eventWindow = resolvedShortcutEventWindow(event), + programaWindowShouldOwnCloseShortcut(eventWindow) { + // Auxiliary cmux windows do not own a terminal tab manager. Let them fall back + // to the active main terminal window so app shortcuts like Cmd+W still route. + } else { #if DEBUG - if event.keyCode == 36 || event.keyCode == 76 { - dlog( - "shortcut.palette.return target={\(debugWindowToken(paletteWindow))} " + - "mode=\(paletteSnapshot.mode) " + - "inline=\(paletteUsesInlineReturnHandling ? 1 : 0) " + - "submit=\(shouldSubmitPalette ? 1 : 0) " + - "marked=\(paletteFieldEditorHasMarkedText ? 1 : 0) " + - "\(debugShortcutRouteSnapshot(event: event))" + logWorkspaceCreationRouting( + phase: "choose", + source: "shortcut.routing", + reason: "event_context_required_no_fallback", + event: event, + chosenContext: nil ) - } #endif - if paletteUsesInlineReturnHandling, - event.keyCode == 36 || event.keyCode == 76 { - return false - } - if shouldSubmitPalette { - if paletteFieldEditorHasMarkedText { - return false - } - NotificationCenter.default.post(name: .commandPaletteSubmitRequested, object: paletteWindow) - return true + return nil } } - // Guard against stale browserAddressBarFocusedPanelId after focus transitions - // (e.g., split that doesn't properly blur the address bar). If the first responder - // is a terminal surface, the address bar can't be focused. - if browserAddressBarFocusedPanelId != nil, - cmuxOwningGhosttyView(for: NSApp.keyWindow?.firstResponder) != nil { + if let keyWindow = NSApp.keyWindow, + let context = contextForMainTerminalWindow(keyWindow) { + return context + } + + if let mainWindow = NSApp.mainWindow, + let context = contextForMainTerminalWindow(mainWindow) { + return context + } + + if let activeManager = tabManager, + let context = mainWindowContexts.values.first(where: { $0.tabManager === activeManager }) { + return context + } + + return mainWindowContexts.values.first + } + + @discardableResult + private func synchronizeShortcutRoutingContext(event: NSEvent) -> Bool { + guard let context = preferredMainWindowContextForShortcutRouting(event: event) else { #if DEBUG - let stalePanelToken = browserAddressBarFocusedPanelId.map { String($0.uuidString.prefix(5)) } ?? "nil" - let firstResponderType = NSApp.keyWindow?.firstResponder.map { String(describing: type(of: $0)) } ?? "nil" - dlog( - "browser.focus.addressBar.staleClear panel=\(stalePanelToken) " + - "reason=terminal_first_responder fr=\(firstResponderType)" + FocusLogStore.shared.append( + "shortcut.route reason=no_context_no_fallback eventWin=\(event.windowNumber) keyCode=\(event.keyCode)" ) #endif - browserAddressBarFocusedPanelId = nil - stopBrowserOmnibarSelectionRepeat() + return false } - // Keep Cmd+P/Cmd+N inside the focused browser omnibar for Chrome-like - // suggestion navigation, and avoid opening command palette switcher. - // Scope the omnibar check to the shortcut's routed window context so a - // focused omnibar in another window does not suppress Cmd+P here. - let hasFocusedAddressBarInShortcutContext = focusedBrowserAddressBarPanelIdForShortcutEvent(event) != nil + let alreadyActive = + tabManager === context.tabManager + && sidebarState === context.sidebarState + && sidebarSelectionState === context.sidebarSelectionState + if alreadyActive { return true } - if commandPaletteEffectiveInTargetWindow { - if matchConfiguredShortcut(event: event, action: .commandPalette) { - let targetWindow = commandPaletteTargetWindow ?? event.window ?? NSApp.keyWindow ?? NSApp.mainWindow - requestCommandPaletteCommands(preferredWindow: targetWindow, source: "shortcut.commandPalette") - return true - } + if let window = context.window ?? windowForMainWindowId(context.windowId) { + setActiveMainWindow(window) + } else { + tabManager = context.tabManager + sidebarState = context.sidebarState + sidebarSelectionState = context.sidebarSelectionState + TerminalController.shared.setActiveTabManager(context.tabManager) + } - if !hasFocusedAddressBarInShortcutContext, - matchConfiguredShortcut(event: event, action: .goToWorkspace) { - let targetWindow = commandPaletteTargetWindow ?? event.window ?? NSApp.keyWindow ?? NSApp.mainWindow - requestCommandPaletteSwitcher(preferredWindow: targetWindow, source: "shortcut.goToWorkspace") - return true - } +#if DEBUG + FocusLogStore.shared.append( + "shortcut.route reason=sync activeTM=\(pointerString(tabManager)) chosen={\(summarizeContextForWorkspaceRouting(context))}" + ) +#endif + return true + } - if activeConfiguredShortcutChordPrefixForCurrentEvent == nil, - armConfiguredShortcutChordIfNeeded(event: event, actions: [.commandPalette]) { - return true - } + @discardableResult + func createMainWindow( + initialWorkingDirectory: String? = nil, + sessionWindowSnapshot: SessionWindowSnapshot? = nil + ) -> UUID { + let windowId = UUID() + let tabManager = TabManager(initialWorkingDirectory: initialWorkingDirectory) + if let tabManagerSnapshot = sessionWindowSnapshot?.tabManager { + tabManager.restoreSessionSnapshot(tabManagerSnapshot) + } - if activeConfiguredShortcutChordPrefixForCurrentEvent == nil, - !hasFocusedAddressBarInShortcutContext, - armConfiguredShortcutChordIfNeeded(event: event, actions: [.goToWorkspace]) { - return true + let sidebarWidth = sessionWindowSnapshot?.sidebar.width + .map(SessionPersistencePolicy.sanitizedSidebarWidth) + ?? SessionPersistencePolicy.defaultSidebarWidth + let sidebarState = SidebarState( + isVisible: sessionWindowSnapshot?.sidebar.isVisible ?? true, + persistedWidth: CGFloat(sidebarWidth) + ) + let sidebarSelectionState = SidebarSelectionState( + selection: sessionWindowSnapshot?.sidebar.selection.sidebarSelection ?? .tabs + ) + let notificationStore = TerminalNotificationStore.shared + + let programaConfigStore = ProgramaConfigStore() + programaConfigStore.wireDirectoryTracking(tabManager: tabManager) + programaConfigStore.loadAll() + + let root = ContentView(updateViewModel: updateViewModel, windowId: windowId) + .environmentObject(tabManager) + .environmentObject(notificationStore) + .environmentObject(sidebarState) + .environmentObject(sidebarSelectionState) + .environmentObject(programaConfigStore) + + // Use the current key window's size for new windows so Cmd+Shift+N + // creates a window matching the previous one's dimensions. + let styleMask: NSWindow.StyleMask = [.titled, .closable, .miniaturizable, .resizable, .fullSizeContentView] + let sourceContext = preferredMainWindowContextForWorkspaceCreation( + debugSource: "createMainWindow.initialGeometry" + ) + let sourceWindow = sourceContext.flatMap { resolvedWindow(for: $0) } + let existingFrame = sourceWindow?.frame + let sourceWindowIsNativeFullScreen: Bool = { +#if DEBUG + if let debugCreateMainWindowSourceIsNativeFullScreenOverride { + return debugCreateMainWindowSourceIsNativeFullScreenOverride } +#endif + return sourceWindow?.styleMask.contains(.fullScreen) == true + }() + let shouldTemporarilyDisallowFullScreenTiling = + sessionWindowSnapshot == nil && sourceWindowIsNativeFullScreen + let initialRect: NSRect + if sessionWindowSnapshot == nil, let existingFrame { + // Convert frame rect to content rect so the new window matches the + // source window's actual size (frame includes titlebar insets). + initialRect = NSWindow.contentRect(forFrameRect: existingFrame, styleMask: styleMask) + } else { + initialRect = NSRect(x: 0, y: 0, width: 460, height: 360) } - if shouldConsumeShortcutWhileCommandPaletteVisible( - isCommandPaletteVisible: commandPaletteEffectiveInTargetWindow, - normalizedFlags: normalizedFlags, - chars: chars, - keyCode: event.keyCode - ) { - return true + let window = NSWindow( + contentRect: initialRect, + styleMask: styleMask, + backing: .buffered, + defer: false + ) + // When creating a new window from an existing native fullscreen window, + // temporarily opt out of fullscreen tiling so AppKit doesn't place the + // new window into the active fullscreen Space. + if shouldTemporarilyDisallowFullScreenTiling { + window.collectionBehavior.insert(.fullScreenDisallowsTiling) } - - // When the terminal has active IME composition (e.g. Korean, Japanese, Chinese - // input), don't intercept non-Cmd key events — let them flow through to the - // input method. Cmd-based shortcuts (Cmd+T, Cmd+Shift+L, etc.) should still - // work during composition since Cmd is never part of IME input sequences. - if !normalizedFlags.contains(.command), - let ghosttyView = cmuxOwningGhosttyView(for: NSApp.keyWindow?.firstResponder), - ghosttyView.hasMarkedText() { - return false + window.title = "" + window.titleVisibility = .hidden + window.titlebarAppearsTransparent = true + window.isMovableByWindowBackground = false + window.isMovable = false + let restoredFrame = resolvedWindowFrame(from: sessionWindowSnapshot) + if let restoredFrame { + window.setFrame(restoredFrame, display: false) + } else { + window.center() + // Cascade using the same algorithm as upstream Ghostty: seed from + // the window's own top-left on the first call, then advance the + // cascade point for each subsequent window. + if mainWindowContexts.count >= 1 { + lastCascadePoint = window.cascadeTopLeft(from: lastCascadePoint) + } else { + lastCascadePoint = window.cascadeTopLeft(from: NSPoint(x: window.frame.minX, y: window.frame.maxY)) + } } + window.contentView = MainWindowHostingView(rootView: root) - // When the notifications popover is open, Escape should dismiss it immediately. - if flags.isEmpty, event.keyCode == 53, titlebarAccessoryController.dismissNotificationsPopoverIfShown() { - return true - } + // Apply shared window styling. + attachUpdateAccessory(to: window) + applyWindowDecorations(to: window) - // When the notifications popover is showing an empty state, consume plain typing - // so key presses do not leak through into the focused terminal. - if flags.isDisjoint(with: [.command, .control, .option]), - titlebarAccessoryController.isNotificationsPopoverShown(), - (notificationStore?.notifications.isEmpty ?? false) { - return true + // Keep a strong reference so the window isn't deallocated. + let controller = MainWindowController(window: window) + controller.onClose = { [weak self, weak controller] in + guard let self, let controller else { return } + self.mainWindowControllers.removeAll(where: { $0 === controller }) } + window.delegate = controller + mainWindowControllers.append(controller) - let hasEventWindowContext = shortcutEventHasAddressableWindow(event) - let didSynchronizeShortcutContext = synchronizeShortcutRoutingContext(event: event) - if hasEventWindowContext && !didSynchronizeShortcutContext { + registerMainWindow( + window, + windowId: windowId, + tabManager: tabManager, + sidebarState: sidebarState, + sidebarSelectionState: sidebarSelectionState + ) + installFileDropOverlay(on: window, tabManager: tabManager) + if TerminalController.shouldSuppressSocketCommandActivation() { + window.orderFront(nil) + if TerminalController.socketCommandAllowsInAppFocusMutations() { + setActiveMainWindow(window) + } + } else { + window.makeKeyAndOrderFront(nil) + setActiveMainWindow(window) + NSApp.activate(ignoringOtherApps: true) + } + if shouldTemporarilyDisallowFullScreenTiling { + DispatchQueue.main.async { [weak window] in + window?.collectionBehavior.remove(.fullScreenDisallowsTiling) + } + } + if let restoredFrame { + window.setFrame(restoredFrame, display: true) #if DEBUG - dlog("handleCustomShortcut: unresolved event window context; bypassing app shortcut handling") + dlog( + "session.restore.frameApplied window=\(windowId.uuidString.prefix(8)) " + + "applied={\(debugNSRectDescription(window.frame))}" + ) #endif - return false } + return windowId + } - // Keep keyboard routing deterministic after split close/reparent transitions: - // before processing shortcuts, converge first responder with the focused terminal panel. - if isControlD { -#if DEBUG - let selected = tabManager?.selectedTabId?.uuidString.prefix(5) ?? "nil" - let focused = tabManager?.selectedWorkspace?.focusedPanelId?.uuidString.prefix(5) ?? "nil" - let frType = NSApp.keyWindow?.firstResponder.map { String(describing: type(of: $0)) } ?? "nil" - dlog("shortcut.ctrlD stage=preReconcile selected=\(selected) focused=\(focused) fr=\(frType)") -#endif - tabManager?.reconcileFocusedPanelFromFirstResponderForKeyboard() - #if DEBUG - let frAfterType = NSApp.keyWindow?.firstResponder.map { String(describing: type(of: $0)) } ?? "nil" - dlog("shortcut.ctrlD stage=postReconcile fr=\(frAfterType)") - writeChildExitKeyboardProbe([:], increments: ["probeAppShortcutCtrlDPassedCount": 1]) - #endif - // Ctrl+D belongs to the focused terminal surface; never treat it as an app shortcut. - return false + @objc func checkForUpdates(_ sender: Any?) { + updateViewModel.overrideState = nil + updateController.checkForUpdates() + } + + func checkForUpdatesInCustomUI() { + updateViewModel.overrideState = nil + updateController.checkForUpdatesInCustomUI() + } + + func openWelcomeWorkspace() { + guard let context = preferredMainWindowContextForWorkspaceCreation(event: nil, debugSource: "welcome") else { + return + } + if let window = context.window ?? windowForMainWindowId(context.windowId) { + setActiveMainWindow(window) + bringToFront(window) } + let workspace = context.tabManager.addWorkspace(select: true, autoWelcomeIfNeeded: false) + sendWelcomeCommandWhenReady(to: workspace) + } - // Chrome-like omnibar navigation while holding Cmd+N / Ctrl+N / Cmd+P / Ctrl+P. - if let delta = commandOmnibarSelectionDelta(flags: flags, chars: chars) { - dispatchBrowserOmnibarSelectionMove(delta: delta) - startBrowserOmnibarSelectionRepeatIfNeeded(keyCode: event.keyCode, delta: delta) - return true + func sendWelcomeCommandWhenReady(to workspace: Workspace, markShownOnSend: Bool = false) { + sendTextWhenReady("programa welcome\n", to: workspace) { + if markShownOnSend { + UserDefaults.standard.set(true, forKey: WelcomeSettings.shownKey) + } } + } - if let delta = browserOmnibarSelectionDeltaForArrowNavigation( - hasFocusedAddressBar: browserAddressBarFocusedPanelId != nil, - flags: event.modifierFlags, - keyCode: event.keyCode - ) { - dispatchBrowserOmnibarSelectionMove(delta: delta) - return true - } + @objc func applyUpdateIfAvailable(_ sender: Any?) { + updateViewModel.overrideState = nil + updateController.installUpdate() + } - // Fast path for normal typing and terminal navigation keys (for example Up-arrow - // history): after command-palette/notification handling and browser omnibar - // arrow navigation above, plain key events have no app-level shortcut behavior. - if normalizedFlags.isEmpty && activeConfiguredShortcutChordPrefixForCurrentEvent == nil { - return false - } + @objc func attemptUpdate(_ sender: Any?) { + updateViewModel.overrideState = nil + updateController.attemptUpdate() + } - // Let omnibar-local Emacs navigation (Cmd/Ctrl+N/P) win while the browser - // address bar is focused. Without this, app-level Cmd+N can steal focus. - if shouldBypassAppShortcutForFocusedBrowserAddressBar(flags: flags, chars: chars) { - return false - } + func isProgramaCLIInstalledInPATH() -> Bool { + ProgramaCLIPathInstaller().isInstalled() + } - if activeConfiguredShortcutChordPrefixForCurrentEvent == nil, - armConfiguredShortcutChordIfNeeded(event: event) { - return true + @objc func installProgramaCLIInPath(_ sender: Any?) { + let installer = ProgramaCLIPathInstaller() + do { + let outcome = try installer.install() + var informativeText = String(localized: "cli.install.symlinkCreated", defaultValue: "Created symlink:\n\n\(outcome.destinationURL.path) -> \(outcome.sourceURL.path)") + if outcome.usedAdministratorPrivileges { + informativeText += "\n\n" + String(localized: "cli.install.adminRequired", defaultValue: "Administrator privileges were required to write to /usr/local/bin.") + } + presentCLIPathAlert( + title: String(localized: "cli.installed", defaultValue: "Programa CLI Installed"), + informativeText: informativeText, + style: .informational + ) + } catch { + presentCLIPathAlert( + title: String(localized: "cli.installFailed", defaultValue: "Couldn't Install Programa CLI"), + informativeText: error.localizedDescription, + style: .warning + ) } + } - if matchConfiguredShortcut(event: event, action: .commandPalette) { - let targetWindow = commandPaletteTargetWindow ?? event.window ?? NSApp.keyWindow ?? NSApp.mainWindow - requestCommandPaletteCommands(preferredWindow: targetWindow, source: "shortcut.commandPalette") - return true + @objc func uninstallProgramaCLIInPath(_ sender: Any?) { + let installer = ProgramaCLIPathInstaller() + do { + let outcome = try installer.uninstall() + let prefix = outcome.removedExistingEntry + ? String(localized: "cli.uninstall.removed", defaultValue: "Removed \(outcome.destinationURL.path).") + : String(localized: "cli.uninstall.notFound", defaultValue: "No Programa CLI symlink was found at \(outcome.destinationURL.path).") + var informativeText = prefix + if outcome.usedAdministratorPrivileges { + informativeText += "\n\n" + String(localized: "cli.uninstall.adminRequired", defaultValue: "Administrator privileges were required to modify /usr/local/bin.") + } + presentCLIPathAlert( + title: String(localized: "cli.uninstalled", defaultValue: "Programa CLI Uninstalled"), + informativeText: informativeText, + style: .informational + ) + } catch { + presentCLIPathAlert( + title: String(localized: "cli.uninstallFailed", defaultValue: "Couldn't Uninstall Programa CLI"), + informativeText: error.localizedDescription, + style: .warning + ) } + } - if !hasFocusedAddressBarInShortcutContext, - matchConfiguredShortcut(event: event, action: .goToWorkspace) { - let targetWindow = commandPaletteTargetWindow ?? event.window ?? NSApp.keyWindow ?? NSApp.mainWindow - requestCommandPaletteSwitcher(preferredWindow: targetWindow, source: "shortcut.goToWorkspace") - return true - } + private func presentCLIPathAlert( + title: String, + informativeText: String, + style: NSAlert.Style + ) { + let alert = NSAlert() + alert.alertStyle = style + alert.messageText = title + alert.informativeText = informativeText + alert.addButton(withTitle: String(localized: "common.ok", defaultValue: "OK")) - if matchConfiguredShortcut(event: event, action: .quit) { - return handleQuitShortcutWarning() + if let window = NSApp.keyWindow ?? NSApp.mainWindow { + alert.beginSheetModal(for: window, completionHandler: nil) + } else { + _ = alert.runModal() } - if matchConfiguredShortcut(event: event, action: .openSettings) { - openPreferencesWindow(debugSource: "shortcut.openSettings") - return true + } + + @objc func restartSocketListener(_ sender: Any?) { + guard tabManager != nil else { + NSSound.beep() + return } - if matchConfiguredShortcut(event: event, action: .reloadConfiguration) { - GhosttyApp.shared.reloadConfiguration(source: "shortcut.reloadConfiguration") - return true + + guard socketListenerConfigurationIfEnabled() != nil else { + TerminalController.shared.stop() + NSSound.beep() + return } + restartSocketListenerIfEnabled(source: "menu.command") + } - if matchConfiguredShortcut(event: event, action: .toggleFullScreen) { - guard let targetWindow = mainWindowForShortcutEvent(event) else { - return false + private func setupMenuBarExtra() { + guard menuBarExtraController == nil else { return } + let store = TerminalNotificationStore.shared + menuBarExtraController = MenuBarExtraController( + notificationStore: store, + onShowNotifications: { [weak self] in + self?.showNotificationsPopoverFromMenuBar() + }, + onOpenNotification: { [weak self] notification in + _ = self?.openNotification( + tabId: notification.tabId, + surfaceId: notification.surfaceId, + notificationId: notification.id + ) + }, + onJumpToLatestUnread: { [weak self] in + self?.jumpToLatestUnread() + }, + onCheckForUpdates: { [weak self] in + self?.checkForUpdates(nil) + }, + onOpenPreferences: { [weak self] in + self?.openPreferencesWindow(debugSource: "menuBarExtra") + }, + onQuitApp: { + NSApp.terminate(nil) + } + ) + } + + private func installMenuBarVisibilityObserver() { + guard menuBarVisibilityObserver == nil else { return } + menuBarVisibilityObserver = NotificationCenter.default.addObserver( + forName: UserDefaults.didChangeNotification, + object: nil, + queue: .main + ) { [weak self] _ in + Task { @MainActor [weak self] in + self?.syncMenuBarExtraVisibility() } - targetWindow.toggleFullScreen(nil) - return true } + } - // Primary UI shortcuts - if matchConfiguredShortcut(event: event, action: .toggleSidebar) { - _ = toggleSidebarInActiveMainWindow() - return true + private func syncMenuBarExtraVisibility(defaults: UserDefaults = .standard) { + if MenuBarExtraSettings.showsMenuBarExtra(defaults: defaults) { + setupMenuBarExtra() + return } - if matchConfiguredShortcut(event: event, action: .newTab) { + menuBarExtraController?.removeFromMenuBar() + menuBarExtraController = nil + } + + @MainActor + static func presentPreferencesWindow( + navigationTarget: SettingsNavigationTarget? = nil, + showFallbackSettingsWindow: @MainActor (SettingsNavigationTarget?) -> Void = { target in + SettingsWindowController.shared.show(navigationTarget: target) + }, + activateApplication: @MainActor () -> Void = { + NSRunningApplication.current.activate(options: [.activateAllWindows, .activateIgnoringOtherApps]) + } + ) { #if DEBUG - dlog("shortcut.action name=newWorkspace \(debugShortcutRouteSnapshot(event: event))") + dlog("settings.open.present path=customWindowDirect") #endif - // Cmd+N semantics: - // - If there are no main windows, create a new window. - // - Otherwise, create a new workspace in the active window. - if mainWindowContexts.isEmpty { - #if DEBUG - logWorkspaceCreationRouting( - phase: "fallback_new_window", - source: "shortcut.cmdN", - reason: "no_main_windows", - event: event, - chosenContext: nil - ) - #endif - openNewMainWindow(nil) - } else if addWorkspaceInPreferredMainWindow(event: event, debugSource: "shortcut.cmdN") == nil { - #if DEBUG - logWorkspaceCreationRouting( - phase: "fallback_new_window", - source: "shortcut.cmdN", - reason: "workspace_creation_returned_nil", - event: event, - chosenContext: nil - ) - #endif - openNewMainWindow(nil) + showFallbackSettingsWindow(navigationTarget) + activateApplication() + if let window = SettingsWindowController.shared.window { + window.orderFrontRegardless() + window.makeKeyAndOrderFront(nil) + DispatchQueue.main.async { + window.orderFrontRegardless() + window.makeKeyAndOrderFront(nil) + } + } +#if DEBUG + dlog("settings.open.present activate=1") +#endif + } + + @MainActor + func openPreferencesWindow(debugSource: String, navigationTarget: SettingsNavigationTarget? = nil) { +#if DEBUG + dlog("settings.open.request source=\(debugSource)") +#endif + Self.presentPreferencesWindow(navigationTarget: navigationTarget) + } + + @objc func openPreferencesWindow() { + openPreferencesWindow(debugSource: "appDelegate") + } + + func refreshMenuBarExtraForDebug() { + menuBarExtraController?.refreshForDebugControls() + } + + func showNotificationsPopoverFromMenuBar() { + let context: MainWindowContext? = { + if let keyWindow = NSApp.keyWindow, + let keyContext = contextForMainTerminalWindow(keyWindow) { + return keyContext } - return true - } + if let first = mainWindowContexts.values.first { + return first + } + let windowId = createMainWindow() + return mainWindowContexts.values.first(where: { $0.windowId == windowId }) + }() - // New Window: Cmd+Shift+N - // Handled here instead of relying on SwiftUI's CommandGroup menu item because - // after a browser panel has been shown, SwiftUI's menu dispatch can silently - // consume the key equivalent without firing the action closure. - if matchConfiguredShortcut(event: event, action: .newWindow) { - openNewMainWindow(nil) - return true + if let context, + let window = context.window ?? windowForMainWindowId(context.windowId) { + setActiveMainWindow(window) + bringToFront(window) } - // Open Folder: Cmd+O - // Handled here to prevent AppKit's default NSDocumentController from opening - // the Documents folder when SwiftUI menu dispatch fails due to focus bugs. - if matchConfiguredShortcut(event: event, action: .openFolder) { - showOpenFolderPanel() - return true + DispatchQueue.main.asyncAfter(deadline: .now() + 0.05) { [weak self] in + self?.titlebarAccessoryController.showNotificationsPopover(animated: false) } + } - // Check Show Notifications shortcut - if matchConfiguredShortcut(event: event, action: .showNotifications) { - toggleNotificationsPopover(animated: false, anchorView: fullscreenControlsViewModel?.notificationsAnchorView) - return true - } + #if DEBUG + @objc func showUpdatePill(_ sender: Any?) { + updateViewModel.debugOverrideText = nil + updateViewModel.overrideState = .installing(.init(isAutoUpdate: true, retryTerminatingApplication: {}, dismiss: {})) + } - if matchConfiguredShortcut(event: event, action: .sendFeedback) { - guard let targetContext = preferredMainWindowContextForShortcuts(event: event), - let targetWindow = targetContext.window ?? windowForMainWindowId(targetContext.windowId) else { - return false - } - setActiveMainWindow(targetWindow) - bringToFront(targetWindow) - NotificationCenter.default.post(name: .feedbackComposerRequested, object: targetWindow) - return true - } + @objc func showUpdatePillLongNightly(_ sender: Any?) { + updateViewModel.debugOverrideText = "Update Available: 0.32.0-nightly+20260216.abc1234" + updateViewModel.overrideState = .notFound(.init(acknowledgement: {})) + } - // Check Jump to Unread shortcut - if matchConfiguredShortcut(event: event, action: .jumpToUnread) { -#if DEBUG - if ProcessInfo.processInfo.environment["PROGRAMA_UI_TEST_JUMP_UNREAD_SETUP"] == "1" { - writeJumpUnreadTestData(["jumpUnreadShortcutHandled": "1"]) - } -#endif - jumpToLatestUnread() - return true - } + @objc func showUpdatePillLoading(_ sender: Any?) { + updateViewModel.debugOverrideText = nil + updateViewModel.overrideState = .checking(.init(cancel: {})) + } - // Flash the currently focused panel so the user can visually confirm focus. - if matchConfiguredShortcut(event: event, action: .triggerFlash) { - tabManager?.triggerFocusFlash() - return true - } + @objc func hideUpdatePill(_ sender: Any?) { + updateViewModel.debugOverrideText = nil + updateViewModel.overrideState = .idle + } - // Surface navigation: Cmd+Shift+] / Cmd+Shift+[ - if matchConfiguredShortcut(event: event, action: .nextSurface) { - tabManager?.selectNextSurface() - return true + @objc func clearUpdatePillOverride(_ sender: Any?) { + updateViewModel.debugOverrideText = nil + updateViewModel.overrideState = nil + } +#endif + + @objc func copyUpdateLogs(_ sender: Any?) { + let logText = UpdateLogStore.shared.snapshot() + let payload: String + if logText.isEmpty { + payload = "No update logs captured.\nLog file: \(UpdateLogStore.shared.logPath())" + } else { + payload = logText + "\nLog file: \(UpdateLogStore.shared.logPath())" } - if matchConfiguredShortcut(event: event, action: .prevSurface) { - tabManager?.selectPreviousSurface() - return true + let pasteboard = NSPasteboard.general + pasteboard.clearContents() + pasteboard.setString(payload, forType: .string) + } + @objc func copyFocusLogs(_ sender: Any?) { + let logText = FocusLogStore.shared.snapshot() + let payload: String + if logText.isEmpty { + payload = "No focus logs captured.\nLog file: \(FocusLogStore.shared.logPath())" + } else { + payload = logText + "\nLog file: \(FocusLogStore.shared.logPath())" } + let pasteboard = NSPasteboard.general + pasteboard.clearContents() + pasteboard.setString(payload, forType: .string) + } - if matchConfiguredShortcut(event: event, action: .toggleTerminalCopyMode) { - let handled = tabManager?.toggleFocusedTerminalCopyMode() ?? false + @objc private func handleReactGrabDidCopySelection(_ notification: Notification) { + let browserPanelId = notification.userInfo?[ReactGrabPastebackNotificationKey.browserPanelId] as? UUID + guard let workspaceId = notification.userInfo?[ReactGrabPastebackNotificationKey.workspaceId] as? UUID, + let returnPanelId = notification.userInfo?[ReactGrabPastebackNotificationKey.returnPanelId] as? UUID, + let content = notification.userInfo?[ReactGrabPastebackNotificationKey.content] as? String else { #if DEBUG dlog( - "shortcut.action name=toggleTerminalCopyMode handled=\(handled ? 1 : 0) " + - "\(debugShortcutRouteSnapshot(event: event))" + "reactGrab.pasteback h3.didCopy.drop " + + "reason=missingNotificationFields " + + "workspace=\(Self.debugShortId(notification.userInfo?[ReactGrabPastebackNotificationKey.workspaceId] as? UUID)) " + + "browser=\(Self.debugShortId(browserPanelId)) " + + "return=\(Self.debugShortId(notification.userInfo?[ReactGrabPastebackNotificationKey.returnPanelId] as? UUID)) " + + "hasContent=\((notification.userInfo?[ReactGrabPastebackNotificationKey.content] as? String) != nil ? 1 : 0)" ) #endif - // Only consume when a focused terminal actually handled the toggle. - // Otherwise allow the event to continue through the responder chain. - return handled + return } - // Workspace navigation: Cmd+Ctrl+] / Cmd+Ctrl+[ - if matchConfiguredShortcut(event: event, action: .nextSidebarTab) { + guard let manager = tabManagerFor(tabId: workspaceId), + let workspace = manager.tabs.first(where: { $0.id == workspaceId }) else { #if DEBUG - let selected = tabManager?.selectedTabId.map { String($0.uuidString.prefix(5)) } ?? "nil" dlog( - "ws.shortcut dir=next repeat=\(event.isARepeat ? 1 : 0) keyCode=\(event.keyCode) selected=\(selected)" + "reactGrab.pasteback h3.didCopy.drop " + + "reason=missingWorkspace workspace=\(Self.debugShortId(workspaceId)) " + + "browser=\(Self.debugShortId(browserPanelId)) return=\(Self.debugShortId(returnPanelId))" ) #endif - tabManager?.selectNextTab() - return true + return } - if matchConfiguredShortcut(event: event, action: .prevSidebarTab) { + guard workspace.terminalPanel(for: returnPanelId) != nil else { #if DEBUG - let selected = tabManager?.selectedTabId.map { String($0.uuidString.prefix(5)) } ?? "nil" dlog( - "ws.shortcut dir=prev repeat=\(event.isARepeat ? 1 : 0) keyCode=\(event.keyCode) selected=\(selected)" + "reactGrab.pasteback h3.didCopy.drop " + + "reason=missingReturnTerminal workspace=\(Self.debugShortId(workspaceId)) " + + "browser=\(Self.debugShortId(browserPanelId)) return=\(Self.debugShortId(returnPanelId)) " + + "focused=\(Self.debugShortId(workspace.focusedPanelId))" ) #endif - tabManager?.selectPreviousTab() - return true - } - - if matchConfiguredShortcut(event: event, action: .renameWorkspace) { - return requestRenameWorkspaceViaCommandPalette( - preferredWindow: commandPaletteTargetWindow ?? event.window ?? NSApp.keyWindow ?? NSApp.mainWindow - ) + return } - if matchConfiguredShortcut(event: event, action: .editWorkspaceDescription) { #if DEBUG - dlog( - "shortcut.editWorkspaceDescription matched target={\(debugWindowToken(commandPaletteTargetWindow ?? event.window ?? NSApp.keyWindow ?? NSApp.mainWindow))} " + - "\(debugShortcutRouteSnapshot(event: event))" - ) + dlog( + "reactGrab.pasteback h3.didCopy " + + "workspace=\(Self.debugShortId(workspaceId)) " + + "browser=\(Self.debugShortId(browserPanelId)) " + + "return=\(Self.debugShortId(returnPanelId)) " + + "focusedBefore=\(Self.debugShortId(workspace.focusedPanelId)) len=\(content.count)" + ) #endif - return requestEditWorkspaceDescriptionViaCommandPalette( - preferredWindow: commandPaletteTargetWindow ?? event.window ?? NSApp.keyWindow ?? NSApp.mainWindow - ) - } + manager.focusTab(workspaceId, surfaceId: returnPanelId, suppressFlash: true) +#if DEBUG + dlog( + "reactGrab.pasteback h1.focusRequested " + + "workspace=\(Self.debugShortId(workspaceId)) " + + "return=\(Self.debugShortId(returnPanelId)) " + + "focusedAfterRequest=\(Self.debugShortId(workspace.focusedPanelId))" + ) +#endif + sendTextWhenReady(content, to: workspace, preferredPanelId: returnPanelId) + } - if matchConfiguredShortcut(event: event, action: .closeOtherTabsInPane) { - if let targetWindow = event.window ?? NSApp.keyWindow ?? NSApp.mainWindow, - targetWindow.identifier?.rawValue == "cmux.settings" { - targetWindow.performClose(nil) - } else { - let targetWindow = event.window ?? NSApp.keyWindow ?? NSApp.mainWindow - if let terminalContext = focusedTerminalShortcutContext(preferredWindow: targetWindow) { - terminalContext.tabManager.closeOtherTabsInFocusedPaneWithConfirmation() - } else { - tabManager?.closeOtherTabsInFocusedPaneWithConfirmation() - } - } - return true + nonisolated private static func debugShortId(_ id: UUID?) -> String { + id.map { String($0.uuidString.prefix(5)) } ?? "nil" + } + + static func resolveTerminalPanelForTextSend(in tab: Tab, preferredPanelId: UUID? = nil) -> TerminalPanel? { + if let preferredPanelId { + return tab.terminalPanel(for: preferredPanelId) } + return tab.focusedTerminalPanel + } - // Cmd+W must close the focused panel even if first-responder momentarily lags on a - // browser NSTextView during split focus transitions. - if matchConfiguredShortcut(event: event, action: .closeTab) { - let targetWindow = resolvedShortcutEventWindow(event) ?? NSApp.keyWindow ?? NSApp.mainWindow - let routedManager = preferredMainWindowContextForShortcutRouting(event: event)?.tabManager ?? tabManager - // Browser popup windows primarily intercept Cmd+W in BrowserPopupPanel. - // This AppDelegate path is a fallback for cases where AppKit routes the - // event through the global shortcut handler first. - if let targetWindow = [targetWindow, NSApp.keyWindow] - .compactMap({ $0 }) - .first(where: { $0.identifier?.rawValue == "programa.browser-popup" }) { -#if DEBUG - dlog("shortcut.cmdW route=browserPopup") -#endif - targetWindow.performClose(nil) - return true - } else if let targetWindow, - programaWindowShouldOwnCloseShortcut(targetWindow) { - targetWindow.performClose(nil) - } else { - if let routedManager { + func sendTextWhenReady( + _ text: String, + to tab: Tab, + preferredPanelId: UUID? = nil, + beforeSend: (() -> Void)? = nil + ) { + let isReactGrabPasteback = preferredPanelId != nil #if DEBUG - let selectedWorkspace = routedManager.selectedWorkspace - dlog( - "shortcut.cmdW route=workspaceModel workspace=\(selectedWorkspace?.id.uuidString.prefix(5) ?? "nil") " + - "panel=\(selectedWorkspace?.focusedPanelId?.uuidString.prefix(5) ?? "nil") " + - "selected=\(routedManager.selectedTabId?.uuidString.prefix(5) ?? "nil")" - ) + let initialTargetPanel = Self.resolveTerminalPanelForTextSend( + in: tab, + preferredPanelId: preferredPanelId + ) + if isReactGrabPasteback { + dlog( + "reactGrab.pasteback h2.send.start " + + "workspace=\(Self.debugShortId(tab.id)) " + + "preferred=\(Self.debugShortId(preferredPanelId)) " + + "focused=\(Self.debugShortId(tab.focusedPanelId)) " + + "focusedTerminal=\(Self.debugShortId(tab.focusedTerminalPanel?.id)) " + + "resolved=\(Self.debugShortId(initialTargetPanel?.id)) " + + "surfaceReady=\(initialTargetPanel?.surface.surface != nil ? 1 : 0) len=\(text.count)" + ) + } #endif - routedManager.closeCurrentPanelWithConfirmation() - } else { + if let terminalPanel = Self.resolveTerminalPanelForTextSend( + in: tab, + preferredPanelId: preferredPanelId + ), + terminalPanel.surface.surface != nil { #if DEBUG - dlog("shortcut.cmdW route=noManager") + if isReactGrabPasteback { + dlog( + "reactGrab.pasteback h2.send.immediate " + + "workspace=\(Self.debugShortId(tab.id)) " + + "target=\(Self.debugShortId(terminalPanel.id)) len=\(text.count)" + ) + } #endif - return false - } + beforeSend?() + terminalPanel.sendText(text) +#if DEBUG + if isReactGrabPasteback { + dlog( + "reactGrab.pasteback h2.send.sent " + + "workspace=\(Self.debugShortId(tab.id)) " + + "target=\(Self.debugShortId(terminalPanel.id)) mode=immediate len=\(text.count)" + ) } - return true +#endif + return } - if matchConfiguredShortcut(event: event, action: .closeWorkspace) { - tabManager?.closeCurrentWorkspaceWithConfirmation() - return true - } + var resolved = false + var readyObserver: NSObjectProtocol? + var focusObserver: NSObjectProtocol? + var firstResponderObserver: NSObjectProtocol? + var panelsCancellable: AnyCancellable? - if matchConfiguredShortcut(event: event, action: .closeWindow) { - guard let targetWindow = event.window ?? NSApp.keyWindow ?? NSApp.mainWindow else { - NSSound.beep() - return true + func cleanupObservers() { + if let readyObserver { + NotificationCenter.default.removeObserver(readyObserver) } - closeWindowWithConfirmation(targetWindow) - return true - } - - if matchConfiguredShortcut(event: event, action: .renameTab) { - // Keep Cmd+R browser reload behavior when a browser panel is focused. - if tabManager?.focusedBrowserPanel != nil { - return false + if let focusObserver { + NotificationCenter.default.removeObserver(focusObserver) } - let targetWindow = commandPaletteTargetWindow ?? event.window ?? NSApp.keyWindow ?? NSApp.mainWindow - requestCommandPaletteRenameTab(preferredWindow: targetWindow, source: "shortcut.renameTab") - return true + if let firstResponderObserver { + NotificationCenter.default.removeObserver(firstResponderObserver) + } + panelsCancellable?.cancel() } - // Numeric shortcuts for specific workspaces (9 = last workspace) - // Always consume the event when the digit matches to prevent Ghostty's - // goto_tab fallback from creating a new window when the index is out of bounds. - if let digit = numberedConfiguredShortcutDigit(event: event, action: .selectWorkspaceByNumber) { - if let manager = tabManager, - let targetIndex = WorkspaceShortcutMapper.workspaceIndex(forDigit: digit, workspaceCount: manager.tabs.count) { + func finishIfReady() { + let terminalPanel = Self.resolveTerminalPanelForTextSend( + in: tab, + preferredPanelId: preferredPanelId + ) #if DEBUG + if isReactGrabPasteback { dlog( - "shortcut.action name=workspaceDigit digit=\(digit) targetIndex=\(targetIndex) manager=\(debugManagerToken(manager)) \(debugShortcutRouteSnapshot(event: event))" + "reactGrab.pasteback h2.finishIfReady " + + "workspace=\(Self.debugShortId(tab.id)) " + + "preferred=\(Self.debugShortId(preferredPanelId)) " + + "focused=\(Self.debugShortId(tab.focusedPanelId)) " + + "resolved=\(Self.debugShortId(terminalPanel?.id)) " + + "surfaceReady=\(terminalPanel?.surface.surface != nil ? 1 : 0) alreadyResolved=\(resolved ? 1 : 0)" ) -#endif - manager.selectTab(at: targetIndex) - } - return true - } - - // Numeric shortcuts for surfaces within the focused pane (9 = last) - if let digit = numberedConfiguredShortcutDigit(event: event, action: .selectSurfaceByNumber) { - if digit == 9 { - tabManager?.selectLastSurface() - } else { - tabManager?.selectSurface(at: digit - 1) } - return true - } - - // Pane focus navigation (defaults to Cmd+Option+Arrow, but can be customized to letter/number keys). - if matchConfiguredDirectionalShortcut( - event: event, - action: .focusLeft, - arrowGlyph: "←", - arrowKeyCode: 123 - ) || (ghosttyGotoSplitLeftShortcut.map { matchDirectionalShortcut(event: event, shortcut: $0, arrowGlyph: "←", arrowKeyCode: 123) } ?? false) { - tabManager?.movePaneFocus(direction: .left) -#if DEBUG - recordGotoSplitMoveIfNeeded(direction: .left) -#endif - return true - } - if matchConfiguredDirectionalShortcut( - event: event, - action: .focusRight, - arrowGlyph: "→", - arrowKeyCode: 124 - ) || (ghosttyGotoSplitRightShortcut.map { matchDirectionalShortcut(event: event, shortcut: $0, arrowGlyph: "→", arrowKeyCode: 124) } ?? false) { - tabManager?.movePaneFocus(direction: .right) -#if DEBUG - recordGotoSplitMoveIfNeeded(direction: .right) #endif - return true - } - if matchConfiguredDirectionalShortcut( - event: event, - action: .focusUp, - arrowGlyph: "↑", - arrowKeyCode: 126 - ) || (ghosttyGotoSplitUpShortcut.map { matchDirectionalShortcut(event: event, shortcut: $0, arrowGlyph: "↑", arrowKeyCode: 126) } ?? false) { - tabManager?.movePaneFocus(direction: .up) + guard !resolved, + let terminalPanel, + terminalPanel.surface.surface != nil else { return } + resolved = true + cleanupObservers() + beforeSend?() + terminalPanel.sendText(text) #if DEBUG - recordGotoSplitMoveIfNeeded(direction: .up) + if isReactGrabPasteback { + dlog( + "reactGrab.pasteback h2.send.sent " + + "workspace=\(Self.debugShortId(tab.id)) " + + "target=\(Self.debugShortId(terminalPanel.id)) mode=delayed len=\(text.count)" + ) + } #endif - return true } - if matchConfiguredDirectionalShortcut( - event: event, - action: .focusDown, - arrowGlyph: "↓", - arrowKeyCode: 125 - ) || (ghosttyGotoSplitDownShortcut.map { matchDirectionalShortcut(event: event, shortcut: $0, arrowGlyph: "↓", arrowKeyCode: 125) } ?? false) { - tabManager?.movePaneFocus(direction: .down) + + panelsCancellable = tab.$panels + .map { _ in () } + .sink { _ in #if DEBUG - recordGotoSplitMoveIfNeeded(direction: .down) + if isReactGrabPasteback { + dlog( + "reactGrab.pasteback h2.panelsChanged " + + "workspace=\(Self.debugShortId(tab.id)) " + + "focused=\(Self.debugShortId(tab.focusedPanelId))" + ) + } #endif - return true - } - - if matchConfiguredShortcut(event: event, action: .toggleSplitZoom) { - _ = tabManager?.toggleFocusedSplitZoom() + finishIfReady() + } + if isReactGrabPasteback { + focusObserver = NotificationCenter.default.addObserver( + forName: .ghosttyDidFocusSurface, + object: nil, + queue: .main + ) { note in + guard let candidateTabId = note.userInfo?[GhosttyNotificationKey.tabId] as? UUID, + candidateTabId == tab.id, + let candidateSurfaceId = note.userInfo?[GhosttyNotificationKey.surfaceId] as? UUID else { + return + } #if DEBUG - recordGotoSplitZoomIfNeeded() + dlog( + "reactGrab.pasteback h1.focusEvent " + + "workspace=\(Self.debugShortId(candidateTabId)) " + + "surface=\(Self.debugShortId(candidateSurfaceId)) " + + "target=\(Self.debugShortId(preferredPanelId)) " + + "match=\(candidateSurfaceId == preferredPanelId ? 1 : 0)" + ) #endif - return true - } - - // Split actions: Cmd+D / Cmd+Shift+D - if matchConfiguredShortcut(event: event, action: .splitRight) { + } + firstResponderObserver = NotificationCenter.default.addObserver( + forName: .ghosttyDidBecomeFirstResponderSurface, + object: nil, + queue: .main + ) { note in + guard let candidateTabId = note.userInfo?[GhosttyNotificationKey.tabId] as? UUID, + candidateTabId == tab.id, + let candidateSurfaceId = note.userInfo?[GhosttyNotificationKey.surfaceId] as? UUID else { + return + } #if DEBUG - dlog("shortcut.action name=splitRight \(debugShortcutRouteSnapshot(event: event))") + dlog( + "reactGrab.pasteback h1.firstResponderEvent " + + "workspace=\(Self.debugShortId(candidateTabId)) " + + "surface=\(Self.debugShortId(candidateSurfaceId)) " + + "target=\(Self.debugShortId(preferredPanelId)) " + + "match=\(candidateSurfaceId == preferredPanelId ? 1 : 0)" + ) #endif - if shouldSuppressSplitShortcutForTransientTerminalFocusState(direction: .right) { - return true } - _ = performSplitShortcut( - direction: .right, - preferredWindow: event.window ?? NSApp.keyWindow ?? NSApp.mainWindow - ) - return true } - - if matchConfiguredShortcut(event: event, action: .splitDown) { + readyObserver = NotificationCenter.default.addObserver( + forName: .terminalSurfaceDidBecomeReady, + object: nil, + queue: .main + ) { note in + guard let workspaceId = note.userInfo?["workspaceId"] as? UUID, + workspaceId == tab.id else { return } + let surfaceId = note.userInfo?["surfaceId"] as? UUID #if DEBUG - dlog("shortcut.action name=splitDown \(debugShortcutRouteSnapshot(event: event))") + if isReactGrabPasteback { + dlog( + "reactGrab.pasteback h2.surfaceReadyEvent " + + "workspace=\(Self.debugShortId(workspaceId)) " + + "surface=\(Self.debugShortId(surfaceId)) " + + "target=\(Self.debugShortId(preferredPanelId)) " + + "match=\(surfaceId == preferredPanelId ? 1 : 0)" + ) + } #endif - if shouldSuppressSplitShortcutForTransientTerminalFocusState(direction: .down) { - return true + if let preferredPanelId, + let surfaceId, + surfaceId != preferredPanelId { + return } - _ = performSplitShortcut( - direction: .down, - preferredWindow: event.window ?? NSApp.keyWindow ?? NSApp.mainWindow - ) - return true + finishIfReady() } - - if matchConfiguredShortcut(event: event, action: .splitBrowserRight) { + DispatchQueue.main.asyncAfter(deadline: .now() + 3.0) { + if !resolved { #if DEBUG - dlog("shortcut.action name=splitBrowserRight \(debugShortcutRouteSnapshot(event: event))") + if isReactGrabPasteback { + dlog( + "reactGrab.pasteback h2.send.timeout " + + "workspace=\(Self.debugShortId(tab.id)) " + + "preferred=\(Self.debugShortId(preferredPanelId)) " + + "focused=\(Self.debugShortId(tab.focusedPanelId)) " + + "focusedTerminal=\(Self.debugShortId(tab.focusedTerminalPanel?.id))" + ) + } #endif - _ = performBrowserSplitShortcut(direction: .right) - return true + cleanupObservers() + NSLog("Command send: surface not ready after 3.0s") + } } + } - if matchConfiguredShortcut(event: event, action: .splitBrowserDown) { #if DEBUG - dlog("shortcut.action name=splitBrowserDown \(debugShortcutRouteSnapshot(event: event))") -#endif - _ = performBrowserSplitShortcut(direction: .down) - return true - } - - // Surface navigation (legacy Ctrl+Tab support) - if matchTabShortcut(event: event, shortcut: StoredShortcut(key: "\t", command: false, shift: false, option: false, control: true)) { - tabManager?.selectNextSurface() - return true - } - if matchTabShortcut(event: event, shortcut: StoredShortcut(key: "\t", command: false, shift: true, option: false, control: true)) { - tabManager?.selectPreviousSurface() - return true - } + private let debugColorWorkspaceTitlePrefix = "Debug Color - " + let debugPerfWorkspaceTitlePrefix = "Debug Perf - " + var debugStressWorkspaceCreationInProgress = false + var debugStressLagProbeEnabled = false + let debugStressWorkspaceCount = 20 + let debugStressPaneCount = 4 + let debugStressTabsPerPane = 4 + let debugStressYieldInterval = 4 + let debugStressSurfaceLoadTimeoutSeconds: TimeInterval = 10.0 - // New surface: Cmd+T - if matchConfiguredShortcut(event: event, action: .newSurface) { - tabManager?.newSurface() - return true - } + @objc func openDebugScrollbackTab(_ sender: Any?) { + guard let tabManager else { return } + let tab = tabManager.addTab() + let config = GhosttyConfig.load() + let lineCount = min(max(config.scrollbackLimit * 2, 2000), 60000) + let command = "for i in {1..\(lineCount)}; do printf \"scrollback %06d\\n\" $i; done\n" + sendTextWhenReady(command, to: tab) + } - // Open browser: Cmd+Shift+L - if matchConfiguredShortcut(event: event, action: .openBrowser) { - _ = openBrowserAndFocusAddressBar(insertAtEnd: true) - return true + @objc func openDebugLoremTab(_ sender: Any?) { + guard let tabManager else { return } + let tab = tabManager.addTab() + let lineCount = 2000 + let base = "Lorem ipsum dolor sit amet, consectetur adipiscing elit. Sed do eiusmod tempor incididunt ut labore." + var lines: [String] = [] + lines.reserveCapacity(lineCount) + for index in 1...lineCount { + lines.append(String(format: "%04d %@", index, base)) } + let payload = lines.joined(separator: "\n") + "\n" + sendTextWhenReady(payload, to: tab) + } - if matchConfiguredShortcut(event: event, action: .focusBrowserAddressBar) { - if let focusedPanel = tabManager?.focusedBrowserPanel { - focusBrowserAddressBar(in: focusedPanel) - return true - } + @objc func openDebugColorComparisonWorkspaces(_ sender: Any?) { + guard let tabManager else { return } - if let browserAddressBarFocusedPanelId, - focusBrowserAddressBar(panelId: browserAddressBarFocusedPanelId) { - return true - } + let palette = WorkspaceTabColorSettings.palette() + guard !palette.isEmpty else { return } - if openBrowserAndFocusAddressBar(insertAtEnd: true) != nil { - return true - } + var existingByTitle: [String: Workspace] = [:] + for tab in tabManager.tabs { + guard let title = tab.customTitle, + title.hasPrefix(debugColorWorkspaceTitlePrefix) else { continue } + existingByTitle[title] = tab } - if matchConfiguredShortcut(event: event, action: .browserBack) { - guard let focusedBrowserPanel = tabManager?.focusedBrowserPanel else { - return false + for entry in palette { + let title = "\(debugColorWorkspaceTitlePrefix)\(entry.name)" + let targetTab: Workspace + if let existing = existingByTitle[title] { + targetTab = existing + } else { + targetTab = tabManager.addTab() } - focusedBrowserPanel.goBack() - return true + tabManager.setCustomTitle(tabId: targetTab.id, title: title) + tabManager.setTabColor(tabId: targetTab.id, color: entry.hex) } + } - if matchConfiguredShortcut(event: event, action: .browserForward) { - guard let focusedBrowserPanel = tabManager?.focusedBrowserPanel else { - return false - } - focusedBrowserPanel.goForward() - return true - } - if matchConfiguredShortcut(event: event, action: .browserReload) { - guard let focusedBrowserPanel = tabManager?.focusedBrowserPanel else { - return false +#endif + + + func attachUpdateAccessory(to window: NSWindow) { + titlebarAccessoryController.start() + titlebarAccessoryController.attach(to: window) + } + + func applyWindowDecorations(to window: NSWindow) { + windowDecorationsController.apply(to: window) + } + + func toggleNotificationsPopover(animated: Bool = true, anchorView: NSView? = nil) { + titlebarAccessoryController.toggleNotificationsPopover(animated: animated, anchorView: anchorView) + } + + @discardableResult + func dismissNotificationsPopoverIfShown() -> Bool { + titlebarAccessoryController.dismissNotificationsPopoverIfShown() + } + + func isNotificationsPopoverShown() -> Bool { + titlebarAccessoryController.isNotificationsPopoverShown() + } + + func jumpToLatestUnread() { + guard let notificationStore else { return } +#if DEBUG + if ProcessInfo.processInfo.environment["PROGRAMA_UI_TEST_JUMP_UNREAD_SETUP"] == "1" { + writeJumpUnreadTestData([ + "jumpUnreadInvoked": "1", + "jumpUnreadNotificationCount": String(notificationStore.notifications.count), + ]) + } +#endif + // Prefer the latest unread that we can actually open. In early startup (especially on the VM), + // the window-context registry can lag behind model initialization, so fall back to whatever + // tab manager currently owns the tab. + for notification in notificationStore.notifications where !notification.isRead { + if openNotification(tabId: notification.tabId, surfaceId: notification.surfaceId, notificationId: notification.id) { + return } - focusedBrowserPanel.reload() - return true } + } + + static func installWindowResponderSwizzlesForTesting() { + _ = didInstallWindowKeyEquivalentSwizzle + _ = didInstallWindowFirstResponderSwizzle + _ = didInstallWindowSendEventSwizzle + } - // Safari defaults: - // - Option+Command+I => Show/Toggle Web Inspector - // - Option+Command+C => Show JavaScript Console - if matchConfiguredShortcut(event: event, action: .toggleBrowserDeveloperTools) { #if DEBUG - logDeveloperToolsShortcutSnapshot(phase: "toggle.pre", event: event) + static func setWindowFirstResponderGuardTesting(currentEvent: NSEvent?, hitView: NSView?) { + programaFirstResponderGuardCurrentEventOverride = currentEvent + programaFirstResponderGuardHitViewOverride = hitView + } + + static func clearWindowFirstResponderGuardTesting() { + programaFirstResponderGuardCurrentEventOverride = nil + programaFirstResponderGuardHitViewOverride = nil + } #endif - let didHandle = tabManager?.toggleDeveloperToolsFocusedBrowser() ?? false + + private func installWindowResponderSwizzles() { + _ = Self.didInstallApplicationSendEventSwizzle + _ = Self.didInstallWindowKeyEquivalentSwizzle + _ = Self.didInstallWindowFirstResponderSwizzle + _ = Self.didInstallWindowSendEventSwizzle + } + + private func installShortcutMonitor() { + // Local monitor only receives events when app is active (not global) + shortcutMonitor = NSEvent.addLocalMonitorForEvents(matching: [.keyDown, .keyUp, .flagsChanged]) { [weak self] event in + guard let self else { return event } + if event.type == .keyDown { #if DEBUG - logDeveloperToolsShortcutSnapshot(phase: "toggle.post", event: event, didHandle: didHandle) - DispatchQueue.main.async { [weak self] in - self?.logDeveloperToolsShortcutSnapshot(phase: "toggle.tick", didHandle: didHandle) - } + let phaseTotalStart = ProcessInfo.processInfo.systemUptime + let preludeStart = ProcessInfo.processInfo.systemUptime + var preludeMs: Double = 0 + var shortcutMs: Double = 0 + ProgramaTypingTiming.logEventDelay(path: "appMonitor", event: event) + let shortcutMonitorTraceEnabled = + ProcessInfo.processInfo.environment["PROGRAMA_SHORTCUT_MONITOR_TRACE"] == "1" + || UserDefaults.standard.bool(forKey: "programaShortcutMonitorTrace") + if shortcutMonitorTraceEnabled { + let frType = NSApp.keyWindow?.firstResponder.map { String(describing: type(of: $0)) } ?? "nil" + dlog( + "monitor.keyDown: \(NSWindow.keyDescription(event)) fr=\(frType) addrBarId=\(self.browserAddressBarFocusedPanelId?.uuidString.prefix(8) ?? "nil") \(self.debugShortcutRouteSnapshot(event: event))" + ) + } + if let probeKind = self.developerToolsShortcutProbeKind(event: event) { + self.logDeveloperToolsShortcutSnapshot(phase: "monitor.pre.\(probeKind)", event: event) + } + preludeMs = (ProcessInfo.processInfo.systemUptime - preludeStart) * 1000.0 + let shortcutTimingStart = ProgramaTypingTiming.start() #endif - if !didHandle { NSSound.beep() } - return true - } - - if matchConfiguredShortcut(event: event, action: .showBrowserJavaScriptConsole) { + let shortcutStart = ProcessInfo.processInfo.systemUptime + let handledByShortcut = self.handleCustomShortcut(event: event) #if DEBUG - logDeveloperToolsShortcutSnapshot(phase: "console.pre", event: event) + shortcutMs = (ProcessInfo.processInfo.systemUptime - shortcutStart) * 1000.0 + ProgramaTypingTiming.logDuration( + path: "appMonitor.handleCustomShortcut", + startedAt: shortcutTimingStart, + event: event, + extra: "handled=\(handledByShortcut ? 1 : 0)" + ) + let shortcutElapsedMs = (ProcessInfo.processInfo.systemUptime - shortcutStart) * 1000.0 + self.logSlowShortcutMonitorLatencyIfNeeded( + event: event, + handledByShortcut: handledByShortcut, + elapsedMs: shortcutElapsedMs + ) + let totalMs = (ProcessInfo.processInfo.systemUptime - phaseTotalStart) * 1000.0 + ProgramaTypingTiming.logBreakdown( + path: "appMonitor.phase", + totalMs: totalMs, + event: event, + thresholdMs: 0.75, + parts: [ + ("preludeMs", preludeMs), + ("shortcutMs", shortcutMs), + ], + extra: "handled=\(handledByShortcut ? 1 : 0)" + ) #endif - let didHandle = tabManager?.showJavaScriptConsoleFocusedBrowser() ?? false + if handledByShortcut { #if DEBUG - logDeveloperToolsShortcutSnapshot(phase: "console.post", event: event, didHandle: didHandle) - DispatchQueue.main.async { [weak self] in - self?.logDeveloperToolsShortcutSnapshot(phase: "console.tick", didHandle: didHandle) - } + dlog(" → consumed by handleCustomShortcut") #endif - if !didHandle { NSSound.beep() } - return true + return nil // Consume the event + } + return event // Pass through + } + self.handleBrowserOmnibarSelectionRepeatLifecycleEvent(event) + if self.clearEscapeSuppressionForKeyUp(event: event, consumeIfSuppressed: true) { + return nil + } + return event } + } - if matchConfiguredShortcut(event: event, action: .toggleReactGrab) { - let didHandle = tabManager?.toggleReactGrabFromCurrentFocus() ?? false - if !didHandle { NSSound.beep() } - return true + private func installShortcutDefaultsObserver() { + guard shortcutDefaultsObserver == nil else { return } + refreshConfiguredShortcutChordActions() + shortcutDefaultsObserver = NotificationCenter.default.addObserver( + forName: KeyboardShortcutSettings.didChangeNotification, + object: nil, + queue: .main + ) { [weak self] _ in + self?.refreshConfiguredShortcutChordActions() + self?.clearConfiguredShortcutChordState() + self?.scheduleSplitButtonTooltipRefreshAcrossWorkspaces() } + } - if matchConfiguredShortcut(event: event, action: .browserZoomIn) { - return tabManager?.zoomInFocusedBrowser() ?? false + private func refreshConfiguredShortcutChordActions() { + configuredShortcutChordActions = KeyboardShortcutSettings.Action.allCases.filter { + KeyboardShortcutSettings.shortcut(for: $0).hasChord } + } - if matchConfiguredShortcut(event: event, action: .browserZoomOut) { - return tabManager?.zoomOutFocusedBrowser() ?? false - } + private func clearConfiguredShortcutChordState() { + pendingConfiguredShortcutChord = nil + activeConfiguredShortcutChordPrefixForCurrentEvent = nil + } - if matchConfiguredShortcut(event: event, action: .browserZoomReset) { - return tabManager?.resetZoomFocusedBrowser() ?? false + /// Coalesce shortcut-default changes and refresh on the next runloop turn to + /// avoid mutating Bonsplit/SwiftUI-observed state during an active update pass. + private func scheduleSplitButtonTooltipRefreshAcrossWorkspaces() { + guard !splitButtonTooltipRefreshScheduled else { return } + splitButtonTooltipRefreshScheduled = true + DispatchQueue.main.async { [weak self] in + guard let self else { return } + self.splitButtonTooltipRefreshScheduled = false + self.refreshSplitButtonTooltipsAcrossWorkspaces() } + } - if matchConfiguredShortcut(event: event, action: .find) { - guard !shouldLetFocusedBrowserOwnFindShortcut(event) else { - return false - } - tabManager?.startSearch() - return true + private func refreshSplitButtonTooltipsAcrossWorkspaces() { + var refreshedManagers: Set = [] + if let manager = tabManager { + manager.refreshSplitButtonTooltips() + refreshedManagers.insert(ObjectIdentifier(manager)) + } + for context in mainWindowContexts.values { + let manager = context.tabManager + let identifier = ObjectIdentifier(manager) + guard refreshedManagers.insert(identifier).inserted else { continue } + manager.refreshSplitButtonTooltips() } + } - if matchConfiguredShortcut(event: event, action: .findNext) { - guard !shouldLetFocusedBrowserOwnFindShortcut(event) else { - return false - } - tabManager?.findNext() - return true + private func installGhosttyConfigObserver() { + guard ghosttyConfigObserver == nil else { return } + ghosttyConfigObserver = NotificationCenter.default.addObserver( + forName: .ghosttyConfigDidReload, + object: nil, + queue: .main + ) { [weak self] _ in + self?.refreshGhosttyGotoSplitShortcuts() } + } - if matchConfiguredShortcut(event: event, action: .findPrevious) { - guard !shouldLetFocusedBrowserOwnFindShortcut(event) else { - return false - } - tabManager?.findPrevious() - return true + private func refreshGhosttyGotoSplitShortcuts() { + guard let config = GhosttyApp.shared.config else { + ghosttyGotoSplitLeftShortcut = nil + ghosttyGotoSplitRightShortcut = nil + ghosttyGotoSplitUpShortcut = nil + ghosttyGotoSplitDownShortcut = nil + return } - if matchConfiguredShortcut(event: event, action: .hideFind) { - guard !shouldLetFocusedBrowserOwnFindShortcut(event) else { - return false + ghosttyGotoSplitLeftShortcut = storedShortcutFromGhosttyTrigger( + ghostty_config_trigger(config, "goto_split:left", UInt("goto_split:left".utf8.count)) + ) + ghosttyGotoSplitRightShortcut = storedShortcutFromGhosttyTrigger( + ghostty_config_trigger(config, "goto_split:right", UInt("goto_split:right".utf8.count)) + ) + ghosttyGotoSplitUpShortcut = storedShortcutFromGhosttyTrigger( + ghostty_config_trigger(config, "goto_split:up", UInt("goto_split:up".utf8.count)) + ) + ghosttyGotoSplitDownShortcut = storedShortcutFromGhosttyTrigger( + ghostty_config_trigger(config, "goto_split:down", UInt("goto_split:down".utf8.count)) + ) + } + + private func storedShortcutFromGhosttyTrigger(_ trigger: ghostty_input_trigger_s) -> StoredShortcut? { + let key: String + switch trigger.tag { + case GHOSTTY_TRIGGER_PHYSICAL: + switch trigger.key.physical { + case GHOSTTY_KEY_ARROW_LEFT: + key = "←" + case GHOSTTY_KEY_ARROW_RIGHT: + key = "→" + case GHOSTTY_KEY_ARROW_UP: + key = "↑" + case GHOSTTY_KEY_ARROW_DOWN: + key = "↓" + case GHOSTTY_KEY_A: key = "a" + case GHOSTTY_KEY_B: key = "b" + case GHOSTTY_KEY_C: key = "c" + case GHOSTTY_KEY_D: key = "d" + case GHOSTTY_KEY_E: key = "e" + case GHOSTTY_KEY_F: key = "f" + case GHOSTTY_KEY_G: key = "g" + case GHOSTTY_KEY_H: key = "h" + case GHOSTTY_KEY_I: key = "i" + case GHOSTTY_KEY_J: key = "j" + case GHOSTTY_KEY_K: key = "k" + case GHOSTTY_KEY_L: key = "l" + case GHOSTTY_KEY_M: key = "m" + case GHOSTTY_KEY_N: key = "n" + case GHOSTTY_KEY_O: key = "o" + case GHOSTTY_KEY_P: key = "p" + case GHOSTTY_KEY_Q: key = "q" + case GHOSTTY_KEY_R: key = "r" + case GHOSTTY_KEY_S: key = "s" + case GHOSTTY_KEY_T: key = "t" + case GHOSTTY_KEY_U: key = "u" + case GHOSTTY_KEY_V: key = "v" + case GHOSTTY_KEY_W: key = "w" + case GHOSTTY_KEY_X: key = "x" + case GHOSTTY_KEY_Y: key = "y" + case GHOSTTY_KEY_Z: key = "z" + case GHOSTTY_KEY_DIGIT_0: key = "0" + case GHOSTTY_KEY_DIGIT_1: key = "1" + case GHOSTTY_KEY_DIGIT_2: key = "2" + case GHOSTTY_KEY_DIGIT_3: key = "3" + case GHOSTTY_KEY_DIGIT_4: key = "4" + case GHOSTTY_KEY_DIGIT_5: key = "5" + case GHOSTTY_KEY_DIGIT_6: key = "6" + case GHOSTTY_KEY_DIGIT_7: key = "7" + case GHOSTTY_KEY_DIGIT_8: key = "8" + case GHOSTTY_KEY_DIGIT_9: key = "9" + case GHOSTTY_KEY_BRACKET_LEFT: key = "[" + case GHOSTTY_KEY_BRACKET_RIGHT: key = "]" + case GHOSTTY_KEY_MINUS: key = "-" + case GHOSTTY_KEY_EQUAL: key = "=" + case GHOSTTY_KEY_COMMA: key = "," + case GHOSTTY_KEY_PERIOD: key = "." + case GHOSTTY_KEY_SLASH: key = "/" + case GHOSTTY_KEY_SEMICOLON: key = ";" + case GHOSTTY_KEY_QUOTE: key = "'" + case GHOSTTY_KEY_BACKQUOTE: key = "`" + case GHOSTTY_KEY_BACKSLASH: key = "\\" + default: + return nil } - tabManager?.hideFind() - return true + case GHOSTTY_TRIGGER_UNICODE: + guard let scalar = UnicodeScalar(trigger.key.unicode) else { return nil } + key = String(Character(scalar)).lowercased() + case GHOSTTY_TRIGGER_CATCH_ALL: + return nil + default: + return nil } - if matchConfiguredShortcut(event: event, action: .useSelectionForFind) { - tabManager?.searchSelection() - return true - } + let mods = trigger.mods.rawValue + let command = (mods & GHOSTTY_MODS_SUPER.rawValue) != 0 + let shift = (mods & GHOSTTY_MODS_SHIFT.rawValue) != 0 + let option = (mods & GHOSTTY_MODS_ALT.rawValue) != 0 + let control = (mods & GHOSTTY_MODS_CTRL.rawValue) != 0 - if matchConfiguredShortcut(event: event, action: .reopenClosedBrowserPanel) { - _ = tabManager?.reopenMostRecentlyClosedBrowserPanel() - return true + // Ignore bogus empty triggers. + if key.isEmpty || (!command && !shift && !option && !control) { + return nil } - return false + return StoredShortcut(key: key, command: command, shift: shift, option: option, control: control) } - private func shouldSuppressSplitShortcutForTransientTerminalFocusState(direction: SplitDirection) -> Bool { - guard let tabManager, - let workspace = tabManager.selectedWorkspace, - let focusedPanelId = workspace.focusedPanelId, - let terminalPanel = workspace.terminalPanel(for: focusedPanelId) else { - return false + private func handleQuitShortcutWarning() -> Bool { + // Tagged DEV builds are ephemeral, skip quit confirmation entirely. + if SocketControlSettings.isTaggedDevBuild() { + NSApp.terminate(nil) + return true } - let hostedView = terminalPanel.hostedView - let hostedSize = hostedView.bounds.size - let hostedHiddenInHierarchy = hostedView.isHiddenOrHasHiddenAncestor - let hostedAttachedToWindow = hostedView.window != nil - let firstResponderIsWindow = NSApp.keyWindow?.firstResponder is NSWindow + if !QuitWarningSettings.isEnabled() { + NSApp.terminate(nil) + return true + } - let shouldSuppress = shouldSuppressSplitShortcutForTransientTerminalFocusInputs( - firstResponderIsWindow: firstResponderIsWindow, - hostedSize: hostedSize, - hostedHiddenInHierarchy: hostedHiddenInHierarchy, - hostedAttachedToWindow: hostedAttachedToWindow - ) - guard shouldSuppress else { return false } + let alert = NSAlert() + alert.alertStyle = .warning + alert.messageText = String(localized: "dialog.quitPrograma.title", defaultValue: "Quit Programa?") + alert.informativeText = String(localized: "dialog.quitPrograma.message", defaultValue: "This will close all windows and workspaces.") + alert.addButton(withTitle: String(localized: "dialog.quitPrograma.quit", defaultValue: "Quit")) + alert.addButton(withTitle: String(localized: "common.cancel", defaultValue: "Cancel")) + alert.showsSuppressionButton = true + alert.suppressionButton?.title = String(localized: "dialog.dontWarnCmdQ", defaultValue: "Don't warn again for Cmd+Q") - tabManager.reconcileFocusedPanelFromFirstResponderForKeyboard() + let response = alert.runModal() + if alert.suppressionButton?.state == .on { + QuitWarningSettings.setEnabled(false) + } -#if DEBUG - let directionLabel: String - switch direction { - case .left: directionLabel = "left" - case .right: directionLabel = "right" - case .up: directionLabel = "up" - case .down: directionLabel = "down" + if response == .alertFirstButtonReturn { + // Mark as confirmed so applicationShouldTerminate does not show a + // second alert when NSApp.terminate re-enters the delegate callback. + isQuitWarningConfirmed = true + NSApp.terminate(nil) } - let firstResponderType = NSApp.keyWindow?.firstResponder.map { String(describing: type(of: $0)) } ?? "nil" - dlog( - "split.shortcut suppressed dir=\(directionLabel) reason=transient_focus_state " + - "fr=\(firstResponderType) hidden=\(hostedHiddenInHierarchy ? 1 : 0) " + - "attached=\(hostedAttachedToWindow ? 1 : 0) " + - "frame=\(String(format: "%.1fx%.1f", hostedSize.width, hostedSize.height))" - ) -#endif return true } -#if DEBUG - private func logBrowserZoomShortcutTrace( - stage: String, - event: NSEvent, - flags: NSEvent.ModifierFlags, - chars: String, - action: BrowserZoomShortcutAction? = nil, - handled: Bool? = nil - ) { - guard browserZoomShortcutTraceCandidate( - flags: flags, - chars: chars, - keyCode: event.keyCode, - literalChars: event.characters - ) else { - return + func promptRenameSelectedWorkspace() -> Bool { + guard let tabManager, + let tabId = tabManager.selectedTabId, + let tab = tabManager.tabs.first(where: { $0.id == tabId }) else { + NSSound.beep() + return false } - let keyWindow = NSApp.keyWindow - let firstResponderType = keyWindow?.firstResponder.map { String(describing: type(of: $0)) } ?? "nil" - let panel = tabManager?.focusedBrowserPanel - let panelToken = panel.map { String($0.id.uuidString.prefix(8)) } ?? "nil" - let panelZoom = panel?.webView.pageZoom ?? -1 - var line = - "zoom.shortcut stage=\(stage) event=\(NSWindow.keyDescription(event)) " + - "chars='\(chars)' flags=\(browserZoomShortcutTraceFlagsString(flags)) " + - "action=\(browserZoomShortcutTraceActionString(action)) keyWin=\(keyWindow?.windowNumber ?? -1) " + - "fr=\(firstResponderType) panel=\(panelToken) zoom=\(String(format: "%.3f", panelZoom)) " + - "addrBarId=\(browserAddressBarFocusedPanelId?.uuidString.prefix(8) ?? "nil")" - if let handled { - line += " handled=\(handled ? 1 : 0)" + let alert = NSAlert() + alert.messageText = String(localized: "dialog.renameWorkspace.title", defaultValue: "Rename Workspace") + alert.informativeText = String(localized: "dialog.renameWorkspace.message", defaultValue: "Enter a custom name for this workspace.") + let input = NSTextField(string: tab.customTitle ?? tab.title) + input.placeholderString = String(localized: "dialog.renameWorkspace.placeholder", defaultValue: "Workspace 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")) + let alertWindow = alert.window + alertWindow.initialFirstResponder = input + DispatchQueue.main.async { + alertWindow.makeFirstResponder(input) + input.selectText(nil) } - dlog(line) - } - private func browserFocusStateSnapshot() -> String { - let selected = tabManager?.selectedTabId.map { String($0.uuidString.prefix(5)) } ?? "nil" - let focused = tabManager?.selectedWorkspace?.focusedPanelId.map { String($0.uuidString.prefix(5)) } ?? "nil" - let addressBar = browserAddressBarFocusedPanelId.map { String($0.uuidString.prefix(5)) } ?? "nil" - let keyWindow = NSApp.keyWindow?.windowNumber ?? -1 - let firstResponderType = NSApp.keyWindow?.firstResponder.map { String(describing: type(of: $0)) } ?? "nil" - return "selected=\(selected) focused=\(focused) addr=\(addressBar) keyWin=\(keyWindow) fr=\(firstResponderType)" + let response = alert.runModal() + guard response == .alertFirstButtonReturn else { return true } + tabManager.setCustomTitle(tabId: tab.id, title: input.stringValue) + return true } - private func redactedDebugURL(_ url: URL?) -> String { - guard let url else { return "nil" } - guard var components = URLComponents(url: url, resolvingAgainstBaseURL: false) else { - return "" + private func handleCustomShortcut(event: NSEvent) -> Bool { + // `charactersIgnoringModifiers` can be nil for some synthetic NSEvents and certain special keys. + // Treat nil as "" and rely on keyCode/layout-aware fallback logic where needed. + // When a non-Latin input source is active (Korean, Chinese, Japanese, etc.), + // charactersIgnoringModifiers returns non-ASCII characters that never match + // Latin shortcut keys. Normalize via KeyboardLayout so downstream comparisons + // (Cmd+1-9, Ctrl+1-9, omnibar N/P, command palette, etc.) work correctly. + let chars = KeyboardLayout.normalizedCharacters(for: event) + let flags = event.modifierFlags.intersection(.deviceIndependentFlagsMask) + let hasControl = flags.contains(.control) + let hasCommand = flags.contains(.command) + let hasOption = flags.contains(.option) + let isControlOnly = hasControl && !hasCommand && !hasOption + let controlDChar = chars == "d" || event.characters == "\u{04}" + let isControlD = isControlOnly && (controlDChar || event.keyCode == 2) + let configuredShortcutEventWindowNumber = configuredShortcutChordWindowNumber(for: event) + if let pendingConfiguredShortcutChord, + pendingConfiguredShortcutChord.windowNumber == configuredShortcutEventWindowNumber { + activeConfiguredShortcutChordPrefixForCurrentEvent = pendingConfiguredShortcutChord.firstStroke + } else { + activeConfiguredShortcutChordPrefixForCurrentEvent = nil } - components.user = nil - components.password = nil - components.query = nil - components.fragment = nil - return components.string ?? "" - } -#endif - - @discardableResult - private func focusBrowserAddressBar(panelId: UUID) -> Bool { - guard let tabManager, - let workspace = tabManager.selectedWorkspace, - let panel = workspace.browserPanel(for: panelId) else { + pendingConfiguredShortcutChord = nil + defer { activeConfiguredShortcutChordPrefixForCurrentEvent = nil } #if DEBUG - dlog( - "browser.focus.addressBar.route panel=\(panelId.uuidString.prefix(5)) " + - "result=miss \(browserFocusStateSnapshot())" + if isControlD { + writeChildExitKeyboardProbe( + [ + "probeAppShortcutCharsHex": childExitKeyboardProbeHex(event.characters), + "probeAppShortcutCharsIgnoringHex": childExitKeyboardProbeHex(event.charactersIgnoringModifiers), + "probeAppShortcutKeyCode": String(event.keyCode), + "probeAppShortcutModsRaw": String(event.modifierFlags.rawValue), + ], + increments: ["probeAppShortcutCtrlDSeenCount": 1] ) + } #endif + + // Don't steal shortcuts from close-confirmation alerts. Keep standard alert key + // equivalents working and avoid surprising actions while the confirmation is up. + let closeConfirmationTitles = [ + String(localized: "dialog.closeWorkspace.title", defaultValue: "Close workspace?"), + String(localized: "dialog.closeWorkspaces.title", defaultValue: "Close workspaces?"), + String(localized: "dialog.closeTab.title", defaultValue: "Close tab?"), + String(localized: "dialog.closeOtherTabs.title", defaultValue: "Close other tabs?"), + String(localized: "dialog.closeWindow.title", defaultValue: "Close window?"), + ] + let closeConfirmationPanel = NSApp.windows + .compactMap { $0 as? NSPanel } + .first { panel in + guard panel.isVisible, let root = panel.contentView else { return false } + return closeConfirmationTitles.contains { title in + findStaticText(in: root, equals: title) + } + } + if let closeConfirmationPanel { + // Special-case: Cmd+D should confirm destructive close on alerts. + // XCUITest key events often hit the app-level local monitor first, so forward the key + // equivalent to the alert panel explicitly. + if matchShortcut( + event: event, + shortcut: StoredShortcut(key: "d", command: true, shift: false, option: false, control: false) + ), + let root = closeConfirmationPanel.contentView, + let closeButton = findButton( + in: root, + titled: String(localized: "common.close", defaultValue: "Close") + ) { + closeButton.performClick(nil) + return true + } return false } + + if NSApp.modalWindow != nil || NSApp.keyWindow?.attachedSheet != nil { + return false + } + + let normalizedFlags = flags.subtracting([.numericPad, .function, .capsLock]) + let commandPaletteTargetWindow = commandPaletteWindowForShortcutEvent(event) + let commandPaletteShortcutWindow = shouldHandleCommandPaletteShortcutEvent( + event, + paletteWindow: commandPaletteTargetWindow + ) ? commandPaletteTargetWindow : nil + let commandPaletteVisibleInTargetWindow = commandPaletteShortcutWindow.map { + isCommandPaletteVisible(for: $0) + } ?? false + let commandPalettePendingOpenInTargetWindow = commandPaletteTargetWindow.map { + isCommandPalettePendingOpen(for: $0) + } ?? false + let commandPaletteOverlayVisibleInTargetWindow = commandPaletteTargetWindow.map { + isCommandPaletteOverlayPresented(in: $0) + } ?? false + let commandPaletteResponderActiveInTargetWindow = commandPaletteTargetWindow.map { + isCommandPaletteResponderActive(in: $0) + } ?? false + let commandPaletteInteractiveInTargetWindow = + commandPaletteVisibleInTargetWindow + || commandPaletteOverlayVisibleInTargetWindow + || commandPaletteResponderActiveInTargetWindow + let commandPaletteEffectiveInTargetWindow = + commandPaletteInteractiveInTargetWindow + || commandPalettePendingOpenInTargetWindow + #if DEBUG - dlog( - "browser.focus.addressBar.route panel=\(panel.id.uuidString.prefix(5)) " + - "workspace=\(workspace.id.uuidString.prefix(5)) result=hit \(browserFocusStateSnapshot())" - ) -#endif - workspace.focusPanel(panel.id) -#if DEBUG - let focusedAfter = workspace.focusedPanelId.map { String($0.uuidString.prefix(5)) } ?? "nil" - dlog( - "browser.focus.addressBar.route panel=\(panel.id.uuidString.prefix(5)) " + - "workspace=\(workspace.id.uuidString.prefix(5)) focusedAfter=\(focusedAfter)" - ) + if event.keyCode == 36 || event.keyCode == 76 { + dlog( + "shortcut.return.raw " + + "interactive=\(commandPaletteInteractiveInTargetWindow ? 1 : 0) " + + "effective=\(commandPaletteEffectiveInTargetWindow ? 1 : 0) " + + "target={\(debugWindowToken(commandPaletteTargetWindow))} " + + "shortcutWindow={\(debugWindowToken(commandPaletteShortcutWindow))} " + + "responderTarget=\(commandPaletteResponderActiveInTargetWindow ? 1 : 0) " + + "overlayTarget=\(commandPaletteOverlayVisibleInTargetWindow ? 1 : 0) " + + "pendingTarget=\(commandPalettePendingOpenInTargetWindow ? 1 : 0) " + + "\(debugShortcutRouteSnapshot(event: event))" + ) + } #endif - focusBrowserAddressBar(in: panel) - return true - } - @discardableResult - func openBrowserAndFocusAddressBar(url: URL? = nil, insertAtEnd: Bool = false) -> UUID? { - let preferredProfileID = - tabManager?.focusedBrowserPanel?.profileID - ?? tabManager?.selectedWorkspace?.preferredBrowserProfileID - guard let panelId = tabManager?.openBrowser( - url: url, - preferredProfileID: preferredProfileID, - insertAtEnd: insertAtEnd - ) else { + if normalizedFlags.isEmpty, event.keyCode == 53 { + let activePaletteWindow = activeCommandPaletteWindow() + let escapePaletteWindow: NSWindow? = { + if let targetWindow = commandPaletteTargetWindow { + guard commandPaletteEffectiveInTargetWindow else { + return nil + } + return targetWindow + } + return activePaletteWindow + }() #if DEBUG dlog( - "browser.focus.openAndFocus result=open_failed insertAtEnd=\(insertAtEnd ? 1 : 0) " + - "url=\(redactedDebugURL(url)) \(browserFocusStateSnapshot())" + "shortcut.escape route target={\(debugWindowToken(commandPaletteTargetWindow))} " + + "active={\(debugWindowToken(activePaletteWindow))} " + + "visibleTarget=\(commandPaletteVisibleInTargetWindow ? 1 : 0) " + + "pendingTarget=\(commandPalettePendingOpenInTargetWindow ? 1 : 0) " + + "overlayTarget=\(commandPaletteOverlayVisibleInTargetWindow ? 1 : 0) " + + "responderTarget=\(commandPaletteResponderActiveInTargetWindow ? 1 : 0) " + + "effectiveTarget=\(commandPaletteEffectiveInTargetWindow ? 1 : 0) " + + "\(debugShortcutRouteSnapshot(event: event))" ) + if commandPaletteTargetWindow != nil, + !commandPaletteVisibleInTargetWindow, + !commandPalettePendingOpenInTargetWindow, + (commandPaletteOverlayVisibleInTargetWindow || commandPaletteResponderActiveInTargetWindow) { + dlog( + "shortcut.escape stateMismatch target={\(debugWindowToken(commandPaletteTargetWindow))} " + + "overlayTarget=\(commandPaletteOverlayVisibleInTargetWindow ? 1 : 0) " + + "responderTarget=\(commandPaletteResponderActiveInTargetWindow ? 1 : 0)" + ) + } #endif - return nil - } + if let paletteWindow = escapePaletteWindow, + isCommandPaletteEffectivelyVisible(in: paletteWindow) { + if commandPaletteMarkedTextInput(in: paletteWindow) != nil { #if DEBUG - dlog( - "browser.focus.openAndFocus result=open_ok panel=\(panelId.uuidString.prefix(5)) " + - "insertAtEnd=\(insertAtEnd ? 1 : 0) url=\(redactedDebugURL(url))" - ) + dlog( + "shortcut.escape imeMarkedTextBypass consumed=0 target={\(debugWindowToken(paletteWindow))}" + ) #endif + return false + } + clearCommandPalettePendingOpen(for: paletteWindow) + beginCommandPaletteEscapeSuppression(for: paletteWindow) + NotificationCenter.default.post(name: .commandPaletteToggleRequested, object: paletteWindow) #if DEBUG - let didFocus = focusBrowserAddressBar(panelId: panelId) - dlog( - "browser.focus.openAndFocus result=focus_request panel=\(panelId.uuidString.prefix(5)) " + - "focused=\(didFocus ? 1 : 0) \(browserFocusStateSnapshot())" - ) -#else - _ = focusBrowserAddressBar(panelId: panelId) + dlog("shortcut.escape paletteDismiss consumed=1 target={\(debugWindowToken(paletteWindow))}") #endif - return panelId - } - - private func focusBrowserAddressBar(in panel: BrowserPanel) { + return true + } + let suppressionWindow = commandPaletteTargetWindow + ?? event.window + ?? NSApp.keyWindow + ?? NSApp.mainWindow + if shouldConsumeSuppressedEscape(event: event, window: suppressionWindow) { #if DEBUG - let requestId = panel.requestAddressBarFocus() - dlog( - "browser.focus.addressBar.request panel=\(panel.id.uuidString.prefix(5)) " + - "request=\(requestId.uuidString.prefix(8)) \(browserFocusStateSnapshot())" - ) -#else - _ = panel.requestAddressBarFocus() + dlog( + "shortcut.escape suppressionConsume consumed=1 target={\(debugWindowToken(suppressionWindow))} " + + "repeat=\(event.isARepeat ? 1 : 0)" + ) #endif - browserAddressBarFocusedPanelId = panel.id + return true + } + if let requestAge = recentCommandPaletteRequestAge(for: suppressionWindow) { + beginCommandPaletteEscapeSuppression(for: suppressionWindow) #if DEBUG - dlog( - "browser.focus.addressBar.sticky panel=\(panel.id.uuidString.prefix(5)) " + - "request=\(requestId.uuidString.prefix(8)) \(browserFocusStateSnapshot())" - ) + dlog( + "shortcut.escape requestGraceConsume consumed=1 target={\(debugWindowToken(suppressionWindow))} " + + "ageMs=\(Int(requestAge * 1000)) repeat=\(event.isARepeat ? 1 : 0)" + ) #endif - NotificationCenter.default.post(name: .browserFocusAddressBar, object: panel.id) + return true + } #if DEBUG - dlog( - "browser.focus.addressBar.notify panel=\(panel.id.uuidString.prefix(5)) " + - "request=\(requestId.uuidString.prefix(8))" - ) + dlog( + "shortcut.escape paletteDismiss consumed=0 target={\(debugWindowToken(commandPaletteTargetWindow))} " + + "active={\(debugWindowToken(activePaletteWindow))}" + ) #endif - } + } - func focusedBrowserAddressBarPanelId() -> UUID? { - browserAddressBarFocusedPanelId - } + let paletteUsesInlineTextHandling = commandPaletteShortcutWindow.map { + isCommandPaletteMultilineTextResponderActive(in: $0) + } ?? false - private func focusedBrowserAddressBarPanelIdForShortcutEvent(_ event: NSEvent) -> UUID? { - guard let panelId = browserAddressBarFocusedPanelId else { return nil } + let paletteSelectionDelta = commandPaletteSelectionDeltaForKeyboardNavigation( + flags: event.modifierFlags, + chars: chars, + keyCode: event.keyCode + ) - guard let context = preferredMainWindowContextForShortcutRouting(event: event) else { -#if DEBUG - dlog( - "browser.focus.addressBar.shortcutContext panel=\(panelId.uuidString.prefix(5)) " + - "accepted=0 reason=no_context event=\(NSWindow.keyDescription(event))" + if shouldRouteCommandPaletteSelectionNavigation( + delta: paletteSelectionDelta, + isInteractive: commandPaletteInteractiveInTargetWindow, + usesInlineTextHandling: paletteUsesInlineTextHandling + ), + let delta = paletteSelectionDelta, + let paletteWindow = commandPaletteShortcutWindow { + NotificationCenter.default.post( + name: .commandPaletteMoveSelection, + object: paletteWindow, + userInfo: ["delta": delta] ) -#endif - return nil + return true } - guard let workspace = context.tabManager.selectedWorkspace else { -#if DEBUG - dlog( - "browser.focus.addressBar.shortcutContext panel=\(panelId.uuidString.prefix(5)) " + - "accepted=0 reason=no_workspace event=\(NSWindow.keyDescription(event))" + if commandPaletteInteractiveInTargetWindow, + let paletteWindow = commandPaletteShortcutWindow { + let paletteFieldEditorHasMarkedText = commandPaletteFieldEditorHasMarkedText(in: paletteWindow) + let paletteSnapshot = mainWindowId(for: paletteWindow).map(commandPaletteSnapshot(windowId:)) ?? .empty + let paletteUsesInlineReturnHandling = paletteUsesInlineTextHandling + if normalizedFlags.isEmpty, event.keyCode == 53 { + if paletteFieldEditorHasMarkedText { + return false + } + NotificationCenter.default.post(name: .commandPaletteDismissRequested, object: paletteWindow) + return true + } + + let shouldSubmitPalette = shouldSubmitCommandPaletteWithReturn( + keyCode: event.keyCode, + flags: event.modifierFlags, + mode: paletteSnapshot.mode ) +#if DEBUG + if event.keyCode == 36 || event.keyCode == 76 { + dlog( + "shortcut.palette.return target={\(debugWindowToken(paletteWindow))} " + + "mode=\(paletteSnapshot.mode) " + + "inline=\(paletteUsesInlineReturnHandling ? 1 : 0) " + + "submit=\(shouldSubmitPalette ? 1 : 0) " + + "marked=\(paletteFieldEditorHasMarkedText ? 1 : 0) " + + "\(debugShortcutRouteSnapshot(event: event))" + ) + } #endif - return nil + if paletteUsesInlineReturnHandling, + event.keyCode == 36 || event.keyCode == 76 { + return false + } + if shouldSubmitPalette { + if paletteFieldEditorHasMarkedText { + return false + } + NotificationCenter.default.post(name: .commandPaletteSubmitRequested, object: paletteWindow) + return true + } } - guard workspace.browserPanel(for: panelId) != nil else { + // Guard against stale browserAddressBarFocusedPanelId after focus transitions + // (e.g., split that doesn't properly blur the address bar). If the first responder + // is a terminal surface, the address bar can't be focused. + if browserAddressBarFocusedPanelId != nil, + cmuxOwningGhosttyView(for: NSApp.keyWindow?.firstResponder) != nil { #if DEBUG + let stalePanelToken = browserAddressBarFocusedPanelId.map { String($0.uuidString.prefix(5)) } ?? "nil" + let firstResponderType = NSApp.keyWindow?.firstResponder.map { String(describing: type(of: $0)) } ?? "nil" dlog( - "browser.focus.addressBar.shortcutContext panel=\(panelId.uuidString.prefix(5)) " + - "accepted=0 reason=panel_not_in_workspace workspace=\(workspace.id.uuidString.prefix(5)) " + - "event=\(NSWindow.keyDescription(event))" + "browser.focus.addressBar.staleClear panel=\(stalePanelToken) " + + "reason=terminal_first_responder fr=\(firstResponderType)" ) #endif - return nil + browserAddressBarFocusedPanelId = nil + stopBrowserOmnibarSelectionRepeat() } -#if DEBUG - dlog( - "browser.focus.addressBar.shortcutContext panel=\(panelId.uuidString.prefix(5)) " + - "accepted=1 workspace=\(workspace.id.uuidString.prefix(5)) event=\(NSWindow.keyDescription(event))" - ) -#endif - return panelId - } + // Keep Cmd+P/Cmd+N inside the focused browser omnibar for Chrome-like + // suggestion navigation, and avoid opening command palette switcher. + // Scope the omnibar check to the shortcut's routed window context so a + // focused omnibar in another window does not suppress Cmd+P here. + let hasFocusedAddressBarInShortcutContext = focusedBrowserAddressBarPanelIdForShortcutEvent(event) != nil - @discardableResult - func requestBrowserAddressBarFocus(panelId: UUID) -> Bool { - focusBrowserAddressBar(panelId: panelId) - } + if commandPaletteEffectiveInTargetWindow { + if matchConfiguredShortcut(event: event, action: .commandPalette) { + let targetWindow = commandPaletteTargetWindow ?? event.window ?? NSApp.keyWindow ?? NSApp.mainWindow + requestCommandPaletteCommands(preferredWindow: targetWindow, source: "shortcut.commandPalette") + return true + } - private func shouldBypassAppShortcutForFocusedBrowserAddressBar( - flags: NSEvent.ModifierFlags, - chars: String - ) -> Bool { - guard browserAddressBarFocusedPanelId != nil else { return false } - let normalizedFlags = browserOmnibarNormalizedModifierFlags(flags) - let isCommandOrControlOnly = normalizedFlags == [.command] || normalizedFlags == [.control] - guard isCommandOrControlOnly else { return false } - let shouldBypass = chars == "n" || chars == "p" -#if DEBUG - if shouldBypass { - let panelToken = browserAddressBarFocusedPanelId.map { String($0.uuidString.prefix(5)) } ?? "nil" - dlog( - "browser.focus.addressBar.shortcutBypass panel=\(panelToken) " + - "chars=\(chars) flags=\(normalizedFlags.rawValue)" - ) + if !hasFocusedAddressBarInShortcutContext, + matchConfiguredShortcut(event: event, action: .goToWorkspace) { + let targetWindow = commandPaletteTargetWindow ?? event.window ?? NSApp.keyWindow ?? NSApp.mainWindow + requestCommandPaletteSwitcher(preferredWindow: targetWindow, source: "shortcut.goToWorkspace") + return true + } + + if activeConfiguredShortcutChordPrefixForCurrentEvent == nil, + armConfiguredShortcutChordIfNeeded(event: event, actions: [.commandPalette]) { + return true + } + + if activeConfiguredShortcutChordPrefixForCurrentEvent == nil, + !hasFocusedAddressBarInShortcutContext, + armConfiguredShortcutChordIfNeeded(event: event, actions: [.goToWorkspace]) { + return true + } + } + + if shouldConsumeShortcutWhileCommandPaletteVisible( + isCommandPaletteVisible: commandPaletteEffectiveInTargetWindow, + normalizedFlags: normalizedFlags, + chars: chars, + keyCode: event.keyCode + ) { + return true + } + + // When the terminal has active IME composition (e.g. Korean, Japanese, Chinese + // input), don't intercept non-Cmd key events — let them flow through to the + // input method. Cmd-based shortcuts (Cmd+T, Cmd+Shift+L, etc.) should still + // work during composition since Cmd is never part of IME input sequences. + if !normalizedFlags.contains(.command), + let ghosttyView = cmuxOwningGhosttyView(for: NSApp.keyWindow?.firstResponder), + ghosttyView.hasMarkedText() { + return false + } + + // When the notifications popover is open, Escape should dismiss it immediately. + if flags.isEmpty, event.keyCode == 53, titlebarAccessoryController.dismissNotificationsPopoverIfShown() { + return true } -#endif - return shouldBypass - } - private func commandOmnibarSelectionDelta( - flags: NSEvent.ModifierFlags, - chars: String - ) -> Int? { - browserOmnibarSelectionDeltaForCommandNavigation( - hasFocusedAddressBar: browserAddressBarFocusedPanelId != nil, - flags: flags, - chars: chars - ) - } + // When the notifications popover is showing an empty state, consume plain typing + // so key presses do not leak through into the focused terminal. + if flags.isDisjoint(with: [.command, .control, .option]), + titlebarAccessoryController.isNotificationsPopoverShown(), + (notificationStore?.notifications.isEmpty ?? false) { + return true + } - private func dispatchBrowserOmnibarSelectionMove(delta: Int) { - guard delta != 0 else { return } - guard let panelId = browserAddressBarFocusedPanelId else { return } + let hasEventWindowContext = shortcutEventHasAddressableWindow(event) + let didSynchronizeShortcutContext = synchronizeShortcutRoutingContext(event: event) + if hasEventWindowContext && !didSynchronizeShortcutContext { #if DEBUG - dlog( - "browser.focus.omnibar.selectionMove panel=\(panelId.uuidString.prefix(5)) " + - "delta=\(delta) repeatKey=\(browserOmnibarRepeatKeyCode.map(String.init) ?? "nil")" - ) + dlog("handleCustomShortcut: unresolved event window context; bypassing app shortcut handling") #endif - NotificationCenter.default.post( - name: .browserMoveOmnibarSelection, - object: panelId, - userInfo: ["delta": delta] - ) - } + return false + } - private func startBrowserOmnibarSelectionRepeatIfNeeded(keyCode: UInt16, delta: Int) { - guard delta != 0 else { return } - guard browserAddressBarFocusedPanelId != nil else { + // Keep keyboard routing deterministic after split close/reparent transitions: + // before processing shortcuts, converge first responder with the focused terminal panel. + if isControlD { #if DEBUG - dlog( - "browser.focus.omnibar.repeat.start key=\(keyCode) delta=\(delta) " + - "result=skip_no_focused_address_bar" - ) + let selected = tabManager?.selectedTabId?.uuidString.prefix(5) ?? "nil" + let focused = tabManager?.selectedWorkspace?.focusedPanelId?.uuidString.prefix(5) ?? "nil" + let frType = NSApp.keyWindow?.firstResponder.map { String(describing: type(of: $0)) } ?? "nil" + dlog("shortcut.ctrlD stage=preReconcile selected=\(selected) focused=\(focused) fr=\(frType)") #endif - return + tabManager?.reconcileFocusedPanelFromFirstResponderForKeyboard() + #if DEBUG + let frAfterType = NSApp.keyWindow?.firstResponder.map { String(describing: type(of: $0)) } ?? "nil" + dlog("shortcut.ctrlD stage=postReconcile fr=\(frAfterType)") + writeChildExitKeyboardProbe([:], increments: ["probeAppShortcutCtrlDPassedCount": 1]) + #endif + // Ctrl+D belongs to the focused terminal surface; never treat it as an app shortcut. + return false } - if browserOmnibarRepeatKeyCode == keyCode, browserOmnibarRepeatDelta == delta { -#if DEBUG - let panelToken = browserAddressBarFocusedPanelId.map { String($0.uuidString.prefix(5)) } ?? "nil" - dlog( - "browser.focus.omnibar.repeat.start panel=\(panelToken) " + - "key=\(keyCode) delta=\(delta) result=reuse" - ) -#endif - return + // Chrome-like omnibar navigation while holding Cmd+N / Ctrl+N / Cmd+P / Ctrl+P. + if let delta = commandOmnibarSelectionDelta(flags: flags, chars: chars) { + dispatchBrowserOmnibarSelectionMove(delta: delta) + startBrowserOmnibarSelectionRepeatIfNeeded(keyCode: event.keyCode, delta: delta) + return true } - stopBrowserOmnibarSelectionRepeat() - browserOmnibarRepeatKeyCode = keyCode - browserOmnibarRepeatDelta = delta -#if DEBUG - let panelToken = browserAddressBarFocusedPanelId.map { String($0.uuidString.prefix(5)) } ?? "nil" - dlog( - "browser.focus.omnibar.repeat.start panel=\(panelToken) " + - "key=\(keyCode) delta=\(delta) result=armed" - ) -#endif + if let delta = browserOmnibarSelectionDeltaForArrowNavigation( + hasFocusedAddressBar: browserAddressBarFocusedPanelId != nil, + flags: event.modifierFlags, + keyCode: event.keyCode + ) { + dispatchBrowserOmnibarSelectionMove(delta: delta) + return true + } - let start = DispatchWorkItem { [weak self] in - self?.scheduleBrowserOmnibarSelectionRepeatTick() + // Fast path for normal typing and terminal navigation keys (for example Up-arrow + // history): after command-palette/notification handling and browser omnibar + // arrow navigation above, plain key events have no app-level shortcut behavior. + if normalizedFlags.isEmpty && activeConfiguredShortcutChordPrefixForCurrentEvent == nil { + return false } - browserOmnibarRepeatStartWorkItem = start - DispatchQueue.main.asyncAfter(deadline: .now() + 0.25, execute: start) - } - private func scheduleBrowserOmnibarSelectionRepeatTick() { - browserOmnibarRepeatStartWorkItem = nil - guard browserAddressBarFocusedPanelId != nil else { -#if DEBUG - dlog("browser.focus.omnibar.repeat.tick result=stop_no_focused_address_bar") -#endif - stopBrowserOmnibarSelectionRepeat() - return + // Let omnibar-local Emacs navigation (Cmd/Ctrl+N/P) win while the browser + // address bar is focused. Without this, app-level Cmd+N can steal focus. + if shouldBypassAppShortcutForFocusedBrowserAddressBar(flags: flags, chars: chars) { + return false } - guard browserOmnibarRepeatKeyCode != nil else { return } -#if DEBUG - let panelToken = browserAddressBarFocusedPanelId.map { String($0.uuidString.prefix(5)) } ?? "nil" - dlog( - "browser.focus.omnibar.repeat.tick panel=\(panelToken) " + - "delta=\(browserOmnibarRepeatDelta)" - ) -#endif - dispatchBrowserOmnibarSelectionMove(delta: browserOmnibarRepeatDelta) + if activeConfiguredShortcutChordPrefixForCurrentEvent == nil, + armConfiguredShortcutChordIfNeeded(event: event) { + return true + } - let tick = DispatchWorkItem { [weak self] in - self?.scheduleBrowserOmnibarSelectionRepeatTick() + if matchConfiguredShortcut(event: event, action: .commandPalette) { + let targetWindow = commandPaletteTargetWindow ?? event.window ?? NSApp.keyWindow ?? NSApp.mainWindow + requestCommandPaletteCommands(preferredWindow: targetWindow, source: "shortcut.commandPalette") + return true } - browserOmnibarRepeatTickWorkItem = tick - DispatchQueue.main.asyncAfter(deadline: .now() + 0.055, execute: tick) - } - private func stopBrowserOmnibarSelectionRepeat() { -#if DEBUG - let previousKeyCode = browserOmnibarRepeatKeyCode - let previousDelta = browserOmnibarRepeatDelta -#endif - browserOmnibarRepeatStartWorkItem?.cancel() - browserOmnibarRepeatTickWorkItem?.cancel() - browserOmnibarRepeatStartWorkItem = nil - browserOmnibarRepeatTickWorkItem = nil - browserOmnibarRepeatKeyCode = nil - browserOmnibarRepeatDelta = 0 -#if DEBUG - if previousKeyCode != nil || previousDelta != 0 { - dlog( - "browser.focus.omnibar.repeat.stop key=\(previousKeyCode.map(String.init) ?? "nil") " + - "delta=\(previousDelta)" - ) + if !hasFocusedAddressBarInShortcutContext, + matchConfiguredShortcut(event: event, action: .goToWorkspace) { + let targetWindow = commandPaletteTargetWindow ?? event.window ?? NSApp.keyWindow ?? NSApp.mainWindow + requestCommandPaletteSwitcher(preferredWindow: targetWindow, source: "shortcut.goToWorkspace") + return true } -#endif - } - private func handleBrowserOmnibarSelectionRepeatLifecycleEvent(_ event: NSEvent) { - guard browserOmnibarRepeatKeyCode != nil else { return } + if matchConfiguredShortcut(event: event, action: .quit) { + return handleQuitShortcutWarning() + } + if matchConfiguredShortcut(event: event, action: .openSettings) { + openPreferencesWindow(debugSource: "shortcut.openSettings") + return true + } + if matchConfiguredShortcut(event: event, action: .reloadConfiguration) { + GhosttyApp.shared.reloadConfiguration(source: "shortcut.reloadConfiguration") + return true + } - switch event.type { - case .keyUp: - if event.keyCode == browserOmnibarRepeatKeyCode { -#if DEBUG - dlog( - "browser.focus.omnibar.repeat.lifecycle event=keyUp key=\(event.keyCode) " + - "action=stop" - ) -#endif - stopBrowserOmnibarSelectionRepeat() + if matchConfiguredShortcut(event: event, action: .toggleFullScreen) { + guard let targetWindow = mainWindowForShortcutEvent(event) else { + return false } - case .flagsChanged: - let flags = event.modifierFlags.intersection(.deviceIndependentFlagsMask) - if !flags.contains(.command) { + targetWindow.toggleFullScreen(nil) + return true + } + + // Primary UI shortcuts + if matchConfiguredShortcut(event: event, action: .toggleSidebar) { + _ = toggleSidebarInActiveMainWindow() + return true + } + + if matchConfiguredShortcut(event: event, action: .newTab) { #if DEBUG - dlog( - "browser.focus.omnibar.repeat.lifecycle event=flagsChanged " + - "flags=\(flags.rawValue) action=stop" - ) + dlog("shortcut.action name=newWorkspace \(debugShortcutRouteSnapshot(event: event))") #endif - stopBrowserOmnibarSelectionRepeat() + // Cmd+N semantics: + // - If there are no main windows, create a new window. + // - Otherwise, create a new workspace in the active window. + if mainWindowContexts.isEmpty { + #if DEBUG + logWorkspaceCreationRouting( + phase: "fallback_new_window", + source: "shortcut.cmdN", + reason: "no_main_windows", + event: event, + chosenContext: nil + ) + #endif + openNewMainWindow(nil) + } else if addWorkspaceInPreferredMainWindow(event: event, debugSource: "shortcut.cmdN") == nil { + #if DEBUG + logWorkspaceCreationRouting( + phase: "fallback_new_window", + source: "shortcut.cmdN", + reason: "workspace_creation_returned_nil", + event: event, + chosenContext: nil + ) + #endif + openNewMainWindow(nil) } - default: - break + return true + } + + // New Window: Cmd+Shift+N + // Handled here instead of relying on SwiftUI's CommandGroup menu item because + // after a browser panel has been shown, SwiftUI's menu dispatch can silently + // consume the key equivalent without firing the action closure. + if matchConfiguredShortcut(event: event, action: .newWindow) { + openNewMainWindow(nil) + return true + } + + // Open Folder: Cmd+O + // Handled here to prevent AppKit's default NSDocumentController from opening + // the Documents folder when SwiftUI menu dispatch fails due to focus bugs. + if matchConfiguredShortcut(event: event, action: .openFolder) { + showOpenFolderPanel() + return true } - } - - private func isLikelyWebInspectorResponder(_ responder: NSResponder?) -> Bool { - programaIsLikelyWebInspectorResponder(responder) - } -#if DEBUG - private func developerToolsShortcutProbeKind(event: NSEvent) -> String? { - if matchShortcut(event: event, shortcut: KeyboardShortcutSettings.shortcut(for: .toggleBrowserDeveloperTools)) { - return "toggle.configured" - } - if matchShortcut(event: event, shortcut: KeyboardShortcutSettings.shortcut(for: .showBrowserJavaScriptConsole)) { - return "console.configured" + // Check Show Notifications shortcut + if matchConfiguredShortcut(event: event, action: .showNotifications) { + toggleNotificationsPopover(animated: false, anchorView: fullscreenControlsViewModel?.notificationsAnchorView) + return true } - let chars = (event.charactersIgnoringModifiers ?? "").lowercased() - let flags = event.modifierFlags.intersection(.deviceIndependentFlagsMask) - if flags == [.command, .option] { - if chars == "i" || event.keyCode == 34 { - return "toggle.literal" - } - if chars == "c" || event.keyCode == 8 { - return "console.literal" + if matchConfiguredShortcut(event: event, action: .sendFeedback) { + guard let targetContext = preferredMainWindowContextForShortcuts(event: event), + let targetWindow = targetContext.window ?? windowForMainWindowId(targetContext.windowId) else { + return false } + setActiveMainWindow(targetWindow) + bringToFront(targetWindow) + NotificationCenter.default.post(name: .feedbackComposerRequested, object: targetWindow) + return true } - return nil - } - private func logDeveloperToolsShortcutSnapshot( - phase: String, - event: NSEvent? = nil, - didHandle: Bool? = nil - ) { - let keyWindow = NSApp.keyWindow - let firstResponder = keyWindow?.firstResponder - let firstResponderType = firstResponder.map { String(describing: type(of: $0)) } ?? "nil" - let firstResponderPtr = firstResponder.map { String(describing: Unmanaged.passUnretained($0).toOpaque()) } ?? "nil" - let eventDescription = event.map(NSWindow.keyDescription) ?? "none" - if let browser = tabManager?.focusedBrowserPanel { - var line = - "browser.devtools shortcut=\(phase) panel=\(browser.id.uuidString.prefix(5)) " + - "\(browser.debugDeveloperToolsStateSummary()) \(browser.debugDeveloperToolsGeometrySummary()) " + - "keyWin=\(keyWindow?.windowNumber ?? -1) fr=\(firstResponderType)@\(firstResponderPtr) event=\(eventDescription)" - if let didHandle { - line += " handled=\(didHandle ? 1 : 0)" + // Check Jump to Unread shortcut + if matchConfiguredShortcut(event: event, action: .jumpToUnread) { +#if DEBUG + if ProcessInfo.processInfo.environment["PROGRAMA_UI_TEST_JUMP_UNREAD_SETUP"] == "1" { + writeJumpUnreadTestData(["jumpUnreadShortcutHandled": "1"]) } - dlog(line) - return +#endif + jumpToLatestUnread() + return true } - var line = - "browser.devtools shortcut=\(phase) panel=nil keyWin=\(keyWindow?.windowNumber ?? -1) " + - "fr=\(firstResponderType)@\(firstResponderPtr) event=\(eventDescription)" - if let didHandle { - line += " handled=\(didHandle ? 1 : 0)" + + // Flash the currently focused panel so the user can visually confirm focus. + if matchConfiguredShortcut(event: event, action: .triggerFlash) { + tabManager?.triggerFocusFlash() + return true } - dlog(line) - } -#endif - private func prepareFocusedBrowserDevToolsForSplit(directionLabel: String) { - guard let browser = tabManager?.focusedBrowserPanel else { return } - guard browser.shouldPreserveWebViewAttachmentDuringTransientHide() else { return } - guard let keyWindow = NSApp.keyWindow else { return } - guard isLikelyWebInspectorResponder(keyWindow.firstResponder) else { return } + // Surface navigation: Cmd+Shift+] / Cmd+Shift+[ + if matchConfiguredShortcut(event: event, action: .nextSurface) { + tabManager?.selectNextSurface() + return true + } + if matchConfiguredShortcut(event: event, action: .prevSurface) { + tabManager?.selectPreviousSurface() + return true + } - let beforeResponder = keyWindow.firstResponder - let movedToWebView = keyWindow.makeFirstResponder(browser.webView) - let movedToNil = movedToWebView ? false : keyWindow.makeFirstResponder(nil) + if matchConfiguredShortcut(event: event, action: .toggleTerminalCopyMode) { + let handled = tabManager?.toggleFocusedTerminalCopyMode() ?? false +#if DEBUG + dlog( + "shortcut.action name=toggleTerminalCopyMode handled=\(handled ? 1 : 0) " + + "\(debugShortcutRouteSnapshot(event: event))" + ) +#endif + // Only consume when a focused terminal actually handled the toggle. + // Otherwise allow the event to continue through the responder chain. + return handled + } - #if DEBUG - let beforeType = beforeResponder.map { String(describing: type(of: $0)) } ?? "nil" - let beforePtr = beforeResponder.map { String(describing: Unmanaged.passUnretained($0).toOpaque()) } ?? "nil" - let afterResponder = keyWindow.firstResponder - let afterType = afterResponder.map { String(describing: type(of: $0)) } ?? "nil" - let afterPtr = afterResponder.map { String(describing: Unmanaged.passUnretained($0).toOpaque()) } ?? "nil" - dlog( - "split.shortcut inspector.preflight dir=\(directionLabel) panel=\(browser.id.uuidString.prefix(5)) " + - "before=\(beforeType)@\(beforePtr) after=\(afterType)@\(afterPtr) " + - "moveWeb=\(movedToWebView ? 1 : 0) moveNil=\(movedToNil ? 1 : 0) \(browser.debugDeveloperToolsStateSummary())" - ) - #endif - } + // Workspace navigation: Cmd+Ctrl+] / Cmd+Ctrl+[ + if matchConfiguredShortcut(event: event, action: .nextSidebarTab) { +#if DEBUG + let selected = tabManager?.selectedTabId.map { String($0.uuidString.prefix(5)) } ?? "nil" + dlog( + "ws.shortcut dir=next repeat=\(event.isARepeat ? 1 : 0) keyCode=\(event.keyCode) selected=\(selected)" + ) +#endif + tabManager?.selectNextTab() + return true + } - @discardableResult - func performSplitShortcut(direction: SplitDirection, preferredWindow: NSWindow? = nil) -> Bool { - let targetWindow = preferredWindow ?? NSApp.keyWindow ?? NSApp.mainWindow - let terminalContext = focusedTerminalShortcutContext(preferredWindow: targetWindow) - _ = synchronizeActiveMainWindowContext(preferredWindow: targetWindow) + if matchConfiguredShortcut(event: event, action: .prevSidebarTab) { +#if DEBUG + let selected = tabManager?.selectedTabId.map { String($0.uuidString.prefix(5)) } ?? "nil" + dlog( + "ws.shortcut dir=prev repeat=\(event.isARepeat ? 1 : 0) keyCode=\(event.keyCode) selected=\(selected)" + ) +#endif + tabManager?.selectPreviousTab() + return true + } - let directionLabel: String - switch direction { - case .left: directionLabel = "left" - case .right: directionLabel = "right" - case .up: directionLabel = "up" - case .down: directionLabel = "down" + if matchConfiguredShortcut(event: event, action: .renameWorkspace) { + return requestRenameWorkspaceViaCommandPalette( + preferredWindow: commandPaletteTargetWindow ?? event.window ?? NSApp.keyWindow ?? NSApp.mainWindow + ) } - #if DEBUG - let keyWindow = NSApp.keyWindow - let firstResponder = keyWindow?.firstResponder - let firstResponderType = firstResponder.map { String(describing: type(of: $0)) } ?? "nil" - let firstResponderPtr = firstResponder.map { String(describing: Unmanaged.passUnretained($0).toOpaque()) } ?? "nil" - let firstResponderWindow: Int = { - if let v = firstResponder as? NSView { - return v.window?.windowNumber ?? -1 - } - if let w = firstResponder as? NSWindow { - return w.windowNumber - } - return -1 - }() - let splitContext = "keyWin=\(keyWindow?.windowNumber ?? -1) mainWin=\(NSApp.mainWindow?.windowNumber ?? -1) fr=\(firstResponderType)@\(firstResponderPtr) frWin=\(firstResponderWindow)" - if let browser = tabManager?.focusedBrowserPanel { - let webWindow = browser.webView.window?.windowNumber ?? -1 - let webSuperview = browser.webView.superview.map { String(describing: Unmanaged.passUnretained($0).toOpaque()) } ?? "nil" - dlog("split.shortcut dir=\(directionLabel) pre panel=\(browser.id.uuidString.prefix(5)) \(browser.debugDeveloperToolsStateSummary()) webWin=\(webWindow) webSuper=\(webSuperview) \(splitContext)") - } else { - dlog("split.shortcut dir=\(directionLabel) pre panel=nil \(splitContext)") + if matchConfiguredShortcut(event: event, action: .editWorkspaceDescription) { +#if DEBUG + dlog( + "shortcut.editWorkspaceDescription matched target={\(debugWindowToken(commandPaletteTargetWindow ?? event.window ?? NSApp.keyWindow ?? NSApp.mainWindow))} " + + "\(debugShortcutRouteSnapshot(event: event))" + ) +#endif + return requestEditWorkspaceDescriptionViaCommandPalette( + preferredWindow: commandPaletteTargetWindow ?? event.window ?? NSApp.keyWindow ?? NSApp.mainWindow + ) } - #endif - prepareFocusedBrowserDevToolsForSplit(directionLabel: directionLabel) - let didCreateSplit: Bool = { - if let terminalContext { - return terminalContext.tabManager.createSplit( - tabId: terminalContext.workspaceId, - surfaceId: terminalContext.panelId, - direction: direction - ) != nil + if matchConfiguredShortcut(event: event, action: .closeOtherTabsInPane) { + if let targetWindow = event.window ?? NSApp.keyWindow ?? NSApp.mainWindow, + targetWindow.identifier?.rawValue == "cmux.settings" { + targetWindow.performClose(nil) + } else { + let targetWindow = event.window ?? NSApp.keyWindow ?? NSApp.mainWindow + if let terminalContext = focusedTerminalShortcutContext(preferredWindow: targetWindow) { + terminalContext.tabManager.closeOtherTabsInFocusedPaneWithConfirmation() + } else { + tabManager?.closeOtherTabsInFocusedPaneWithConfirmation() + } } - return tabManager?.createSplit(direction: direction) != nil - }() + return true + } + + // Cmd+W must close the focused panel even if first-responder momentarily lags on a + // browser NSTextView during split focus transitions. + if matchConfiguredShortcut(event: event, action: .closeTab) { + let targetWindow = resolvedShortcutEventWindow(event) ?? NSApp.keyWindow ?? NSApp.mainWindow + let routedManager = preferredMainWindowContextForShortcutRouting(event: event)?.tabManager ?? tabManager + // Browser popup windows primarily intercept Cmd+W in BrowserPopupPanel. + // This AppDelegate path is a fallback for cases where AppKit routes the + // event through the global shortcut handler first. + if let targetWindow = [targetWindow, NSApp.keyWindow] + .compactMap({ $0 }) + .first(where: { $0.identifier?.rawValue == "programa.browser-popup" }) { #if DEBUG - DispatchQueue.main.asyncAfter(deadline: .now() + 0.15) { [weak self] in - let keyWindow = NSApp.keyWindow - let firstResponder = keyWindow?.firstResponder - let firstResponderType = firstResponder.map { String(describing: type(of: $0)) } ?? "nil" - let firstResponderPtr = firstResponder.map { String(describing: Unmanaged.passUnretained($0).toOpaque()) } ?? "nil" - let firstResponderWindow: Int = { - if let v = firstResponder as? NSView { - return v.window?.windowNumber ?? -1 - } - if let w = firstResponder as? NSWindow { - return w.windowNumber - } - return -1 - }() - let splitContext = "keyWin=\(keyWindow?.windowNumber ?? -1) mainWin=\(NSApp.mainWindow?.windowNumber ?? -1) fr=\(firstResponderType)@\(firstResponderPtr) frWin=\(firstResponderWindow)" - if let browser = self?.tabManager?.focusedBrowserPanel { - let webWindow = browser.webView.window?.windowNumber ?? -1 - let webSuperview = browser.webView.superview.map { String(describing: Unmanaged.passUnretained($0).toOpaque()) } ?? "nil" - dlog("split.shortcut dir=\(directionLabel) post panel=\(browser.id.uuidString.prefix(5)) \(browser.debugDeveloperToolsStateSummary()) webWin=\(webWindow) webSuper=\(webSuperview) \(splitContext)") + dlog("shortcut.cmdW route=browserPopup") +#endif + targetWindow.performClose(nil) + return true + } else if let targetWindow, + programaWindowShouldOwnCloseShortcut(targetWindow) { + targetWindow.performClose(nil) } else { - dlog("split.shortcut dir=\(directionLabel) post panel=nil \(splitContext)") + if let routedManager { +#if DEBUG + let selectedWorkspace = routedManager.selectedWorkspace + dlog( + "shortcut.cmdW route=workspaceModel workspace=\(selectedWorkspace?.id.uuidString.prefix(5) ?? "nil") " + + "panel=\(selectedWorkspace?.focusedPanelId?.uuidString.prefix(5) ?? "nil") " + + "selected=\(routedManager.selectedTabId?.uuidString.prefix(5) ?? "nil")" + ) +#endif + routedManager.closeCurrentPanelWithConfirmation() + } else { +#if DEBUG + dlog("shortcut.cmdW route=noManager") +#endif + return false + } } + return true } - recordGotoSplitSplitIfNeeded(direction: direction) -#endif - return didCreateSplit - } - - @discardableResult - func performBrowserSplitShortcut(direction: SplitDirection) -> Bool { - _ = synchronizeActiveMainWindowContext(preferredWindow: NSApp.keyWindow ?? NSApp.mainWindow) - #if DEBUG - let directionLabel: String - switch direction { - case .left: directionLabel = "left" - case .right: directionLabel = "right" - case .up: directionLabel = "up" - case .down: directionLabel = "down" + if matchConfiguredShortcut(event: event, action: .closeWorkspace) { + tabManager?.closeCurrentWorkspaceWithConfirmation() + return true } - let selectedTabBefore = tabManager?.selectedTabId?.uuidString.prefix(5) ?? "nil" - let focusedPanelBefore = tabManager?.selectedWorkspace?.focusedPanelId?.uuidString.prefix(5) ?? "nil" - dlog( - "split.browser.shortcut pre dir=\(directionLabel) " + - "tab=\(selectedTabBefore) focusedPanel=\(focusedPanelBefore)" - ) - #endif - guard let panelId = tabManager?.createBrowserSplit(direction: direction) else { - #if DEBUG - dlog("split.browser.shortcut failed dir=\(directionLabel)") - #endif - return false + if matchConfiguredShortcut(event: event, action: .closeWindow) { + guard let targetWindow = event.window ?? NSApp.keyWindow ?? NSApp.mainWindow else { + NSSound.beep() + return true + } + closeWindowWithConfirmation(targetWindow) + return true } - #if DEBUG - let selectedTabAfter = tabManager?.selectedTabId?.uuidString.prefix(5) ?? "nil" - let focusedPanelAfter = tabManager?.selectedWorkspace?.focusedPanelId?.uuidString.prefix(5) ?? "nil" - dlog( - "split.browser.shortcut post dir=\(directionLabel) " + - "created=\(panelId.uuidString.prefix(5)) tab=\(selectedTabAfter) focusedPanel=\(focusedPanelAfter)" - ) - #endif - - _ = focusBrowserAddressBar(panelId: panelId) - return true - } + if matchConfiguredShortcut(event: event, action: .renameTab) { + // Keep Cmd+R browser reload behavior when a browser panel is focused. + if tabManager?.focusedBrowserPanel != nil { + return false + } + let targetWindow = commandPaletteTargetWindow ?? event.window ?? NSApp.keyWindow ?? NSApp.mainWindow + requestCommandPaletteRenameTab(preferredWindow: targetWindow, source: "shortcut.renameTab") + return true + } - /// Allow AppKit-backed browser surfaces (WKWebView) to route non-menu shortcuts - /// through the same app-level shortcut handler used by the local key monitor. - @discardableResult - func handleBrowserSurfaceKeyEquivalent(_ event: NSEvent) -> Bool { - handleCustomShortcut(event: event) - } + // Numeric shortcuts for specific workspaces (9 = last workspace) + // Always consume the event when the digit matches to prevent Ghostty's + // goto_tab fallback from creating a new window when the index is out of bounds. + if let digit = numberedConfiguredShortcutDigit(event: event, action: .selectWorkspaceByNumber) { + if let manager = tabManager, + let targetIndex = WorkspaceShortcutMapper.workspaceIndex(forDigit: digit, workspaceCount: manager.tabs.count) { +#if DEBUG + dlog( + "shortcut.action name=workspaceDigit digit=\(digit) targetIndex=\(targetIndex) manager=\(debugManagerToken(manager)) \(debugShortcutRouteSnapshot(event: event))" + ) +#endif + manager.selectTab(at: targetIndex) + } + return true + } - @discardableResult - func requestRenameWorkspaceViaCommandPalette(preferredWindow: NSWindow? = nil) -> Bool { - let targetWindow = preferredWindow ?? NSApp.keyWindow ?? NSApp.mainWindow - requestCommandPaletteRenameWorkspace( - preferredWindow: targetWindow, - source: "shortcut.renameWorkspace" - ) - return true - } + // Numeric shortcuts for surfaces within the focused pane (9 = last) + if let digit = numberedConfiguredShortcutDigit(event: event, action: .selectSurfaceByNumber) { + if digit == 9 { + tabManager?.selectLastSurface() + } else { + tabManager?.selectSurface(at: digit - 1) + } + return true + } - @discardableResult - func requestEditWorkspaceDescriptionViaCommandPalette(preferredWindow: NSWindow? = nil) -> Bool { - let targetWindow = preferredWindow ?? NSApp.keyWindow ?? NSApp.mainWindow + // Pane focus navigation (defaults to Cmd+Option+Arrow, but can be customized to letter/number keys). + if matchConfiguredDirectionalShortcut( + event: event, + action: .focusLeft, + arrowGlyph: "←", + arrowKeyCode: 123 + ) || (ghosttyGotoSplitLeftShortcut.map { matchDirectionalShortcut(event: event, shortcut: $0, arrowGlyph: "←", arrowKeyCode: 123) } ?? false) { + tabManager?.movePaneFocus(direction: .left) #if DEBUG - dlog( - "shortcut.editWorkspaceDescription request target={\(debugWindowToken(targetWindow))} " + - "fr=\(targetWindow?.firstResponder.map { String(describing: type(of: $0)) } ?? "nil")" - ) + recordGotoSplitMoveIfNeeded(direction: .left) #endif - requestCommandPaletteEditWorkspaceDescription( - preferredWindow: targetWindow, - source: "shortcut.editWorkspaceDescription" - ) - return true - } - + return true + } + if matchConfiguredDirectionalShortcut( + event: event, + action: .focusRight, + arrowGlyph: "→", + arrowKeyCode: 124 + ) || (ghosttyGotoSplitRightShortcut.map { matchDirectionalShortcut(event: event, shortcut: $0, arrowGlyph: "→", arrowKeyCode: 124) } ?? false) { + tabManager?.movePaneFocus(direction: .right) #if DEBUG - // Debug/test hook: allow socket-driven shortcut simulation to reuse the same shortcut routing - // logic as the local NSEvent monitor, without relying on AppKit event monitor behavior for - // synthetic NSEvents. - func debugHandleCustomShortcut(event: NSEvent) -> Bool { - handleCustomShortcut(event: event) - } + recordGotoSplitMoveIfNeeded(direction: .right) +#endif + return true + } + if matchConfiguredDirectionalShortcut( + event: event, + action: .focusUp, + arrowGlyph: "↑", + arrowKeyCode: 126 + ) || (ghosttyGotoSplitUpShortcut.map { matchDirectionalShortcut(event: event, shortcut: $0, arrowGlyph: "↑", arrowKeyCode: 126) } ?? false) { + tabManager?.movePaneFocus(direction: .up) +#if DEBUG + recordGotoSplitMoveIfNeeded(direction: .up) +#endif + return true + } + if matchConfiguredDirectionalShortcut( + event: event, + action: .focusDown, + arrowGlyph: "↓", + arrowKeyCode: 125 + ) || (ghosttyGotoSplitDownShortcut.map { matchDirectionalShortcut(event: event, shortcut: $0, arrowGlyph: "↓", arrowKeyCode: 125) } ?? false) { + tabManager?.movePaneFocus(direction: .down) +#if DEBUG + recordGotoSplitMoveIfNeeded(direction: .down) +#endif + return true + } - // Debug/test hook: mirrors local monitor routing (keyDown + keyUp lifecycle). - func debugHandleShortcutMonitorEvent(event: NSEvent) -> Bool { - if event.type == .keyDown { - return handleCustomShortcut(event: event) + if matchConfiguredShortcut(event: event, action: .toggleSplitZoom) { + _ = tabManager?.toggleFocusedSplitZoom() +#if DEBUG + recordGotoSplitZoomIfNeeded() +#endif + return true } - handleBrowserOmnibarSelectionRepeatLifecycleEvent(event) - return clearEscapeSuppressionForKeyUp(event: event, consumeIfSuppressed: true) - } - func debugMarkCommandPaletteOpenPending(window: NSWindow) { - markCommandPaletteOpenRequested(for: window) - } + // Split actions: Cmd+D / Cmd+Shift+D + if matchConfiguredShortcut(event: event, action: .splitRight) { +#if DEBUG + dlog("shortcut.action name=splitRight \(debugShortcutRouteSnapshot(event: event))") +#endif + if shouldSuppressSplitShortcutForTransientTerminalFocusState(direction: .right) { + return true + } + _ = performSplitShortcut( + direction: .right, + preferredWindow: event.window ?? NSApp.keyWindow ?? NSApp.mainWindow + ) + return true + } - @discardableResult - func debugSetCommandPalettePendingOpenAge(window: NSWindow, age: TimeInterval) -> Bool { - guard let windowId = mainWindowId(for: window) else { return false } - commandPalettePendingOpenByWindowId[windowId] = true - commandPaletteRecentRequestAtByWindowId[windowId] = ProcessInfo.processInfo.systemUptime - max(age, 0) - return true - } + if matchConfiguredShortcut(event: event, action: .splitDown) { +#if DEBUG + dlog("shortcut.action name=splitDown \(debugShortcutRouteSnapshot(event: event))") +#endif + if shouldSuppressSplitShortcutForTransientTerminalFocusState(direction: .down) { + return true + } + _ = performSplitShortcut( + direction: .down, + preferredWindow: event.window ?? NSApp.keyWindow ?? NSApp.mainWindow + ) + return true + } - // Test hook: remap a window context under a detached window key so direct - // ObjectIdentifier(window) lookups fail and fallback logic is exercised. - @discardableResult - func debugInjectWindowContextKeyMismatch(windowId: UUID) -> Bool { - guard let context = mainWindowContexts.values.first(where: { $0.windowId == windowId }), - let window = context.window ?? windowForMainWindowId(windowId) else { - return false + if matchConfiguredShortcut(event: event, action: .splitBrowserRight) { +#if DEBUG + dlog("shortcut.action name=splitBrowserRight \(debugShortcutRouteSnapshot(event: event))") +#endif + _ = performBrowserSplitShortcut(direction: .right) + return true } - let detachedWindow = NSWindow( - contentRect: NSRect(x: 0, y: 0, width: 16, height: 16), - styleMask: [.borderless], - backing: .buffered, - defer: false - ) - debugDetachedContextWindows.append(detachedWindow) + if matchConfiguredShortcut(event: event, action: .splitBrowserDown) { +#if DEBUG + dlog("shortcut.action name=splitBrowserDown \(debugShortcutRouteSnapshot(event: event))") +#endif + _ = performBrowserSplitShortcut(direction: .down) + return true + } - let contextKeys = mainWindowContexts.compactMap { key, value in - value === context ? key : nil + // Surface navigation (legacy Ctrl+Tab support) + if matchTabShortcut(event: event, shortcut: StoredShortcut(key: "\t", command: false, shift: false, option: false, control: true)) { + tabManager?.selectNextSurface() + return true } - for key in contextKeys { - mainWindowContexts.removeValue(forKey: key) + if matchTabShortcut(event: event, shortcut: StoredShortcut(key: "\t", command: false, shift: true, option: false, control: true)) { + tabManager?.selectPreviousSurface() + return true } - mainWindowContexts[ObjectIdentifier(detachedWindow)] = context - context.window = window - return true - } -#endif - private func findButton(in view: NSView, titled title: String) -> NSButton? { - if let button = view as? NSButton, button.title == title { - return button - } - for subview in view.subviews { - if let found = findButton(in: subview, titled: title) { - return found - } + // New surface: Cmd+T + if matchConfiguredShortcut(event: event, action: .newSurface) { + tabManager?.newSurface() + return true } - return nil - } - private func findStaticText(in view: NSView, equals text: String) -> Bool { - if let field = view as? NSTextField, field.stringValue == text { + // Open browser: Cmd+Shift+L + if matchConfiguredShortcut(event: event, action: .openBrowser) { + _ = openBrowserAndFocusAddressBar(insertAtEnd: true) return true } - for subview in view.subviews { - if findStaticText(in: subview, equals: text) { + + if matchConfiguredShortcut(event: event, action: .focusBrowserAddressBar) { + if let focusedPanel = tabManager?.focusedBrowserPanel { + focusBrowserAddressBar(in: focusedPanel) return true } - } - return false - } - private func matchConfiguredShortcut(event: NSEvent, action: KeyboardShortcutSettings.Action) -> Bool { - let shortcut = KeyboardShortcutSettings.shortcut(for: action) - if let prefix = activeConfiguredShortcutChordPrefixForCurrentEvent { - guard let secondStroke = shortcut.secondStroke, - shortcut.firstStroke == prefix else { - return false + if let browserAddressBarFocusedPanelId, + focusBrowserAddressBar(panelId: browserAddressBarFocusedPanelId) { + return true } - return matchShortcutStroke(event: event, stroke: secondStroke) - } - guard !shortcut.hasChord else { return false } - return matchShortcutStroke(event: event, stroke: shortcut.firstStroke) - } - private func numberedConfiguredShortcutDigit( - event: NSEvent, - action: KeyboardShortcutSettings.Action - ) -> Int? { - let shortcut = KeyboardShortcutSettings.shortcut(for: action) - if let prefix = activeConfiguredShortcutChordPrefixForCurrentEvent { - guard let secondStroke = shortcut.secondStroke, - shortcut.firstStroke == prefix else { - return nil + if openBrowserAndFocusAddressBar(insertAtEnd: true) != nil { + return true } - return numberedShortcutDigit(event: event, stroke: secondStroke) } - guard !shortcut.hasChord else { return nil } - return numberedShortcutDigit(event: event, stroke: shortcut.firstStroke) - } - private func matchConfiguredDirectionalShortcut( - event: NSEvent, - action: KeyboardShortcutSettings.Action, - arrowGlyph: String, - arrowKeyCode: UInt16 - ) -> Bool { - let shortcut = KeyboardShortcutSettings.shortcut(for: action) - if let prefix = activeConfiguredShortcutChordPrefixForCurrentEvent { - guard let secondStroke = shortcut.secondStroke, - shortcut.firstStroke == prefix else { + if matchConfiguredShortcut(event: event, action: .browserBack) { + guard let focusedBrowserPanel = tabManager?.focusedBrowserPanel else { return false } - return matchDirectionalShortcut( - event: event, - stroke: secondStroke, - arrowGlyph: arrowGlyph, - arrowKeyCode: arrowKeyCode - ) - } - guard !shortcut.hasChord else { return false } - return matchDirectionalShortcut( - event: event, - stroke: shortcut.firstStroke, - arrowGlyph: arrowGlyph, - arrowKeyCode: arrowKeyCode - ) - } - - private func configuredShortcutChordWindowNumber(for event: NSEvent) -> Int? { - if let window = mainWindowForShortcutEvent(event) { - return window.windowNumber - } - if let window = event.window { - return window.windowNumber + focusedBrowserPanel.goBack() + return true } - return event.windowNumber > 0 ? event.windowNumber : nil - } - private func armConfiguredShortcutChordIfNeeded( - event: NSEvent, - actions: [KeyboardShortcutSettings.Action]? = nil - ) -> Bool { - for action in actions ?? configuredShortcutChordActions { - let shortcut = KeyboardShortcutSettings.shortcut(for: action) - guard shortcut.hasChord else { continue } - if matchShortcutStroke(event: event, stroke: shortcut.firstStroke) { - pendingConfiguredShortcutChord = PendingConfiguredShortcutChord( - firstStroke: shortcut.firstStroke, - windowNumber: configuredShortcutChordWindowNumber(for: event) - ) - return true + if matchConfiguredShortcut(event: event, action: .browserForward) { + guard let focusedBrowserPanel = tabManager?.focusedBrowserPanel else { + return false } + focusedBrowserPanel.goForward() + return true } - return false - } - - /// Match a shortcut stroke against an event, handling normal keys. - private func matchShortcutStroke(event: NSEvent, stroke: ShortcutStroke) -> Bool { - // Some keys can include extra flags (e.g. .function) depending on the responder chain. - // Strip those for consistent matching across first responders (terminal, WebKit, etc). - let flags = event.modifierFlags.intersection(.deviceIndependentFlagsMask) - .subtracting([.numericPad, .function, .capsLock]) - guard flags == stroke.modifierFlags else { return false } - let shortcutKey = stroke.key.lowercased() - if shortcutKey == "\r" { - return event.keyCode == 36 || event.keyCode == 76 + if matchConfiguredShortcut(event: event, action: .browserReload) { + guard let focusedBrowserPanel = tabManager?.focusedBrowserPanel else { + return false + } + focusedBrowserPanel.reload() + return true } - let eventCharsIgnoringModifiers = event.charactersIgnoringModifiers - if shortcutCharacterMatches( - eventCharacter: eventCharsIgnoringModifiers, - shortcutKey: shortcutKey, - applyShiftSymbolNormalization: flags.contains(.shift), - eventKeyCode: event.keyCode - ) { + // Safari defaults: + // - Option+Command+I => Show/Toggle Web Inspector + // - Option+Command+C => Show JavaScript Console + if matchConfiguredShortcut(event: event, action: .toggleBrowserDeveloperTools) { +#if DEBUG + logDeveloperToolsShortcutSnapshot(phase: "toggle.pre", event: event) +#endif + let didHandle = tabManager?.toggleDeveloperToolsFocusedBrowser() ?? false +#if DEBUG + logDeveloperToolsShortcutSnapshot(phase: "toggle.post", event: event, didHandle: didHandle) + DispatchQueue.main.async { [weak self] in + self?.logDeveloperToolsShortcutSnapshot(phase: "toggle.tick", didHandle: didHandle) + } +#endif + if !didHandle { NSSound.beep() } return true } - // For command-based shortcuts, trust AppKit's layout-aware characters when present. - // Keep this strict for letter shortcuts to avoid physical-key collisions across layouts, - // while still allowing keyCode fallback for digit/punctuation shortcuts on non-US layouts. - // When a non-Latin input source is active (Russian, Korean, Chinese, Japanese, etc.), - // charactersIgnoringModifiers returns non-ASCII characters that can never match - // a Latin shortcut key — skip this guard and fall through to layout-based matching. - let hasEventChars = !(eventCharsIgnoringModifiers?.isEmpty ?? true) - let eventCharsAreASCII = eventCharsIgnoringModifiers?.allSatisfy(\.isASCII) ?? true - let shortcutKeyIsDigit = shortcutKey.count == 1 && shortcutKey.first?.isNumber == true - if shortcutKeyIsDigit, - hasEventChars, - eventCharsAreASCII, - digitForNumberKeyCode(event.keyCode) == nil { - return false - } - if hasEventChars, - eventCharsAreASCII, - flags.contains(.command), - !flags.contains(.control), - shouldRequireCharacterMatchForCommandShortcut(shortcutKey: shortcutKey) { - return false + if matchConfiguredShortcut(event: event, action: .showBrowserJavaScriptConsole) { +#if DEBUG + logDeveloperToolsShortcutSnapshot(phase: "console.pre", event: event) +#endif + let didHandle = tabManager?.showJavaScriptConsoleFocusedBrowser() ?? false +#if DEBUG + logDeveloperToolsShortcutSnapshot(phase: "console.post", event: event, didHandle: didHandle) + DispatchQueue.main.async { [weak self] in + self?.logDeveloperToolsShortcutSnapshot(phase: "console.tick", didHandle: didHandle) + } +#endif + if !didHandle { NSSound.beep() } + return true } - // Match using the current keyboard layout so Command shortcuts stay character-based - // across layouts (QWERTY, Dvorak, etc.) instead of being tied to ANSI physical keys. - let layoutCharacter = shortcutLayoutCharacterProvider(event.keyCode, event.modifierFlags) - if shortcutCharacterMatches( - eventCharacter: layoutCharacter, - shortcutKey: shortcutKey, - applyShiftSymbolNormalization: false, - eventKeyCode: event.keyCode - ) { + if matchConfiguredShortcut(event: event, action: .toggleReactGrab) { + let didHandle = tabManager?.toggleReactGrabFromCurrentFocus() ?? false + if !didHandle { NSSound.beep() } return true } - // Control-key combos can surface as ASCII control characters (e.g. Ctrl+H => backspace), - // so keep ANSI keyCode fallback for control-modified shortcuts. Also allow fallback for - // command punctuation shortcuts, since some non-US layouts report different characters - // for the same physical key even when menu-equivalent semantics should still apply. - // When a non-Latin input source is active (Russian, Korean, Chinese, Japanese, etc.), - // event chars carry no usable Latin key identity. Always allow keyCode fallback as a - // safety net — even when the layout-based translation resolved a character, the - // physical key code is the definitive identifier for the intended shortcut. - // For empty-character events (synthetic/browser key equivalents), preserve the original - // behavior: only fall back when the layout translation also failed. - let hasUsableEventChars = hasEventChars && eventCharsAreASCII - let allowANSIKeyCodeFallback = flags.contains(.control) - || (flags.contains(.command) - && !flags.contains(.control) - && ( - !shouldRequireCharacterMatchForCommandShortcut(shortcutKey: shortcutKey) - || (hasEventChars && !eventCharsAreASCII) - || (!hasEventChars && (layoutCharacter?.isEmpty ?? true)) - )) - if allowANSIKeyCodeFallback, let expectedKeyCode = keyCodeForShortcutKey(shortcutKey) { - return event.keyCode == expectedKeyCode + if matchConfiguredShortcut(event: event, action: .browserZoomIn) { + return tabManager?.zoomInFocusedBrowser() ?? false } - return false - } - private func matchShortcut(event: NSEvent, shortcut: StoredShortcut) -> Bool { - guard !shortcut.hasChord else { return false } - return matchShortcutStroke(event: event, stroke: shortcut.firstStroke) - } + if matchConfiguredShortcut(event: event, action: .browserZoomOut) { + return tabManager?.zoomOutFocusedBrowser() ?? false + } - private func numberedShortcutDigit(event: NSEvent, stroke: ShortcutStroke) -> Int? { - let flags = event.modifierFlags.intersection(.deviceIndependentFlagsMask) - .subtracting([.numericPad, .function, .capsLock]) - guard flags == stroke.modifierFlags else { return nil } - let numberKeyDigit = digitForNumberKeyCode(event.keyCode) + if matchConfiguredShortcut(event: event, action: .browserZoomReset) { + return tabManager?.resetZoomFocusedBrowser() ?? false + } - if let digit = numberedShortcutDigit( - eventCharacter: event.charactersIgnoringModifiers, - applyShiftSymbolNormalization: flags.contains(.shift), - eventKeyCode: event.keyCode - ) { - return digit + if matchConfiguredShortcut(event: event, action: .find) { + guard !shouldLetFocusedBrowserOwnFindShortcut(event) else { + return false + } + tabManager?.startSearch() + return true } - let eventCharsIgnoringModifiers = event.charactersIgnoringModifiers - let hasUsableASCIIEventChars = !(eventCharsIgnoringModifiers?.isEmpty ?? true) - && (eventCharsIgnoringModifiers?.allSatisfy(\.isASCII) ?? true) - if !hasUsableASCIIEventChars || numberKeyDigit != nil { - let layoutCharacter = shortcutLayoutCharacterProvider(event.keyCode, event.modifierFlags) - if let digit = numberedShortcutDigit( - eventCharacter: layoutCharacter, - applyShiftSymbolNormalization: false, - eventKeyCode: event.keyCode - ) { - return digit + if matchConfiguredShortcut(event: event, action: .findNext) { + guard !shouldLetFocusedBrowserOwnFindShortcut(event) else { + return false } + tabManager?.findNext() + return true } - return numberKeyDigit - } - - private func numberedShortcutDigit(event: NSEvent, shortcut: StoredShortcut) -> Int? { - guard !shortcut.hasChord else { return nil } - return numberedShortcutDigit(event: event, stroke: shortcut.firstStroke) - } + if matchConfiguredShortcut(event: event, action: .findPrevious) { + guard !shouldLetFocusedBrowserOwnFindShortcut(event) else { + return false + } + tabManager?.findPrevious() + return true + } - private func numberedShortcutDigit( - eventCharacter: String?, - applyShiftSymbolNormalization: Bool, - eventKeyCode: UInt16 - ) -> Int? { - guard let eventCharacter, !eventCharacter.isEmpty else { return nil } - let normalized = normalizedShortcutEventCharacter( - eventCharacter, - applyShiftSymbolNormalization: applyShiftSymbolNormalization, - eventKeyCode: eventKeyCode - ) - guard let digit = Int(normalized), (1...9).contains(digit) else { return nil } - return digit - } + if matchConfiguredShortcut(event: event, action: .hideFind) { + guard !shouldLetFocusedBrowserOwnFindShortcut(event) else { + return false + } + tabManager?.hideFind() + return true + } - private func shouldRequireCharacterMatchForCommandShortcut(shortcutKey: String) -> Bool { - guard shortcutKey.count == 1, let scalar = shortcutKey.unicodeScalars.first else { - return false + if matchConfiguredShortcut(event: event, action: .useSelectionForFind) { + tabManager?.searchSelection() + return true } - return CharacterSet.letters.contains(scalar) - } - private func shortcutCharacterMatches( - eventCharacter: String?, - shortcutKey: String, - applyShiftSymbolNormalization: Bool, - eventKeyCode: UInt16 - ) -> Bool { - guard let eventCharacter, !eventCharacter.isEmpty else { return false } - if normalizedShortcutEventCharacter( - eventCharacter, - applyShiftSymbolNormalization: applyShiftSymbolNormalization, - eventKeyCode: eventKeyCode - ) == shortcutKey { + if matchConfiguredShortcut(event: event, action: .reopenClosedBrowserPanel) { + _ = tabManager?.reopenMostRecentlyClosedBrowserPanel() return true } + return false } - private func normalizedShortcutEventCharacter( - _ eventCharacter: String, - applyShiftSymbolNormalization: Bool, - eventKeyCode: UInt16 - ) -> String { - let lowered = eventCharacter.lowercased() - guard applyShiftSymbolNormalization else { return lowered } - - switch lowered { - case "{": return "[" - case "}": return "]" - case "<": return eventKeyCode == 43 ? "," : lowered // kVK_ANSI_Comma - case ">": return eventKeyCode == 47 ? "." : lowered // kVK_ANSI_Period - case "?": return "/" - case ":": return ";" - case "\"": return "'" - case "|": return "\\" - case "~": return "`" - case "+": return "=" - case "_": return "-" - case "!": return eventKeyCode == 18 ? "1" : lowered // kVK_ANSI_1 - case "@": return eventKeyCode == 19 ? "2" : lowered // kVK_ANSI_2 - case "#": return eventKeyCode == 20 ? "3" : lowered // kVK_ANSI_3 - case "$": return eventKeyCode == 21 ? "4" : lowered // kVK_ANSI_4 - case "%": return eventKeyCode == 23 ? "5" : lowered // kVK_ANSI_5 - case "^": return eventKeyCode == 22 ? "6" : lowered // kVK_ANSI_6 - case "&": return eventKeyCode == 26 ? "7" : lowered // kVK_ANSI_7 - case "*": return eventKeyCode == 28 ? "8" : lowered // kVK_ANSI_8 - case "(": return eventKeyCode == 25 ? "9" : lowered // kVK_ANSI_9 - case ")": return eventKeyCode == 29 ? "0" : lowered // kVK_ANSI_0 - default: return lowered + private func shouldSuppressSplitShortcutForTransientTerminalFocusState(direction: SplitDirection) -> Bool { + guard let tabManager, + let workspace = tabManager.selectedWorkspace, + let focusedPanelId = workspace.focusedPanelId, + let terminalPanel = workspace.terminalPanel(for: focusedPanelId) else { + return false } - } - private func keyCodeForShortcutKey(_ key: String) -> UInt16? { - // Matches macOS ANSI key codes. This is intentionally limited to keys we - // support in StoredShortcut/ghostty trigger translation. - switch key { - case "a": return 0 // kVK_ANSI_A - case "s": return 1 // kVK_ANSI_S - case "d": return 2 // kVK_ANSI_D - case "f": return 3 // kVK_ANSI_F - case "h": return 4 // kVK_ANSI_H - case "g": return 5 // kVK_ANSI_G - case "z": return 6 // kVK_ANSI_Z - case "x": return 7 // kVK_ANSI_X - case "c": return 8 // kVK_ANSI_C - case "v": return 9 // kVK_ANSI_V - case "b": return 11 // kVK_ANSI_B - case "q": return 12 // kVK_ANSI_Q - case "w": return 13 // kVK_ANSI_W - case "e": return 14 // kVK_ANSI_E - case "r": return 15 // kVK_ANSI_R - case "y": return 16 // kVK_ANSI_Y - case "t": return 17 // kVK_ANSI_T - case "1": return 18 // kVK_ANSI_1 - case "2": return 19 // kVK_ANSI_2 - case "3": return 20 // kVK_ANSI_3 - case "4": return 21 // kVK_ANSI_4 - case "6": return 22 // kVK_ANSI_6 - case "5": return 23 // kVK_ANSI_5 - case "=": return 24 // kVK_ANSI_Equal - case "9": return 25 // kVK_ANSI_9 - case "7": return 26 // kVK_ANSI_7 - case "-": return 27 // kVK_ANSI_Minus - case "8": return 28 // kVK_ANSI_8 - case "0": return 29 // kVK_ANSI_0 - case "]": return 30 // kVK_ANSI_RightBracket - case "o": return 31 // kVK_ANSI_O - case "u": return 32 // kVK_ANSI_U - case "[": return 33 // kVK_ANSI_LeftBracket - case "i": return 34 // kVK_ANSI_I - case "p": return 35 // kVK_ANSI_P - case "l": return 37 // kVK_ANSI_L - case "j": return 38 // kVK_ANSI_J - case "'": return 39 // kVK_ANSI_Quote - case "k": return 40 // kVK_ANSI_K - case ";": return 41 // kVK_ANSI_Semicolon - case "\\": return 42 // kVK_ANSI_Backslash - case ",": return 43 // kVK_ANSI_Comma - case "/": return 44 // kVK_ANSI_Slash - case "n": return 45 // kVK_ANSI_N - case "m": return 46 // kVK_ANSI_M - case ".": return 47 // kVK_ANSI_Period - case "`": return 50 // kVK_ANSI_Grave - case "\r": return 36 // kVK_Return - case "←": return 123 // kVK_LeftArrow - case "→": return 124 // kVK_RightArrow - case "↓": return 125 // kVK_DownArrow - case "↑": return 126 // kVK_UpArrow - default: - return nil - } - } + let hostedView = terminalPanel.hostedView + let hostedSize = hostedView.bounds.size + let hostedHiddenInHierarchy = hostedView.isHiddenOrHasHiddenAncestor + let hostedAttachedToWindow = hostedView.window != nil + let firstResponderIsWindow = NSApp.keyWindow?.firstResponder is NSWindow - private func digitForNumberKeyCode(_ keyCode: UInt16) -> Int? { - switch keyCode { - case 18: return 1 // kVK_ANSI_1 - case 19: return 2 // kVK_ANSI_2 - case 20: return 3 // kVK_ANSI_3 - case 21: return 4 // kVK_ANSI_4 - case 23: return 5 // kVK_ANSI_5 - case 22: return 6 // kVK_ANSI_6 - case 26: return 7 // kVK_ANSI_7 - case 28: return 8 // kVK_ANSI_8 - case 25: return 9 // kVK_ANSI_9 - default: - return nil + let shouldSuppress = shouldSuppressSplitShortcutForTransientTerminalFocusInputs( + firstResponderIsWindow: firstResponderIsWindow, + hostedSize: hostedSize, + hostedHiddenInHierarchy: hostedHiddenInHierarchy, + hostedAttachedToWindow: hostedAttachedToWindow + ) + guard shouldSuppress else { return false } + + tabManager.reconcileFocusedPanelFromFirstResponderForKeyboard() + +#if DEBUG + let directionLabel: String + switch direction { + case .left: directionLabel = "left" + case .right: directionLabel = "right" + case .up: directionLabel = "up" + case .down: directionLabel = "down" } + let firstResponderType = NSApp.keyWindow?.firstResponder.map { String(describing: type(of: $0)) } ?? "nil" + dlog( + "split.shortcut suppressed dir=\(directionLabel) reason=transient_focus_state " + + "fr=\(firstResponderType) hidden=\(hostedHiddenInHierarchy ? 1 : 0) " + + "attached=\(hostedAttachedToWindow ? 1 : 0) " + + "frame=\(String(format: "%.1fx%.1f", hostedSize.width, hostedSize.height))" + ) +#endif + return true } - /// Match arrow key shortcuts using keyCode - /// Arrow keys include .numericPad and .function in their modifierFlags, so strip those before comparing. - private func matchArrowShortcut(event: NSEvent, stroke: ShortcutStroke, keyCode: UInt16) -> Bool { - let flags = event.modifierFlags.intersection(.deviceIndependentFlagsMask) - .subtracting([.numericPad, .function]) - return event.keyCode == keyCode && flags == stroke.modifierFlags - } +#if DEBUG + private func logBrowserZoomShortcutTrace( + stage: String, + event: NSEvent, + flags: NSEvent.ModifierFlags, + chars: String, + action: BrowserZoomShortcutAction? = nil, + handled: Bool? = nil + ) { + guard browserZoomShortcutTraceCandidate( + flags: flags, + chars: chars, + keyCode: event.keyCode, + literalChars: event.characters + ) else { + return + } - /// Match tab key shortcuts using keyCode 48 - private func matchTabShortcut(event: NSEvent, stroke: ShortcutStroke) -> Bool { - let flags = event.modifierFlags.intersection(.deviceIndependentFlagsMask) - return event.keyCode == 48 && flags == stroke.modifierFlags + let keyWindow = NSApp.keyWindow + let firstResponderType = keyWindow?.firstResponder.map { String(describing: type(of: $0)) } ?? "nil" + let panel = tabManager?.focusedBrowserPanel + let panelToken = panel.map { String($0.id.uuidString.prefix(8)) } ?? "nil" + let panelZoom = panel?.webView.pageZoom ?? -1 + var line = + "zoom.shortcut stage=\(stage) event=\(NSWindow.keyDescription(event)) " + + "chars='\(chars)' flags=\(browserZoomShortcutTraceFlagsString(flags)) " + + "action=\(browserZoomShortcutTraceActionString(action)) keyWin=\(keyWindow?.windowNumber ?? -1) " + + "fr=\(firstResponderType) panel=\(panelToken) zoom=\(String(format: "%.3f", panelZoom)) " + + "addrBarId=\(browserAddressBarFocusedPanelId?.uuidString.prefix(8) ?? "nil")" + if let handled { + line += " handled=\(handled ? 1 : 0)" + } + dlog(line) } - private func matchTabShortcut(event: NSEvent, shortcut: StoredShortcut) -> Bool { - guard !shortcut.hasChord else { return false } - return matchTabShortcut(event: event, stroke: shortcut.firstStroke) + private func browserFocusStateSnapshot() -> String { + let selected = tabManager?.selectedTabId.map { String($0.uuidString.prefix(5)) } ?? "nil" + let focused = tabManager?.selectedWorkspace?.focusedPanelId.map { String($0.uuidString.prefix(5)) } ?? "nil" + let addressBar = browserAddressBarFocusedPanelId.map { String($0.uuidString.prefix(5)) } ?? "nil" + let keyWindow = NSApp.keyWindow?.windowNumber ?? -1 + let firstResponderType = NSApp.keyWindow?.firstResponder.map { String(describing: type(of: $0)) } ?? "nil" + return "selected=\(selected) focused=\(focused) addr=\(addressBar) keyWin=\(keyWindow) fr=\(firstResponderType)" } - /// Directional shortcuts default to arrow keys, but the shortcut recorder only supports letter/number keys. - /// Support both so users can customize pane navigation (e.g. Cmd+Ctrl+H/J/K/L). - private func matchDirectionalShortcut( - event: NSEvent, - stroke: ShortcutStroke, - arrowGlyph: String, - arrowKeyCode: UInt16 - ) -> Bool { - if stroke.key == arrowGlyph { - return matchArrowShortcut(event: event, stroke: stroke, keyCode: arrowKeyCode) + private func redactedDebugURL(_ url: URL?) -> String { + guard let url else { return "nil" } + guard var components = URLComponents(url: url, resolvingAgainstBaseURL: false) else { + return "" } - return matchShortcutStroke(event: event, stroke: stroke) + components.user = nil + components.password = nil + components.query = nil + components.fragment = nil + return components.string ?? "" } +#endif - private func matchDirectionalShortcut( - event: NSEvent, - shortcut: StoredShortcut, - arrowGlyph: String, - arrowKeyCode: UInt16 - ) -> Bool { - guard !shortcut.hasChord else { return false } - return matchDirectionalShortcut( - event: event, - stroke: shortcut.firstStroke, - arrowGlyph: arrowGlyph, - arrowKeyCode: arrowKeyCode + @discardableResult + private func focusBrowserAddressBar(panelId: UUID) -> Bool { + guard let tabManager, + let workspace = tabManager.selectedWorkspace, + let panel = workspace.browserPanel(for: panelId) else { +#if DEBUG + dlog( + "browser.focus.addressBar.route panel=\(panelId.uuidString.prefix(5)) " + + "result=miss \(browserFocusStateSnapshot())" + ) +#endif + return false + } +#if DEBUG + dlog( + "browser.focus.addressBar.route panel=\(panel.id.uuidString.prefix(5)) " + + "workspace=\(workspace.id.uuidString.prefix(5)) result=hit \(browserFocusStateSnapshot())" ) +#endif + workspace.focusPanel(panel.id) +#if DEBUG + let focusedAfter = workspace.focusedPanelId.map { String($0.uuidString.prefix(5)) } ?? "nil" + dlog( + "browser.focus.addressBar.route panel=\(panel.id.uuidString.prefix(5)) " + + "workspace=\(workspace.id.uuidString.prefix(5)) focusedAfter=\(focusedAfter)" + ) +#endif + focusBrowserAddressBar(in: panel) + return true } - func validateMenuItem(_ item: NSMenuItem) -> Bool { - updateController.validateMenuItem(item) - } - - - private func configureUserNotifications() { - let actions = [ - UNNotificationAction( - identifier: TerminalNotificationStore.actionShowIdentifier, - title: "Show" + @discardableResult + func openBrowserAndFocusAddressBar(url: URL? = nil, insertAtEnd: Bool = false) -> UUID? { + let preferredProfileID = + tabManager?.focusedBrowserPanel?.profileID + ?? tabManager?.selectedWorkspace?.preferredBrowserProfileID + guard let panelId = tabManager?.openBrowser( + url: url, + preferredProfileID: preferredProfileID, + insertAtEnd: insertAtEnd + ) else { +#if DEBUG + dlog( + "browser.focus.openAndFocus result=open_failed insertAtEnd=\(insertAtEnd ? 1 : 0) " + + "url=\(redactedDebugURL(url)) \(browserFocusStateSnapshot())" ) - ] - - let category = UNNotificationCategory( - identifier: TerminalNotificationStore.categoryIdentifier, - actions: actions, - intentIdentifiers: [], - options: [.customDismissAction] +#endif + return nil + } +#if DEBUG + dlog( + "browser.focus.openAndFocus result=open_ok panel=\(panelId.uuidString.prefix(5)) " + + "insertAtEnd=\(insertAtEnd ? 1 : 0) url=\(redactedDebugURL(url))" + ) +#endif +#if DEBUG + let didFocus = focusBrowserAddressBar(panelId: panelId) + dlog( + "browser.focus.openAndFocus result=focus_request panel=\(panelId.uuidString.prefix(5)) " + + "focused=\(didFocus ? 1 : 0) \(browserFocusStateSnapshot())" ) +#else + _ = focusBrowserAddressBar(panelId: panelId) +#endif + return panelId + } - let center = UNUserNotificationCenter.current() - center.setNotificationCategories([category]) - center.delegate = self + private func focusBrowserAddressBar(in panel: BrowserPanel) { +#if DEBUG + let requestId = panel.requestAddressBarFocus() + dlog( + "browser.focus.addressBar.request panel=\(panel.id.uuidString.prefix(5)) " + + "request=\(requestId.uuidString.prefix(8)) \(browserFocusStateSnapshot())" + ) +#else + _ = panel.requestAddressBarFocus() +#endif + browserAddressBarFocusedPanelId = panel.id +#if DEBUG + dlog( + "browser.focus.addressBar.sticky panel=\(panel.id.uuidString.prefix(5)) " + + "request=\(requestId.uuidString.prefix(8)) \(browserFocusStateSnapshot())" + ) +#endif + NotificationCenter.default.post(name: .browserFocusAddressBar, object: panel.id) +#if DEBUG + dlog( + "browser.focus.addressBar.notify panel=\(panel.id.uuidString.prefix(5)) " + + "request=\(requestId.uuidString.prefix(8))" + ) +#endif } - private func disableNativeTabbingShortcut() { - guard let menu = NSApp.mainMenu else { return } - disableMenuItemShortcut(in: menu, action: #selector(NSWindow.toggleTabBar(_:))) + func focusedBrowserAddressBarPanelId() -> UUID? { + browserAddressBarFocusedPanelId } - private func disableMenuItemShortcut(in menu: NSMenu, action: Selector) { - for item in menu.items { - if item.action == action { - item.keyEquivalent = "" - item.keyEquivalentModifierMask = [] - item.isEnabled = false - } - if let submenu = item.submenu { - disableMenuItemShortcut(in: submenu, action: action) - } + private func focusedBrowserAddressBarPanelIdForShortcutEvent(_ event: NSEvent) -> UUID? { + guard let panelId = browserAddressBarFocusedPanelId else { return nil } + + guard let context = preferredMainWindowContextForShortcutRouting(event: event) else { +#if DEBUG + dlog( + "browser.focus.addressBar.shortcutContext panel=\(panelId.uuidString.prefix(5)) " + + "accepted=0 reason=no_context event=\(NSWindow.keyDescription(event))" + ) +#endif + return nil } - } - private func ensureApplicationIcon() { - // Manual light/dark override removed; the icon always tracks system - // appearance. Clear any stale pre-removal override so the dock-tile - // plugin (which reads the raw key cross-process) falls back too. - UserDefaults.standard.removeObject(forKey: "appIconMode") - AppIconAppearanceObserver.shared.startObserving() - } + guard let workspace = context.tabManager.selectedWorkspace else { +#if DEBUG + dlog( + "browser.focus.addressBar.shortcutContext panel=\(panelId.uuidString.prefix(5)) " + + "accepted=0 reason=no_workspace event=\(NSWindow.keyDescription(event))" + ) +#endif + return nil + } - private func scheduleLaunchServicesBundleRegistration( - bundleURL: URL = Bundle.main.bundleURL.standardizedFileURL, - scheduler: @escaping (@escaping @Sendable () -> Void) -> Void = AppDelegate.enqueueLaunchServicesRegistrationWork, - register: @escaping (CFURL) -> OSStatus = { url in - LSRegisterURL(url, true) - }, - breadcrumb: @escaping (_ message: String, _ data: [String: Any]) -> Void = { _, _ in } - ) { - let normalizedURL = bundleURL.standardizedFileURL - breadcrumb("launchservices.register.schedule", [ - "bundlePath": normalizedURL.path - ]) + guard workspace.browserPanel(for: panelId) != nil else { +#if DEBUG + dlog( + "browser.focus.addressBar.shortcutContext panel=\(panelId.uuidString.prefix(5)) " + + "accepted=0 reason=panel_not_in_workspace workspace=\(workspace.id.uuidString.prefix(5)) " + + "event=\(NSWindow.keyDescription(event))" + ) +#endif + return nil + } - scheduler { - let startedAt = CFAbsoluteTimeGetCurrent() - let registerStatus = register(normalizedURL as CFURL) - let durationMs = Int(((CFAbsoluteTimeGetCurrent() - startedAt) * 1000).rounded()) +#if DEBUG + dlog( + "browser.focus.addressBar.shortcutContext panel=\(panelId.uuidString.prefix(5)) " + + "accepted=1 workspace=\(workspace.id.uuidString.prefix(5)) event=\(NSWindow.keyDescription(event))" + ) +#endif + return panelId + } - breadcrumb("launchservices.register.complete", [ - "bundlePath": normalizedURL.path, - "status": Int(registerStatus), - "durationMs": durationMs - ]) + @discardableResult + func requestBrowserAddressBarFocus(panelId: UUID) -> Bool { + focusBrowserAddressBar(panelId: panelId) + } - if registerStatus != noErr { - NSLog("LaunchServices registration failed (status: \(registerStatus)) for \(normalizedURL.path)") - } + private func shouldBypassAppShortcutForFocusedBrowserAddressBar( + flags: NSEvent.ModifierFlags, + chars: String + ) -> Bool { + guard browserAddressBarFocusedPanelId != nil else { return false } + let normalizedFlags = browserOmnibarNormalizedModifierFlags(flags) + let isCommandOrControlOnly = normalizedFlags == [.command] || normalizedFlags == [.control] + guard isCommandOrControlOnly else { return false } + let shouldBypass = chars == "n" || chars == "p" +#if DEBUG + if shouldBypass { + let panelToken = browserAddressBarFocusedPanelId.map { String($0.uuidString.prefix(5)) } ?? "nil" + dlog( + "browser.focus.addressBar.shortcutBypass panel=\(panelToken) " + + "chars=\(chars) flags=\(normalizedFlags.rawValue)" + ) } +#endif + return shouldBypass } -#if DEBUG - func scheduleLaunchServicesBundleRegistrationForTesting( - bundleURL: URL, - scheduler: @escaping (@escaping @Sendable () -> Void) -> Void, - register: @escaping (CFURL) -> OSStatus, - breadcrumb: @escaping (_ message: String, _ data: [String: Any]) -> Void = { _, _ in } - ) { - scheduleLaunchServicesBundleRegistration( - bundleURL: bundleURL, - scheduler: scheduler, - register: register, - breadcrumb: breadcrumb + private func commandOmnibarSelectionDelta( + flags: NSEvent.ModifierFlags, + chars: String + ) -> Int? { + browserOmnibarSelectionDeltaForCommandNavigation( + hasFocusedAddressBar: browserAddressBarFocusedPanelId != nil, + flags: flags, + chars: chars ) } -#endif - - private func enforceSingleInstance() { - guard let bundleId = Bundle.main.bundleIdentifier else { return } - let currentPid = ProcessInfo.processInfo.processIdentifier - for app in NSRunningApplication.runningApplications(withBundleIdentifier: bundleId) { - guard app.processIdentifier != currentPid else { continue } - app.terminate() - if !app.isTerminated { - _ = app.forceTerminate() - } - } + private func dispatchBrowserOmnibarSelectionMove(delta: Int) { + guard delta != 0 else { return } + guard let panelId = browserAddressBarFocusedPanelId else { return } +#if DEBUG + dlog( + "browser.focus.omnibar.selectionMove panel=\(panelId.uuidString.prefix(5)) " + + "delta=\(delta) repeatKey=\(browserOmnibarRepeatKeyCode.map(String.init) ?? "nil")" + ) +#endif + NotificationCenter.default.post( + name: .browserMoveOmnibarSelection, + object: panelId, + userInfo: ["delta": delta] + ) } - private func observeDuplicateLaunches() { - guard let bundleId = Bundle.main.bundleIdentifier else { return } - let embeddedCLIURL = Bundle.main.bundleURL - .appendingPathComponent("Contents/Resources/bin/programa", isDirectory: false) - .standardizedFileURL - .resolvingSymlinksInPath() - let currentPid = ProcessInfo.processInfo.processIdentifier - - workspaceObserver = NSWorkspace.shared.notificationCenter.addObserver( - forName: NSWorkspace.didLaunchApplicationNotification, - object: nil, - queue: .main - ) { [weak self] notification in - guard self != nil else { return } - guard let app = notification.userInfo?[NSWorkspace.applicationUserInfoKey] as? NSRunningApplication else { return } - guard app.bundleIdentifier == bundleId, app.processIdentifier != currentPid else { return } - if let executableURL = app.executableURL? - .standardizedFileURL - .resolvingSymlinksInPath(), - executableURL == embeddedCLIURL { - return - } + private func startBrowserOmnibarSelectionRepeatIfNeeded(keyCode: UInt16, delta: Int) { + guard delta != 0 else { return } + guard browserAddressBarFocusedPanelId != nil else { +#if DEBUG + dlog( + "browser.focus.omnibar.repeat.start key=\(keyCode) delta=\(delta) " + + "result=skip_no_focused_address_bar" + ) +#endif + return + } - app.terminate() - if !app.isTerminated { - _ = app.forceTerminate() - } - NSRunningApplication.current.activate(options: [.activateAllWindows, .activateIgnoringOtherApps]) + if browserOmnibarRepeatKeyCode == keyCode, browserOmnibarRepeatDelta == delta { +#if DEBUG + let panelToken = browserAddressBarFocusedPanelId.map { String($0.uuidString.prefix(5)) } ?? "nil" + dlog( + "browser.focus.omnibar.repeat.start panel=\(panelToken) " + + "key=\(keyCode) delta=\(delta) result=reuse" + ) +#endif + return } - } - func userNotificationCenter( - _ center: UNUserNotificationCenter, - didReceive response: UNNotificationResponse, - withCompletionHandler completionHandler: @escaping () -> Void - ) { - handleNotificationResponse(response) - completionHandler() - } + stopBrowserOmnibarSelectionRepeat() + browserOmnibarRepeatKeyCode = keyCode + browserOmnibarRepeatDelta = delta +#if DEBUG + let panelToken = browserAddressBarFocusedPanelId.map { String($0.uuidString.prefix(5)) } ?? "nil" + dlog( + "browser.focus.omnibar.repeat.start panel=\(panelToken) " + + "key=\(keyCode) delta=\(delta) result=armed" + ) +#endif - func userNotificationCenter( - _ center: UNUserNotificationCenter, - willPresent notification: UNNotification, - withCompletionHandler completionHandler: @escaping (UNNotificationPresentationOptions) -> Void - ) { - var options: UNNotificationPresentationOptions = [.banner, .list] - if notification.request.content.sound != nil { - options.insert(.sound) + let start = DispatchWorkItem { [weak self] in + self?.scheduleBrowserOmnibarSelectionRepeatTick() } - completionHandler(options) + browserOmnibarRepeatStartWorkItem = start + DispatchQueue.main.asyncAfter(deadline: .now() + 0.25, execute: start) } - private func handleNotificationResponse(_ response: UNNotificationResponse) { - guard let tabIdString = response.notification.request.content.userInfo["tabId"] as? String, - let tabId = UUID(uuidString: tabIdString) else { + private func scheduleBrowserOmnibarSelectionRepeatTick() { + browserOmnibarRepeatStartWorkItem = nil + guard browserAddressBarFocusedPanelId != nil else { +#if DEBUG + dlog("browser.focus.omnibar.repeat.tick result=stop_no_focused_address_bar") +#endif + stopBrowserOmnibarSelectionRepeat() return } - let surfaceId: UUID? = { - guard let surfaceIdString = response.notification.request.content.userInfo["surfaceId"] as? String else { - return nil - } - return UUID(uuidString: surfaceIdString) - }() + guard browserOmnibarRepeatKeyCode != nil else { return } - switch response.actionIdentifier { - case UNNotificationDefaultActionIdentifier, TerminalNotificationStore.actionShowIdentifier: - let notificationId: UUID? = { - if let id = UUID(uuidString: response.notification.request.identifier) { - return id - } - if let idString = response.notification.request.content.userInfo["notificationId"] as? String, - let id = UUID(uuidString: idString) { - return id - } - return nil - }() - DispatchQueue.main.async { - _ = self.openNotification(tabId: tabId, surfaceId: surfaceId, notificationId: notificationId) - } - case UNNotificationDismissActionIdentifier: - DispatchQueue.main.async { - if let notificationId = UUID(uuidString: response.notification.request.identifier) { - self.notificationStore?.markRead(id: notificationId) - } else if let notificationIdString = response.notification.request.content.userInfo["notificationId"] as? String, - let notificationId = UUID(uuidString: notificationIdString) { - self.notificationStore?.markRead(id: notificationId) - } - } - default: - break +#if DEBUG + let panelToken = browserAddressBarFocusedPanelId.map { String($0.uuidString.prefix(5)) } ?? "nil" + dlog( + "browser.focus.omnibar.repeat.tick panel=\(panelToken) " + + "delta=\(browserOmnibarRepeatDelta)" + ) +#endif + dispatchBrowserOmnibarSelectionMove(delta: browserOmnibarRepeatDelta) + + let tick = DispatchWorkItem { [weak self] in + self?.scheduleBrowserOmnibarSelectionRepeatTick() } + browserOmnibarRepeatTickWorkItem = tick + DispatchQueue.main.asyncAfter(deadline: .now() + 0.055, execute: tick) } - private func installMainWindowKeyObserver() { - guard windowKeyObserver == nil else { return } - windowKeyObserver = NotificationCenter.default.addObserver( - forName: NSWindow.didBecomeKeyNotification, - object: nil, - queue: .main - ) { [weak self] note in - guard let self, let window = note.object as? NSWindow else { return } - self.setActiveMainWindow(window) + private func stopBrowserOmnibarSelectionRepeat() { +#if DEBUG + let previousKeyCode = browserOmnibarRepeatKeyCode + let previousDelta = browserOmnibarRepeatDelta +#endif + browserOmnibarRepeatStartWorkItem?.cancel() + browserOmnibarRepeatTickWorkItem?.cancel() + browserOmnibarRepeatStartWorkItem = nil + browserOmnibarRepeatTickWorkItem = nil + browserOmnibarRepeatKeyCode = nil + browserOmnibarRepeatDelta = 0 +#if DEBUG + if previousKeyCode != nil || previousDelta != 0 { + dlog( + "browser.focus.omnibar.repeat.stop key=\(previousKeyCode.map(String.init) ?? "nil") " + + "delta=\(previousDelta)" + ) } +#endif } - private func installBrowserAddressBarFocusObservers() { - guard browserAddressBarFocusObserver == nil, browserAddressBarBlurObserver == nil else { return } + private func handleBrowserOmnibarSelectionRepeatLifecycleEvent(_ event: NSEvent) { + guard browserOmnibarRepeatKeyCode != nil else { return } - browserAddressBarFocusObserver = NotificationCenter.default.addObserver( - forName: .browserDidFocusAddressBar, - object: nil, - queue: .main - ) { [weak self] notification in - guard let self else { return } - guard let panelId = notification.object as? UUID else { return } - self.browserPanel(for: panelId)?.beginSuppressWebViewFocusForAddressBar() - self.browserAddressBarFocusedPanelId = panelId - self.stopBrowserOmnibarSelectionRepeat() + switch event.type { + case .keyUp: + if event.keyCode == browserOmnibarRepeatKeyCode { #if DEBUG - dlog("addressBar FOCUS panelId=\(panelId.uuidString.prefix(8))") + dlog( + "browser.focus.omnibar.repeat.lifecycle event=keyUp key=\(event.keyCode) " + + "action=stop" + ) #endif - } - - browserAddressBarBlurObserver = NotificationCenter.default.addObserver( - forName: .browserDidBlurAddressBar, - object: nil, - queue: .main - ) { [weak self] notification in - guard let self else { return } - guard let panelId = notification.object as? UUID else { return } - self.browserPanel(for: panelId)?.endSuppressWebViewFocusForAddressBar() - if self.browserAddressBarFocusedPanelId == panelId { - self.browserAddressBarFocusedPanelId = nil - self.stopBrowserOmnibarSelectionRepeat() + stopBrowserOmnibarSelectionRepeat() + } + case .flagsChanged: + let flags = event.modifierFlags.intersection(.deviceIndependentFlagsMask) + if !flags.contains(.command) { #if DEBUG - dlog("addressBar BLUR panelId=\(panelId.uuidString.prefix(8))") + dlog( + "browser.focus.omnibar.repeat.lifecycle event=flagsChanged " + + "flags=\(flags.rawValue) action=stop" + ) #endif + stopBrowserOmnibarSelectionRepeat() } + default: + break } } - private func browserPanel(for panelId: UUID) -> BrowserPanel? { - return tabManager?.selectedWorkspace?.browserPanel(for: panelId) - } - - fileprivate func browserFindBarIsVisible(for webView: ProgramaWebView) -> Bool { - browserPanelOwning(webView)?.searchState != nil - } - - private func shouldLetFocusedBrowserOwnFindShortcut(_ event: NSEvent) -> Bool { - let shortcutWindow = resolvedShortcutEventWindow(event) ?? NSApp.keyWindow ?? NSApp.mainWindow - let shortcutResponder = shortcutWindow?.firstResponder - let owningWebView = tabManager?.focusedBrowserPanel?.webView as? ProgramaWebView - guard let owningWebView else { return false } - return shouldRouteBrowserFindCommandEquivalentThroughWebContentFirst( - event, - responder: shortcutResponder, - owningWebView: owningWebView - ) + private func isLikelyWebInspectorResponder(_ responder: NSResponder?) -> Bool { + programaIsLikelyWebInspectorResponder(responder) } - private func browserPanelOwning(_ webView: ProgramaWebView) -> BrowserPanel? { - var candidateManagers: [TabManager] = [] - var seenManagers = Set() - - func appendCandidate(_ manager: TabManager?) { - guard let manager else { return } - let identifier = ObjectIdentifier(manager) - guard seenManagers.insert(identifier).inserted else { return } - candidateManagers.append(manager) - } - - if let window = webView.window, - let context = contextForMainWindow(window) { - appendCandidate(context.tabManager) +#if DEBUG + private func developerToolsShortcutProbeKind(event: NSEvent) -> String? { + if matchShortcut(event: event, shortcut: KeyboardShortcutSettings.shortcut(for: .toggleBrowserDeveloperTools)) { + return "toggle.configured" } - appendCandidate(tabManager) - for context in mainWindowContexts.values { - appendCandidate(context.tabManager) + if matchShortcut(event: event, shortcut: KeyboardShortcutSettings.shortcut(for: .showBrowserJavaScriptConsole)) { + return "console.configured" } - for manager in candidateManagers { - if let panel = browserPanelOwning(webView, in: manager) { - return panel + let chars = (event.charactersIgnoringModifiers ?? "").lowercased() + let flags = event.modifierFlags.intersection(.deviceIndependentFlagsMask) + if flags == [.command, .option] { + if chars == "i" || event.keyCode == 34 { + return "toggle.literal" + } + if chars == "c" || event.keyCode == 8 { + return "console.literal" } } return nil } - private func browserPanelOwning(_ webView: ProgramaWebView, in manager: TabManager) -> BrowserPanel? { - for workspace in manager.tabs { - if let panel = workspace.panels.values - .compactMap({ $0 as? BrowserPanel }) - .first(where: { $0.webView === webView }) { - return panel + private func logDeveloperToolsShortcutSnapshot( + phase: String, + event: NSEvent? = nil, + didHandle: Bool? = nil + ) { + let keyWindow = NSApp.keyWindow + let firstResponder = keyWindow?.firstResponder + let firstResponderType = firstResponder.map { String(describing: type(of: $0)) } ?? "nil" + let firstResponderPtr = firstResponder.map { String(describing: Unmanaged.passUnretained($0).toOpaque()) } ?? "nil" + let eventDescription = event.map(NSWindow.keyDescription) ?? "none" + if let browser = tabManager?.focusedBrowserPanel { + var line = + "browser.devtools shortcut=\(phase) panel=\(browser.id.uuidString.prefix(5)) " + + "\(browser.debugDeveloperToolsStateSummary()) \(browser.debugDeveloperToolsGeometrySummary()) " + + "keyWin=\(keyWindow?.windowNumber ?? -1) fr=\(firstResponderType)@\(firstResponderPtr) event=\(eventDescription)" + if let didHandle { + line += " handled=\(didHandle ? 1 : 0)" } + dlog(line) + return } - return nil + var line = + "browser.devtools shortcut=\(phase) panel=nil keyWin=\(keyWindow?.windowNumber ?? -1) " + + "fr=\(firstResponderType)@\(firstResponderPtr) event=\(eventDescription)" + if let didHandle { + line += " handled=\(didHandle ? 1 : 0)" + } + dlog(line) } - - private func setActiveMainWindow(_ window: NSWindow) { - guard let context = contextForMainTerminalWindow(window) else { return } -#if DEBUG - let beforeManagerToken = debugManagerToken(tabManager) #endif - tabManager = context.tabManager - sidebarState = context.sidebarState - sidebarSelectionState = context.sidebarSelectionState - TerminalController.shared.setActiveTabManager(context.tabManager) -#if DEBUG + + private func prepareFocusedBrowserDevToolsForSplit(directionLabel: String) { + guard let browser = tabManager?.focusedBrowserPanel else { return } + guard browser.shouldPreserveWebViewAttachmentDuringTransientHide() else { return } + guard let keyWindow = NSApp.keyWindow else { return } + guard isLikelyWebInspectorResponder(keyWindow.firstResponder) else { return } + + let beforeResponder = keyWindow.firstResponder + let movedToWebView = keyWindow.makeFirstResponder(browser.webView) + let movedToNil = movedToWebView ? false : keyWindow.makeFirstResponder(nil) + + #if DEBUG + let beforeType = beforeResponder.map { String(describing: type(of: $0)) } ?? "nil" + let beforePtr = beforeResponder.map { String(describing: Unmanaged.passUnretained($0).toOpaque()) } ?? "nil" + let afterResponder = keyWindow.firstResponder + let afterType = afterResponder.map { String(describing: type(of: $0)) } ?? "nil" + let afterPtr = afterResponder.map { String(describing: Unmanaged.passUnretained($0).toOpaque()) } ?? "nil" dlog( - "mainWindow.active window={\(debugWindowToken(window))} context={\(debugContextToken(context))} beforeMgr=\(beforeManagerToken) afterMgr=\(debugManagerToken(tabManager)) \(debugShortcutRouteSnapshot())" + "split.shortcut inspector.preflight dir=\(directionLabel) panel=\(browser.id.uuidString.prefix(5)) " + + "before=\(beforeType)@\(beforePtr) after=\(afterType)@\(afterPtr) " + + "moveWeb=\(movedToWebView ? 1 : 0) moveNil=\(movedToNil ? 1 : 0) \(browser.debugDeveloperToolsStateSummary())" ) -#endif + #endif } - private func unregisterMainWindow(_ window: NSWindow) { - // Reset cascade point so the next new window appears near the closing - // window's position, matching upstream Ghostty behavior. - let frame = window.frame - lastCascadePoint = NSPoint(x: frame.minX, y: frame.maxY) + @discardableResult + func performSplitShortcut(direction: SplitDirection, preferredWindow: NSWindow? = nil) -> Bool { + let targetWindow = preferredWindow ?? NSApp.keyWindow ?? NSApp.mainWindow + let terminalContext = focusedTerminalShortcutContext(preferredWindow: targetWindow) + _ = synchronizeActiveMainWindowContext(preferredWindow: targetWindow) - // Keep geometry available as a fallback even if the full session snapshot - // is removed when the last window closes. - persistWindowGeometry(from: window) - guard let removed = unregisterMainWindowContext(for: window) else { return } - commandPaletteVisibilityByWindowId.removeValue(forKey: removed.windowId) - commandPalettePendingOpenByWindowId.removeValue(forKey: removed.windowId) - commandPaletteRecentRequestAtByWindowId.removeValue(forKey: removed.windowId) - commandPaletteEscapeSuppressionByWindowId.remove(removed.windowId) - commandPaletteEscapeSuppressionStartedAtByWindowId.removeValue(forKey: removed.windowId) - commandPaletteSelectionByWindowId.removeValue(forKey: removed.windowId) - commandPaletteSnapshotByWindowId.removeValue(forKey: removed.windowId) + let directionLabel: String + switch direction { + case .left: directionLabel = "left" + case .right: directionLabel = "right" + case .up: directionLabel = "up" + case .down: directionLabel = "down" + } - // Avoid stale notifications that can no longer be opened once the owning window is gone. - if let store = notificationStore { - for tab in removed.tabManager.tabs { - store.clearNotifications(forTabId: tab.id) + #if DEBUG + let keyWindow = NSApp.keyWindow + let firstResponder = keyWindow?.firstResponder + let firstResponderType = firstResponder.map { String(describing: type(of: $0)) } ?? "nil" + let firstResponderPtr = firstResponder.map { String(describing: Unmanaged.passUnretained($0).toOpaque()) } ?? "nil" + let firstResponderWindow: Int = { + if let v = firstResponder as? NSView { + return v.window?.windowNumber ?? -1 + } + if let w = firstResponder as? NSWindow { + return w.windowNumber } + return -1 + }() + let splitContext = "keyWin=\(keyWindow?.windowNumber ?? -1) mainWin=\(NSApp.mainWindow?.windowNumber ?? -1) fr=\(firstResponderType)@\(firstResponderPtr) frWin=\(firstResponderWindow)" + if let browser = tabManager?.focusedBrowserPanel { + let webWindow = browser.webView.window?.windowNumber ?? -1 + let webSuperview = browser.webView.superview.map { String(describing: Unmanaged.passUnretained($0).toOpaque()) } ?? "nil" + dlog("split.shortcut dir=\(directionLabel) pre panel=\(browser.id.uuidString.prefix(5)) \(browser.debugDeveloperToolsStateSummary()) webWin=\(webWindow) webSuper=\(webSuperview) \(splitContext)") + } else { + dlog("split.shortcut dir=\(directionLabel) pre panel=nil \(splitContext)") } + #endif - if tabManager === removed.tabManager { - // Repoint "active" pointers to any remaining main terminal window. - let nextContext: MainWindowContext? = { - if let keyWindow = NSApp.keyWindow, - let ctx = contextForMainTerminalWindow(keyWindow, reindex: false) { - return ctx + prepareFocusedBrowserDevToolsForSplit(directionLabel: directionLabel) + let didCreateSplit: Bool = { + if let terminalContext { + return terminalContext.tabManager.createSplit( + tabId: terminalContext.workspaceId, + surfaceId: terminalContext.panelId, + direction: direction + ) != nil + } + return tabManager?.createSplit(direction: direction) != nil + }() +#if DEBUG + DispatchQueue.main.asyncAfter(deadline: .now() + 0.15) { [weak self] in + let keyWindow = NSApp.keyWindow + let firstResponder = keyWindow?.firstResponder + let firstResponderType = firstResponder.map { String(describing: type(of: $0)) } ?? "nil" + let firstResponderPtr = firstResponder.map { String(describing: Unmanaged.passUnretained($0).toOpaque()) } ?? "nil" + let firstResponderWindow: Int = { + if let v = firstResponder as? NSView { + return v.window?.windowNumber ?? -1 } - return mainWindowContexts.values.first + if let w = firstResponder as? NSWindow { + return w.windowNumber + } + return -1 }() - - if let nextContext { - tabManager = nextContext.tabManager - sidebarState = nextContext.sidebarState - sidebarSelectionState = nextContext.sidebarSelectionState - TerminalController.shared.setActiveTabManager(nextContext.tabManager) + let splitContext = "keyWin=\(keyWindow?.windowNumber ?? -1) mainWin=\(NSApp.mainWindow?.windowNumber ?? -1) fr=\(firstResponderType)@\(firstResponderPtr) frWin=\(firstResponderWindow)" + if let browser = self?.tabManager?.focusedBrowserPanel { + let webWindow = browser.webView.window?.windowNumber ?? -1 + let webSuperview = browser.webView.superview.map { String(describing: Unmanaged.passUnretained($0).toOpaque()) } ?? "nil" + dlog("split.shortcut dir=\(directionLabel) post panel=\(browser.id.uuidString.prefix(5)) \(browser.debugDeveloperToolsStateSummary()) webWin=\(webWindow) webSuper=\(webSuperview) \(splitContext)") } else { - tabManager = nil - sidebarState = nil - sidebarSelectionState = nil - TerminalController.shared.setActiveTabManager(nil) + dlog("split.shortcut dir=\(directionLabel) post panel=nil \(splitContext)") } } + recordGotoSplitSplitIfNeeded(direction: direction) +#endif + return didCreateSplit + } - // During app termination we already persisted a full snapshot (with scrollback) - // in applicationShouldTerminate/applicationWillTerminate. Saving again here would - // overwrite it as windows tear down one-by-one, dropping closed windows and replay. - if Self.shouldPersistSnapshotOnWindowUnregister(isTerminatingApp: isTerminatingApp) { - _ = saveSessionSnapshot( - includeScrollback: false, - removeWhenEmpty: Self.shouldRemoveSnapshotWhenNoWindowsRemainOnWindowUnregister( - isTerminatingApp: isTerminatingApp - ) - ) + @discardableResult + func performBrowserSplitShortcut(direction: SplitDirection) -> Bool { + _ = synchronizeActiveMainWindowContext(preferredWindow: NSApp.keyWindow ?? NSApp.mainWindow) + + #if DEBUG + let directionLabel: String + switch direction { + case .left: directionLabel = "left" + case .right: directionLabel = "right" + case .up: directionLabel = "up" + case .down: directionLabel = "down" + } + let selectedTabBefore = tabManager?.selectedTabId?.uuidString.prefix(5) ?? "nil" + let focusedPanelBefore = tabManager?.selectedWorkspace?.focusedPanelId?.uuidString.prefix(5) ?? "nil" + dlog( + "split.browser.shortcut pre dir=\(directionLabel) " + + "tab=\(selectedTabBefore) focusedPanel=\(focusedPanelBefore)" + ) + #endif + + guard let panelId = tabManager?.createBrowserSplit(direction: direction) else { + #if DEBUG + dlog("split.browser.shortcut failed dir=\(directionLabel)") + #endif + return false } + + #if DEBUG + let selectedTabAfter = tabManager?.selectedTabId?.uuidString.prefix(5) ?? "nil" + let focusedPanelAfter = tabManager?.selectedWorkspace?.focusedPanelId?.uuidString.prefix(5) ?? "nil" + dlog( + "split.browser.shortcut post dir=\(directionLabel) " + + "created=\(panelId.uuidString.prefix(5)) tab=\(selectedTabAfter) focusedPanel=\(focusedPanelAfter)" + ) + #endif + + _ = focusBrowserAddressBar(panelId: panelId) + return true } - private func isMainTerminalWindow(_ window: NSWindow) -> Bool { - if mainWindowContexts[ObjectIdentifier(window)] != nil { - return true - } - guard let raw = window.identifier?.rawValue else { return false } - return raw == "cmux.main" || raw.hasPrefix("cmux.main.") + /// Allow AppKit-backed browser surfaces (WKWebView) to route non-menu shortcuts + /// through the same app-level shortcut handler used by the local key monitor. + @discardableResult + func handleBrowserSurfaceKeyEquivalent(_ event: NSEvent) -> Bool { + handleCustomShortcut(event: event) } - private func contextContainingTabId(_ tabId: UUID) -> MainWindowContext? { - for context in mainWindowContexts.values { - if context.tabManager.tabs.contains(where: { $0.id == tabId }) { - return context - } - } - return nil + @discardableResult + func requestRenameWorkspaceViaCommandPalette(preferredWindow: NSWindow? = nil) -> Bool { + let targetWindow = preferredWindow ?? NSApp.keyWindow ?? NSApp.mainWindow + requestCommandPaletteRenameWorkspace( + preferredWindow: targetWindow, + source: "shortcut.renameWorkspace" + ) + return true } - /// Returns the `TabManager` that owns `tabId`, if any. - func tabManagerFor(tabId: UUID) -> TabManager? { - contextContainingTabId(tabId)?.tabManager + @discardableResult + func requestEditWorkspaceDescriptionViaCommandPalette(preferredWindow: NSWindow? = nil) -> Bool { + let targetWindow = preferredWindow ?? NSApp.keyWindow ?? NSApp.mainWindow +#if DEBUG + dlog( + "shortcut.editWorkspaceDescription request target={\(debugWindowToken(targetWindow))} " + + "fr=\(targetWindow?.firstResponder.map { String(describing: type(of: $0)) } ?? "nil")" + ) +#endif + requestCommandPaletteEditWorkspaceDescription( + preferredWindow: targetWindow, + source: "shortcut.editWorkspaceDescription" + ) + return true } - private func workspaceForMainActor(tabId: UUID) -> Workspace? { - contextContainingTabId(tabId)?.tabManager.tabs.first(where: { $0.id == tabId }) +#if DEBUG + // Debug/test hook: allow socket-driven shortcut simulation to reuse the same shortcut routing + // logic as the local NSEvent monitor, without relying on AppKit event monitor behavior for + // synthetic NSEvents. + func debugHandleCustomShortcut(event: NSEvent) -> Bool { + handleCustomShortcut(event: event) } - /// Returns the `Workspace` that owns `tabId`, if any. - @MainActor - func workspaceFor(tabId: UUID) -> Workspace? { - workspaceForMainActor(tabId: tabId) + // Debug/test hook: mirrors local monitor routing (keyDown + keyUp lifecycle). + func debugHandleShortcutMonitorEvent(event: NSEvent) -> Bool { + if event.type == .keyDown { + return handleCustomShortcut(event: event) + } + handleBrowserOmnibarSelectionRepeatLifecycleEvent(event) + return clearEscapeSuppressionForKeyUp(event: event, consumeIfSuppressed: true) } - func closeMainWindowContainingTabId(_ tabId: UUID) { - guard let context = contextContainingTabId(tabId) else { return } - let expectedIdentifier = "cmux.main.\(context.windowId.uuidString)" - let window: NSWindow? = context.window ?? NSApp.windows.first(where: { $0.identifier?.rawValue == expectedIdentifier }) - window?.performClose(nil) + func debugMarkCommandPaletteOpenPending(window: NSWindow) { + markCommandPaletteOpenRequested(for: window) } @discardableResult - func openNotification(tabId: UUID, surfaceId: UUID?, notificationId: UUID?) -> Bool { -#if DEBUG - let isJumpUnreadUITest = ProcessInfo.processInfo.environment["PROGRAMA_UI_TEST_JUMP_UNREAD_SETUP"] == "1" - if isJumpUnreadUITest { - writeJumpUnreadTestData([ - "jumpUnreadOpenCalled": "1", - "jumpUnreadOpenTabId": tabId.uuidString, - "jumpUnreadOpenSurfaceId": surfaceId?.uuidString ?? "", - ]) + func debugSetCommandPalettePendingOpenAge(window: NSWindow, age: TimeInterval) -> Bool { + guard let windowId = mainWindowId(for: window) else { return false } + commandPalettePendingOpenByWindowId[windowId] = true + commandPaletteRecentRequestAtByWindowId[windowId] = ProcessInfo.processInfo.systemUptime - max(age, 0) + return true + } + + // Test hook: remap a window context under a detached window key so direct + // ObjectIdentifier(window) lookups fail and fallback logic is exercised. + @discardableResult + func debugInjectWindowContextKeyMismatch(windowId: UUID) -> Bool { + guard let context = mainWindowContexts.values.first(where: { $0.windowId == windowId }), + let window = context.window ?? windowForMainWindowId(windowId) else { + return false } -#endif - guard let context = contextContainingTabId(tabId) else { -#if DEBUG - recordMultiWindowNotificationOpenFailureIfNeeded( - tabId: tabId, - surfaceId: surfaceId, - notificationId: notificationId, - reason: "missing_context" - ) -#endif -#if DEBUG - if isJumpUnreadUITest { - writeJumpUnreadTestData(["jumpUnreadOpenContextFound": "0", "jumpUnreadOpenUsedFallback": "1"]) - } -#endif - let ok = openNotificationFallback(tabId: tabId, surfaceId: surfaceId, notificationId: notificationId) -#if DEBUG - if isJumpUnreadUITest { - writeJumpUnreadTestData(["jumpUnreadOpenResult": ok ? "1" : "0"]) - } -#endif - return ok + + let detachedWindow = NSWindow( + contentRect: NSRect(x: 0, y: 0, width: 16, height: 16), + styleMask: [.borderless], + backing: .buffered, + defer: false + ) + debugDetachedContextWindows.append(detachedWindow) + + let contextKeys = mainWindowContexts.compactMap { key, value in + value === context ? key : nil } -#if DEBUG - if isJumpUnreadUITest { - writeJumpUnreadTestData(["jumpUnreadOpenContextFound": "1", "jumpUnreadOpenUsedFallback": "0"]) + for key in contextKeys { + mainWindowContexts.removeValue(forKey: key) } + mainWindowContexts[ObjectIdentifier(detachedWindow)] = context + context.window = window + return true + } #endif - return openNotificationInContext(context, tabId: tabId, surfaceId: surfaceId, notificationId: notificationId) + + private func findButton(in view: NSView, titled title: String) -> NSButton? { + if let button = view as? NSButton, button.title == title { + return button + } + for subview in view.subviews { + if let found = findButton(in: subview, titled: title) { + return found + } + } + return nil } - private func openNotificationInContext(_ context: MainWindowContext, tabId: UUID, surfaceId: UUID?, notificationId: UUID?) -> Bool { - let expectedIdentifier = "cmux.main.\(context.windowId.uuidString)" - let window: NSWindow? = context.window ?? NSApp.windows.first(where: { $0.identifier?.rawValue == expectedIdentifier }) - guard let window else { -#if DEBUG - recordMultiWindowNotificationOpenFailureIfNeeded( - tabId: tabId, - surfaceId: surfaceId, - notificationId: notificationId, - reason: "missing_window expectedIdentifier=\(expectedIdentifier)" - ) -#endif - return false + private func findStaticText(in view: NSView, equals text: String) -> Bool { + if let field = view as? NSTextField, field.stringValue == text { + return true + } + for subview in view.subviews { + if findStaticText(in: subview, equals: text) { + return true + } } + return false + } - context.sidebarSelectionState.selection = .tabs - bringToFront(window) - guard context.tabManager.focusTabFromNotification(tabId, surfaceId: surfaceId) else { -#if DEBUG - recordMultiWindowNotificationOpenFailureIfNeeded( - tabId: tabId, - surfaceId: surfaceId, - notificationId: notificationId, - reason: "focus_failed" - ) - if ProcessInfo.processInfo.environment["PROGRAMA_UI_TEST_JUMP_UNREAD_SETUP"] == "1" { - writeJumpUnreadTestData(["jumpUnreadOpenResult": "0"]) + private func matchConfiguredShortcut(event: NSEvent, action: KeyboardShortcutSettings.Action) -> Bool { + let shortcut = KeyboardShortcutSettings.shortcut(for: action) + if let prefix = activeConfiguredShortcutChordPrefixForCurrentEvent { + guard let secondStroke = shortcut.secondStroke, + shortcut.firstStroke == prefix else { + return false } -#endif - return false + return matchShortcutStroke(event: event, stroke: secondStroke) } + guard !shortcut.hasChord else { return false } + return matchShortcutStroke(event: event, stroke: shortcut.firstStroke) + } -#if DEBUG - // UI test support: Jump-to-unread asserts that the correct workspace/panel is focused. - // Recording via first-responder can be flaky on the VM, so verify focus via the model. - recordJumpUnreadFocusFromModelIfNeeded( - tabManager: context.tabManager, - tabId: tabId, - expectedSurfaceId: surfaceId - ) -#endif + private func numberedConfiguredShortcutDigit( + event: NSEvent, + action: KeyboardShortcutSettings.Action + ) -> Int? { + let shortcut = KeyboardShortcutSettings.shortcut(for: action) + if let prefix = activeConfiguredShortcutChordPrefixForCurrentEvent { + guard let secondStroke = shortcut.secondStroke, + shortcut.firstStroke == prefix else { + return nil + } + return numberedShortcutDigit(event: event, stroke: secondStroke) + } + guard !shortcut.hasChord else { return nil } + return numberedShortcutDigit(event: event, stroke: shortcut.firstStroke) + } - if let notificationId, let store = notificationStore { - markReadIfFocused( - notificationId: notificationId, - tabId: tabId, - surfaceId: surfaceId, - tabManager: context.tabManager, - notificationStore: store + private func matchConfiguredDirectionalShortcut( + event: NSEvent, + action: KeyboardShortcutSettings.Action, + arrowGlyph: String, + arrowKeyCode: UInt16 + ) -> Bool { + let shortcut = KeyboardShortcutSettings.shortcut(for: action) + if let prefix = activeConfiguredShortcutChordPrefixForCurrentEvent { + guard let secondStroke = shortcut.secondStroke, + shortcut.firstStroke == prefix else { + return false + } + return matchDirectionalShortcut( + event: event, + stroke: secondStroke, + arrowGlyph: arrowGlyph, + arrowKeyCode: arrowKeyCode ) } - -#if DEBUG - recordMultiWindowNotificationFocusIfNeeded( - windowId: context.windowId, - tabId: tabId, - surfaceId: surfaceId, - sidebarSelection: context.sidebarSelectionState.selection + guard !shortcut.hasChord else { return false } + return matchDirectionalShortcut( + event: event, + stroke: shortcut.firstStroke, + arrowGlyph: arrowGlyph, + arrowKeyCode: arrowKeyCode ) - if ProcessInfo.processInfo.environment["PROGRAMA_UI_TEST_JUMP_UNREAD_SETUP"] == "1" { - writeJumpUnreadTestData(["jumpUnreadOpenInContext": "1", "jumpUnreadOpenResult": "1"]) - } -#endif - return true } - private func openNotificationFallback(tabId: UUID, surfaceId: UUID?, notificationId: UUID?) -> Bool { - // If the owning window context hasn't been registered yet, fall back to the "active" window. - guard let tabManager else { -#if DEBUG - if ProcessInfo.processInfo.environment["PROGRAMA_UI_TEST_JUMP_UNREAD_SETUP"] == "1" { - writeJumpUnreadTestData(["jumpUnreadFallbackFail": "missing_tabManager"]) - } -#endif - return false - } - guard tabManager.tabs.contains(where: { $0.id == tabId }) else { -#if DEBUG - if ProcessInfo.processInfo.environment["PROGRAMA_UI_TEST_JUMP_UNREAD_SETUP"] == "1" { - writeJumpUnreadTestData(["jumpUnreadFallbackFail": "tab_not_in_active_manager"]) - } -#endif - return false + private func configuredShortcutChordWindowNumber(for event: NSEvent) -> Int? { + if let window = mainWindowForShortcutEvent(event) { + return window.windowNumber } - guard let window = (NSApp.keyWindow ?? NSApp.windows.first(where: { isMainTerminalWindow($0) })) else { -#if DEBUG - if ProcessInfo.processInfo.environment["PROGRAMA_UI_TEST_JUMP_UNREAD_SETUP"] == "1" { - writeJumpUnreadTestData(["jumpUnreadFallbackFail": "missing_window"]) - } -#endif - return false + if let window = event.window { + return window.windowNumber } + return event.windowNumber > 0 ? event.windowNumber : nil + } - sidebarSelectionState?.selection = .tabs - bringToFront(window) - guard tabManager.focusTabFromNotification(tabId, surfaceId: surfaceId) else { -#if DEBUG - if ProcessInfo.processInfo.environment["PROGRAMA_UI_TEST_JUMP_UNREAD_SETUP"] == "1" { - writeJumpUnreadTestData([ - "jumpUnreadFallbackFail": "focus_failed", - "jumpUnreadOpenResult": "0", - ]) + private func armConfiguredShortcutChordIfNeeded( + event: NSEvent, + actions: [KeyboardShortcutSettings.Action]? = nil + ) -> Bool { + for action in actions ?? configuredShortcutChordActions { + let shortcut = KeyboardShortcutSettings.shortcut(for: action) + guard shortcut.hasChord else { continue } + if matchShortcutStroke(event: event, stroke: shortcut.firstStroke) { + pendingConfiguredShortcutChord = PendingConfiguredShortcutChord( + firstStroke: shortcut.firstStroke, + windowNumber: configuredShortcutChordWindowNumber(for: event) + ) + return true } -#endif - return false } + return false + } -#if DEBUG - recordJumpUnreadFocusFromModelIfNeeded( - tabManager: tabManager, - tabId: tabId, - expectedSurfaceId: surfaceId - ) -#endif + /// Match a shortcut stroke against an event, handling normal keys. + private func matchShortcutStroke(event: NSEvent, stroke: ShortcutStroke) -> Bool { + // Some keys can include extra flags (e.g. .function) depending on the responder chain. + // Strip those for consistent matching across first responders (terminal, WebKit, etc). + let flags = event.modifierFlags.intersection(.deviceIndependentFlagsMask) + .subtracting([.numericPad, .function, .capsLock]) + guard flags == stroke.modifierFlags else { return false } - if let notificationId, let store = notificationStore { - markReadIfFocused( - notificationId: notificationId, - tabId: tabId, - surfaceId: surfaceId, - tabManager: tabManager, - notificationStore: store - ) + let shortcutKey = stroke.key.lowercased() + if shortcutKey == "\r" { + return event.keyCode == 36 || event.keyCode == 76 } -#if DEBUG - if ProcessInfo.processInfo.environment["PROGRAMA_UI_TEST_JUMP_UNREAD_SETUP"] == "1" { - writeJumpUnreadTestData(["jumpUnreadOpenInFallback": "1", "jumpUnreadOpenResult": "1"]) + + let eventCharsIgnoringModifiers = event.charactersIgnoringModifiers + if shortcutCharacterMatches( + eventCharacter: eventCharsIgnoringModifiers, + shortcutKey: shortcutKey, + applyShiftSymbolNormalization: flags.contains(.shift), + eventKeyCode: event.keyCode + ) { + return true } -#endif - return true - } -#if DEBUG - private func recordJumpUnreadFocusFromModelIfNeeded( - tabManager: TabManager, - tabId: UUID, - expectedSurfaceId: UUID? - ) { - let env = ProcessInfo.processInfo.environment - guard env["PROGRAMA_UI_TEST_JUMP_UNREAD_SETUP"] == "1" else { return } - guard let expectedSurfaceId else { return } + // For command-based shortcuts, trust AppKit's layout-aware characters when present. + // Keep this strict for letter shortcuts to avoid physical-key collisions across layouts, + // while still allowing keyCode fallback for digit/punctuation shortcuts on non-US layouts. + // When a non-Latin input source is active (Russian, Korean, Chinese, Japanese, etc.), + // charactersIgnoringModifiers returns non-ASCII characters that can never match + // a Latin shortcut key — skip this guard and fall through to layout-based matching. + let hasEventChars = !(eventCharsIgnoringModifiers?.isEmpty ?? true) + let eventCharsAreASCII = eventCharsIgnoringModifiers?.allSatisfy(\.isASCII) ?? true + let shortcutKeyIsDigit = shortcutKey.count == 1 && shortcutKey.first?.isNumber == true + if shortcutKeyIsDigit, + hasEventChars, + eventCharsAreASCII, + digitForNumberKeyCode(event.keyCode) == nil { + return false + } + if hasEventChars, + eventCharsAreASCII, + flags.contains(.command), + !flags.contains(.control), + shouldRequireCharacterMatchForCommandShortcut(shortcutKey: shortcutKey) { + return false + } - // Ensure the expectation is armed even if the view doesn't become first responder. - armJumpUnreadFocusRecord(tabId: tabId, surfaceId: expectedSurfaceId) + // Match using the current keyboard layout so Command shortcuts stay character-based + // across layouts (QWERTY, Dvorak, etc.) instead of being tied to ANSI physical keys. + let layoutCharacter = shortcutLayoutCharacterProvider(event.keyCode, event.modifierFlags) + if shortcutCharacterMatches( + eventCharacter: layoutCharacter, + shortcutKey: shortcutKey, + applyShiftSymbolNormalization: false, + eventKeyCode: event.keyCode + ) { + return true + } - if tabManager.selectedTabId == tabId, - tabManager.focusedSurfaceId(for: tabId) == expectedSurfaceId { - recordJumpUnreadFocusIfExpected(tabId: tabId, surfaceId: expectedSurfaceId) - return + // Control-key combos can surface as ASCII control characters (e.g. Ctrl+H => backspace), + // so keep ANSI keyCode fallback for control-modified shortcuts. Also allow fallback for + // command punctuation shortcuts, since some non-US layouts report different characters + // for the same physical key even when menu-equivalent semantics should still apply. + // When a non-Latin input source is active (Russian, Korean, Chinese, Japanese, etc.), + // event chars carry no usable Latin key identity. Always allow keyCode fallback as a + // safety net — even when the layout-based translation resolved a character, the + // physical key code is the definitive identifier for the intended shortcut. + // For empty-character events (synthetic/browser key equivalents), preserve the original + // behavior: only fall back when the layout translation also failed. + let hasUsableEventChars = hasEventChars && eventCharsAreASCII + let allowANSIKeyCodeFallback = flags.contains(.control) + || (flags.contains(.command) + && !flags.contains(.control) + && ( + !shouldRequireCharacterMatchForCommandShortcut(shortcutKey: shortcutKey) + || (hasEventChars && !eventCharsAreASCII) + || (!hasEventChars && (layoutCharacter?.isEmpty ?? true)) + )) + if allowANSIKeyCodeFallback, let expectedKeyCode = keyCodeForShortcutKey(shortcutKey) { + return event.keyCode == expectedKeyCode } + return false + } - var resolved = false - var observers: [NSObjectProtocol] = [] - var cancellables: [AnyCancellable] = [] + private func matchShortcut(event: NSEvent, shortcut: StoredShortcut) -> Bool { + guard !shortcut.hasChord else { return false } + return matchShortcutStroke(event: event, stroke: shortcut.firstStroke) + } - func cleanup() { - observers.forEach { NotificationCenter.default.removeObserver($0) } - observers.removeAll() - cancellables.forEach { $0.cancel() } - cancellables.removeAll() - } + private func numberedShortcutDigit(event: NSEvent, stroke: ShortcutStroke) -> Int? { + let flags = event.modifierFlags.intersection(.deviceIndependentFlagsMask) + .subtracting([.numericPad, .function, .capsLock]) + guard flags == stroke.modifierFlags else { return nil } + let numberKeyDigit = digitForNumberKeyCode(event.keyCode) - @MainActor - func finishIfFocused() { - guard !resolved else { return } - guard tabManager.selectedTabId == tabId, - tabManager.focusedSurfaceId(for: tabId) == expectedSurfaceId else { - return - } - resolved = true - cleanup() - self.recordJumpUnreadFocusIfExpected(tabId: tabId, surfaceId: expectedSurfaceId) + if let digit = numberedShortcutDigit( + eventCharacter: event.charactersIgnoringModifiers, + applyShiftSymbolNormalization: flags.contains(.shift), + eventKeyCode: event.keyCode + ) { + return digit } - observers.append(NotificationCenter.default.addObserver( - forName: .ghosttyDidFocusSurface, - object: nil, - queue: .main - ) { note in - guard let surfaceId = note.userInfo?[GhosttyNotificationKey.surfaceId] as? UUID, - surfaceId == expectedSurfaceId else { return } - Task { @MainActor in finishIfFocused() } - }) - cancellables.append(tabManager.$selectedTabId.sink { _ in - Task { @MainActor in finishIfFocused() } - }) - if let workspace = tabManager.tabs.first(where: { $0.id == tabId }) { - cancellables.append(workspace.$panels - .map { _ in () } - .sink { _ in - Task { @MainActor in finishIfFocused() } - }) - } - DispatchQueue.main.asyncAfter(deadline: .now() + 2.0) { - Task { @MainActor in - guard !resolved else { return } - cleanup() + let eventCharsIgnoringModifiers = event.charactersIgnoringModifiers + let hasUsableASCIIEventChars = !(eventCharsIgnoringModifiers?.isEmpty ?? true) + && (eventCharsIgnoringModifiers?.allSatisfy(\.isASCII) ?? true) + if !hasUsableASCIIEventChars || numberKeyDigit != nil { + let layoutCharacter = shortcutLayoutCharacterProvider(event.keyCode, event.modifierFlags) + if let digit = numberedShortcutDigit( + eventCharacter: layoutCharacter, + applyShiftSymbolNormalization: false, + eventKeyCode: event.keyCode + ) { + return digit } } - Task { @MainActor in finishIfFocused() } + + return numberKeyDigit } -#endif - func tabTitle(for tabId: UUID) -> String? { - if let context = contextContainingTabId(tabId) { - return context.tabManager.tabs.first(where: { $0.id == tabId })?.title - } - return tabManager?.tabs.first(where: { $0.id == tabId })?.title + private func numberedShortcutDigit(event: NSEvent, shortcut: StoredShortcut) -> Int? { + guard !shortcut.hasChord else { return nil } + return numberedShortcutDigit(event: event, stroke: shortcut.firstStroke) } - private func bringToFront(_ window: NSWindow) { - if TerminalController.shouldSuppressSocketCommandActivation(), - !TerminalController.socketCommandAllowsInAppFocusMutations() { - return - } - setActiveMainWindow(window) - if window.isMiniaturized { - window.deminiaturize(nil) - } - window.makeKeyAndOrderFront(nil) - // Improve reliability across Spaces / when other helper panels are key. - NSRunningApplication.current.activate(options: [.activateAllWindows, .activateIgnoringOtherApps]) + private func numberedShortcutDigit( + eventCharacter: String?, + applyShiftSymbolNormalization: Bool, + eventKeyCode: UInt16 + ) -> Int? { + guard let eventCharacter, !eventCharacter.isEmpty else { return nil } + let normalized = normalizedShortcutEventCharacter( + eventCharacter, + applyShiftSymbolNormalization: applyShiftSymbolNormalization, + eventKeyCode: eventKeyCode + ) + guard let digit = Int(normalized), (1...9).contains(digit) else { return nil } + return digit } - private func markReadIfFocused( - notificationId: UUID, - tabId: UUID, - surfaceId: UUID?, - tabManager: TabManager, - notificationStore: TerminalNotificationStore - ) { - DispatchQueue.main.asyncAfter(deadline: .now() + 0.05) { - guard tabManager.selectedTabId == tabId else { return } - if let surfaceId { - guard tabManager.focusedSurfaceId(for: tabId) == surfaceId else { return } - } - notificationStore.markRead(id: notificationId) + private func shouldRequireCharacterMatchForCommandShortcut(shortcutKey: String) -> Bool { + guard shortcutKey.count == 1, let scalar = shortcutKey.unicodeScalars.first else { + return false } + return CharacterSet.letters.contains(scalar) } -#if DEBUG - private func recordMultiWindowNotificationOpenFailureIfNeeded( - tabId: UUID, - surfaceId: UUID?, - notificationId: UUID?, - reason: String - ) { - let env = ProcessInfo.processInfo.environment - guard let path = env["PROGRAMA_UI_TEST_MULTI_WINDOW_NOTIF_PATH"], !path.isEmpty else { return } - - let contextSummaries: [String] = mainWindowContexts.values.map { ctx in - let tabIds = ctx.tabManager.tabs.map { $0.id.uuidString }.joined(separator: ",") - let hasWindow = (ctx.window != nil) ? "1" : "0" - return "windowId=\(ctx.windowId.uuidString) hasWindow=\(hasWindow) tabs=[\(tabIds)]" + private func shortcutCharacterMatches( + eventCharacter: String?, + shortcutKey: String, + applyShiftSymbolNormalization: Bool, + eventKeyCode: UInt16 + ) -> Bool { + guard let eventCharacter, !eventCharacter.isEmpty else { return false } + if normalizedShortcutEventCharacter( + eventCharacter, + applyShiftSymbolNormalization: applyShiftSymbolNormalization, + eventKeyCode: eventKeyCode + ) == shortcutKey { + return true } - - writeMultiWindowNotificationTestData([ - "focusToken": UUID().uuidString, - "openFailureTabId": tabId.uuidString, - "openFailureSurfaceId": surfaceId?.uuidString ?? "", - "openFailureNotificationId": notificationId?.uuidString ?? "", - "openFailureReason": reason, - "openFailureContexts": contextSummaries.joined(separator: "; "), - ], at: path) + return false } -#endif - - /// Configures a Programa main window's AppKit-level presentation (identifier, - /// titlebar, movability, transparency, glass effect, decorations) and registers - /// it for window-context tracking, then installs the file-drop overlay. - /// - /// Extracted verbatim from `ContentView`'s `WindowAccessor` trailing closure - /// (nuclear-review CV2b) so this AppKit window mutation lives with the - /// window-context layer instead of the SwiftUI view layer. `ContentView` still - /// owns tracking its own `@State` (`observedWindow`, `isFullScreen`, - /// `titlebarPadding`) — this method returns the computed titlebar padding so - /// the caller can decide whether to update that `@State`. - @discardableResult - func configureMainWindow( - _ window: NSWindow, - windowId: UUID, - windowIdentifier: String, - tabManager: TabManager, - sidebarState: SidebarState, - sidebarSelectionState: SidebarSelectionState, - sidebarBlendMode: String, - bgGlassEnabled: Bool, - bgGlassTintHex: String, - bgGlassTintOpacity: Double - ) -> CGFloat { - window.identifier = NSUserInterfaceItemIdentifier(windowIdentifier) - window.titlebarAppearsTransparent = true - // Keep window immovable; the sidebar's WindowDragHandleView handles - // drag-to-move via performDrag with temporary movable override. - // isMovableByWindowBackground=true breaks tab reordering, and - // isMovable=true blocks clicks on sidebar buttons in minimal mode. - window.isMovableByWindowBackground = false - window.isMovable = false - window.styleMask.insert(.fullSizeContentView) - // Keep content below the titlebar so drags on Bonsplit's tab bar don't - // get interpreted as window drags. - let computedTitlebarHeight = window.frame.height - window.contentLayoutRect.height - let nextTitlebarPadding = max(28, min(72, computedTitlebarHeight)) -#if DEBUG - if ProcessInfo.processInfo.environment["PROGRAMA_UI_TEST_MODE"] == "1" { - UpdateLogStore.shared.append("ui test window accessor: id=\(windowIdentifier) visible=\(window.isVisible)") - } -#endif - // User settings decide whether window glass is active. The native Tahoe - // NSGlassEffectView path vs the older NSVisualEffectView fallback is chosen - // inside WindowGlassEffect.apply. - let currentThemeBackground = GhosttyBackgroundTheme.currentColor() - let shouldApplyWindowGlass = cmuxShouldApplyWindowGlass( - sidebarBlendMode: sidebarBlendMode, - bgGlassEnabled: bgGlassEnabled, - glassEffectAvailable: WindowGlassEffect.isAvailable - ) - let shouldForceTransparentHosting = - shouldApplyWindowGlass || currentThemeBackground.alphaComponent < 0.999 + private func normalizedShortcutEventCharacter( + _ eventCharacter: String, + applyShiftSymbolNormalization: Bool, + eventKeyCode: UInt16 + ) -> String { + let lowered = eventCharacter.lowercased() + guard applyShiftSymbolNormalization else { return lowered } - if shouldForceTransparentHosting { - window.isOpaque = false - // Keep the window clear whenever translucency is active. Relying only on - // terminal focus-driven updates can leave stale opaque window fills. - window.backgroundColor = NSColor.white.withAlphaComponent(0.001) - // Configure contentView hierarchy for transparency. - if let contentView = window.contentView { - ContentView.makeViewHierarchyTransparent(contentView) - } - } else { - // Browser-focused workspaces may not have an active terminal panel to refresh - // the NSWindow background. Keep opaque theme changes applied here as well. - window.backgroundColor = currentThemeBackground - window.isOpaque = currentThemeBackground.alphaComponent >= 0.999 + switch lowered { + case "{": return "[" + case "}": return "]" + case "<": return eventKeyCode == 43 ? "," : lowered // kVK_ANSI_Comma + case ">": return eventKeyCode == 47 ? "." : lowered // kVK_ANSI_Period + case "?": return "/" + case ":": return ";" + case "\"": return "'" + case "|": return "\\" + case "~": return "`" + case "+": return "=" + case "_": return "-" + case "!": return eventKeyCode == 18 ? "1" : lowered // kVK_ANSI_1 + case "@": return eventKeyCode == 19 ? "2" : lowered // kVK_ANSI_2 + case "#": return eventKeyCode == 20 ? "3" : lowered // kVK_ANSI_3 + case "$": return eventKeyCode == 21 ? "4" : lowered // kVK_ANSI_4 + case "%": return eventKeyCode == 23 ? "5" : lowered // kVK_ANSI_5 + case "^": return eventKeyCode == 22 ? "6" : lowered // kVK_ANSI_6 + case "&": return eventKeyCode == 26 ? "7" : lowered // kVK_ANSI_7 + case "*": return eventKeyCode == 28 ? "8" : lowered // kVK_ANSI_8 + case "(": return eventKeyCode == 25 ? "9" : lowered // kVK_ANSI_9 + case ")": return eventKeyCode == 29 ? "0" : lowered // kVK_ANSI_0 + default: return lowered } + } - if shouldApplyWindowGlass { - // Apply liquid glass effect to the window with tint from settings - let tintColor = (NSColor(hex: bgGlassTintHex) ?? .black).withAlphaComponent(bgGlassTintOpacity) - WindowGlassEffect.apply(to: window, tintColor: tintColor) - } else { - WindowGlassEffect.remove(from: window) + private func keyCodeForShortcutKey(_ key: String) -> UInt16? { + // Matches macOS ANSI key codes. This is intentionally limited to keys we + // support in StoredShortcut/ghostty trigger translation. + switch key { + case "a": return 0 // kVK_ANSI_A + case "s": return 1 // kVK_ANSI_S + case "d": return 2 // kVK_ANSI_D + case "f": return 3 // kVK_ANSI_F + case "h": return 4 // kVK_ANSI_H + case "g": return 5 // kVK_ANSI_G + case "z": return 6 // kVK_ANSI_Z + case "x": return 7 // kVK_ANSI_X + case "c": return 8 // kVK_ANSI_C + case "v": return 9 // kVK_ANSI_V + case "b": return 11 // kVK_ANSI_B + case "q": return 12 // kVK_ANSI_Q + case "w": return 13 // kVK_ANSI_W + case "e": return 14 // kVK_ANSI_E + case "r": return 15 // kVK_ANSI_R + case "y": return 16 // kVK_ANSI_Y + case "t": return 17 // kVK_ANSI_T + case "1": return 18 // kVK_ANSI_1 + case "2": return 19 // kVK_ANSI_2 + case "3": return 20 // kVK_ANSI_3 + case "4": return 21 // kVK_ANSI_4 + case "6": return 22 // kVK_ANSI_6 + case "5": return 23 // kVK_ANSI_5 + case "=": return 24 // kVK_ANSI_Equal + case "9": return 25 // kVK_ANSI_9 + case "7": return 26 // kVK_ANSI_7 + case "-": return 27 // kVK_ANSI_Minus + case "8": return 28 // kVK_ANSI_8 + case "0": return 29 // kVK_ANSI_0 + case "]": return 30 // kVK_ANSI_RightBracket + case "o": return 31 // kVK_ANSI_O + case "u": return 32 // kVK_ANSI_U + case "[": return 33 // kVK_ANSI_LeftBracket + case "i": return 34 // kVK_ANSI_I + case "p": return 35 // kVK_ANSI_P + case "l": return 37 // kVK_ANSI_L + case "j": return 38 // kVK_ANSI_J + case "'": return 39 // kVK_ANSI_Quote + case "k": return 40 // kVK_ANSI_K + case ";": return 41 // kVK_ANSI_Semicolon + case "\\": return 42 // kVK_ANSI_Backslash + case ",": return 43 // kVK_ANSI_Comma + case "/": return 44 // kVK_ANSI_Slash + case "n": return 45 // kVK_ANSI_N + case "m": return 46 // kVK_ANSI_M + case ".": return 47 // kVK_ANSI_Period + case "`": return 50 // kVK_ANSI_Grave + case "\r": return 36 // kVK_Return + case "←": return 123 // kVK_LeftArrow + case "→": return 124 // kVK_RightArrow + case "↓": return 125 // kVK_DownArrow + case "↑": return 126 // kVK_UpArrow + default: + return nil } - AppDelegate.shared?.attachUpdateAccessory(to: window) - AppDelegate.shared?.applyWindowDecorations(to: window) - AppDelegate.shared?.registerMainWindow( - window, - windowId: windowId, - tabManager: tabManager, - sidebarState: sidebarState, - sidebarSelectionState: sidebarSelectionState - ) - installFileDropOverlay(on: window, tabManager: tabManager) - - return nextTitlebarPadding } -} - -@MainActor -final class MenuBarExtraController: NSObject, NSMenuDelegate { - private let statusItem: NSStatusItem - private let menu = NSMenu(title: "Programa") - private let notificationStore: TerminalNotificationStore - private let onShowNotifications: () -> Void - private let onOpenNotification: (TerminalNotification) -> Void - private let onJumpToLatestUnread: () -> Void - private let onCheckForUpdates: () -> Void - private let onOpenPreferences: () -> Void - private let onQuitApp: () -> Void - private var notificationsCancellable: AnyCancellable? - private let buildHintTitle: String? - - private let stateHintItem = NSMenuItem(title: String(localized: "statusMenu.noUnread", defaultValue: "No unread notifications"), action: nil, keyEquivalent: "") - private let buildHintItem = NSMenuItem(title: "", action: nil, keyEquivalent: "") - private let notificationListSeparator = NSMenuItem.separator() - private let notificationSectionSeparator = NSMenuItem.separator() - private let showNotificationsItem = NSMenuItem(title: String(localized: "statusMenu.showNotifications", defaultValue: "Show Notifications"), action: nil, keyEquivalent: "") - private let jumpToUnreadItem = NSMenuItem(title: String(localized: "statusMenu.jumpToLatestUnread", defaultValue: "Jump to Latest Unread"), action: nil, keyEquivalent: "") - private let markAllReadItem = NSMenuItem(title: String(localized: "statusMenu.markAllRead", defaultValue: "Mark All Read"), action: nil, keyEquivalent: "") - private let clearAllItem = NSMenuItem(title: String(localized: "statusMenu.clearAll", defaultValue: "Clear All"), action: nil, keyEquivalent: "") - private let checkForUpdatesItem = NSMenuItem(title: String(localized: "menu.checkForUpdates", defaultValue: "Check for Updates…"), action: nil, keyEquivalent: "") - private let preferencesItem = NSMenuItem(title: String(localized: "menu.preferences", defaultValue: "Preferences…"), action: nil, keyEquivalent: "") - private let quitItem = NSMenuItem(title: String(localized: "menu.quitPrograma", defaultValue: "Quit Programa"), action: nil, keyEquivalent: "") - - private var notificationItems: [NSMenuItem] = [] - private let maxInlineNotificationItems = 6 - - init( - notificationStore: TerminalNotificationStore, - onShowNotifications: @escaping () -> Void, - onOpenNotification: @escaping (TerminalNotification) -> Void, - onJumpToLatestUnread: @escaping () -> Void, - onCheckForUpdates: @escaping () -> Void, - onOpenPreferences: @escaping () -> Void, - onQuitApp: @escaping () -> Void - ) { - self.notificationStore = notificationStore - self.onShowNotifications = onShowNotifications - self.onOpenNotification = onOpenNotification - self.onJumpToLatestUnread = onJumpToLatestUnread - self.onCheckForUpdates = onCheckForUpdates - self.onOpenPreferences = onOpenPreferences - self.onQuitApp = onQuitApp - self.buildHintTitle = MenuBarBuildHintFormatter.menuTitle() - self.statusItem = NSStatusBar.system.statusItem(withLength: NSStatusItem.variableLength) - super.init() - - buildMenu() - statusItem.menu = menu - if let button = statusItem.button { - button.imagePosition = .imageOnly - button.imageScaling = .scaleProportionallyDown - button.image = MenuBarIconRenderer.makeImage(unreadCount: 0) - button.toolTip = "Programa" + private func digitForNumberKeyCode(_ keyCode: UInt16) -> Int? { + switch keyCode { + case 18: return 1 // kVK_ANSI_1 + case 19: return 2 // kVK_ANSI_2 + case 20: return 3 // kVK_ANSI_3 + case 21: return 4 // kVK_ANSI_4 + case 23: return 5 // kVK_ANSI_5 + case 22: return 6 // kVK_ANSI_6 + case 26: return 7 // kVK_ANSI_7 + case 28: return 8 // kVK_ANSI_8 + case 25: return 9 // kVK_ANSI_9 + default: + return nil } - - notificationsCancellable = notificationStore.$notifications - .receive(on: DispatchQueue.main) - .sink { [weak self] _ in - self?.refreshUI() - } - - refreshUI() } - private func buildMenu() { - menu.autoenablesItems = false - menu.delegate = self - - stateHintItem.isEnabled = false - menu.addItem(stateHintItem) - if let buildHintTitle { - buildHintItem.title = buildHintTitle - buildHintItem.isEnabled = false - menu.addItem(buildHintItem) - } - - menu.addItem(notificationListSeparator) - notificationSectionSeparator.isHidden = true - menu.addItem(notificationSectionSeparator) - - showNotificationsItem.target = self - showNotificationsItem.action = #selector(showNotificationsAction) - menu.addItem(showNotificationsItem) - - jumpToUnreadItem.target = self - jumpToUnreadItem.action = #selector(jumpToUnreadAction) - menu.addItem(jumpToUnreadItem) - - markAllReadItem.target = self - markAllReadItem.action = #selector(markAllReadAction) - menu.addItem(markAllReadItem) - - clearAllItem.target = self - clearAllItem.action = #selector(clearAllAction) - menu.addItem(clearAllItem) - - menu.addItem(.separator()) - - checkForUpdatesItem.target = self - checkForUpdatesItem.action = #selector(checkForUpdatesAction) - menu.addItem(checkForUpdatesItem) - - preferencesItem.target = self - preferencesItem.action = #selector(preferencesAction) - menu.addItem(preferencesItem) - - menu.addItem(.separator()) - - quitItem.target = self - quitItem.action = #selector(quitAction) - menu.addItem(quitItem) + /// Match arrow key shortcuts using keyCode + /// Arrow keys include .numericPad and .function in their modifierFlags, so strip those before comparing. + private func matchArrowShortcut(event: NSEvent, stroke: ShortcutStroke, keyCode: UInt16) -> Bool { + let flags = event.modifierFlags.intersection(.deviceIndependentFlagsMask) + .subtracting([.numericPad, .function]) + return event.keyCode == keyCode && flags == stroke.modifierFlags } - func menuWillOpen(_ menu: NSMenu) { - refreshUI() + /// Match tab key shortcuts using keyCode 48 + private func matchTabShortcut(event: NSEvent, stroke: ShortcutStroke) -> Bool { + let flags = event.modifierFlags.intersection(.deviceIndependentFlagsMask) + return event.keyCode == 48 && flags == stroke.modifierFlags } - func refreshForDebugControls() { - refreshUI() + private func matchTabShortcut(event: NSEvent, shortcut: StoredShortcut) -> Bool { + guard !shortcut.hasChord else { return false } + return matchTabShortcut(event: event, stroke: shortcut.firstStroke) } - func removeFromMenuBar() { - notificationsCancellable?.cancel() - notificationsCancellable = nil - statusItem.menu = nil - NSStatusBar.system.removeStatusItem(statusItem) + /// Directional shortcuts default to arrow keys, but the shortcut recorder only supports letter/number keys. + /// Support both so users can customize pane navigation (e.g. Cmd+Ctrl+H/J/K/L). + private func matchDirectionalShortcut( + event: NSEvent, + stroke: ShortcutStroke, + arrowGlyph: String, + arrowKeyCode: UInt16 + ) -> Bool { + if stroke.key == arrowGlyph { + return matchArrowShortcut(event: event, stroke: stroke, keyCode: arrowKeyCode) + } + return matchShortcutStroke(event: event, stroke: stroke) } - private func refreshUI() { - let snapshot = NotificationMenuSnapshotBuilder.make( - notifications: notificationStore.notifications, - maxInlineNotificationItems: maxInlineNotificationItems + private func matchDirectionalShortcut( + event: NSEvent, + shortcut: StoredShortcut, + arrowGlyph: String, + arrowKeyCode: UInt16 + ) -> Bool { + guard !shortcut.hasChord else { return false } + return matchDirectionalShortcut( + event: event, + stroke: shortcut.firstStroke, + arrowGlyph: arrowGlyph, + arrowKeyCode: arrowKeyCode ) - let actualUnreadCount = snapshot.unreadCount - - let displayedUnreadCount: Int -#if DEBUG - displayedUnreadCount = MenuBarIconDebugSettings.displayedUnreadCount(actualUnreadCount: actualUnreadCount) -#else - displayedUnreadCount = actualUnreadCount -#endif + } - stateHintItem.title = snapshot.stateHintTitle + func validateMenuItem(_ item: NSMenuItem) -> Bool { + updateController.validateMenuItem(item) + } - applyShortcut(KeyboardShortcutSettings.shortcut(for: .showNotifications), to: showNotificationsItem) - applyShortcut(KeyboardShortcutSettings.shortcut(for: .jumpToUnread), to: jumpToUnreadItem) - jumpToUnreadItem.isEnabled = snapshot.hasUnreadNotifications - markAllReadItem.isEnabled = snapshot.hasUnreadNotifications - clearAllItem.isEnabled = snapshot.hasNotifications + private func configureUserNotifications() { + let actions = [ + UNNotificationAction( + identifier: TerminalNotificationStore.actionShowIdentifier, + title: "Show" + ) + ] - rebuildInlineNotificationItems(recentNotifications: snapshot.recentNotifications) + let category = UNNotificationCategory( + identifier: TerminalNotificationStore.categoryIdentifier, + actions: actions, + intentIdentifiers: [], + options: [.customDismissAction] + ) - if let button = statusItem.button { - button.image = MenuBarIconRenderer.makeImage(unreadCount: displayedUnreadCount) - button.toolTip = displayedUnreadCount == 0 - ? "Programa" - : displayedUnreadCount == 1 - ? "Programa: " + String(localized: "statusMenu.tooltip.unread.one", defaultValue: "1 unread notification") - : "Programa: " + String(localized: "statusMenu.tooltip.unread.other", defaultValue: "\(displayedUnreadCount) unread notifications") - } + let center = UNUserNotificationCenter.current() + center.setNotificationCategories([category]) + center.delegate = self } - private func applyShortcut(_ shortcut: StoredShortcut, to item: NSMenuItem) { - guard let keyEquivalent = shortcut.menuItemKeyEquivalent else { - item.keyEquivalent = "" - item.keyEquivalentModifierMask = [] - return - } - item.keyEquivalent = keyEquivalent - item.keyEquivalentModifierMask = shortcut.modifierFlags + private func disableNativeTabbingShortcut() { + guard let menu = NSApp.mainMenu else { return } + disableMenuItemShortcut(in: menu, action: #selector(NSWindow.toggleTabBar(_:))) } - private func rebuildInlineNotificationItems(recentNotifications: [TerminalNotification]) { - for item in notificationItems { - menu.removeItem(item) - } - notificationItems.removeAll(keepingCapacity: true) - - notificationListSeparator.isHidden = recentNotifications.isEmpty - notificationSectionSeparator.isHidden = recentNotifications.isEmpty - guard !recentNotifications.isEmpty else { return } - - let insertionIndex = menu.index(of: showNotificationsItem) - guard insertionIndex >= 0 else { return } - - for (offset, notification) in recentNotifications.enumerated() { - let tabTitle = AppDelegate.shared?.tabTitle(for: notification.tabId) - let item = makeNotificationItem(notification: notification, tabTitle: tabTitle) - menu.insertItem(item, at: insertionIndex + offset) - notificationItems.append(item) + private func disableMenuItemShortcut(in menu: NSMenu, action: Selector) { + for item in menu.items { + if item.action == action { + item.keyEquivalent = "" + item.keyEquivalentModifierMask = [] + item.isEnabled = false + } + if let submenu = item.submenu { + disableMenuItemShortcut(in: submenu, action: action) + } } } - private func makeNotificationItem(notification: TerminalNotification, tabTitle: String?) -> NSMenuItem { - let item = NSMenuItem(title: "", action: #selector(openNotificationItemAction(_:)), keyEquivalent: "") - item.target = self - item.attributedTitle = MenuBarNotificationLineFormatter.attributedTitle(notification: notification, tabTitle: tabTitle) - item.toolTip = MenuBarNotificationLineFormatter.tooltip(notification: notification, tabTitle: tabTitle) - item.representedObject = NotificationMenuItemPayload(notification: notification) - return item + private func ensureApplicationIcon() { + // Manual light/dark override removed; the icon always tracks system + // appearance. Clear any stale pre-removal override so the dock-tile + // plugin (which reads the raw key cross-process) falls back too. + UserDefaults.standard.removeObject(forKey: "appIconMode") + AppIconAppearanceObserver.shared.startObserving() } - @objc private func openNotificationItemAction(_ sender: NSMenuItem) { - guard let payload = sender.representedObject as? NotificationMenuItemPayload else { return } - onOpenNotification(payload.notification) - } + private func scheduleLaunchServicesBundleRegistration( + bundleURL: URL = Bundle.main.bundleURL.standardizedFileURL, + scheduler: @escaping (@escaping @Sendable () -> Void) -> Void = AppDelegate.enqueueLaunchServicesRegistrationWork, + register: @escaping (CFURL) -> OSStatus = { url in + LSRegisterURL(url, true) + }, + breadcrumb: @escaping (_ message: String, _ data: [String: Any]) -> Void = { _, _ in } + ) { + let normalizedURL = bundleURL.standardizedFileURL + breadcrumb("launchservices.register.schedule", [ + "bundlePath": normalizedURL.path + ]) - @objc private func showNotificationsAction() { - onShowNotifications() - } + scheduler { + let startedAt = CFAbsoluteTimeGetCurrent() + let registerStatus = register(normalizedURL as CFURL) + let durationMs = Int(((CFAbsoluteTimeGetCurrent() - startedAt) * 1000).rounded()) - @objc private func jumpToUnreadAction() { - onJumpToLatestUnread() - } + breadcrumb("launchservices.register.complete", [ + "bundlePath": normalizedURL.path, + "status": Int(registerStatus), + "durationMs": durationMs + ]) - @objc private func markAllReadAction() { - notificationStore.markAllRead() + if registerStatus != noErr { + NSLog("LaunchServices registration failed (status: \(registerStatus)) for \(normalizedURL.path)") + } + } } - @objc private func clearAllAction() { - notificationStore.clearAll() +#if DEBUG + func scheduleLaunchServicesBundleRegistrationForTesting( + bundleURL: URL, + scheduler: @escaping (@escaping @Sendable () -> Void) -> Void, + register: @escaping (CFURL) -> OSStatus, + breadcrumb: @escaping (_ message: String, _ data: [String: Any]) -> Void = { _, _ in } + ) { + scheduleLaunchServicesBundleRegistration( + bundleURL: bundleURL, + scheduler: scheduler, + register: register, + breadcrumb: breadcrumb + ) } +#endif - @objc private func checkForUpdatesAction() { - onCheckForUpdates() - } + private func enforceSingleInstance() { + guard let bundleId = Bundle.main.bundleIdentifier else { return } + let currentPid = ProcessInfo.processInfo.processIdentifier - @objc private func preferencesAction() { - onOpenPreferences() + for app in NSRunningApplication.runningApplications(withBundleIdentifier: bundleId) { + guard app.processIdentifier != currentPid else { continue } + app.terminate() + if !app.isTerminated { + _ = app.forceTerminate() + } + } } - @objc private func quitAction() { - onQuitApp() - } -} + private func observeDuplicateLaunches() { + guard let bundleId = Bundle.main.bundleIdentifier else { return } + let embeddedCLIURL = Bundle.main.bundleURL + .appendingPathComponent("Contents/Resources/bin/programa", isDirectory: false) + .standardizedFileURL + .resolvingSymlinksInPath() + let currentPid = ProcessInfo.processInfo.processIdentifier -private final class NotificationMenuItemPayload: NSObject { - let notification: TerminalNotification + workspaceObserver = NSWorkspace.shared.notificationCenter.addObserver( + forName: NSWorkspace.didLaunchApplicationNotification, + object: nil, + queue: .main + ) { [weak self] notification in + guard self != nil else { return } + guard let app = notification.userInfo?[NSWorkspace.applicationUserInfoKey] as? NSRunningApplication else { return } + guard app.bundleIdentifier == bundleId, app.processIdentifier != currentPid else { return } + if let executableURL = app.executableURL? + .standardizedFileURL + .resolvingSymlinksInPath(), + executableURL == embeddedCLIURL { + return + } - init(notification: TerminalNotification) { - self.notification = notification - super.init() + app.terminate() + if !app.isTerminated { + _ = app.forceTerminate() + } + NSRunningApplication.current.activate(options: [.activateAllWindows, .activateIgnoringOtherApps]) + } } -} - -struct NotificationMenuSnapshot { - let unreadCount: Int - let hasNotifications: Bool - let recentNotifications: [TerminalNotification] - var hasUnreadNotifications: Bool { - unreadCount > 0 + func userNotificationCenter( + _ center: UNUserNotificationCenter, + didReceive response: UNNotificationResponse, + withCompletionHandler completionHandler: @escaping () -> Void + ) { + handleNotificationResponse(response) + completionHandler() } - var stateHintTitle: String { - NotificationMenuSnapshotBuilder.stateHintTitle(unreadCount: unreadCount) + func userNotificationCenter( + _ center: UNUserNotificationCenter, + willPresent notification: UNNotification, + withCompletionHandler completionHandler: @escaping (UNNotificationPresentationOptions) -> Void + ) { + var options: UNNotificationPresentationOptions = [.banner, .list] + if notification.request.content.sound != nil { + options.insert(.sound) + } + completionHandler(options) } -} -enum NotificationMenuSnapshotBuilder { - static let defaultInlineNotificationLimit = 6 + private func handleNotificationResponse(_ response: UNNotificationResponse) { + guard let tabIdString = response.notification.request.content.userInfo["tabId"] as? String, + let tabId = UUID(uuidString: tabIdString) else { + return + } + let surfaceId: UUID? = { + guard let surfaceIdString = response.notification.request.content.userInfo["surfaceId"] as? String else { + return nil + } + return UUID(uuidString: surfaceIdString) + }() - static func make( - notifications: [TerminalNotification], - maxInlineNotificationItems: Int = defaultInlineNotificationLimit - ) -> NotificationMenuSnapshot { - let unreadCount = notifications.reduce(into: 0) { count, notification in - if !notification.isRead { - count += 1 + switch response.actionIdentifier { + case UNNotificationDefaultActionIdentifier, TerminalNotificationStore.actionShowIdentifier: + let notificationId: UUID? = { + if let id = UUID(uuidString: response.notification.request.identifier) { + return id + } + if let idString = response.notification.request.content.userInfo["notificationId"] as? String, + let id = UUID(uuidString: idString) { + return id + } + return nil + }() + DispatchQueue.main.async { + _ = self.openNotification(tabId: tabId, surfaceId: surfaceId, notificationId: notificationId) + } + case UNNotificationDismissActionIdentifier: + DispatchQueue.main.async { + if let notificationId = UUID(uuidString: response.notification.request.identifier) { + self.notificationStore?.markRead(id: notificationId) + } else if let notificationIdString = response.notification.request.content.userInfo["notificationId"] as? String, + let notificationId = UUID(uuidString: notificationIdString) { + self.notificationStore?.markRead(id: notificationId) + } } - } - - let inlineLimit = max(0, maxInlineNotificationItems) - return NotificationMenuSnapshot( - unreadCount: unreadCount, - hasNotifications: !notifications.isEmpty, - recentNotifications: Array(notifications.prefix(inlineLimit)) - ) - } - - static func stateHintTitle(unreadCount: Int) -> String { - switch unreadCount { - case 0: - return String(localized: "statusMenu.noUnread", defaultValue: "No unread notifications") - case 1: - return String(localized: "statusMenu.unreadCount.one", defaultValue: "1 unread notification") default: - return String(localized: "statusMenu.unreadCount.other", defaultValue: "\(unreadCount) unread notifications") + break } } -} -enum MenuBarBadgeLabelFormatter { - static func badgeText(for unreadCount: Int) -> String? { - guard unreadCount > 0 else { return nil } - if unreadCount > 9 { - return "9+" + private func installMainWindowKeyObserver() { + guard windowKeyObserver == nil else { return } + windowKeyObserver = NotificationCenter.default.addObserver( + forName: NSWindow.didBecomeKeyNotification, + object: nil, + queue: .main + ) { [weak self] note in + guard let self, let window = note.object as? NSWindow else { return } + self.setActiveMainWindow(window) } - return String(unreadCount) } -} - -enum MenuBarNotificationLineFormatter { - static let defaultMaxMenuTextWidth: CGFloat = 280 - static let defaultMaxMenuTextLines = 3 - static func plainTitle(notification: TerminalNotification, tabTitle: String?) -> String { - let dot = notification.isRead ? " " : "● " - let timeText = notification.createdAt.formatted(date: .omitted, time: .shortened) - var lines: [String] = [] - lines.append("\(dot)\(notification.title) \(timeText)") + private func installBrowserAddressBarFocusObservers() { + guard browserAddressBarFocusObserver == nil, browserAddressBarBlurObserver == nil else { return } - let detail = notification.body.isEmpty ? notification.subtitle : notification.body - if !detail.isEmpty { - lines.append(detail) + browserAddressBarFocusObserver = NotificationCenter.default.addObserver( + forName: .browserDidFocusAddressBar, + object: nil, + queue: .main + ) { [weak self] notification in + guard let self else { return } + guard let panelId = notification.object as? UUID else { return } + self.browserPanel(for: panelId)?.beginSuppressWebViewFocusForAddressBar() + self.browserAddressBarFocusedPanelId = panelId + self.stopBrowserOmnibarSelectionRepeat() +#if DEBUG + dlog("addressBar FOCUS panelId=\(panelId.uuidString.prefix(8))") +#endif } - if let tabTitle, !tabTitle.isEmpty { - lines.append(tabTitle) + browserAddressBarBlurObserver = NotificationCenter.default.addObserver( + forName: .browserDidBlurAddressBar, + object: nil, + queue: .main + ) { [weak self] notification in + guard let self else { return } + guard let panelId = notification.object as? UUID else { return } + self.browserPanel(for: panelId)?.endSuppressWebViewFocusForAddressBar() + if self.browserAddressBarFocusedPanelId == panelId { + self.browserAddressBarFocusedPanelId = nil + self.stopBrowserOmnibarSelectionRepeat() +#if DEBUG + dlog("addressBar BLUR panelId=\(panelId.uuidString.prefix(8))") +#endif + } } - - return lines.joined(separator: "\n") } - static func menuTitle( - notification: TerminalNotification, - tabTitle: String?, - maxWidth: CGFloat = defaultMaxMenuTextWidth, - maxLines: Int = defaultMaxMenuTextLines - ) -> String { - let base = plainTitle(notification: notification, tabTitle: tabTitle) - return wrappedAndTruncated(base, maxWidth: maxWidth, maxLines: maxLines) - } - - static func attributedTitle(notification: TerminalNotification, tabTitle: String?) -> NSAttributedString { - let paragraph = NSMutableParagraphStyle() - paragraph.lineBreakMode = .byWordWrapping - return NSAttributedString( - string: menuTitle(notification: notification, tabTitle: tabTitle), - attributes: [ - .font: NSFont.menuFont(ofSize: NSFont.systemFontSize), - .foregroundColor: NSColor.labelColor, - .paragraphStyle: paragraph, - ] - ) + private func browserPanel(for panelId: UUID) -> BrowserPanel? { + return tabManager?.selectedWorkspace?.browserPanel(for: panelId) } - static func tooltip(notification: TerminalNotification, tabTitle: String?) -> String { - plainTitle(notification: notification, tabTitle: tabTitle) + fileprivate func browserFindBarIsVisible(for webView: ProgramaWebView) -> Bool { + browserPanelOwning(webView)?.searchState != nil } - private static func wrappedAndTruncated(_ text: String, maxWidth: CGFloat, maxLines: Int) -> String { - let width = max(60, maxWidth) - let lines = max(1, maxLines) - let font = NSFont.menuFont(ofSize: NSFont.systemFontSize) - let wrapped = wrappedLines(for: text, maxWidth: width, font: font) - guard wrapped.count > lines else { return wrapped.joined(separator: "\n") } - - var clipped = Array(wrapped.prefix(lines)) - clipped[lines - 1] = truncateLine(clipped[lines - 1], maxWidth: width, font: font) - return clipped.joined(separator: "\n") + private func shouldLetFocusedBrowserOwnFindShortcut(_ event: NSEvent) -> Bool { + let shortcutWindow = resolvedShortcutEventWindow(event) ?? NSApp.keyWindow ?? NSApp.mainWindow + let shortcutResponder = shortcutWindow?.firstResponder + let owningWebView = tabManager?.focusedBrowserPanel?.webView as? ProgramaWebView + guard let owningWebView else { return false } + return shouldRouteBrowserFindCommandEquivalentThroughWebContentFirst( + event, + responder: shortcutResponder, + owningWebView: owningWebView + ) } - private static func wrappedLines(for text: String, maxWidth: CGFloat, font: NSFont) -> [String] { - let storage = NSTextStorage(string: text, attributes: [.font: font]) - let layout = NSLayoutManager() - let container = NSTextContainer(size: NSSize(width: maxWidth, height: .greatestFiniteMagnitude)) - container.lineFragmentPadding = 0 - container.lineBreakMode = .byWordWrapping - layout.addTextContainer(container) - storage.addLayoutManager(layout) - _ = layout.glyphRange(for: container) - - let fullText = text as NSString - var rows: [String] = [] - var glyphIndex = 0 - while glyphIndex < layout.numberOfGlyphs { - var glyphRange = NSRange() - layout.lineFragmentRect(forGlyphAt: glyphIndex, effectiveRange: &glyphRange) - if glyphRange.length == 0 { break } + private func browserPanelOwning(_ webView: ProgramaWebView) -> BrowserPanel? { + var candidateManagers: [TabManager] = [] + var seenManagers = Set() - let charRange = layout.characterRange(forGlyphRange: glyphRange, actualGlyphRange: nil) - let row = fullText.substring(with: charRange).trimmingCharacters(in: .newlines) - rows.append(row) - glyphIndex = NSMaxRange(glyphRange) + func appendCandidate(_ manager: TabManager?) { + guard let manager else { return } + let identifier = ObjectIdentifier(manager) + guard seenManagers.insert(identifier).inserted else { return } + candidateManagers.append(manager) } - if rows.isEmpty { - return [text] + if let window = webView.window, + let context = contextForMainWindow(window) { + appendCandidate(context.tabManager) } - return rows - } - - private static func truncateLine(_ line: String, maxWidth: CGFloat, font: NSFont) -> String { - let ellipsis = "…" - let full = line.trimmingCharacters(in: .whitespacesAndNewlines) - if full.isEmpty { return ellipsis } - - if measuredWidth(full + ellipsis, font: font) <= maxWidth { - return full + ellipsis + appendCandidate(tabManager) + for context in mainWindowContexts.values { + appendCandidate(context.tabManager) } - var chars = Array(full) - while !chars.isEmpty { - chars.removeLast() - let candidateBase = String(chars).trimmingCharacters(in: .whitespacesAndNewlines) - let candidate = (candidateBase.isEmpty ? "" : candidateBase) + ellipsis - if measuredWidth(candidate, font: font) <= maxWidth { - return candidate + for manager in candidateManagers { + if let panel = browserPanelOwning(webView, in: manager) { + return panel } } - return ellipsis - } - - private static func measuredWidth(_ text: String, font: NSFont) -> CGFloat { - (text as NSString).size(withAttributes: [.font: font]).width - } -} - -enum MenuBarBuildHintFormatter { - static func menuTitle( - appName: String = defaultAppName(), - isDebugBuild: Bool = _isDebugAssertConfiguration() - ) -> String? { - guard isDebugBuild else { return nil } - let normalized = appName.trimmingCharacters(in: .whitespacesAndNewlines) - let prefix = "cmux DEV" - guard normalized.hasPrefix(prefix) else { return "Build: DEV" } - - let suffix = String(normalized.dropFirst(prefix.count)).trimmingCharacters(in: .whitespacesAndNewlines) - if suffix.isEmpty { - return "Build: DEV (untagged)" - } - return "Build Tag: \(suffix)" - } - - private static func defaultAppName() -> String { - let bundle = Bundle.main - if let displayName = bundle.object(forInfoDictionaryKey: "CFBundleDisplayName") as? String, - !displayName.isEmpty { - return displayName - } - if let name = bundle.object(forInfoDictionaryKey: "CFBundleName") as? String, !name.isEmpty { - return name - } - return ProcessInfo.processInfo.processName + return nil } -} -enum MenuBarExtraSettings { - static let showInMenuBarKey = "showMenuBarExtra" - static let defaultShowInMenuBar = true - - static func showsMenuBarExtra(defaults: UserDefaults = .standard) -> Bool { - if defaults.object(forKey: showInMenuBarKey) == nil { - return defaultShowInMenuBar + private func browserPanelOwning(_ webView: ProgramaWebView, in manager: TabManager) -> BrowserPanel? { + for workspace in manager.tabs { + if let panel = workspace.panels.values + .compactMap({ $0 as? BrowserPanel }) + .first(where: { $0.webView === webView }) { + return panel + } } - return defaults.bool(forKey: showInMenuBarKey) + return nil } -} -struct MenuBarBadgeRenderConfig { - var badgeRect: NSRect - var singleDigitFontSize: CGFloat - var multiDigitFontSize: CGFloat - var singleDigitYOffset: CGFloat - var multiDigitYOffset: CGFloat - var singleDigitXAdjust: CGFloat - var multiDigitXAdjust: CGFloat - var textRectWidthAdjust: CGFloat -} - -enum MenuBarIconDebugSettings { - static let previewEnabledKey = "menubarDebugPreviewEnabled" - static let previewCountKey = "menubarDebugPreviewCount" - static let badgeRectXKey = "menubarDebugBadgeRectX" - static let badgeRectYKey = "menubarDebugBadgeRectY" - static let badgeRectWidthKey = "menubarDebugBadgeRectWidth" - static let badgeRectHeightKey = "menubarDebugBadgeRectHeight" - static let singleDigitFontSizeKey = "menubarDebugSingleDigitFontSize" - static let multiDigitFontSizeKey = "menubarDebugMultiDigitFontSize" - static let singleDigitYOffsetKey = "menubarDebugSingleDigitYOffset" - static let multiDigitYOffsetKey = "menubarDebugMultiDigitYOffset" - static let singleDigitXAdjustKey = "menubarDebugSingleDigitXAdjust" - static let legacySingleDigitXAdjustKey = "menubarDebugTextRectXAdjust" - static let multiDigitXAdjustKey = "menubarDebugMultiDigitXAdjust" - static let textRectWidthAdjustKey = "menubarDebugTextRectWidthAdjust" - - static let defaultBadgeRect = NSRect(x: 5.38, y: 6.43, width: 10.75, height: 11.58) - static let defaultSingleDigitFontSize: CGFloat = 6.7 - static let defaultMultiDigitFontSize: CGFloat = 6.7 - static let defaultSingleDigitYOffset: CGFloat = 0.6 - static let defaultMultiDigitYOffset: CGFloat = 0.6 - static let defaultSingleDigitXAdjust: CGFloat = -1.1 - static let defaultMultiDigitXAdjust: CGFloat = 2.42 - static let defaultTextRectWidthAdjust: CGFloat = 1.8 - - static func displayedUnreadCount(actualUnreadCount: Int, defaults: UserDefaults = .standard) -> Int { - guard defaults.bool(forKey: previewEnabledKey) else { return actualUnreadCount } - let value = defaults.integer(forKey: previewCountKey) - return max(0, min(value, 99)) - } - - static func badgeRenderConfig(defaults: UserDefaults = .standard) -> MenuBarBadgeRenderConfig { - let x = value(defaults, key: badgeRectXKey, fallback: defaultBadgeRect.origin.x, range: 0...20) - let y = value(defaults, key: badgeRectYKey, fallback: defaultBadgeRect.origin.y, range: 0...20) - let width = value(defaults, key: badgeRectWidthKey, fallback: defaultBadgeRect.width, range: 4...14) - let height = value(defaults, key: badgeRectHeightKey, fallback: defaultBadgeRect.height, range: 4...14) - let singleFont = value(defaults, key: singleDigitFontSizeKey, fallback: defaultSingleDigitFontSize, range: 6...14) - let multiFont = value(defaults, key: multiDigitFontSizeKey, fallback: defaultMultiDigitFontSize, range: 6...14) - let singleY = value(defaults, key: singleDigitYOffsetKey, fallback: defaultSingleDigitYOffset, range: -3...4) - let multiY = value(defaults, key: multiDigitYOffsetKey, fallback: defaultMultiDigitYOffset, range: -3...4) - let singleX = value( - defaults, - key: singleDigitXAdjustKey, - legacyKey: legacySingleDigitXAdjustKey, - fallback: defaultSingleDigitXAdjust, - range: -4...4 - ) - let multiX = value(defaults, key: multiDigitXAdjustKey, fallback: defaultMultiDigitXAdjust, range: -4...4) - let widthAdjust = value(defaults, key: textRectWidthAdjustKey, fallback: defaultTextRectWidthAdjust, range: -3...5) - - return MenuBarBadgeRenderConfig( - badgeRect: NSRect(x: x, y: y, width: width, height: height), - singleDigitFontSize: singleFont, - multiDigitFontSize: multiFont, - singleDigitYOffset: singleY, - multiDigitYOffset: multiY, - singleDigitXAdjust: singleX, - multiDigitXAdjust: multiX, - textRectWidthAdjust: widthAdjust + private func setActiveMainWindow(_ window: NSWindow) { + guard let context = contextForMainTerminalWindow(window) else { return } +#if DEBUG + let beforeManagerToken = debugManagerToken(tabManager) +#endif + tabManager = context.tabManager + sidebarState = context.sidebarState + sidebarSelectionState = context.sidebarSelectionState + TerminalController.shared.setActiveTabManager(context.tabManager) +#if DEBUG + dlog( + "mainWindow.active window={\(debugWindowToken(window))} context={\(debugContextToken(context))} beforeMgr=\(beforeManagerToken) afterMgr=\(debugManagerToken(tabManager)) \(debugShortcutRouteSnapshot())" ) +#endif } - static func copyPayload(defaults: UserDefaults = .standard) -> String { - let config = badgeRenderConfig(defaults: defaults) - let previewEnabled = defaults.bool(forKey: previewEnabledKey) - let previewCount = max(0, min(defaults.integer(forKey: previewCountKey), 99)) - return """ - menubarDebugPreviewEnabled=\(previewEnabled) - menubarDebugPreviewCount=\(previewCount) - menubarDebugBadgeRectX=\(String(format: "%.2f", config.badgeRect.origin.x)) - menubarDebugBadgeRectY=\(String(format: "%.2f", config.badgeRect.origin.y)) - menubarDebugBadgeRectWidth=\(String(format: "%.2f", config.badgeRect.width)) - menubarDebugBadgeRectHeight=\(String(format: "%.2f", config.badgeRect.height)) - menubarDebugSingleDigitFontSize=\(String(format: "%.2f", config.singleDigitFontSize)) - menubarDebugMultiDigitFontSize=\(String(format: "%.2f", config.multiDigitFontSize)) - menubarDebugSingleDigitYOffset=\(String(format: "%.2f", config.singleDigitYOffset)) - menubarDebugMultiDigitYOffset=\(String(format: "%.2f", config.multiDigitYOffset)) - menubarDebugSingleDigitXAdjust=\(String(format: "%.2f", config.singleDigitXAdjust)) - menubarDebugMultiDigitXAdjust=\(String(format: "%.2f", config.multiDigitXAdjust)) - menubarDebugTextRectWidthAdjust=\(String(format: "%.2f", config.textRectWidthAdjust)) - """ - } - - private static func value( - _ defaults: UserDefaults, - key: String, - legacyKey: String? = nil, - fallback: CGFloat, - range: ClosedRange - ) -> CGFloat { - if let parsed = parse(defaults.object(forKey: key), fallback: fallback, range: range) { - return parsed - } - if let legacyKey, let parsed = parse(defaults.object(forKey: legacyKey), fallback: fallback, range: range) { - return parsed - } - return fallback - } - - private static func parse( - _ object: Any?, - fallback: CGFloat, - range: ClosedRange - ) -> CGFloat? { - guard let number = object as? NSNumber else { - return nil - } - let candidate = CGFloat(number.doubleValue) - guard candidate.isFinite else { return fallback } - return max(range.lowerBound, min(candidate, range.upperBound)) - } -} - -enum MenuBarIconRenderer { - - static func makeImage(unreadCount: Int) -> NSImage { - let badgeText = MenuBarBadgeLabelFormatter.badgeText(for: unreadCount) - let config = MenuBarIconDebugSettings.badgeRenderConfig() - let size = NSSize(width: 18, height: 18) - let image = NSImage(size: size) - image.lockFocus() - defer { image.unlockFocus() } + private func unregisterMainWindow(_ window: NSWindow) { + // Reset cascade point so the next new window appears near the closing + // window's position, matching upstream Ghostty behavior. + let frame = window.frame + lastCascadePoint = NSPoint(x: frame.minX, y: frame.maxY) - let glyphRect = NSRect(x: 1.2, y: 1.5, width: 11.6, height: 15.0) - drawGlyph(in: glyphRect) + // Keep geometry available as a fallback even if the full session snapshot + // is removed when the last window closes. + persistWindowGeometry(from: window) + guard let removed = unregisterMainWindowContext(for: window) else { return } + commandPaletteVisibilityByWindowId.removeValue(forKey: removed.windowId) + commandPalettePendingOpenByWindowId.removeValue(forKey: removed.windowId) + commandPaletteRecentRequestAtByWindowId.removeValue(forKey: removed.windowId) + commandPaletteEscapeSuppressionByWindowId.remove(removed.windowId) + commandPaletteEscapeSuppressionStartedAtByWindowId.removeValue(forKey: removed.windowId) + commandPaletteSelectionByWindowId.removeValue(forKey: removed.windowId) + commandPaletteSnapshotByWindowId.removeValue(forKey: removed.windowId) - if let text = badgeText { - drawBadge(text: text, in: config.badgeRect, config: config) + // Avoid stale notifications that can no longer be opened once the owning window is gone. + if let store = notificationStore { + for tab in removed.tabManager.tabs { + store.clearNotifications(forTabId: tab.id) + } } - image.isTemplate = true - return image - } + if tabManager === removed.tabManager { + // Repoint "active" pointers to any remaining main terminal window. + let nextContext: MainWindowContext? = { + if let keyWindow = NSApp.keyWindow, + let ctx = contextForMainTerminalWindow(keyWindow, reindex: false) { + return ctx + } + return mainWindowContexts.values.first + }() - private static func drawGlyph(in rect: NSRect) { - // Match the canonical cmux center-mark path from Icon Center Image Artwork.svg. - let srcMinX: CGFloat = 384.0 - let srcMinY: CGFloat = 255.0 - let srcWidth: CGFloat = 369.0 - let srcHeight: CGFloat = 513.0 + if let nextContext { + tabManager = nextContext.tabManager + sidebarState = nextContext.sidebarState + sidebarSelectionState = nextContext.sidebarSelectionState + TerminalController.shared.setActiveTabManager(nextContext.tabManager) + } else { + tabManager = nil + sidebarState = nil + sidebarSelectionState = nil + TerminalController.shared.setActiveTabManager(nil) + } + } - func map(_ x: CGFloat, _ y: CGFloat) -> NSPoint { - let nx = (x - srcMinX) / srcWidth - let ny = (y - srcMinY) / srcHeight - return NSPoint( - x: rect.minX + nx * rect.width, - y: rect.minY + (1.0 - ny) * rect.height + // During app termination we already persisted a full snapshot (with scrollback) + // in applicationShouldTerminate/applicationWillTerminate. Saving again here would + // overwrite it as windows tear down one-by-one, dropping closed windows and replay. + if Self.shouldPersistSnapshotOnWindowUnregister(isTerminatingApp: isTerminatingApp) { + _ = saveSessionSnapshot( + includeScrollback: false, + removeWhenEmpty: Self.shouldRemoveSnapshotWhenNoWindowsRemainOnWindowUnregister( + isTerminatingApp: isTerminatingApp + ) ) } - - let path = NSBezierPath() - path.move(to: map(384.0, 255.0)) - path.line(to: map(753.0, 511.5)) - path.line(to: map(384.0, 768.0)) - path.line(to: map(384.0, 654.0)) - path.line(to: map(582.692, 511.5)) - path.line(to: map(384.0, 369.0)) - path.close() - - NSColor.black.setFill() - path.fill() } - private static func drawBadge(text: String, in rect: NSRect, config: MenuBarBadgeRenderConfig) { - let paragraph = NSMutableParagraphStyle() - paragraph.alignment = .center - let fontSize: CGFloat = text.count > 1 ? config.multiDigitFontSize : config.singleDigitFontSize - let attrs: [NSAttributedString.Key: Any] = [ - .font: NSFont.systemFont(ofSize: fontSize, weight: .bold), - .foregroundColor: NSColor.systemBlue, - .paragraphStyle: paragraph, - ] - let yOffset: CGFloat = text.count > 1 ? config.multiDigitYOffset : config.singleDigitYOffset - let xAdjust: CGFloat = text.count > 1 ? config.multiDigitXAdjust : config.singleDigitXAdjust - let textRect = NSRect( - x: rect.origin.x + xAdjust, - y: rect.origin.y + yOffset, - width: rect.width + config.textRectWidthAdjust, - height: rect.height - ) - (text as NSString).draw(in: textRect, withAttributes: attrs) + private func isMainTerminalWindow(_ window: NSWindow) -> Bool { + if mainWindowContexts[ObjectIdentifier(window)] != nil { + return true + } + guard let raw = window.identifier?.rawValue else { return false } + return raw == "cmux.main" || raw.hasPrefix("cmux.main.") } -} - -#if DEBUG -private var programaFirstResponderGuardCurrentEventOverride: NSEvent? -private var programaFirstResponderGuardHitViewOverride: NSView? -#endif -private var programaFirstResponderGuardCurrentEventContext: NSEvent? -private var programaFirstResponderGuardHitViewContext: NSView? -private var programaFirstResponderGuardContextWindowNumber: Int? -private var programaBrowserReturnForwardingDepth = 0 -private var programaWindowFirstResponderBypassDepth = 0 -private var programaFieldEditorOwningWebViewAssociationKey: UInt8 = 0 - -@discardableResult -func cmuxWithWindowFirstResponderBypass(_ body: () -> T) -> T { - programaWindowFirstResponderBypassDepth += 1 - defer { - programaWindowFirstResponderBypassDepth = max(0, programaWindowFirstResponderBypassDepth - 1) + private func contextContainingTabId(_ tabId: UUID) -> MainWindowContext? { + for context in mainWindowContexts.values { + if context.tabManager.tabs.contains(where: { $0.id == tabId }) { + return context + } + } + return nil } - return body() -} -func programaIsWindowFirstResponderBypassActive() -> Bool { - programaWindowFirstResponderBypassDepth > 0 -} - -private final class ProgramaFieldEditorOwningWebViewBox: NSObject { - weak var webView: ProgramaWebView? - - init(webView: ProgramaWebView?) { - self.webView = webView + /// Returns the `TabManager` that owns `tabId`, if any. + func tabManagerFor(tabId: UUID) -> TabManager? { + contextContainingTabId(tabId)?.tabManager } -} -private extension NSApplication { - @objc func programa_applicationSendEvent(_ event: NSEvent) { -#if DEBUG - let typingTimingStart = event.type == .keyDown ? ProgramaTypingTiming.start() : nil - let phaseTotalStart = event.type == .keyDown ? ProcessInfo.processInfo.systemUptime : 0 - if event.type == .keyDown { - ProgramaTypingTiming.logEventDelay(path: "app.sendEvent", event: event) - } - defer { - if event.type == .keyDown { - let totalMs = (ProcessInfo.processInfo.systemUptime - phaseTotalStart) * 1000.0 - ProgramaTypingTiming.logBreakdown( - path: "app.sendEvent.phase", - totalMs: totalMs, - event: event, - thresholdMs: 1.0, - parts: [("dispatchMs", totalMs)] - ) - ProgramaTypingTiming.logDuration( - path: "app.sendEvent", - startedAt: typingTimingStart, - event: event - ) - } - } -#endif - programa_applicationSendEvent(event) + private func workspaceForMainActor(tabId: UUID) -> Workspace? { + contextContainingTabId(tabId)?.tabManager.tabs.first(where: { $0.id == tabId }) } -} -private extension AppDelegate { - @objc func handleThemesReloadNotification(_ notification: Notification) { - DispatchQueue.main.async { - GhosttyApp.shared.reloadConfiguration(source: "distributed.cmux.themes") - } + /// Returns the `Workspace` that owns `tabId`, if any. + @MainActor + func workspaceFor(tabId: UUID) -> Workspace? { + workspaceForMainActor(tabId: tabId) } -} - -private extension NSWindow { - @objc func programa_makeFirstResponder(_ responder: NSResponder?) -> Bool { - if programaIsWindowFirstResponderBypassActive() { -#if DEBUG - dlog( - "focus.guard bypassFirstResponder responder=\(String(describing: responder.map { type(of: $0) })) " + - "window=\(ObjectIdentifier(self))" - ) -#endif - return false - } - let currentEvent = Self.programaCurrentEvent(for: self) - let responderWebView = responder.flatMap { - Self.programaOwningWebView(for: $0, in: self, event: currentEvent) - } - var pointerInitiatedWebFocus = false + func closeMainWindowContainingTabId(_ tabId: UUID) { + guard let context = contextContainingTabId(tabId) else { return } + let expectedIdentifier = "cmux.main.\(context.windowId.uuidString)" + let window: NSWindow? = context.window ?? NSApp.windows.first(where: { $0.identifier?.rawValue == expectedIdentifier }) + window?.performClose(nil) + } - if AppDelegate.shared?.shouldBlockFirstResponderChangeWhileCommandPaletteVisible( - window: self, - responder: responder - ) == true { + @discardableResult + func openNotification(tabId: UUID, surfaceId: UUID?, notificationId: UUID?) -> Bool { #if DEBUG - dlog( - "focus.guard commandPaletteBlocked responder=\(String(describing: responder.map { type(of: $0) })) " + - "window=\(ObjectIdentifier(self))" - ) -#endif - return false + let isJumpUnreadUITest = ProcessInfo.processInfo.environment["PROGRAMA_UI_TEST_JUMP_UNREAD_SETUP"] == "1" + if isJumpUnreadUITest { + writeJumpUnreadTestData([ + "jumpUnreadOpenCalled": "1", + "jumpUnreadOpenTabId": tabId.uuidString, + "jumpUnreadOpenSurfaceId": surfaceId?.uuidString ?? "", + ]) } - - if let responder, - let webView = responderWebView, - !webView.allowsFirstResponderAcquisitionEffective { - let pointerInitiatedFocus = Self.programaShouldAllowPointerInitiatedWebViewFocus( - window: self, - webView: webView, - event: currentEvent - ) - if pointerInitiatedFocus { - pointerInitiatedWebFocus = true -#if DEBUG - dlog( - "focus.guard allowPointerFirstResponder responder=\(String(describing: type(of: responder))) " + - "window=\(ObjectIdentifier(self)) " + - "web=\(ObjectIdentifier(webView)) " + - "policy=\(webView.allowsFirstResponderAcquisition ? 1 : 0) " + - "pointerDepth=\(webView.debugPointerFocusAllowanceDepth) " + - "eventType=\(currentEvent.map { String(describing: $0.type) } ?? "nil")" - ) -#endif - } else { -#if DEBUG - dlog( - "focus.guard blockedFirstResponder responder=\(String(describing: type(of: responder))) " + - "window=\(ObjectIdentifier(self)) " + - "web=\(ObjectIdentifier(webView)) " + - "policy=\(webView.allowsFirstResponderAcquisition ? 1 : 0) " + - "pointerDepth=\(webView.debugPointerFocusAllowanceDepth) " + - "eventType=\(currentEvent.map { String(describing: $0.type) } ?? "nil")" - ) #endif - return false - } - } + guard let context = contextContainingTabId(tabId) else { #if DEBUG - if let responder, - let webView = responderWebView { - dlog( - "focus.guard allowFirstResponder responder=\(String(describing: type(of: responder))) " + - "window=\(ObjectIdentifier(self)) " + - "web=\(ObjectIdentifier(webView)) " + - "policy=\(webView.allowsFirstResponderAcquisition ? 1 : 0) " + - "pointerDepth=\(webView.debugPointerFocusAllowanceDepth)" + recordMultiWindowNotificationOpenFailureIfNeeded( + tabId: tabId, + surfaceId: surfaceId, + notificationId: notificationId, + reason: "missing_context" ) - } #endif - let result: Bool - if pointerInitiatedWebFocus, let webView = responderWebView { - // `NSWindow.makeFirstResponder` may run before `ProgramaWebView.mouseDown(with:)`. - // Preserve pointer intent during this synchronous responder change. - result = webView.withPointerFocusAllowance { - programa_makeFirstResponder(responder) - } - } else { - result = programa_makeFirstResponder(responder) - } - if result { - if let fieldEditor = responder as? NSTextView, fieldEditor.isFieldEditor { - Self.programaTrackFieldEditor(fieldEditor, owningWebView: responderWebView) - } else if let fieldEditor = self.firstResponder as? NSTextView, fieldEditor.isFieldEditor { - Self.programaTrackFieldEditor(fieldEditor, owningWebView: responderWebView) - } - } - return result - } - - @objc func programa_sendEvent(_ event: NSEvent) { #if DEBUG - let typingTimingStart = event.type == .keyDown ? ProgramaTypingTiming.start() : nil - let phaseTotalStart = event.type == .keyDown ? ProcessInfo.processInfo.systemUptime : 0 - var contextSetupMs: Double = 0 - var focusRepairMs: Double = 0 - var folderGuardMs: Double = 0 - var originalDispatchMs: Double = 0 - let typingTimingExtra: String? = { - guard event.type == .keyDown else { return nil } - let responderWebView = self.firstResponder.flatMap { - Self.programaOwningWebView(for: $0, in: self, event: event) - } - let hitWebView = Self.programaHitViewForEventDispatch(in: self, event: event).flatMap { - Self.programaOwningWebView(for: $0) + if isJumpUnreadUITest { + writeJumpUnreadTestData(["jumpUnreadOpenContextFound": "0", "jumpUnreadOpenUsedFallback": "1"]) } - let firstResponderType = self.firstResponder.map { String(describing: type(of: $0)) } ?? "nil" - return "browser=\((responderWebView != nil || hitWebView != nil) ? 1 : 0) firstResponder=\(firstResponderType)" - }() - if event.type == .keyDown { - ProgramaTypingTiming.logEventDelay(path: "window.sendEvent", event: event) - } #endif - // recordTypingActivity must run in all builds so runSessionAutosaveTick - // can honor the typing quiet period in release. - if event.type == .keyDown { - AppDelegate.shared?.recordTypingActivity() - } + let ok = openNotificationFallback(tabId: tabId, surfaceId: surfaceId, notificationId: notificationId) #if DEBUG - defer { - if event.type == .keyDown { - let totalMs = (ProcessInfo.processInfo.systemUptime - phaseTotalStart) * 1000.0 - ProgramaTypingTiming.logBreakdown( - path: "window.sendEvent.phase", - totalMs: totalMs, - event: event, - thresholdMs: 1.0, - parts: [ - ("contextSetupMs", contextSetupMs), - ("focusRepairMs", focusRepairMs), - ("folderGuardMs", folderGuardMs), - ("originalDispatchMs", originalDispatchMs), - ], - extra: typingTimingExtra - ) - ProgramaTypingTiming.logDuration( - path: "window.sendEvent", - startedAt: typingTimingStart, - event: event, - extra: typingTimingExtra - ) + if isJumpUnreadUITest { + writeJumpUnreadTestData(["jumpUnreadOpenResult": ok ? "1" : "0"]) } - } - let contextSetupStart = event.type == .keyDown ? ProcessInfo.processInfo.systemUptime : 0 #endif - let previousContextEvent = programaFirstResponderGuardCurrentEventContext - let previousContextHitView = programaFirstResponderGuardHitViewContext - let previousContextWindowNumber = programaFirstResponderGuardContextWindowNumber - programaFirstResponderGuardCurrentEventContext = event - programaFirstResponderGuardHitViewContext = Self.programaHitViewForEventDispatch(in: self, event: event) - programaFirstResponderGuardContextWindowNumber = self.windowNumber + return ok + } #if DEBUG - if event.type == .keyDown { - contextSetupMs = (ProcessInfo.processInfo.systemUptime - contextSetupStart) * 1000.0 + if isJumpUnreadUITest { + writeJumpUnreadTestData(["jumpUnreadOpenContextFound": "1", "jumpUnreadOpenUsedFallback": "0"]) } - let focusRepairStart = event.type == .keyDown ? ProcessInfo.processInfo.systemUptime : 0 #endif - if event.type == .keyDown { - AppDelegate.shared?.repairFocusedTerminalKeyboardRoutingIfNeeded( - window: self, - event: event - ) - } + return openNotificationInContext(context, tabId: tabId, surfaceId: surfaceId, notificationId: notificationId) + } + + private func openNotificationInContext(_ context: MainWindowContext, tabId: UUID, surfaceId: UUID?, notificationId: UUID?) -> Bool { + let expectedIdentifier = "cmux.main.\(context.windowId.uuidString)" + let window: NSWindow? = context.window ?? NSApp.windows.first(where: { $0.identifier?.rawValue == expectedIdentifier }) + guard let window else { #if DEBUG - if event.type == .keyDown { - focusRepairMs = (ProcessInfo.processInfo.systemUptime - focusRepairStart) * 1000.0 - } - let folderGuardStart = event.type == .keyDown ? ProcessInfo.processInfo.systemUptime : 0 + recordMultiWindowNotificationOpenFailureIfNeeded( + tabId: tabId, + surfaceId: surfaceId, + notificationId: notificationId, + reason: "missing_window expectedIdentifier=\(expectedIdentifier)" + ) #endif - defer { - programaFirstResponderGuardCurrentEventContext = previousContextEvent - programaFirstResponderGuardHitViewContext = previousContextHitView - programaFirstResponderGuardContextWindowNumber = previousContextWindowNumber + return false } - guard shouldSuppressWindowMoveForFolderDrag(window: self, event: event), - let contentView = self.contentView else { + context.sidebarSelectionState.selection = .tabs + bringToFront(window) + guard context.tabManager.focusTabFromNotification(tabId, surfaceId: surfaceId) else { #if DEBUG - if event.type == .keyDown { - folderGuardMs = (ProcessInfo.processInfo.systemUptime - folderGuardStart) * 1000.0 - let originalDispatchStart = ProcessInfo.processInfo.systemUptime - programa_sendEvent(event) - originalDispatchMs = (ProcessInfo.processInfo.systemUptime - originalDispatchStart) * 1000.0 - return + recordMultiWindowNotificationOpenFailureIfNeeded( + tabId: tabId, + surfaceId: surfaceId, + notificationId: notificationId, + reason: "focus_failed" + ) + if ProcessInfo.processInfo.environment["PROGRAMA_UI_TEST_JUMP_UNREAD_SETUP"] == "1" { + writeJumpUnreadTestData(["jumpUnreadOpenResult": "0"]) } #endif - programa_sendEvent(event) - return + return false } + #if DEBUG - if event.type == .keyDown { - folderGuardMs = (ProcessInfo.processInfo.systemUptime - folderGuardStart) * 1000.0 - } - let originalDispatchStart = event.type == .keyDown ? ProcessInfo.processInfo.systemUptime : 0 + // UI test support: Jump-to-unread asserts that the correct workspace/panel is focused. + // Recording via first-responder can be flaky on the VM, so verify focus via the model. + recordJumpUnreadFocusFromModelIfNeeded( + tabManager: context.tabManager, + tabId: tabId, + expectedSurfaceId: surfaceId + ) #endif - let contentPoint = contentView.convert(event.locationInWindow, from: nil) - let hitView = contentView.hitTest(contentPoint) - let previousMovableState = isMovable - if previousMovableState { - isMovable = false + if let notificationId, let store = notificationStore { + markReadIfFocused( + notificationId: notificationId, + tabId: tabId, + surfaceId: surfaceId, + tabManager: context.tabManager, + notificationStore: store + ) } - #if DEBUG - let hitDesc = hitView.map { String(describing: type(of: $0)) } ?? "nil" - dlog("window.sendEvent.folderDown suppress=1 hit=\(hitDesc) wasMovable=\(previousMovableState)") - #endif - - programa_sendEvent(event) #if DEBUG - if event.type == .keyDown { - originalDispatchMs = (ProcessInfo.processInfo.systemUptime - originalDispatchStart) * 1000.0 + recordMultiWindowNotificationFocusIfNeeded( + windowId: context.windowId, + tabId: tabId, + surfaceId: surfaceId, + sidebarSelection: context.sidebarSelectionState.selection + ) + if ProcessInfo.processInfo.environment["PROGRAMA_UI_TEST_JUMP_UNREAD_SETUP"] == "1" { + writeJumpUnreadTestData(["jumpUnreadOpenInContext": "1", "jumpUnreadOpenResult": "1"]) } #endif - - if previousMovableState { - isMovable = previousMovableState - } - - #if DEBUG - dlog("window.sendEvent.folderDown restore nowMovable=\(isMovable)") - #endif + return true } - @objc func programa_performKeyEquivalent(with event: NSEvent) -> Bool { -#if DEBUG - let typingTimingStart = ProgramaTypingTiming.start() - defer { - ProgramaTypingTiming.logDuration( - path: "window.performKeyEquivalent", - startedAt: typingTimingStart, - event: event - ) - } - let frType = self.firstResponder.map { String(describing: type(of: $0)) } ?? "nil" - dlog("performKeyEquiv: \(Self.keyDescription(event)) fr=\(frType)") -#endif - - // When the terminal surface is the first responder, prevent SwiftUI's - // hosting view from consuming key events via performKeyEquivalent. - // After a browser panel (WKWebView) has been in the responder chain, - // SwiftUI's internal focus system can get into a broken state where it - // intercepts key events in the content view hierarchy, returns true - // (claiming consumption), but never actually fires the action closure. - // - // For non-Command keys: bypass the view hierarchy entirely and send - // directly to the terminal so arrow keys, Ctrl+N/P, etc. reach keyDown. - // - // For Command keys: bypass the SwiftUI content view hierarchy and - // dispatch directly to the main menu. No SwiftUI view should be handling - // Command shortcuts when the terminal is focused — the local event monitor - // (handleCustomShortcut) already handles app-level shortcuts, and anything - // remaining should be menu items. - let firstResponderGhosttyView = cmuxOwningGhosttyView(for: self.firstResponder) - let firstResponderWebView = self.firstResponder.flatMap { - Self.programaOwningWebView(for: $0, in: self, event: event) - } - let firstResponderHasMarkedText = browserResponderHasMarkedText(self.firstResponder) - if let ghosttyView = firstResponderGhosttyView { - // If the IME is composing and the key has no Cmd modifier, don't intercept — - // let it flow through normal AppKit event dispatch so the input method can - // process it. Cmd-based shortcuts should still work during composition since - // Cmd is never part of IME input sequences. - if ghosttyView.hasMarkedText(), !event.modifierFlags.intersection(.deviceIndependentFlagsMask).contains(.command) { - return programa_performKeyEquivalent(with: event) - } - - let flags = event.modifierFlags.intersection(.deviceIndependentFlagsMask) - if !flags.contains(.command) { - let result = ghosttyView.performKeyEquivalent(with: event) + private func openNotificationFallback(tabId: UUID, surfaceId: UUID?, notificationId: UUID?) -> Bool { + // If the owning window context hasn't been registered yet, fall back to the "active" window. + guard let tabManager else { #if DEBUG - dlog(" → ghostty direct: \(result)") -#endif - return result + if ProcessInfo.processInfo.environment["PROGRAMA_UI_TEST_JUMP_UNREAD_SETUP"] == "1" { + writeJumpUnreadTestData(["jumpUnreadFallbackFail": "missing_tabManager"]) } - - // Preserve Ghostty's terminal font-size shortcuts (Cmd +/−/0) when - // the terminal is focused. Otherwise our browser menu shortcuts can - // consume the event even when no browser panel is focused. - if shouldRouteTerminalFontZoomShortcutToGhostty( - firstResponderIsGhostty: true, - flags: event.modifierFlags, - chars: event.charactersIgnoringModifiers ?? "", - keyCode: event.keyCode, - literalChars: event.characters - ) { - ghosttyView.keyDown(with: event) -#if DEBUG - dlog("zoom.shortcut stage=window.ghosttyKeyDownDirect event=\(Self.keyDescription(event)) handled=1") #endif - return true - } + return false } - - // Web forms rely on Return/Enter flowing through keyDown. If the original - // NSWindow.performKeyEquivalent consumes Enter first, submission never reaches - // WebKit. Route Return/Enter directly to the current first responder and - // mark handled to avoid the AppKit alert sound path. - if shouldDispatchBrowserReturnViaFirstResponderKeyDown( - keyCode: event.keyCode, - firstResponderIsBrowser: firstResponderWebView != nil, - firstResponderHasMarkedText: firstResponderHasMarkedText, - flags: event.modifierFlags - ) { - // Forwarding keyDown can re-enter performKeyEquivalent in WebKit/AppKit internals. - // On re-entry, fall back to normal dispatch to avoid an infinite loop. - if programaBrowserReturnForwardingDepth > 0 { + guard tabManager.tabs.contains(where: { $0.id == tabId }) else { #if DEBUG - dlog(" → browser Return/Enter reentry; using normal dispatch") -#endif - return false + if ProcessInfo.processInfo.environment["PROGRAMA_UI_TEST_JUMP_UNREAD_SETUP"] == "1" { + writeJumpUnreadTestData(["jumpUnreadFallbackFail": "tab_not_in_active_manager"]) } - programaBrowserReturnForwardingDepth += 1 - defer { programaBrowserReturnForwardingDepth = max(0, programaBrowserReturnForwardingDepth - 1) } -#if DEBUG - dlog(" → browser Return/Enter routed to firstResponder.keyDown") #endif - self.firstResponder?.keyDown(with: event) - return true + return false } - - if let firstResponderWebView, - shouldRouteBrowserFindCommandEquivalentThroughWebContentFirst( - event, - responder: self.firstResponder, - owningWebView: firstResponderWebView - ) { - let result = firstResponderWebView.performKeyEquivalent(with: event) + guard let window = (NSApp.keyWindow ?? NSApp.windows.first(where: { isMainTerminalWindow($0) })) else { #if DEBUG - if result { - dlog(" → browser find command resolved before window menu path") - } else { - dlog(" → browser find command preflight left unclaimed; suppressing replay") + if ProcessInfo.processInfo.environment["PROGRAMA_UI_TEST_JUMP_UNREAD_SETUP"] == "1" { + writeJumpUnreadTestData(["jumpUnreadFallbackFail": "missing_window"]) } #endif - // The focused web view has already received this Find-family shortcut once. - // Do not fall through into the original NSWindow.performKeyEquivalent path, - // or WebKit can observe the same key equivalent a second time before AppKit - // reaches keyDown/menu fallback. - return true - } - - if AppDelegate.shared?.handleBrowserSurfaceKeyEquivalent(event) == true { -#if DEBUG - dlog(" → consumed by handleBrowserSurfaceKeyEquivalent") -#endif - return true + return false } - // When the terminal is focused, skip the full NSWindow.performKeyEquivalent - // (which walks the SwiftUI content view hierarchy) and dispatch Command-key - // events directly to the main menu. This avoids the broken SwiftUI focus path. - if firstResponderGhosttyView != nil, - shouldRouteCommandEquivalentDirectlyToMainMenu(event), - let mainMenu = NSApp.mainMenu { - let consumedByMenu = mainMenu.performKeyEquivalent(with: event) + sidebarSelectionState?.selection = .tabs + bringToFront(window) + guard tabManager.focusTabFromNotification(tabId, surfaceId: surfaceId) else { #if DEBUG - if browserZoomShortcutTraceCandidate( - flags: event.modifierFlags, - chars: event.charactersIgnoringModifiers ?? "", - keyCode: event.keyCode, - literalChars: event.characters - ) { - dlog( - "zoom.shortcut stage=window.mainMenuBypass event=\(Self.keyDescription(event)) " + - "consumed=\(consumedByMenu ? 1 : 0) fr=GhosttyNSView" - ) + if ProcessInfo.processInfo.environment["PROGRAMA_UI_TEST_JUMP_UNREAD_SETUP"] == "1" { + writeJumpUnreadTestData([ + "jumpUnreadFallbackFail": "focus_failed", + "jumpUnreadOpenResult": "0", + ]) } #endif - if !consumedByMenu { - // Fall through to the original performKeyEquivalent path below. - } else { -#if DEBUG - dlog(" → consumed by mainMenu (bypassed SwiftUI)") -#endif - return true - } + return false } - let result = programa_performKeyEquivalent(with: event) #if DEBUG - if result { dlog(" → consumed by original performKeyEquivalent") } + recordJumpUnreadFocusFromModelIfNeeded( + tabManager: tabManager, + tabId: tabId, + expectedSurfaceId: surfaceId + ) #endif - return result - } - - static func keyDescription(_ event: NSEvent) -> String { - var parts: [String] = [] - let flags = event.modifierFlags.intersection(.deviceIndependentFlagsMask) - if flags.contains(.command) { parts.append("Cmd") } - if flags.contains(.shift) { parts.append("Shift") } - if flags.contains(.option) { parts.append("Opt") } - if flags.contains(.control) { parts.append("Ctrl") } - let chars = event.charactersIgnoringModifiers ?? "?" - parts.append("'\(chars)'(\(event.keyCode))") - return parts.joined(separator: "+") - } - private static func programaOwningWebView(for responder: NSResponder) -> ProgramaWebView? { - if let webView = responder as? ProgramaWebView { - return webView - } - - if let view = responder as? NSView, - let webView = programaOwningWebView(for: view) { - return webView + if let notificationId, let store = notificationStore { + markReadIfFocused( + notificationId: notificationId, + tabId: tabId, + surfaceId: surfaceId, + tabManager: tabManager, + notificationStore: store + ) } - - // NSTextView.delegate is unsafe-unretained in AppKit. Reading it here while - // a responder chain is tearing down can trap with "unowned reference". - var current = responder.nextResponder - while let next = current { - if let webView = next as? ProgramaWebView { - return webView - } - if let view = next as? NSView, - let webView = programaOwningWebView(for: view) { - return webView - } - current = next.nextResponder +#if DEBUG + if ProcessInfo.processInfo.environment["PROGRAMA_UI_TEST_JUMP_UNREAD_SETUP"] == "1" { + writeJumpUnreadTestData(["jumpUnreadOpenInFallback": "1", "jumpUnreadOpenResult": "1"]) } - - return nil +#endif + return true } - private static func programaOwningWebView( - for responder: NSResponder, - in window: NSWindow, - event: NSEvent? - ) -> ProgramaWebView? { - // Browser find runs in the portal slot alongside the hosted WKWebView. - // Treat its native field editor chain as browser chrome, not as web content, - // so Cmd+F can move first responder into the find field while web focus is suppressed. - if BrowserWindowPortalRegistry.searchOverlayPanelId(for: responder, in: window) != nil { - return nil - } - - if let webView = programaOwningWebView(for: responder) { - return webView - } +#if DEBUG + private func recordJumpUnreadFocusFromModelIfNeeded( + tabManager: TabManager, + tabId: UUID, + expectedSurfaceId: UUID? + ) { + let env = ProcessInfo.processInfo.environment + guard env["PROGRAMA_UI_TEST_JUMP_UNREAD_SETUP"] == "1" else { return } + guard let expectedSurfaceId else { return } - guard let textView = responder as? NSTextView, textView.isFieldEditor else { - return nil - } + // Ensure the expectation is armed even if the view doesn't become first responder. + armJumpUnreadFocusRecord(tabId: tabId, surfaceId: expectedSurfaceId) - if let event, - let hitWebView = programaPointerHitWebView(in: window, event: event) { - programaTrackFieldEditor(textView, owningWebView: hitWebView) - return hitWebView + if tabManager.selectedTabId == tabId, + tabManager.focusedSurfaceId(for: tabId) == expectedSurfaceId { + recordJumpUnreadFocusIfExpected(tabId: tabId, surfaceId: expectedSurfaceId) + return } - return programaTrackedOwningWebView(for: textView) - } + var resolved = false + var observers: [NSObjectProtocol] = [] + var cancellables: [AnyCancellable] = [] - private static func programaOwningWebView(for view: NSView) -> ProgramaWebView? { - if let webView = view as? ProgramaWebView { - return webView + func cleanup() { + observers.forEach { NotificationCenter.default.removeObserver($0) } + observers.removeAll() + cancellables.forEach { $0.cancel() } + cancellables.removeAll() } - var current: NSView? = view.superview - while let candidate = current { - if let webView = candidate as? ProgramaWebView { - return webView - } - if String(describing: type(of: candidate)).contains("WindowBrowserSlotView"), - let portalWebView = programaUniqueBrowserWebView(in: candidate) { - // Portal-hosted browser chrome (for example the Cmd+F overlay) is a - // sibling of the hosted WKWebView inside WindowBrowserSlotView, not a - // descendant of it. Allow native text-entry controls in that slot to - // acquire first responder directly, but keep generic sibling views - // associated with the hosted web view so blocked browser focus policy - // still protects inspector/overlay chrome from stray focus changes. - if view === portalWebView || view.isDescendant(of: portalWebView) { - return portalWebView - } - if programaAllowsPortalSlotTextEntryFocus(view) { - return nil - } - return portalWebView + @MainActor + func finishIfFocused() { + guard !resolved else { return } + guard tabManager.selectedTabId == tabId, + tabManager.focusedSurfaceId(for: tabId) == expectedSurfaceId else { + return } - current = candidate.superview + resolved = true + cleanup() + self.recordJumpUnreadFocusIfExpected(tabId: tabId, surfaceId: expectedSurfaceId) } - return nil - } - - private static func programaAllowsPortalSlotTextEntryFocus(_ view: NSView) -> Bool { - var current: NSView? = view - while let candidate = current { - if let textField = candidate as? NSTextField { - return textField.isEditable || textField.acceptsFirstResponder - } - if let textView = candidate as? NSTextView { - return textView.isEditable || textView.isSelectable || textView.isFieldEditor + observers.append(NotificationCenter.default.addObserver( + forName: .ghosttyDidFocusSurface, + object: nil, + queue: .main + ) { note in + guard let surfaceId = note.userInfo?[GhosttyNotificationKey.surfaceId] as? UUID, + surfaceId == expectedSurfaceId else { return } + Task { @MainActor in finishIfFocused() } + }) + cancellables.append(tabManager.$selectedTabId.sink { _ in + Task { @MainActor in finishIfFocused() } + }) + if let workspace = tabManager.tabs.first(where: { $0.id == tabId }) { + cancellables.append(workspace.$panels + .map { _ in () } + .sink { _ in + Task { @MainActor in finishIfFocused() } + }) + } + DispatchQueue.main.asyncAfter(deadline: .now() + 2.0) { + Task { @MainActor in + guard !resolved else { return } + cleanup() } - current = candidate.superview } - return false + Task { @MainActor in finishIfFocused() } } +#endif - private static func programaUniqueBrowserWebView(in root: NSView) -> ProgramaWebView? { - var stack: [NSView] = [root] - var found: ProgramaWebView? - while let current = stack.popLast() { - if let webView = current as? ProgramaWebView { - if found == nil { - found = webView - } else if found !== webView { - return nil - } - } - stack.append(contentsOf: current.subviews) + func tabTitle(for tabId: UUID) -> String? { + if let context = contextContainingTabId(tabId) { + return context.tabManager.tabs.first(where: { $0.id == tabId })?.title } - return found + return tabManager?.tabs.first(where: { $0.id == tabId })?.title } - private static func programaCurrentEvent(for window: NSWindow) -> NSEvent? { -#if DEBUG - if let override = programaFirstResponderGuardCurrentEventOverride { - return override + private func bringToFront(_ window: NSWindow) { + if TerminalController.shouldSuppressSocketCommandActivation(), + !TerminalController.socketCommandAllowsInAppFocusMutations() { + return } -#endif - if programaFirstResponderGuardContextWindowNumber == window.windowNumber { - return programaFirstResponderGuardCurrentEventContext + setActiveMainWindow(window) + if window.isMiniaturized { + window.deminiaturize(nil) } - return NSApp.currentEvent + window.makeKeyAndOrderFront(nil) + // Improve reliability across Spaces / when other helper panels are key. + NSRunningApplication.current.activate(options: [.activateAllWindows, .activateIgnoringOtherApps]) } - private static func programaHitViewInThemeFrame(in window: NSWindow, event: NSEvent) -> NSView? { - guard let contentView = window.contentView, - let themeFrame = contentView.superview else { - return nil + private func markReadIfFocused( + notificationId: UUID, + tabId: UUID, + surfaceId: UUID?, + tabManager: TabManager, + notificationStore: TerminalNotificationStore + ) { + DispatchQueue.main.asyncAfter(deadline: .now() + 0.05) { + guard tabManager.selectedTabId == tabId else { return } + if let surfaceId { + guard tabManager.focusedSurfaceId(for: tabId) == surfaceId else { return } + } + notificationStore.markRead(id: notificationId) } - let pointInTheme = themeFrame.convert(event.locationInWindow, from: nil) - return themeFrame.hitTest(pointInTheme) } - private static func programaHitViewInContentView(in window: NSWindow, event: NSEvent) -> NSView? { - guard let contentView = window.contentView else { - return nil - } - let pointInContent = contentView.convert(event.locationInWindow, from: nil) - return contentView.hitTest(pointInContent) - } +#if DEBUG + private func recordMultiWindowNotificationOpenFailureIfNeeded( + tabId: UUID, + surfaceId: UUID?, + notificationId: UUID?, + reason: String + ) { + let env = ProcessInfo.processInfo.environment + guard let path = env["PROGRAMA_UI_TEST_MULTI_WINDOW_NOTIF_PATH"], !path.isEmpty else { return } - private static func programaTopHitViewForEvent(in window: NSWindow, event: NSEvent) -> NSView? { - if let hitInThemeFrame = programaHitViewInThemeFrame(in: window, event: event) { - return hitInThemeFrame + let contextSummaries: [String] = mainWindowContexts.values.map { ctx in + let tabIds = ctx.tabManager.tabs.map { $0.id.uuidString }.joined(separator: ",") + let hasWindow = (ctx.window != nil) ? "1" : "0" + return "windowId=\(ctx.windowId.uuidString) hasWindow=\(hasWindow) tabs=[\(tabIds)]" } - return programaHitViewInContentView(in: window, event: event) - } - private static func programaHitViewForEventDispatch(in window: NSWindow, event: NSEvent) -> NSView? { - if event.windowNumber != 0, event.windowNumber != window.windowNumber { - return nil - } - if let eventWindow = event.window, eventWindow !== window { - return nil - } - return programaTopHitViewForEvent(in: window, event: event) + writeMultiWindowNotificationTestData([ + "focusToken": UUID().uuidString, + "openFailureTabId": tabId.uuidString, + "openFailureSurfaceId": surfaceId?.uuidString ?? "", + "openFailureNotificationId": notificationId?.uuidString ?? "", + "openFailureReason": reason, + "openFailureContexts": contextSummaries.joined(separator: "; "), + ], at: path) } +#endif + + /// Configures a Programa main window's AppKit-level presentation (identifier, + /// titlebar, movability, transparency, glass effect, decorations) and registers + /// it for window-context tracking, then installs the file-drop overlay. + /// + /// Extracted verbatim from `ContentView`'s `WindowAccessor` trailing closure + /// (nuclear-review CV2b) so this AppKit window mutation lives with the + /// window-context layer instead of the SwiftUI view layer. `ContentView` still + /// owns tracking its own `@State` (`observedWindow`, `isFullScreen`, + /// `titlebarPadding`) — this method returns the computed titlebar padding so + /// the caller can decide whether to update that `@State`. + @discardableResult + func configureMainWindow( + _ window: NSWindow, + windowId: UUID, + windowIdentifier: String, + tabManager: TabManager, + sidebarState: SidebarState, + sidebarSelectionState: SidebarSelectionState, + sidebarBlendMode: String, + bgGlassEnabled: Bool, + bgGlassTintHex: String, + bgGlassTintOpacity: Double + ) -> CGFloat { + window.identifier = NSUserInterfaceItemIdentifier(windowIdentifier) + window.titlebarAppearsTransparent = true + // Keep window immovable; the sidebar's WindowDragHandleView handles + // drag-to-move via performDrag with temporary movable override. + // isMovableByWindowBackground=true breaks tab reordering, and + // isMovable=true blocks clicks on sidebar buttons in minimal mode. + window.isMovableByWindowBackground = false + window.isMovable = false + window.styleMask.insert(.fullSizeContentView) - private static func programaHitViewForCurrentEvent(in window: NSWindow, event: NSEvent) -> NSView? { + // Keep content below the titlebar so drags on Bonsplit's tab bar don't + // get interpreted as window drags. + let computedTitlebarHeight = window.frame.height - window.contentLayoutRect.height + let nextTitlebarPadding = max(28, min(72, computedTitlebarHeight)) #if DEBUG - if let override = programaFirstResponderGuardHitViewOverride { - return override + if ProcessInfo.processInfo.environment["PROGRAMA_UI_TEST_MODE"] == "1" { + UpdateLogStore.shared.append("ui test window accessor: id=\(windowIdentifier) visible=\(window.isVisible)") } #endif - if programaFirstResponderGuardContextWindowNumber == window.windowNumber, - let contextHitView = programaFirstResponderGuardHitViewContext { - return contextHitView - } - return programaTopHitViewForEvent(in: window, event: event) - } + // User settings decide whether window glass is active. The native Tahoe + // NSGlassEffectView path vs the older NSVisualEffectView fallback is chosen + // inside WindowGlassEffect.apply. + let currentThemeBackground = GhosttyBackgroundTheme.currentColor() + let shouldApplyWindowGlass = cmuxShouldApplyWindowGlass( + sidebarBlendMode: sidebarBlendMode, + bgGlassEnabled: bgGlassEnabled, + glassEffectAvailable: WindowGlassEffect.isAvailable + ) + let shouldForceTransparentHosting = + shouldApplyWindowGlass || currentThemeBackground.alphaComponent < 0.999 - private static func programaTrackFieldEditor(_ fieldEditor: NSTextView, owningWebView webView: ProgramaWebView?) { - if let webView { - objc_setAssociatedObject( - fieldEditor, - &programaFieldEditorOwningWebViewAssociationKey, - ProgramaFieldEditorOwningWebViewBox(webView: webView), - .OBJC_ASSOCIATION_RETAIN_NONATOMIC - ) + if shouldForceTransparentHosting { + window.isOpaque = false + // Keep the window clear whenever translucency is active. Relying only on + // terminal focus-driven updates can leave stale opaque window fills. + window.backgroundColor = NSColor.white.withAlphaComponent(0.001) + // Configure contentView hierarchy for transparency. + if let contentView = window.contentView { + ContentView.makeViewHierarchyTransparent(contentView) + } } else { - objc_setAssociatedObject( - fieldEditor, - &programaFieldEditorOwningWebViewAssociationKey, - nil, - .OBJC_ASSOCIATION_RETAIN_NONATOMIC - ) + // Browser-focused workspaces may not have an active terminal panel to refresh + // the NSWindow background. Keep opaque theme changes applied here as well. + window.backgroundColor = currentThemeBackground + window.isOpaque = currentThemeBackground.alphaComponent >= 0.999 } - } - private static func programaTrackedOwningWebView(for fieldEditor: NSTextView) -> ProgramaWebView? { - guard let box = objc_getAssociatedObject( - fieldEditor, - &programaFieldEditorOwningWebViewAssociationKey - ) as? ProgramaFieldEditorOwningWebViewBox else { - return nil - } - guard let webView = box.webView else { - programaTrackFieldEditor(fieldEditor, owningWebView: nil) - return nil + if shouldApplyWindowGlass { + // Apply liquid glass effect to the window with tint from settings + let tintColor = (NSColor(hex: bgGlassTintHex) ?? .black).withAlphaComponent(bgGlassTintOpacity) + WindowGlassEffect.apply(to: window, tintColor: tintColor) + } else { + WindowGlassEffect.remove(from: window) } - return webView - } + AppDelegate.shared?.attachUpdateAccessory(to: window) + AppDelegate.shared?.applyWindowDecorations(to: window) + AppDelegate.shared?.registerMainWindow( + window, + windowId: windowId, + tabManager: tabManager, + sidebarState: sidebarState, + sidebarSelectionState: sidebarSelectionState + ) + installFileDropOverlay(on: window, tabManager: tabManager) - private static func programaIsPointerDownEvent(_ event: NSEvent) -> Bool { - switch event.type { - case .leftMouseDown, .rightMouseDown, .otherMouseDown: - return true - default: - return false - } + return nextTitlebarPadding } - private static func programaPointerHitWebView(in window: NSWindow, event: NSEvent) -> ProgramaWebView? { - guard programaIsPointerDownEvent(event) else { return nil } - if event.windowNumber != 0, event.windowNumber != window.windowNumber { - return nil - } - if let eventWindow = event.window, eventWindow !== window { - return nil - } - if let portalWebView = BrowserWindowPortalRegistry.webViewAtWindowPoint( - event.locationInWindow, - in: window - ) as? ProgramaWebView { - return portalWebView - } - guard let hitView = programaHitViewForCurrentEvent(in: window, event: event) else { - return nil - } - return programaOwningWebView(for: hitView) - } +} - private static func programaShouldAllowPointerInitiatedWebViewFocus( - window: NSWindow, - webView: ProgramaWebView, - event: NSEvent? - ) -> Bool { - guard let event, - let hitWebView = programaPointerHitWebView(in: window, event: event) else { - return false +private extension AppDelegate { + @objc func handleThemesReloadNotification(_ notification: Notification) { + DispatchQueue.main.async { + GhosttyApp.shared.reloadConfiguration(source: "distributed.cmux.themes") } - return hitWebView === webView } - } + diff --git a/Sources/CLIInstaller.swift b/Sources/CLIInstaller.swift new file mode 100644 index 00000000000..e41902b633f --- /dev/null +++ b/Sources/CLIInstaller.swift @@ -0,0 +1,324 @@ +import AppKit +import SwiftUI +import Bonsplit +import CoreServices +import UserNotifications +import WebKit +import Combine +import ObjectiveC.runtime +import Darwin + +struct ProgramaCLIPathInstaller { + struct InstallOutcome { + let usedAdministratorPrivileges: Bool + let destinationURL: URL + let sourceURL: URL + } + + struct UninstallOutcome { + let usedAdministratorPrivileges: Bool + let destinationURL: URL + let removedExistingEntry: Bool + } + + enum InstallerError: LocalizedError { + case bundledCLIMissing(expectedPath: String) + case destinationParentNotDirectory(path: String) + case destinationIsDirectory(path: String) + case installVerificationFailed(path: String) + case uninstallVerificationFailed(path: String) + case privilegedCommandFailed(message: String) + + var errorDescription: String? { + switch self { + case .bundledCLIMissing(let expectedPath): + return "Bundled Programa CLI was not found at \(expectedPath)." + case .destinationParentNotDirectory(let path): + return "Expected \(path) to be a directory." + case .destinationIsDirectory(let path): + return "\(path) is a directory. Remove or rename it and try again." + case .installVerificationFailed(let path): + return "Installed symlink at \(path) did not point to the bundled programa CLI." + case .uninstallVerificationFailed(let path): + return "Failed to remove \(path)." + case .privilegedCommandFailed(let message): + return "Administrator action failed: \(message)" + } + } + } + + typealias PrivilegedInstallHandler = (_ sourceURL: URL, _ destinationURL: URL) throws -> Void + typealias PrivilegedUninstallHandler = (_ destinationURL: URL) throws -> Void + + let fileManager: FileManager + let destinationURL: URL + private let bundledCLIURLProvider: () -> URL? + private let expectedBundledCLIPath: String + private let privilegedInstaller: PrivilegedInstallHandler + private let privilegedUninstaller: PrivilegedUninstallHandler + + init( + fileManager: FileManager = .default, + destinationURL: URL = URL(fileURLWithPath: "/usr/local/bin/programa"), + bundledCLIURLProvider: @escaping () -> URL? = { + ProgramaCLIPathInstaller.defaultBundledCLIURL() + }, + expectedBundledCLIPath: String = ProgramaCLIPathInstaller.defaultBundledCLIExpectedPath(), + privilegedInstaller: PrivilegedInstallHandler? = nil, + privilegedUninstaller: PrivilegedUninstallHandler? = nil + ) { + self.fileManager = fileManager + self.destinationURL = destinationURL + self.bundledCLIURLProvider = bundledCLIURLProvider + self.expectedBundledCLIPath = expectedBundledCLIPath + self.privilegedInstaller = privilegedInstaller ?? Self.installWithAdministratorPrivileges(sourceURL:destinationURL:) + self.privilegedUninstaller = privilegedUninstaller ?? Self.uninstallWithAdministratorPrivileges(destinationURL:) + } + + var destinationPath: String { + destinationURL.path + } + + func install() throws -> InstallOutcome { + let sourceURL = try resolveBundledCLIURL() + do { + try installWithoutAdministratorPrivileges(sourceURL: sourceURL) + return InstallOutcome( + usedAdministratorPrivileges: false, + destinationURL: destinationURL, + sourceURL: sourceURL + ) + } catch { + guard Self.isPermissionDenied(error) else { throw error } + try ensureDestinationIsNotDirectory() + try privilegedInstaller(sourceURL, destinationURL) + try verifyInstalledSymlinkTarget(sourceURL: sourceURL) + return InstallOutcome( + usedAdministratorPrivileges: true, + destinationURL: destinationURL, + sourceURL: sourceURL + ) + } + } + + func uninstall() throws -> UninstallOutcome { + do { + let removedExistingEntry = try uninstallWithoutAdministratorPrivileges() + return UninstallOutcome( + usedAdministratorPrivileges: false, + destinationURL: destinationURL, + removedExistingEntry: removedExistingEntry + ) + } catch { + guard Self.isPermissionDenied(error) else { throw error } + try ensureDestinationIsNotDirectory() + let removedExistingEntry = destinationEntryExists() + try privilegedUninstaller(destinationURL) + if destinationEntryExists() { + throw InstallerError.uninstallVerificationFailed(path: destinationURL.path) + } + return UninstallOutcome( + usedAdministratorPrivileges: true, + destinationURL: destinationURL, + removedExistingEntry: removedExistingEntry + ) + } + } + + func isInstalled() -> Bool { + guard let sourceURL = bundledCLIURLProvider()?.standardizedFileURL else { return false } + guard let installedTargetURL = symlinkDestinationURL() else { return false } + return installedTargetURL == sourceURL + } + + private func resolveBundledCLIURL() throws -> URL { + guard let sourceURL = bundledCLIURLProvider()?.standardizedFileURL else { + throw InstallerError.bundledCLIMissing(expectedPath: expectedBundledCLIPath) + } + + var isDirectory: ObjCBool = false + guard fileManager.fileExists(atPath: sourceURL.path, isDirectory: &isDirectory), !isDirectory.boolValue else { + throw InstallerError.bundledCLIMissing(expectedPath: sourceURL.path) + } + return sourceURL + } + + private func installWithoutAdministratorPrivileges(sourceURL: URL) throws { + try ensureDestinationParentDirectoryExists() + try ensureDestinationIsNotDirectory() + if destinationEntryExists() { + try fileManager.removeItem(at: destinationURL) + } + try fileManager.createSymbolicLink(at: destinationURL, withDestinationURL: sourceURL) + try verifyInstalledSymlinkTarget(sourceURL: sourceURL) + } + + @discardableResult + private func uninstallWithoutAdministratorPrivileges() throws -> Bool { + try ensureDestinationIsNotDirectory() + let existed = destinationEntryExists() + if existed { + try fileManager.removeItem(at: destinationURL) + } + if destinationEntryExists() { + throw InstallerError.uninstallVerificationFailed(path: destinationURL.path) + } + return existed + } + + /// Check if the destination path has any filesystem entry (including dangling symlinks). + /// `FileManager.fileExists` follows symlinks, so a dangling symlink returns false. + private func destinationEntryExists() -> Bool { + (try? fileManager.attributesOfItem(atPath: destinationURL.path)) != nil + } + + private func verifyInstalledSymlinkTarget(sourceURL: URL) throws { + guard let installedTargetURL = symlinkDestinationURL(), + installedTargetURL == sourceURL.standardizedFileURL else { + throw InstallerError.installVerificationFailed(path: destinationURL.path) + } + } + + private func symlinkDestinationURL() -> URL? { + guard fileManager.fileExists(atPath: destinationURL.path) else { return nil } + guard let destinationPath = try? fileManager.destinationOfSymbolicLink(atPath: destinationURL.path) else { + return nil + } + return URL( + fileURLWithPath: destinationPath, + relativeTo: destinationURL.deletingLastPathComponent() + ).standardizedFileURL + } + + private func ensureDestinationParentDirectoryExists() throws { + let parentURL = destinationURL.deletingLastPathComponent() + var isDirectory: ObjCBool = false + if fileManager.fileExists(atPath: parentURL.path, isDirectory: &isDirectory) { + guard isDirectory.boolValue else { + throw InstallerError.destinationParentNotDirectory(path: parentURL.path) + } + return + } + try fileManager.createDirectory(at: parentURL, withIntermediateDirectories: true) + } + + private func ensureDestinationIsNotDirectory() throws { + guard let values = try resourceValuesIfFileExists( + at: destinationURL, + keys: [.isDirectoryKey, .isSymbolicLinkKey] + ) else { + return + } + + if values.isDirectory == true, values.isSymbolicLink != true { + throw InstallerError.destinationIsDirectory(path: destinationURL.path) + } + } + + private func resourceValuesIfFileExists( + at url: URL, + keys: Set + ) throws -> URLResourceValues? { + do { + return try url.resourceValues(forKeys: keys) + } catch { + let nsError = error as NSError + if nsError.domain == NSCocoaErrorDomain && nsError.code == NSFileReadNoSuchFileError { + return nil + } + if nsError.domain == NSPOSIXErrorDomain, + POSIXErrorCode(rawValue: Int32(nsError.code)) == .ENOENT { + return nil + } + throw error + } + } + + private static func defaultBundledCLIURL(bundle: Bundle = .main) -> URL? { + bundle.resourceURL?.appendingPathComponent("bin/programa", isDirectory: false) + } + + private static func defaultBundledCLIExpectedPath(bundle: Bundle = .main) -> String { + bundle.bundleURL + .appendingPathComponent("Contents/Resources/bin/programa", isDirectory: false) + .path + } + + private static func installWithAdministratorPrivileges(sourceURL: URL, destinationURL: URL) throws { + let destinationPath = destinationURL.path + let parentPath = destinationURL.deletingLastPathComponent().path + let command = "/bin/mkdir -p \(shellQuoted(parentPath)) && " + + "/bin/rm -f \(shellQuoted(destinationPath)) && " + + "/bin/ln -s \(shellQuoted(sourceURL.path)) \(shellQuoted(destinationPath))" + try runPrivilegedShellCommand(command) + } + + private static func uninstallWithAdministratorPrivileges(destinationURL: URL) throws { + let command = "/bin/rm -f \(shellQuoted(destinationURL.path))" + try runPrivilegedShellCommand(command) + } + + private static func runPrivilegedShellCommand(_ command: String) throws { + let process = Process() + process.executableURL = URL(fileURLWithPath: "/usr/bin/osascript") + process.arguments = [ + "-e", "on run argv", + "-e", "do shell script (item 1 of argv) with administrator privileges", + "-e", "end run", + command + ] + let stdout = Pipe() + let stderr = Pipe() + process.standardOutput = stdout + process.standardError = stderr + try process.run() + process.waitUntilExit() + + guard process.terminationStatus == 0 else { + let stderrText = String( + data: stderr.fileHandleForReading.readDataToEndOfFile(), + encoding: .utf8 + )?.trimmingCharacters(in: .whitespacesAndNewlines) ?? "" + let stdoutText = String( + data: stdout.fileHandleForReading.readDataToEndOfFile(), + encoding: .utf8 + )?.trimmingCharacters(in: .whitespacesAndNewlines) ?? "" + let details = stderrText.isEmpty ? stdoutText : stderrText + let message = details.isEmpty + ? "osascript exited with status \(process.terminationStatus)." + : details + throw InstallerError.privilegedCommandFailed(message: message) + } + } + + private static func shellQuoted(_ value: String) -> String { + "'" + value.replacingOccurrences(of: "'", with: "'\\''") + "'" + } + + private static func isPermissionDenied(_ error: Error) -> Bool { + isPermissionDenied(error as NSError) + } + + private static func isPermissionDenied(_ error: NSError) -> Bool { + if error.domain == NSPOSIXErrorDomain, + let code = POSIXErrorCode(rawValue: Int32(error.code)), + code == .EACCES || code == .EPERM || code == .EROFS { + return true + } + + if error.domain == NSCocoaErrorDomain { + switch error.code { + case NSFileWriteNoPermissionError, NSFileReadNoPermissionError, NSFileWriteVolumeReadOnlyError: + return true + default: + break + } + } + + if let underlying = error.userInfo[NSUnderlyingErrorKey] as? NSError { + return isPermissionDenied(underlying) + } + + return false + } +} diff --git a/Sources/MainWindowHostingView.swift b/Sources/MainWindowHostingView.swift new file mode 100644 index 00000000000..21e9cf4a877 --- /dev/null +++ b/Sources/MainWindowHostingView.swift @@ -0,0 +1,33 @@ +import AppKit +import SwiftUI +import Bonsplit +import CoreServices +import UserNotifications +import WebKit +import Combine +import ObjectiveC.runtime +import Darwin + +final class MainWindowHostingView: NSHostingView { + private let zeroSafeAreaLayoutGuide = NSLayoutGuide() + + override var safeAreaInsets: NSEdgeInsets { NSEdgeInsetsZero } + override var safeAreaRect: NSRect { bounds } + override var safeAreaLayoutGuide: NSLayoutGuide { zeroSafeAreaLayoutGuide } + + required init(rootView: Content) { + super.init(rootView: rootView) + addLayoutGuide(zeroSafeAreaLayoutGuide) + NSLayoutConstraint.activate([ + zeroSafeAreaLayoutGuide.leadingAnchor.constraint(equalTo: leadingAnchor), + zeroSafeAreaLayoutGuide.trailingAnchor.constraint(equalTo: trailingAnchor), + zeroSafeAreaLayoutGuide.topAnchor.constraint(equalTo: topAnchor), + zeroSafeAreaLayoutGuide.bottomAnchor.constraint(equalTo: bottomAnchor), + ]) + } + + @available(*, unavailable) + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } +} diff --git a/Sources/MenuBarIconRenderer.swift b/Sources/MenuBarIconRenderer.swift new file mode 100644 index 00000000000..d9d35ba6fd8 --- /dev/null +++ b/Sources/MenuBarIconRenderer.swift @@ -0,0 +1,669 @@ +import AppKit +import SwiftUI +import Bonsplit +import CoreServices +import UserNotifications +import WebKit +import Combine +import ObjectiveC.runtime +import Darwin + +@MainActor +final class MenuBarExtraController: NSObject, NSMenuDelegate { + private let statusItem: NSStatusItem + private let menu = NSMenu(title: "Programa") + private let notificationStore: TerminalNotificationStore + private let onShowNotifications: () -> Void + private let onOpenNotification: (TerminalNotification) -> Void + private let onJumpToLatestUnread: () -> Void + private let onCheckForUpdates: () -> Void + private let onOpenPreferences: () -> Void + private let onQuitApp: () -> Void + private var notificationsCancellable: AnyCancellable? + private let buildHintTitle: String? + + private let stateHintItem = NSMenuItem(title: String(localized: "statusMenu.noUnread", defaultValue: "No unread notifications"), action: nil, keyEquivalent: "") + private let buildHintItem = NSMenuItem(title: "", action: nil, keyEquivalent: "") + private let notificationListSeparator = NSMenuItem.separator() + private let notificationSectionSeparator = NSMenuItem.separator() + private let showNotificationsItem = NSMenuItem(title: String(localized: "statusMenu.showNotifications", defaultValue: "Show Notifications"), action: nil, keyEquivalent: "") + private let jumpToUnreadItem = NSMenuItem(title: String(localized: "statusMenu.jumpToLatestUnread", defaultValue: "Jump to Latest Unread"), action: nil, keyEquivalent: "") + private let markAllReadItem = NSMenuItem(title: String(localized: "statusMenu.markAllRead", defaultValue: "Mark All Read"), action: nil, keyEquivalent: "") + private let clearAllItem = NSMenuItem(title: String(localized: "statusMenu.clearAll", defaultValue: "Clear All"), action: nil, keyEquivalent: "") + private let checkForUpdatesItem = NSMenuItem(title: String(localized: "menu.checkForUpdates", defaultValue: "Check for Updates…"), action: nil, keyEquivalent: "") + private let preferencesItem = NSMenuItem(title: String(localized: "menu.preferences", defaultValue: "Preferences…"), action: nil, keyEquivalent: "") + private let quitItem = NSMenuItem(title: String(localized: "menu.quitPrograma", defaultValue: "Quit Programa"), action: nil, keyEquivalent: "") + + private var notificationItems: [NSMenuItem] = [] + private let maxInlineNotificationItems = 6 + + init( + notificationStore: TerminalNotificationStore, + onShowNotifications: @escaping () -> Void, + onOpenNotification: @escaping (TerminalNotification) -> Void, + onJumpToLatestUnread: @escaping () -> Void, + onCheckForUpdates: @escaping () -> Void, + onOpenPreferences: @escaping () -> Void, + onQuitApp: @escaping () -> Void + ) { + self.notificationStore = notificationStore + self.onShowNotifications = onShowNotifications + self.onOpenNotification = onOpenNotification + self.onJumpToLatestUnread = onJumpToLatestUnread + self.onCheckForUpdates = onCheckForUpdates + self.onOpenPreferences = onOpenPreferences + self.onQuitApp = onQuitApp + self.buildHintTitle = MenuBarBuildHintFormatter.menuTitle() + self.statusItem = NSStatusBar.system.statusItem(withLength: NSStatusItem.variableLength) + super.init() + + buildMenu() + statusItem.menu = menu + if let button = statusItem.button { + button.imagePosition = .imageOnly + button.imageScaling = .scaleProportionallyDown + button.image = MenuBarIconRenderer.makeImage(unreadCount: 0) + button.toolTip = "Programa" + } + + notificationsCancellable = notificationStore.$notifications + .receive(on: DispatchQueue.main) + .sink { [weak self] _ in + self?.refreshUI() + } + + refreshUI() + } + + private func buildMenu() { + menu.autoenablesItems = false + menu.delegate = self + + stateHintItem.isEnabled = false + menu.addItem(stateHintItem) + if let buildHintTitle { + buildHintItem.title = buildHintTitle + buildHintItem.isEnabled = false + menu.addItem(buildHintItem) + } + + menu.addItem(notificationListSeparator) + notificationSectionSeparator.isHidden = true + menu.addItem(notificationSectionSeparator) + + showNotificationsItem.target = self + showNotificationsItem.action = #selector(showNotificationsAction) + menu.addItem(showNotificationsItem) + + jumpToUnreadItem.target = self + jumpToUnreadItem.action = #selector(jumpToUnreadAction) + menu.addItem(jumpToUnreadItem) + + markAllReadItem.target = self + markAllReadItem.action = #selector(markAllReadAction) + menu.addItem(markAllReadItem) + + clearAllItem.target = self + clearAllItem.action = #selector(clearAllAction) + menu.addItem(clearAllItem) + + menu.addItem(.separator()) + + checkForUpdatesItem.target = self + checkForUpdatesItem.action = #selector(checkForUpdatesAction) + menu.addItem(checkForUpdatesItem) + + preferencesItem.target = self + preferencesItem.action = #selector(preferencesAction) + menu.addItem(preferencesItem) + + menu.addItem(.separator()) + + quitItem.target = self + quitItem.action = #selector(quitAction) + menu.addItem(quitItem) + } + + func menuWillOpen(_ menu: NSMenu) { + refreshUI() + } + + func refreshForDebugControls() { + refreshUI() + } + + func removeFromMenuBar() { + notificationsCancellable?.cancel() + notificationsCancellable = nil + statusItem.menu = nil + NSStatusBar.system.removeStatusItem(statusItem) + } + + private func refreshUI() { + let snapshot = NotificationMenuSnapshotBuilder.make( + notifications: notificationStore.notifications, + maxInlineNotificationItems: maxInlineNotificationItems + ) + let actualUnreadCount = snapshot.unreadCount + + let displayedUnreadCount: Int +#if DEBUG + displayedUnreadCount = MenuBarIconDebugSettings.displayedUnreadCount(actualUnreadCount: actualUnreadCount) +#else + displayedUnreadCount = actualUnreadCount +#endif + + stateHintItem.title = snapshot.stateHintTitle + + applyShortcut(KeyboardShortcutSettings.shortcut(for: .showNotifications), to: showNotificationsItem) + applyShortcut(KeyboardShortcutSettings.shortcut(for: .jumpToUnread), to: jumpToUnreadItem) + + jumpToUnreadItem.isEnabled = snapshot.hasUnreadNotifications + markAllReadItem.isEnabled = snapshot.hasUnreadNotifications + clearAllItem.isEnabled = snapshot.hasNotifications + + rebuildInlineNotificationItems(recentNotifications: snapshot.recentNotifications) + + if let button = statusItem.button { + button.image = MenuBarIconRenderer.makeImage(unreadCount: displayedUnreadCount) + button.toolTip = displayedUnreadCount == 0 + ? "Programa" + : displayedUnreadCount == 1 + ? "Programa: " + String(localized: "statusMenu.tooltip.unread.one", defaultValue: "1 unread notification") + : "Programa: " + String(localized: "statusMenu.tooltip.unread.other", defaultValue: "\(displayedUnreadCount) unread notifications") + } + } + + private func applyShortcut(_ shortcut: StoredShortcut, to item: NSMenuItem) { + guard let keyEquivalent = shortcut.menuItemKeyEquivalent else { + item.keyEquivalent = "" + item.keyEquivalentModifierMask = [] + return + } + item.keyEquivalent = keyEquivalent + item.keyEquivalentModifierMask = shortcut.modifierFlags + } + + private func rebuildInlineNotificationItems(recentNotifications: [TerminalNotification]) { + for item in notificationItems { + menu.removeItem(item) + } + notificationItems.removeAll(keepingCapacity: true) + + notificationListSeparator.isHidden = recentNotifications.isEmpty + notificationSectionSeparator.isHidden = recentNotifications.isEmpty + guard !recentNotifications.isEmpty else { return } + + let insertionIndex = menu.index(of: showNotificationsItem) + guard insertionIndex >= 0 else { return } + + for (offset, notification) in recentNotifications.enumerated() { + let tabTitle = AppDelegate.shared?.tabTitle(for: notification.tabId) + let item = makeNotificationItem(notification: notification, tabTitle: tabTitle) + menu.insertItem(item, at: insertionIndex + offset) + notificationItems.append(item) + } + } + + private func makeNotificationItem(notification: TerminalNotification, tabTitle: String?) -> NSMenuItem { + let item = NSMenuItem(title: "", action: #selector(openNotificationItemAction(_:)), keyEquivalent: "") + item.target = self + item.attributedTitle = MenuBarNotificationLineFormatter.attributedTitle(notification: notification, tabTitle: tabTitle) + item.toolTip = MenuBarNotificationLineFormatter.tooltip(notification: notification, tabTitle: tabTitle) + item.representedObject = NotificationMenuItemPayload(notification: notification) + return item + } + + @objc private func openNotificationItemAction(_ sender: NSMenuItem) { + guard let payload = sender.representedObject as? NotificationMenuItemPayload else { return } + onOpenNotification(payload.notification) + } + + @objc private func showNotificationsAction() { + onShowNotifications() + } + + @objc private func jumpToUnreadAction() { + onJumpToLatestUnread() + } + + @objc private func markAllReadAction() { + notificationStore.markAllRead() + } + + @objc private func clearAllAction() { + notificationStore.clearAll() + } + + @objc private func checkForUpdatesAction() { + onCheckForUpdates() + } + + @objc private func preferencesAction() { + onOpenPreferences() + } + + @objc private func quitAction() { + onQuitApp() + } +} + +private final class NotificationMenuItemPayload: NSObject { + let notification: TerminalNotification + + init(notification: TerminalNotification) { + self.notification = notification + super.init() + } +} + +struct NotificationMenuSnapshot { + let unreadCount: Int + let hasNotifications: Bool + let recentNotifications: [TerminalNotification] + + var hasUnreadNotifications: Bool { + unreadCount > 0 + } + + var stateHintTitle: String { + NotificationMenuSnapshotBuilder.stateHintTitle(unreadCount: unreadCount) + } +} + +enum NotificationMenuSnapshotBuilder { + static let defaultInlineNotificationLimit = 6 + + static func make( + notifications: [TerminalNotification], + maxInlineNotificationItems: Int = defaultInlineNotificationLimit + ) -> NotificationMenuSnapshot { + let unreadCount = notifications.reduce(into: 0) { count, notification in + if !notification.isRead { + count += 1 + } + } + + let inlineLimit = max(0, maxInlineNotificationItems) + return NotificationMenuSnapshot( + unreadCount: unreadCount, + hasNotifications: !notifications.isEmpty, + recentNotifications: Array(notifications.prefix(inlineLimit)) + ) + } + + static func stateHintTitle(unreadCount: Int) -> String { + switch unreadCount { + case 0: + return String(localized: "statusMenu.noUnread", defaultValue: "No unread notifications") + case 1: + return String(localized: "statusMenu.unreadCount.one", defaultValue: "1 unread notification") + default: + return String(localized: "statusMenu.unreadCount.other", defaultValue: "\(unreadCount) unread notifications") + } + } +} + +enum MenuBarBadgeLabelFormatter { + static func badgeText(for unreadCount: Int) -> String? { + guard unreadCount > 0 else { return nil } + if unreadCount > 9 { + return "9+" + } + return String(unreadCount) + } +} + +enum MenuBarNotificationLineFormatter { + static let defaultMaxMenuTextWidth: CGFloat = 280 + static let defaultMaxMenuTextLines = 3 + + static func plainTitle(notification: TerminalNotification, tabTitle: String?) -> String { + let dot = notification.isRead ? " " : "● " + let timeText = notification.createdAt.formatted(date: .omitted, time: .shortened) + var lines: [String] = [] + lines.append("\(dot)\(notification.title) \(timeText)") + + let detail = notification.body.isEmpty ? notification.subtitle : notification.body + if !detail.isEmpty { + lines.append(detail) + } + + if let tabTitle, !tabTitle.isEmpty { + lines.append(tabTitle) + } + + return lines.joined(separator: "\n") + } + + static func menuTitle( + notification: TerminalNotification, + tabTitle: String?, + maxWidth: CGFloat = defaultMaxMenuTextWidth, + maxLines: Int = defaultMaxMenuTextLines + ) -> String { + let base = plainTitle(notification: notification, tabTitle: tabTitle) + return wrappedAndTruncated(base, maxWidth: maxWidth, maxLines: maxLines) + } + + static func attributedTitle(notification: TerminalNotification, tabTitle: String?) -> NSAttributedString { + let paragraph = NSMutableParagraphStyle() + paragraph.lineBreakMode = .byWordWrapping + return NSAttributedString( + string: menuTitle(notification: notification, tabTitle: tabTitle), + attributes: [ + .font: NSFont.menuFont(ofSize: NSFont.systemFontSize), + .foregroundColor: NSColor.labelColor, + .paragraphStyle: paragraph, + ] + ) + } + + static func tooltip(notification: TerminalNotification, tabTitle: String?) -> String { + plainTitle(notification: notification, tabTitle: tabTitle) + } + + private static func wrappedAndTruncated(_ text: String, maxWidth: CGFloat, maxLines: Int) -> String { + let width = max(60, maxWidth) + let lines = max(1, maxLines) + let font = NSFont.menuFont(ofSize: NSFont.systemFontSize) + let wrapped = wrappedLines(for: text, maxWidth: width, font: font) + guard wrapped.count > lines else { return wrapped.joined(separator: "\n") } + + var clipped = Array(wrapped.prefix(lines)) + clipped[lines - 1] = truncateLine(clipped[lines - 1], maxWidth: width, font: font) + return clipped.joined(separator: "\n") + } + + private static func wrappedLines(for text: String, maxWidth: CGFloat, font: NSFont) -> [String] { + let storage = NSTextStorage(string: text, attributes: [.font: font]) + let layout = NSLayoutManager() + let container = NSTextContainer(size: NSSize(width: maxWidth, height: .greatestFiniteMagnitude)) + container.lineFragmentPadding = 0 + container.lineBreakMode = .byWordWrapping + layout.addTextContainer(container) + storage.addLayoutManager(layout) + _ = layout.glyphRange(for: container) + + let fullText = text as NSString + var rows: [String] = [] + var glyphIndex = 0 + while glyphIndex < layout.numberOfGlyphs { + var glyphRange = NSRange() + layout.lineFragmentRect(forGlyphAt: glyphIndex, effectiveRange: &glyphRange) + if glyphRange.length == 0 { break } + + let charRange = layout.characterRange(forGlyphRange: glyphRange, actualGlyphRange: nil) + let row = fullText.substring(with: charRange).trimmingCharacters(in: .newlines) + rows.append(row) + glyphIndex = NSMaxRange(glyphRange) + } + + if rows.isEmpty { + return [text] + } + return rows + } + + private static func truncateLine(_ line: String, maxWidth: CGFloat, font: NSFont) -> String { + let ellipsis = "…" + let full = line.trimmingCharacters(in: .whitespacesAndNewlines) + if full.isEmpty { return ellipsis } + + if measuredWidth(full + ellipsis, font: font) <= maxWidth { + return full + ellipsis + } + + var chars = Array(full) + while !chars.isEmpty { + chars.removeLast() + let candidateBase = String(chars).trimmingCharacters(in: .whitespacesAndNewlines) + let candidate = (candidateBase.isEmpty ? "" : candidateBase) + ellipsis + if measuredWidth(candidate, font: font) <= maxWidth { + return candidate + } + } + return ellipsis + } + + private static func measuredWidth(_ text: String, font: NSFont) -> CGFloat { + (text as NSString).size(withAttributes: [.font: font]).width + } +} + +enum MenuBarBuildHintFormatter { + static func menuTitle( + appName: String = defaultAppName(), + isDebugBuild: Bool = _isDebugAssertConfiguration() + ) -> String? { + guard isDebugBuild else { return nil } + let normalized = appName.trimmingCharacters(in: .whitespacesAndNewlines) + let prefix = "cmux DEV" + guard normalized.hasPrefix(prefix) else { return "Build: DEV" } + + let suffix = String(normalized.dropFirst(prefix.count)).trimmingCharacters(in: .whitespacesAndNewlines) + if suffix.isEmpty { + return "Build: DEV (untagged)" + } + return "Build Tag: \(suffix)" + } + + private static func defaultAppName() -> String { + let bundle = Bundle.main + if let displayName = bundle.object(forInfoDictionaryKey: "CFBundleDisplayName") as? String, + !displayName.isEmpty { + return displayName + } + if let name = bundle.object(forInfoDictionaryKey: "CFBundleName") as? String, !name.isEmpty { + return name + } + return ProcessInfo.processInfo.processName + } +} + +enum MenuBarExtraSettings { + static let showInMenuBarKey = "showMenuBarExtra" + static let defaultShowInMenuBar = true + + static func showsMenuBarExtra(defaults: UserDefaults = .standard) -> Bool { + if defaults.object(forKey: showInMenuBarKey) == nil { + return defaultShowInMenuBar + } + return defaults.bool(forKey: showInMenuBarKey) + } +} + +struct MenuBarBadgeRenderConfig { + var badgeRect: NSRect + var singleDigitFontSize: CGFloat + var multiDigitFontSize: CGFloat + var singleDigitYOffset: CGFloat + var multiDigitYOffset: CGFloat + var singleDigitXAdjust: CGFloat + var multiDigitXAdjust: CGFloat + var textRectWidthAdjust: CGFloat +} + +enum MenuBarIconDebugSettings { + static let previewEnabledKey = "menubarDebugPreviewEnabled" + static let previewCountKey = "menubarDebugPreviewCount" + static let badgeRectXKey = "menubarDebugBadgeRectX" + static let badgeRectYKey = "menubarDebugBadgeRectY" + static let badgeRectWidthKey = "menubarDebugBadgeRectWidth" + static let badgeRectHeightKey = "menubarDebugBadgeRectHeight" + static let singleDigitFontSizeKey = "menubarDebugSingleDigitFontSize" + static let multiDigitFontSizeKey = "menubarDebugMultiDigitFontSize" + static let singleDigitYOffsetKey = "menubarDebugSingleDigitYOffset" + static let multiDigitYOffsetKey = "menubarDebugMultiDigitYOffset" + static let singleDigitXAdjustKey = "menubarDebugSingleDigitXAdjust" + static let legacySingleDigitXAdjustKey = "menubarDebugTextRectXAdjust" + static let multiDigitXAdjustKey = "menubarDebugMultiDigitXAdjust" + static let textRectWidthAdjustKey = "menubarDebugTextRectWidthAdjust" + + static let defaultBadgeRect = NSRect(x: 5.38, y: 6.43, width: 10.75, height: 11.58) + static let defaultSingleDigitFontSize: CGFloat = 6.7 + static let defaultMultiDigitFontSize: CGFloat = 6.7 + static let defaultSingleDigitYOffset: CGFloat = 0.6 + static let defaultMultiDigitYOffset: CGFloat = 0.6 + static let defaultSingleDigitXAdjust: CGFloat = -1.1 + static let defaultMultiDigitXAdjust: CGFloat = 2.42 + static let defaultTextRectWidthAdjust: CGFloat = 1.8 + + static func displayedUnreadCount(actualUnreadCount: Int, defaults: UserDefaults = .standard) -> Int { + guard defaults.bool(forKey: previewEnabledKey) else { return actualUnreadCount } + let value = defaults.integer(forKey: previewCountKey) + return max(0, min(value, 99)) + } + + static func badgeRenderConfig(defaults: UserDefaults = .standard) -> MenuBarBadgeRenderConfig { + let x = value(defaults, key: badgeRectXKey, fallback: defaultBadgeRect.origin.x, range: 0...20) + let y = value(defaults, key: badgeRectYKey, fallback: defaultBadgeRect.origin.y, range: 0...20) + let width = value(defaults, key: badgeRectWidthKey, fallback: defaultBadgeRect.width, range: 4...14) + let height = value(defaults, key: badgeRectHeightKey, fallback: defaultBadgeRect.height, range: 4...14) + let singleFont = value(defaults, key: singleDigitFontSizeKey, fallback: defaultSingleDigitFontSize, range: 6...14) + let multiFont = value(defaults, key: multiDigitFontSizeKey, fallback: defaultMultiDigitFontSize, range: 6...14) + let singleY = value(defaults, key: singleDigitYOffsetKey, fallback: defaultSingleDigitYOffset, range: -3...4) + let multiY = value(defaults, key: multiDigitYOffsetKey, fallback: defaultMultiDigitYOffset, range: -3...4) + let singleX = value( + defaults, + key: singleDigitXAdjustKey, + legacyKey: legacySingleDigitXAdjustKey, + fallback: defaultSingleDigitXAdjust, + range: -4...4 + ) + let multiX = value(defaults, key: multiDigitXAdjustKey, fallback: defaultMultiDigitXAdjust, range: -4...4) + let widthAdjust = value(defaults, key: textRectWidthAdjustKey, fallback: defaultTextRectWidthAdjust, range: -3...5) + + return MenuBarBadgeRenderConfig( + badgeRect: NSRect(x: x, y: y, width: width, height: height), + singleDigitFontSize: singleFont, + multiDigitFontSize: multiFont, + singleDigitYOffset: singleY, + multiDigitYOffset: multiY, + singleDigitXAdjust: singleX, + multiDigitXAdjust: multiX, + textRectWidthAdjust: widthAdjust + ) + } + + static func copyPayload(defaults: UserDefaults = .standard) -> String { + let config = badgeRenderConfig(defaults: defaults) + let previewEnabled = defaults.bool(forKey: previewEnabledKey) + let previewCount = max(0, min(defaults.integer(forKey: previewCountKey), 99)) + return """ + menubarDebugPreviewEnabled=\(previewEnabled) + menubarDebugPreviewCount=\(previewCount) + menubarDebugBadgeRectX=\(String(format: "%.2f", config.badgeRect.origin.x)) + menubarDebugBadgeRectY=\(String(format: "%.2f", config.badgeRect.origin.y)) + menubarDebugBadgeRectWidth=\(String(format: "%.2f", config.badgeRect.width)) + menubarDebugBadgeRectHeight=\(String(format: "%.2f", config.badgeRect.height)) + menubarDebugSingleDigitFontSize=\(String(format: "%.2f", config.singleDigitFontSize)) + menubarDebugMultiDigitFontSize=\(String(format: "%.2f", config.multiDigitFontSize)) + menubarDebugSingleDigitYOffset=\(String(format: "%.2f", config.singleDigitYOffset)) + menubarDebugMultiDigitYOffset=\(String(format: "%.2f", config.multiDigitYOffset)) + menubarDebugSingleDigitXAdjust=\(String(format: "%.2f", config.singleDigitXAdjust)) + menubarDebugMultiDigitXAdjust=\(String(format: "%.2f", config.multiDigitXAdjust)) + menubarDebugTextRectWidthAdjust=\(String(format: "%.2f", config.textRectWidthAdjust)) + """ + } + + private static func value( + _ defaults: UserDefaults, + key: String, + legacyKey: String? = nil, + fallback: CGFloat, + range: ClosedRange + ) -> CGFloat { + if let parsed = parse(defaults.object(forKey: key), fallback: fallback, range: range) { + return parsed + } + if let legacyKey, let parsed = parse(defaults.object(forKey: legacyKey), fallback: fallback, range: range) { + return parsed + } + return fallback + } + + private static func parse( + _ object: Any?, + fallback: CGFloat, + range: ClosedRange + ) -> CGFloat? { + guard let number = object as? NSNumber else { + return nil + } + let candidate = CGFloat(number.doubleValue) + guard candidate.isFinite else { return fallback } + return max(range.lowerBound, min(candidate, range.upperBound)) + } +} + +enum MenuBarIconRenderer { + + static func makeImage(unreadCount: Int) -> NSImage { + let badgeText = MenuBarBadgeLabelFormatter.badgeText(for: unreadCount) + let config = MenuBarIconDebugSettings.badgeRenderConfig() + let size = NSSize(width: 18, height: 18) + let image = NSImage(size: size) + image.lockFocus() + defer { image.unlockFocus() } + + let glyphRect = NSRect(x: 1.2, y: 1.5, width: 11.6, height: 15.0) + drawGlyph(in: glyphRect) + + if let text = badgeText { + drawBadge(text: text, in: config.badgeRect, config: config) + } + + image.isTemplate = true + return image + } + + private static func drawGlyph(in rect: NSRect) { + // Match the canonical cmux center-mark path from Icon Center Image Artwork.svg. + let srcMinX: CGFloat = 384.0 + let srcMinY: CGFloat = 255.0 + let srcWidth: CGFloat = 369.0 + let srcHeight: CGFloat = 513.0 + + func map(_ x: CGFloat, _ y: CGFloat) -> NSPoint { + let nx = (x - srcMinX) / srcWidth + let ny = (y - srcMinY) / srcHeight + return NSPoint( + x: rect.minX + nx * rect.width, + y: rect.minY + (1.0 - ny) * rect.height + ) + } + + let path = NSBezierPath() + path.move(to: map(384.0, 255.0)) + path.line(to: map(753.0, 511.5)) + path.line(to: map(384.0, 768.0)) + path.line(to: map(384.0, 654.0)) + path.line(to: map(582.692, 511.5)) + path.line(to: map(384.0, 369.0)) + path.close() + + NSColor.black.setFill() + path.fill() + } + + private static func drawBadge(text: String, in rect: NSRect, config: MenuBarBadgeRenderConfig) { + let paragraph = NSMutableParagraphStyle() + paragraph.alignment = .center + let fontSize: CGFloat = text.count > 1 ? config.multiDigitFontSize : config.singleDigitFontSize + let attrs: [NSAttributedString.Key: Any] = [ + .font: NSFont.systemFont(ofSize: fontSize, weight: .bold), + .foregroundColor: NSColor.systemBlue, + .paragraphStyle: paragraph, + ] + let yOffset: CGFloat = text.count > 1 ? config.multiDigitYOffset : config.singleDigitYOffset + let xAdjust: CGFloat = text.count > 1 ? config.multiDigitXAdjust : config.singleDigitXAdjust + let textRect = NSRect( + x: rect.origin.x + xAdjust, + y: rect.origin.y + yOffset, + width: rect.width + config.textRectWidthAdjust, + height: rect.height + ) + (text as NSString).draw(in: textRect, withAttributes: attrs) + } +} diff --git a/Sources/TerminalDirectoryOpener.swift b/Sources/TerminalDirectoryOpener.swift new file mode 100644 index 00000000000..f40f4ef0e85 --- /dev/null +++ b/Sources/TerminalDirectoryOpener.swift @@ -0,0 +1,297 @@ +import AppKit +import SwiftUI +import Bonsplit +import CoreServices +import UserNotifications +import WebKit +import Combine +import ObjectiveC.runtime +import Darwin + +enum FinderServicePathResolver { + private static func canonicalDirectoryPath(_ path: String) -> String { + guard path.count > 1 else { return path } + var canonical = path + while canonical.count > 1 && canonical.hasSuffix("/") { + canonical.removeLast() + } + return canonical + } + + private static func normalizedComparisonURL(_ url: URL) -> URL { + url.standardizedFileURL.resolvingSymlinksInPath() + } + + private static func isSameOrDescendant(_ url: URL, of rootURL: URL) -> Bool { + let urlPathComponents = normalizedComparisonURL(url).pathComponents + let rootPathComponents = normalizedComparisonURL(rootURL).pathComponents + guard urlPathComponents.count >= rootPathComponents.count else { return false } + return Array(urlPathComponents.prefix(rootPathComponents.count)) == rootPathComponents + } + + private static func resolvedDirectoryURL(from url: URL) -> URL { + let standardized = url.standardizedFileURL + if standardized.hasDirectoryPath { + return standardized + } + if let resourceValues = try? standardized.resourceValues(forKeys: [.isDirectoryKey]), + resourceValues.isDirectory == true { + return standardized + } + return standardized.deletingLastPathComponent() + } + + static func orderedUniqueDirectories( + from pathURLs: [URL], + excludingDescendantsOf excludedRootURLs: [URL] = [] + ) -> [String] { + var seen: Set = [] + var directories: [String] = [] + + for url in pathURLs { + let directoryURL = resolvedDirectoryURL(from: url) + guard !excludedRootURLs.contains(where: { isSameOrDescendant(directoryURL, of: $0) }) else { + continue + } + let path = canonicalDirectoryPath(directoryURL.path(percentEncoded: false)) + guard !path.isEmpty else { continue } + if seen.insert(path).inserted { + directories.append(path) + } + } + + return directories + } +} + +enum TerminalDirectoryOpenTarget: String, CaseIterable { + case androidStudio + case antigravity + case cursor + case finder + case ghostty + case intellij + case iterm2 + case terminal + case tower + case vscode + case vscodeInline + case warp + case windsurf + case xcode + case zed + + struct DetectionEnvironment { + let homeDirectoryPath: String + let fileExistsAtPath: (String) -> Bool + let isExecutableFileAtPath: (String) -> Bool + let applicationPathForName: (String) -> String? + + static let live = DetectionEnvironment( + homeDirectoryPath: FileManager.default.homeDirectoryForCurrentUser.path, + fileExistsAtPath: { FileManager.default.fileExists(atPath: $0) }, + isExecutableFileAtPath: { FileManager.default.isExecutableFile(atPath: $0) }, + applicationPathForName: { NSWorkspace.shared.fullPath(forApplication: $0) } + ) + } + + static var commandPaletteShortcutTargets: [Self] { + Array(allCases) + } + + static func availableTargets(in environment: DetectionEnvironment = .live) -> Set { + Set(commandPaletteShortcutTargets.filter { $0.isAvailable(in: environment) }) + } + + var commandPaletteCommandId: String { + "palette.terminalOpenDirectory.\(rawValue)" + } + + var commandPaletteTitle: String { + switch self { + case .androidStudio: + return String(localized: "menu.openInAndroidStudio", defaultValue: "Open Current Directory in Android Studio") + case .antigravity: + return String(localized: "menu.openInAntigravity", defaultValue: "Open Current Directory in Antigravity") + case .cursor: + return String(localized: "menu.openInCursor", defaultValue: "Open Current Directory in Cursor") + case .finder: + return String(localized: "menu.openInFinder", defaultValue: "Open Current Directory in Finder") + case .ghostty: + return String(localized: "menu.openInGhostty", defaultValue: "Open Current Directory in Ghostty") + case .intellij: + return String(localized: "menu.openInIntelliJ", defaultValue: "Open Current Directory in IntelliJ IDEA") + case .iterm2: + return String(localized: "menu.openInITerm2", defaultValue: "Open Current Directory in iTerm2") + case .terminal: + return String(localized: "menu.openInTerminal", defaultValue: "Open Current Directory in Terminal") + case .tower: + return String(localized: "menu.openInTower", defaultValue: "Open Current Directory in Tower") + case .vscode: + return String(localized: "menu.openInVSCodeDesktop", defaultValue: "Open Current Directory in VS Code") + case .vscodeInline: + return String(localized: "menu.openInVSCode", defaultValue: "Open Current Directory in VS Code (Inline)") + case .warp: + return String(localized: "menu.openInWarp", defaultValue: "Open Current Directory in Warp") + case .windsurf: + return String(localized: "menu.openInWindsurf", defaultValue: "Open Current Directory in Windsurf") + case .xcode: + return String(localized: "menu.openInXcode", defaultValue: "Open Current Directory in Xcode") + case .zed: + return String(localized: "menu.openInZed", defaultValue: "Open Current Directory in Zed") + } + } + + var commandPaletteKeywords: [String] { + let common = ["terminal", "directory", "open", "ide"] + switch self { + case .androidStudio: + return common + ["android", "studio"] + case .antigravity: + return common + ["antigravity"] + case .cursor: + return common + ["cursor"] + case .finder: + return common + ["finder", "file", "manager", "reveal"] + case .ghostty: + return common + ["ghostty", "terminal", "shell"] + case .intellij: + return common + ["intellij", "idea", "jetbrains"] + case .iterm2: + return common + ["iterm", "iterm2", "terminal", "shell"] + case .terminal: + return common + ["terminal", "shell"] + case .tower: + return common + ["tower", "git", "client"] + case .vscode: + return common + ["vs", "code", "visual", "studio", "desktop", "app"] + case .vscodeInline: + return common + ["vs", "code", "visual", "studio", "inline", "browser", "serve-web"] + case .warp: + return common + ["warp", "terminal", "shell"] + case .windsurf: + return common + ["windsurf"] + case .xcode: + return common + ["xcode", "apple"] + case .zed: + return common + ["zed"] + } + } + + func isAvailable(in environment: DetectionEnvironment = .live) -> Bool { + guard let applicationPath = applicationPath(in: environment) else { return false } + guard self == .vscodeInline else { return true } + return VSCodeCLILaunchConfigurationBuilder.launchConfiguration( + vscodeApplicationURL: URL(fileURLWithPath: applicationPath, isDirectory: true), + isExecutableAtPath: environment.isExecutableFileAtPath + ) != nil + } + + func applicationURL(in environment: DetectionEnvironment = .live) -> URL? { + guard let path = applicationPath(in: environment) else { return nil } + return URL(fileURLWithPath: path, isDirectory: true) + } + + private func applicationPath(in environment: DetectionEnvironment) -> String? { + for path in expandedCandidatePaths(in: environment) where environment.fileExistsAtPath(path) { + return path + } + + // Fall back to LaunchServices so apps outside the standard bundle paths + // still appear in the command palette. + for applicationName in applicationSearchNames { + guard let resolvedPath = environment.applicationPathForName(applicationName), + environment.fileExistsAtPath(resolvedPath) else { + continue + } + return resolvedPath + } + + return nil + } + + private func expandedCandidatePaths(in environment: DetectionEnvironment) -> [String] { + let globalPrefix = "/Applications/" + let userPrefix = "\(environment.homeDirectoryPath)/Applications/" + var expanded: [String] = [] + + for candidate in applicationBundlePathCandidates { + expanded.append(candidate) + if candidate.hasPrefix(globalPrefix) { + let suffix = String(candidate.dropFirst(globalPrefix.count)) + expanded.append(userPrefix + suffix) + } + } + + return uniquePreservingOrder(expanded) + } + + private var applicationSearchNames: [String] { + uniquePreservingOrder( + applicationBundlePathCandidates.map { + URL(fileURLWithPath: $0).deletingPathExtension().lastPathComponent + } + ) + } + + private var applicationBundlePathCandidates: [String] { + switch self { + case .androidStudio: + return ["/Applications/Android Studio.app"] + case .antigravity: + return ["/Applications/Antigravity.app"] + case .cursor: + return [ + "/Applications/Cursor.app", + "/Applications/Cursor Preview.app", + "/Applications/Cursor Nightly.app", + ] + case .finder: + return ["/System/Library/CoreServices/Finder.app"] + case .ghostty: + return ["/Applications/Ghostty.app"] + case .intellij: + return ["/Applications/IntelliJ IDEA.app"] + case .iterm2: + return [ + "/Applications/iTerm.app", + "/Applications/iTerm2.app", + ] + case .terminal: + return ["/System/Applications/Utilities/Terminal.app"] + case .tower: + return ["/Applications/Tower.app"] + case .vscode: + return [ + "/Applications/Visual Studio Code.app", + "/Applications/Code.app", + ] + case .vscodeInline: + return [ + "/Applications/Visual Studio Code.app", + "/Applications/Code.app", + ] + case .warp: + return ["/Applications/Warp.app"] + case .windsurf: + return ["/Applications/Windsurf.app"] + case .xcode: + return ["/Applications/Xcode.app"] + case .zed: + return [ + "/Applications/Zed.app", + "/Applications/Zed Preview.app", + "/Applications/Zed Nightly.app", + ] + } + } + + private func uniquePreservingOrder(_ paths: [String]) -> [String] { + var seen: Set = [] + var deduped: [String] = [] + for path in paths where seen.insert(path).inserted { + deduped.append(path) + } + return deduped + } +} diff --git a/Sources/TypingProfiler.swift b/Sources/TypingProfiler.swift new file mode 100644 index 00000000000..a6406fa107c --- /dev/null +++ b/Sources/TypingProfiler.swift @@ -0,0 +1,354 @@ +import AppKit +import SwiftUI +import Bonsplit +import CoreServices +import UserNotifications +import WebKit +import Combine +import ObjectiveC.runtime +import Darwin + +#if DEBUG +enum ProgramaTypingTiming { + static let isEnabled: Bool = { + let environment = ProcessInfo.processInfo.environment + if environment["PROGRAMA_TYPING_TIMING_LOGS"] == "1" || environment["PROGRAMA_KEY_LATENCY_PROBE"] == "1" { + return true + } + let defaults = UserDefaults.standard + return defaults.bool(forKey: "programaTypingTimingLogs") || defaults.bool(forKey: "programaKeyLatencyProbe") + }() + static let isVerboseProbeEnabled: Bool = { + let environment = ProcessInfo.processInfo.environment + if environment["PROGRAMA_KEY_LATENCY_PROBE"] == "1" { + return true + } + return UserDefaults.standard.bool(forKey: "programaKeyLatencyProbe") + }() + private static let delayLogThresholdMs: Double = 6.0 + private static let durationLogThresholdMs: Double = 1.0 + + @inline(__always) + static func start() -> TimeInterval? { + guard isEnabled else { return nil } + return ProcessInfo.processInfo.systemUptime + } + + @inline(__always) + static func logEventDelay(path: String, event: NSEvent) { + guard isEnabled else { return } + guard event.timestamp > 0 else { return } + let delayMs = max(0, (ProcessInfo.processInfo.systemUptime - event.timestamp) * 1000.0) + guard shouldLog(delayMs: delayMs, elapsedMs: nil) else { return } + dlog("typing.delay path=\(path) delayMs=\(format(delayMs)) \(eventFields(event))") + } + + @inline(__always) + static func logDuration(path: String, startedAt: TimeInterval?, event: NSEvent? = nil, extra: String? = nil) { + ProgramaMainThreadTurnProfiler.endMeasure(path, startedAt: startedAt) + guard let startedAt else { return } + let elapsedMs = max(0, (ProcessInfo.processInfo.systemUptime - startedAt) * 1000.0) + let delayMs: Double? = { + guard let event, event.timestamp > 0 else { return nil } + return max(0, (ProcessInfo.processInfo.systemUptime - event.timestamp) * 1000.0) + }() + guard shouldLog(delayMs: delayMs, elapsedMs: elapsedMs) else { return } + var line = "typing.timing path=\(path) elapsedMs=\(format(elapsedMs))" + if let event { + line += " \(eventFields(event))" + if let delayMs { + line += " delayMs=\(format(delayMs))" + } + } + if let extra, !extra.isEmpty { + line += " \(extra)" + } + dlog(line) + } + + @inline(__always) + static func logBreakdown( + path: String, + totalMs: Double, + event: NSEvent? = nil, + thresholdMs: Double = 2.0, + parts: [(String, Double)], + extra: String? = nil + ) { + guard isEnabled else { return } + let delayMs: Double? = { + guard let event, event.timestamp > 0 else { return nil } + return max(0, (ProcessInfo.processInfo.systemUptime - event.timestamp) * 1000.0) + }() + let hasSlowPart = parts.contains { $0.1 >= thresholdMs } + guard isVerboseProbeEnabled || totalMs >= thresholdMs || hasSlowPart || (delayMs ?? 0) >= delayLogThresholdMs else { + return + } + var line = "typing.phase path=\(path) totalMs=\(format(totalMs))" + if let event { + line += " \(eventFields(event))" + } + if let delayMs { + line += " delayMs=\(format(delayMs))" + } + for (name, value) in parts where isVerboseProbeEnabled || value >= 0.05 { + line += " \(name)=\(format(value))" + } + if let extra, !extra.isEmpty { + line += " \(extra)" + } + dlog(line) + } + + @inline(__always) + private static func eventFields(_ event: NSEvent) -> String { + "eventType=\(event.type.rawValue) keyCode=\(event.keyCode) mods=\(event.modifierFlags.rawValue) repeat=\(event.isARepeat ? 1 : 0)" + } + + @inline(__always) + private static func shouldLog(delayMs: Double?, elapsedMs: Double?) -> Bool { + if isVerboseProbeEnabled { + return true + } + if let delayMs, delayMs >= delayLogThresholdMs { + return true + } + if let elapsedMs, elapsedMs >= durationLogThresholdMs { + return true + } + return false + } + + @inline(__always) + private static func format(_ value: Double) -> String { + String(format: "%.2f", value) + } +} + +final class ProgramaMainRunLoopStallMonitor { + static let shared = ProgramaMainRunLoopStallMonitor() + + private let thresholdMs: Double = 8.0 + private var observer: CFRunLoopObserver? + private var installed = false + private var lastActivity: CFRunLoopActivity? + private var lastTimestamp: TimeInterval? + + private init() {} + + func installIfNeeded() { + guard ProgramaTypingTiming.isEnabled else { return } + guard !installed else { return } + + var context = CFRunLoopObserverContext( + version: 0, + info: Unmanaged.passUnretained(self).toOpaque(), + retain: nil, + release: nil, + copyDescription: nil + ) + + observer = CFRunLoopObserverCreate( + kCFAllocatorDefault, + CFRunLoopActivity.allActivities.rawValue, + true, + CFIndex.max, + { _, activity, info in + guard let info else { return } + let monitor = Unmanaged.fromOpaque(info).takeUnretainedValue() + monitor.handle(activity: activity) + }, + &context + ) + + guard let observer else { return } + CFRunLoopAddObserver(CFRunLoopGetMain(), observer, .commonModes) + installed = true + } + + private func handle(activity: CFRunLoopActivity) { + let now = ProcessInfo.processInfo.systemUptime + defer { + lastActivity = activity + lastTimestamp = now + } + + guard let lastActivity, let lastTimestamp else { return } + let elapsedMs = max(0, (now - lastTimestamp) * 1000.0) + guard elapsedMs >= thresholdMs else { return } + if lastActivity == .beforeWaiting && activity == .afterWaiting { + return + } + + let mode = CFRunLoopCopyCurrentMode(CFRunLoopGetMain()).map { String(describing: $0) } ?? "nil" + let firstResponder = NSApp.keyWindow?.firstResponder.map { String(describing: type(of: $0)) } ?? "nil" + let currentEvent = NSApp.currentEvent.map { + "eventType=\($0.type.rawValue) keyCode=\($0.keyCode) mods=\($0.modifierFlags.rawValue)" + } ?? "event=nil" + dlog( + "runloop.stall gapMs=\(String(format: "%.2f", elapsedMs)) prev=\(label(for: lastActivity)) " + + "next=\(label(for: activity)) mode=\(mode) firstResponder=\(firstResponder) \(currentEvent)" + ) + } + + private func label(for activity: CFRunLoopActivity) -> String { + switch activity { + case .entry: + return "entry" + case .beforeTimers: + return "beforeTimers" + case .beforeSources: + return "beforeSources" + case .beforeWaiting: + return "beforeWaiting" + case .afterWaiting: + return "afterWaiting" + case .exit: + return "exit" + default: + return "unknown(\(activity.rawValue))" + } + } +} + +final class ProgramaMainThreadTurnProfiler { + static let shared = ProgramaMainThreadTurnProfiler() + + private struct BucketStats { + var count: Int = 0 + var totalMs: Double = 0 + var maxMs: Double = 0 + } + + private let trackedThresholdMs: Double = 3.0 + private let countThreshold: Int = 16 + private var observer: CFRunLoopObserver? + private var installed = false + private var turnStart: TimeInterval? + private var buckets: [String: BucketStats] = [:] + + private init() {} + + @inline(__always) + static func endMeasure(_ bucket: String, startedAt: TimeInterval?) { + guard let startedAt, ProgramaTypingTiming.isEnabled, Thread.isMainThread else { return } + let elapsedMs = max(0, (ProcessInfo.processInfo.systemUptime - startedAt) * 1000.0) + shared.record(bucket: bucket, elapsedMs: elapsedMs, count: 1) + } + + func installIfNeeded() { + guard ProgramaTypingTiming.isEnabled else { return } + guard !installed else { return } + + var context = CFRunLoopObserverContext( + version: 0, + info: Unmanaged.passUnretained(self).toOpaque(), + retain: nil, + release: nil, + copyDescription: nil + ) + + observer = CFRunLoopObserverCreate( + kCFAllocatorDefault, + CFRunLoopActivity.allActivities.rawValue, + true, + CFIndex.max, + { _, activity, info in + guard let info else { return } + let profiler = Unmanaged.fromOpaque(info).takeUnretainedValue() + profiler.handle(activity: activity) + }, + &context + ) + + guard let observer else { return } + CFRunLoopAddObserver(CFRunLoopGetMain(), observer, .commonModes) + installed = true + } + + private func handle(activity: CFRunLoopActivity) { + let now = ProcessInfo.processInfo.systemUptime + switch activity { + case .entry, .afterWaiting: + turnStart = now + buckets.removeAll(keepingCapacity: true) + case .beforeWaiting, .exit: + flushTurn(at: now, nextActivity: activity) + default: + break + } + } + + private func record(bucket: String, elapsedMs: Double, count: Int) { + if turnStart == nil { + turnStart = ProcessInfo.processInfo.systemUptime + } + var stats = buckets[bucket, default: BucketStats()] + stats.count += count + stats.totalMs += elapsedMs + stats.maxMs = max(stats.maxMs, elapsedMs) + buckets[bucket] = stats + } + + private func flushTurn(at now: TimeInterval, nextActivity: CFRunLoopActivity) { + defer { + turnStart = nil + buckets.removeAll(keepingCapacity: true) + } + + guard let turnStart else { return } + guard !buckets.isEmpty else { return } + + let turnMs = max(0, (now - turnStart) * 1000.0) + let trackedMs = buckets.values.reduce(0) { $0 + $1.totalMs } + let totalCount = buckets.values.reduce(0) { $0 + $1.count } + guard trackedMs >= trackedThresholdMs || totalCount >= countThreshold else { return } + + let mode = CFRunLoopCopyCurrentMode(CFRunLoopGetMain()).map { String(describing: $0) } ?? "nil" + let firstResponder = NSApp.keyWindow?.firstResponder.map { String(describing: type(of: $0)) } ?? "nil" + let eventSummary = NSApp.currentEvent.map { + "eventType=\($0.type.rawValue) keyCode=\($0.keyCode) mods=\($0.modifierFlags.rawValue)" + } ?? "event=nil" + let bucketSummary = buckets + .sorted { + if abs($0.value.totalMs - $1.value.totalMs) > 0.01 { + return $0.value.totalMs > $1.value.totalMs + } + return $0.value.count > $1.value.count + } + .prefix(8) + .map { key, value in + if value.totalMs > 0.05 || value.maxMs > 0.05 { + return "\(key)=\(value.count)/\(String(format: "%.2f", value.totalMs))/\(String(format: "%.2f", value.maxMs))" + } + return "\(key)=\(value.count)" + } + .joined(separator: " ") + + dlog( + "main.turn.work turnMs=\(String(format: "%.2f", turnMs)) trackedMs=\(String(format: "%.2f", trackedMs)) totalCount=\(totalCount) " + + "next=\(label(for: nextActivity)) mode=\(mode) firstResponder=\(firstResponder) \(eventSummary) " + + "\(bucketSummary)" + ) + } + + private func label(for activity: CFRunLoopActivity) -> String { + switch activity { + case .entry: + return "entry" + case .beforeTimers: + return "beforeTimers" + case .beforeSources: + return "beforeSources" + case .beforeWaiting: + return "beforeWaiting" + case .afterWaiting: + return "afterWaiting" + case .exit: + return "exit" + default: + return "unknown(\(activity.rawValue))" + } + } +} +#endif diff --git a/Sources/VSCodeIntegration.swift b/Sources/VSCodeIntegration.swift new file mode 100644 index 00000000000..0b5bb6cdc7a --- /dev/null +++ b/Sources/VSCodeIntegration.swift @@ -0,0 +1,593 @@ +import AppKit +import SwiftUI +import Bonsplit +import CoreServices +import UserNotifications +import WebKit +import Combine +import ObjectiveC.runtime +import Darwin + +enum VSCodeServeWebURLBuilder { + static func extractWebUIURL(from output: String) -> URL? { + let prefix = "Web UI available at " + for line in output.split(whereSeparator: \.isNewline).reversed() { + guard let range = line.range(of: prefix) else { continue } + let rawURL = line[range.upperBound...].trimmingCharacters(in: .whitespacesAndNewlines) + guard !rawURL.isEmpty, let url = URL(string: rawURL) else { continue } + return url + } + return nil + } + + static func openFolderURL(baseWebUIURL: URL, directoryPath: String) -> URL? { + var components = URLComponents(url: baseWebUIURL, resolvingAgainstBaseURL: false) + var queryItems = components?.queryItems ?? [] + queryItems.removeAll { $0.name == "folder" } + queryItems.append(URLQueryItem(name: "folder", value: directoryPath)) + components?.queryItems = queryItems + return components?.url + } +} + +struct VSCodeCLILaunchConfiguration { + let executableURL: URL + let argumentsPrefix: [String] + let environment: [String: String] +} + +enum VSCodeCLILaunchConfigurationBuilder { + static func launchConfiguration( + vscodeApplicationURL: URL, + baseEnvironment: [String: String] = ProcessInfo.processInfo.environment, + isExecutableAtPath: (String) -> Bool = { FileManager.default.isExecutableFile(atPath: $0) } + ) -> VSCodeCLILaunchConfiguration? { + let contentsURL = vscodeApplicationURL.appendingPathComponent("Contents", isDirectory: true) + let codeTunnelURL = contentsURL.appendingPathComponent("Resources/app/bin/code-tunnel", isDirectory: false) + guard isExecutableAtPath(codeTunnelURL.path) else { return nil } + + var environment = baseEnvironment + environment["ELECTRON_RUN_AS_NODE"] = "1" + environment.removeValue(forKey: "VSCODE_NODE_OPTIONS") + environment.removeValue(forKey: "VSCODE_NODE_REPL_EXTERNAL_MODULE") + if let nodeOptions = environment["NODE_OPTIONS"] { + environment["VSCODE_NODE_OPTIONS"] = nodeOptions + } + if let nodeReplExternalModule = environment["NODE_REPL_EXTERNAL_MODULE"] { + environment["VSCODE_NODE_REPL_EXTERNAL_MODULE"] = nodeReplExternalModule + } + environment.removeValue(forKey: "NODE_OPTIONS") + environment.removeValue(forKey: "NODE_REPL_EXTERNAL_MODULE") + environment["VSCODE_CLI_USE_FILE_KEYRING"] = "1" + + return VSCodeCLILaunchConfiguration( + executableURL: codeTunnelURL, + argumentsPrefix: [], + environment: environment + ) + } +} + +final class VSCodeServeWebController { + static let shared = VSCodeServeWebController() + private static let serveWebStartupTimeoutSeconds: TimeInterval = 60 + + private let queue = DispatchQueue(label: "programa.vscode.serveWeb") + private let launchQueue = DispatchQueue(label: "programa.vscode.serveWeb.launch") + private let launchProcessOverride: ((URL, UInt64) -> (process: Process, url: URL)?)? + private var serveWebProcess: Process? + private var launchingProcess: Process? + private var connectionTokenFilesByProcessID: [ObjectIdentifier: URL] = [:] + private var serveWebURL: URL? + private var pendingCompletions: [(generation: UInt64, completion: (URL?) -> Void)] = [] + private var isLaunching = false + private var activeLaunchGeneration: UInt64? + private var lifecycleGeneration: UInt64 = 0 +#if DEBUG + private var testingTrackedProcesses: [Process] = [] +#endif + + private init(launchProcessOverride: ((URL, UInt64) -> (process: Process, url: URL)?)? = nil) { + self.launchProcessOverride = launchProcessOverride + } + +#if DEBUG + static func makeForTesting( + launchProcessOverride: @escaping (URL, UInt64) -> (process: Process, url: URL)? + ) -> VSCodeServeWebController { + VSCodeServeWebController(launchProcessOverride: launchProcessOverride) + } + + func trackConnectionTokenFileForTesting( + _ connectionTokenFileURL: URL, + setAsLaunchingProcess: Bool = false, + setAsServeWebProcess: Bool = false + ) { + let process = Process() + queue.sync { + if setAsLaunchingProcess { + self.launchingProcess = process + } + if setAsServeWebProcess { + self.serveWebProcess = process + } + if !setAsLaunchingProcess && !setAsServeWebProcess { + self.testingTrackedProcesses.append(process) + } + self.connectionTokenFilesByProcessID[ObjectIdentifier(process)] = connectionTokenFileURL + } + } +#endif + + func ensureServeWebURL(vscodeApplicationURL: URL, completion: @escaping (URL?) -> Void) { + queue.async { + if let process = self.serveWebProcess, + process.isRunning, + let url = self.serveWebURL { + DispatchQueue.main.async { + completion(url) + } + return + } + + let completionGeneration = self.lifecycleGeneration + self.pendingCompletions.append((generation: completionGeneration, completion: completion)) + guard !self.isLaunching else { return } + + self.isLaunching = true + let launchGeneration = completionGeneration + self.activeLaunchGeneration = launchGeneration + + self.launchQueue.async { + let shouldLaunch = self.queue.sync { + self.lifecycleGeneration == launchGeneration + } + guard shouldLaunch else { + self.queue.async { + guard self.activeLaunchGeneration == launchGeneration else { return } + self.isLaunching = false + self.activeLaunchGeneration = nil + } + return + } + let launchResult = self.launchServeWebProcess( + vscodeApplicationURL: vscodeApplicationURL, + expectedGeneration: launchGeneration + ) + self.queue.async { + guard self.activeLaunchGeneration == launchGeneration else { + if let process = launchResult?.process, process.isRunning { + process.terminate() + } + return + } + self.isLaunching = false + self.activeLaunchGeneration = nil + + guard self.lifecycleGeneration == launchGeneration else { + if let launchedProcess = launchResult?.process, + self.launchingProcess === launchedProcess { + self.launchingProcess = nil + } + if let process = launchResult?.process, process.isRunning { + process.terminate() + } + return + } + + if let launchResult { + self.launchingProcess = nil + self.serveWebProcess = launchResult.process + self.serveWebURL = launchResult.url + } else { + self.launchingProcess = nil + self.serveWebProcess = nil + self.serveWebURL = nil + } + + var completions: [(URL?) -> Void] = [] + var remaining: [(generation: UInt64, completion: (URL?) -> Void)] = [] + for pending in self.pendingCompletions { + if pending.generation == launchGeneration { + completions.append(pending.completion) + } else { + remaining.append(pending) + } + } + self.pendingCompletions = remaining + let resolvedURL = self.serveWebURL + DispatchQueue.main.async { + completions.forEach { $0(resolvedURL) } + } + } + } + } + } + + func stop() { + let (processes, tokenFileURLs, completions): ([Process], [URL], [(URL?) -> Void]) = queue.sync { + self.lifecycleGeneration &+= 1 + self.isLaunching = false + self.activeLaunchGeneration = nil + var processes: [Process] = [] + if let process = self.serveWebProcess { + processes.append(process) + } + if let process = self.launchingProcess, + !processes.contains(where: { $0 === process }) { + processes.append(process) + } + self.serveWebProcess = nil + self.launchingProcess = nil +#if DEBUG + self.testingTrackedProcesses.removeAll() +#endif + var tokenFileURLs = processes.compactMap { + self.connectionTokenFilesByProcessID.removeValue(forKey: ObjectIdentifier($0)) + } + tokenFileURLs.append(contentsOf: self.connectionTokenFilesByProcessID.values) + self.connectionTokenFilesByProcessID.removeAll() + self.serveWebURL = nil + let completions = self.pendingCompletions.map(\.completion) + self.pendingCompletions.removeAll() + return (processes, tokenFileURLs, completions) + } + + for tokenFileURL in tokenFileURLs where tokenFileURL.path.hasPrefix(NSTemporaryDirectory()) { + Self.removeConnectionTokenFile(at: tokenFileURL) + } + + for process in processes where process.isRunning { + process.terminate() + } + + if !completions.isEmpty { + DispatchQueue.main.async { + completions.forEach { $0(nil) } + } + } + } + + func restart(vscodeApplicationURL: URL, completion: @escaping (URL?) -> Void) { + stop() + ensureServeWebURL(vscodeApplicationURL: vscodeApplicationURL, completion: completion) + } + + private func launchServeWebProcess( + vscodeApplicationURL: URL, + expectedGeneration: UInt64 + ) -> (process: Process, url: URL)? { + if let launchProcessOverride { + return launchProcessOverride(vscodeApplicationURL, expectedGeneration) + } + + guard let launchConfiguration = VSCodeCLILaunchConfigurationBuilder.launchConfiguration( + vscodeApplicationURL: vscodeApplicationURL + ) else { return nil } + + guard let connectionTokenFileURL = Self.makeConnectionTokenFile() else { + return nil + } + + let process = Process() + process.executableURL = launchConfiguration.executableURL + // #21: reuse the port VS Code assigned on a previous run so the embedded browser + // keeps the same URL across restarts. ServeWebPortStore returns the persisted port + // only when it is still bindable, otherwise "0" (OS-assigned) — so a now-occupied + // port falls back gracefully instead of failing the launch. The --server-data-dir + // and persistent connection-token already fix Settings Sync / OAuth auth. + process.arguments = launchConfiguration.argumentsPrefix + [ + "serve-web", + "--accept-server-license-terms", + "--host", "127.0.0.1", + "--port", ServeWebPortStore.portArgument(persistedIn: Self.vscodeServerDataDir), + "--server-data-dir", Self.vscodeServerDataDir?.path ?? NSTemporaryDirectory(), + "--connection-token-file", connectionTokenFileURL.path, + ] + process.environment = launchConfiguration.environment + + let stdoutPipe = Pipe() + let stderrPipe = Pipe() + process.standardOutput = stdoutPipe + process.standardError = stderrPipe + + let collector = ServeWebOutputCollector() + let outputReader: (FileHandle) -> Void = { fileHandle in + let data = fileHandle.availableData + guard !data.isEmpty else { return } + collector.append(data) + } + stdoutPipe.fileHandleForReading.readabilityHandler = outputReader + stderrPipe.fileHandleForReading.readabilityHandler = outputReader + + process.terminationHandler = { [weak self] terminatedProcess in + stdoutPipe.fileHandleForReading.readabilityHandler = nil + stderrPipe.fileHandleForReading.readabilityHandler = nil + Self.drainAvailableOutput(from: stdoutPipe.fileHandleForReading, collector: collector) + Self.drainAvailableOutput(from: stderrPipe.fileHandleForReading, collector: collector) + collector.markProcessExited() + self?.queue.async { + guard let self else { return } + if self.launchingProcess === terminatedProcess { + self.launchingProcess = nil + } + if self.serveWebProcess === terminatedProcess { + self.serveWebProcess = nil + self.serveWebURL = nil + } + if let tokenFileURL = self.connectionTokenFilesByProcessID.removeValue( + forKey: ObjectIdentifier(terminatedProcess) + ), tokenFileURL.path.hasPrefix(NSTemporaryDirectory()) { + Self.removeConnectionTokenFile(at: tokenFileURL) + } + } + } + + let didStart: Bool = queue.sync { + guard self.lifecycleGeneration == expectedGeneration, + self.activeLaunchGeneration == expectedGeneration else { + return false + } + self.launchingProcess = process + self.connectionTokenFilesByProcessID[ObjectIdentifier(process)] = connectionTokenFileURL + do { + try process.run() + return true + } catch { + if self.launchingProcess === process { + self.launchingProcess = nil + } + if let tokenFileURL = self.connectionTokenFilesByProcessID.removeValue( + forKey: ObjectIdentifier(process) + ), tokenFileURL.path.hasPrefix(NSTemporaryDirectory()) { + Self.removeConnectionTokenFile(at: tokenFileURL) + } + return false + } + } + guard didStart else { + stdoutPipe.fileHandleForReading.readabilityHandler = nil + stderrPipe.fileHandleForReading.readabilityHandler = nil + if connectionTokenFileURL.path.hasPrefix(NSTemporaryDirectory()) { + Self.removeConnectionTokenFile(at: connectionTokenFileURL) + } + return nil + } + + guard collector.waitForURL(timeoutSeconds: Self.serveWebStartupTimeoutSeconds), + let serveWebURL = collector.webUIURL else { + stdoutPipe.fileHandleForReading.readabilityHandler = nil + stderrPipe.fileHandleForReading.readabilityHandler = nil + if process.isRunning { + process.terminate() + } else { + queue.sync { + if self.launchingProcess === process { + self.launchingProcess = nil + } + if self.serveWebProcess === process { + self.serveWebProcess = nil + self.serveWebURL = nil + } + if let tokenFileURL = self.connectionTokenFilesByProcessID.removeValue( + forKey: ObjectIdentifier(process) + ), tokenFileURL.path.hasPrefix(NSTemporaryDirectory()) { + Self.removeConnectionTokenFile(at: tokenFileURL) + } + } + } + return nil + } + + // #21: remember the assigned port so the next launch can request it again. + if let assignedPort = serveWebURL.port { + ServeWebPortStore.persist(port: assignedPort, in: Self.vscodeServerDataDir) + } + + return (process, serveWebURL) + } + + private static func drainAvailableOutput(from fileHandle: FileHandle, collector: ServeWebOutputCollector) { + while true { + let data = fileHandle.availableData + guard !data.isEmpty else { return } + collector.append(data) + } + } + + /// Stable Application Support directory for VS Code serve-web state. + /// Mirrors the "programa" subdirectory convention used elsewhere in the app. + private static var vscodeServerDataDir: URL? { + FileManager.default.urls(for: .applicationSupportDirectory, in: .userDomainMask) + .first? + .appendingPathComponent("programa", isDirectory: true) + .appendingPathComponent("vscode-server", isDirectory: true) + } + + private static func randomConnectionToken() -> String { + UUID().uuidString.replacingOccurrences(of: "-", with: "") + } + + /// Returns a URL for the connection token file. Prefers a persistent file + /// under Application Support so the token (and the browser's vscode-tkn + /// cookie) survives Programa restarts (issue #21). Falls back to an + /// ephemeral temp-dir file when Application Support is unavailable. + private static func makeConnectionTokenFile() -> URL? { + if let persistentURL = vscodeServerDataDir? + .appendingPathComponent("connection-token", isDirectory: false) { + if let url = makePersistentConnectionTokenFile(at: persistentURL) { + return url + } + } + return makeEphemeralConnectionTokenFile() + } + + private static func makePersistentConnectionTokenFile(at tokenFileURL: URL) -> URL? { + let dir = tokenFileURL.deletingLastPathComponent() + try? FileManager.default.createDirectory(at: dir, withIntermediateDirectories: true) + // Reuse an existing non-empty token so the browser cookie stays valid. + if let existingData = try? Data(contentsOf: tokenFileURL), !existingData.isEmpty { + return tokenFileURL + } + let token = randomConnectionToken() + guard let tokenData = token.data(using: .utf8) else { return nil } + let fd = open(tokenFileURL.path, O_WRONLY | O_CREAT | O_TRUNC, S_IRUSR | S_IWUSR) + guard fd >= 0 else { return nil } + defer { _ = close(fd) } + let wroteAllBytes = tokenData.withUnsafeBytes { rawBuffer in + guard let baseAddress = rawBuffer.baseAddress else { return false } + return write(fd, baseAddress, rawBuffer.count) == rawBuffer.count + } + guard wroteAllBytes else { + try? FileManager.default.removeItem(at: tokenFileURL) + return nil + } + return tokenFileURL + } + + private static func makeEphemeralConnectionTokenFile() -> URL? { + let token = randomConnectionToken() + let tokenFileName = "cmux-vscode-token-\(UUID().uuidString)" + let tokenFileURL = URL(fileURLWithPath: NSTemporaryDirectory(), isDirectory: true) + .appendingPathComponent(tokenFileName, isDirectory: false) + guard let tokenData = token.data(using: .utf8) else { return nil } + let fileDescriptor = open(tokenFileURL.path, O_WRONLY | O_CREAT | O_EXCL, S_IRUSR | S_IWUSR) + guard fileDescriptor >= 0 else { return nil } + defer { _ = close(fileDescriptor) } + let wroteAllBytes = tokenData.withUnsafeBytes { rawBuffer in + guard let baseAddress = rawBuffer.baseAddress else { return false } + return write(fileDescriptor, baseAddress, rawBuffer.count) == rawBuffer.count + } + guard wroteAllBytes else { + removeConnectionTokenFile(at: tokenFileURL) + return nil + } + return tokenFileURL + } + + private static func removeConnectionTokenFile(at url: URL) { + try? FileManager.default.removeItem(at: url) + } +} + +final class ServeWebOutputCollector { + private let lock = NSLock() + private let semaphore = DispatchSemaphore(value: 0) + private var outputBuffer = "" + private var resolvedURL: URL? + private var didSignal = false + + var webUIURL: URL? { + lock.lock() + defer { lock.unlock() } + return resolvedURL + } + + func append(_ data: Data) { + guard let text = String(data: data, encoding: .utf8), !text.isEmpty else { return } + lock.lock() + defer { lock.unlock() } + guard resolvedURL == nil else { return } + outputBuffer.append(text) + while let newlineIndex = outputBuffer.firstIndex(where: \.isNewline) { + let line = String(outputBuffer[.. Bool { + if webUIURL != nil { return true } + _ = semaphore.wait(timeout: .now() + timeoutSeconds) + return webUIURL != nil + } +} + +/// Persists the VS Code serve-web port across restarts (#21) so the embedded browser +/// keeps the same URL. The port `code serve-web` assigns is written under the serve-web +/// data dir and reused on the next launch — but only when it is still bindable, so a +/// now-occupied port falls back to an OS-assigned one instead of failing the launch. +enum ServeWebPortStore { + static let fileName = "serve-web-port" + + /// The `--port` argument for `code serve-web`: the persisted port when it is valid and + /// currently free, otherwise "0" (let the OS assign one). `isPortAvailable` is injectable + /// for testing; it defaults to a real loopback bind probe. + static func portArgument( + persistedIn directory: URL?, + isPortAvailable: (Int) -> Bool = ServeWebPortStore.isPortAvailable + ) -> String { + guard let url = portFileURL(in: directory), + let data = try? Data(contentsOf: url), + let raw = String(data: data, encoding: .utf8), + let port = parsePort(raw), + isPortAvailable(port) else { + return "0" + } + return String(port) + } + + /// Records the port VS Code assigned so the next launch can request it again. No-op for + /// out-of-range ports or when the data dir is unavailable. + static func persist(port: Int, in directory: URL?) { + guard isValidPort(port), let url = portFileURL(in: directory) else { return } + let dir = url.deletingLastPathComponent() + try? FileManager.default.createDirectory(at: dir, withIntermediateDirectories: true) + try? Data(String(port).utf8).write(to: url, options: .atomic) + } + + /// Parses a stored port string, returning nil for malformed or out-of-range values. + static func parsePort(_ raw: String) -> Int? { + let trimmed = raw.trimmingCharacters(in: .whitespacesAndNewlines) + guard let port = Int(trimmed), isValidPort(port) else { return nil } + return port + } + + /// True when a TCP socket can bind 127.0.0.1:port right now. + static func isPortAvailable(_ port: Int) -> Bool { + guard isValidPort(port) else { return false } + let fd = socket(AF_INET, SOCK_STREAM, 0) + guard fd >= 0 else { return false } + defer { close(fd) } + var reuse: Int32 = 1 + _ = setsockopt(fd, SOL_SOCKET, SO_REUSEADDR, &reuse, socklen_t(MemoryLayout.size)) + var addr = sockaddr_in() + addr.sin_family = sa_family_t(AF_INET) + addr.sin_port = in_port_t(UInt16(port)).bigEndian + addr.sin_addr.s_addr = inet_addr("127.0.0.1") + let bound = withUnsafePointer(to: &addr) { pointer in + pointer.withMemoryRebound(to: sockaddr.self, capacity: 1) { sockaddrPointer in + bind(fd, sockaddrPointer, socklen_t(MemoryLayout.size)) + } + } + return bound == 0 + } + + private static func isValidPort(_ port: Int) -> Bool { (1...65535).contains(port) } + + private static func portFileURL(in directory: URL?) -> URL? { + directory?.appendingPathComponent(fileName, isDirectory: false) + } +} diff --git a/Sources/WindowSwizzles.swift b/Sources/WindowSwizzles.swift new file mode 100644 index 00000000000..6e67f8ae87c --- /dev/null +++ b/Sources/WindowSwizzles.swift @@ -0,0 +1,734 @@ +import AppKit +import SwiftUI +import Bonsplit +import CoreServices +import UserNotifications +import WebKit +import Combine +import ObjectiveC.runtime +import Darwin + +#if DEBUG +// Widened from `private` to `internal`: AppDelegate.setWindowFirstResponderGuardTesting(...) +// / .clearWindowFirstResponderGuardTesting() (in AppDelegate.swift) write these directly. Refs #95. +var programaFirstResponderGuardCurrentEventOverride: NSEvent? +var programaFirstResponderGuardHitViewOverride: NSView? +#endif +private var programaFirstResponderGuardCurrentEventContext: NSEvent? +private var programaFirstResponderGuardHitViewContext: NSView? +private var programaFirstResponderGuardContextWindowNumber: Int? +private var programaBrowserReturnForwardingDepth = 0 +private var programaWindowFirstResponderBypassDepth = 0 +private var programaFieldEditorOwningWebViewAssociationKey: UInt8 = 0 + +@discardableResult +func cmuxWithWindowFirstResponderBypass(_ body: () -> T) -> T { + programaWindowFirstResponderBypassDepth += 1 + defer { + programaWindowFirstResponderBypassDepth = max(0, programaWindowFirstResponderBypassDepth - 1) + } + return body() +} + +func programaIsWindowFirstResponderBypassActive() -> Bool { + programaWindowFirstResponderBypassDepth > 0 +} + +private final class ProgramaFieldEditorOwningWebViewBox: NSObject { + weak var webView: ProgramaWebView? + + init(webView: ProgramaWebView?) { + self.webView = webView + } +} + +// Widened from `private extension` to `extension`: AppDelegate.installWindowResponderSwizzles() +// (in AppDelegate.swift) references these @objc methods via #selector(...) for method swizzling. Refs #95. +extension NSApplication { + @objc func programa_applicationSendEvent(_ event: NSEvent) { +#if DEBUG + let typingTimingStart = event.type == .keyDown ? ProgramaTypingTiming.start() : nil + let phaseTotalStart = event.type == .keyDown ? ProcessInfo.processInfo.systemUptime : 0 + if event.type == .keyDown { + ProgramaTypingTiming.logEventDelay(path: "app.sendEvent", event: event) + } + defer { + if event.type == .keyDown { + let totalMs = (ProcessInfo.processInfo.systemUptime - phaseTotalStart) * 1000.0 + ProgramaTypingTiming.logBreakdown( + path: "app.sendEvent.phase", + totalMs: totalMs, + event: event, + thresholdMs: 1.0, + parts: [("dispatchMs", totalMs)] + ) + ProgramaTypingTiming.logDuration( + path: "app.sendEvent", + startedAt: typingTimingStart, + event: event + ) + } + } +#endif + programa_applicationSendEvent(event) + } +} + +// Widened from `private extension` to `extension`: AppDelegate.installWindowResponderSwizzles() +// (in AppDelegate.swift) references these @objc methods via #selector(...) for method swizzling. Refs #95. +extension NSWindow { + @objc func programa_makeFirstResponder(_ responder: NSResponder?) -> Bool { + if programaIsWindowFirstResponderBypassActive() { +#if DEBUG + dlog( + "focus.guard bypassFirstResponder responder=\(String(describing: responder.map { type(of: $0) })) " + + "window=\(ObjectIdentifier(self))" + ) +#endif + return false + } + + let currentEvent = Self.programaCurrentEvent(for: self) + let responderWebView = responder.flatMap { + Self.programaOwningWebView(for: $0, in: self, event: currentEvent) + } + var pointerInitiatedWebFocus = false + + if AppDelegate.shared?.shouldBlockFirstResponderChangeWhileCommandPaletteVisible( + window: self, + responder: responder + ) == true { +#if DEBUG + dlog( + "focus.guard commandPaletteBlocked responder=\(String(describing: responder.map { type(of: $0) })) " + + "window=\(ObjectIdentifier(self))" + ) +#endif + return false + } + + if let responder, + let webView = responderWebView, + !webView.allowsFirstResponderAcquisitionEffective { + let pointerInitiatedFocus = Self.programaShouldAllowPointerInitiatedWebViewFocus( + window: self, + webView: webView, + event: currentEvent + ) + if pointerInitiatedFocus { + pointerInitiatedWebFocus = true +#if DEBUG + dlog( + "focus.guard allowPointerFirstResponder responder=\(String(describing: type(of: responder))) " + + "window=\(ObjectIdentifier(self)) " + + "web=\(ObjectIdentifier(webView)) " + + "policy=\(webView.allowsFirstResponderAcquisition ? 1 : 0) " + + "pointerDepth=\(webView.debugPointerFocusAllowanceDepth) " + + "eventType=\(currentEvent.map { String(describing: $0.type) } ?? "nil")" + ) +#endif + } else { +#if DEBUG + dlog( + "focus.guard blockedFirstResponder responder=\(String(describing: type(of: responder))) " + + "window=\(ObjectIdentifier(self)) " + + "web=\(ObjectIdentifier(webView)) " + + "policy=\(webView.allowsFirstResponderAcquisition ? 1 : 0) " + + "pointerDepth=\(webView.debugPointerFocusAllowanceDepth) " + + "eventType=\(currentEvent.map { String(describing: $0.type) } ?? "nil")" + ) +#endif + return false + } + } +#if DEBUG + if let responder, + let webView = responderWebView { + dlog( + "focus.guard allowFirstResponder responder=\(String(describing: type(of: responder))) " + + "window=\(ObjectIdentifier(self)) " + + "web=\(ObjectIdentifier(webView)) " + + "policy=\(webView.allowsFirstResponderAcquisition ? 1 : 0) " + + "pointerDepth=\(webView.debugPointerFocusAllowanceDepth)" + ) + } +#endif + let result: Bool + if pointerInitiatedWebFocus, let webView = responderWebView { + // `NSWindow.makeFirstResponder` may run before `ProgramaWebView.mouseDown(with:)`. + // Preserve pointer intent during this synchronous responder change. + result = webView.withPointerFocusAllowance { + programa_makeFirstResponder(responder) + } + } else { + result = programa_makeFirstResponder(responder) + } + if result { + if let fieldEditor = responder as? NSTextView, fieldEditor.isFieldEditor { + Self.programaTrackFieldEditor(fieldEditor, owningWebView: responderWebView) + } else if let fieldEditor = self.firstResponder as? NSTextView, fieldEditor.isFieldEditor { + Self.programaTrackFieldEditor(fieldEditor, owningWebView: responderWebView) + } + } + return result + } + + @objc func programa_sendEvent(_ event: NSEvent) { +#if DEBUG + let typingTimingStart = event.type == .keyDown ? ProgramaTypingTiming.start() : nil + let phaseTotalStart = event.type == .keyDown ? ProcessInfo.processInfo.systemUptime : 0 + var contextSetupMs: Double = 0 + var focusRepairMs: Double = 0 + var folderGuardMs: Double = 0 + var originalDispatchMs: Double = 0 + let typingTimingExtra: String? = { + guard event.type == .keyDown else { return nil } + let responderWebView = self.firstResponder.flatMap { + Self.programaOwningWebView(for: $0, in: self, event: event) + } + let hitWebView = Self.programaHitViewForEventDispatch(in: self, event: event).flatMap { + Self.programaOwningWebView(for: $0) + } + let firstResponderType = self.firstResponder.map { String(describing: type(of: $0)) } ?? "nil" + return "browser=\((responderWebView != nil || hitWebView != nil) ? 1 : 0) firstResponder=\(firstResponderType)" + }() + if event.type == .keyDown { + ProgramaTypingTiming.logEventDelay(path: "window.sendEvent", event: event) + } +#endif + // recordTypingActivity must run in all builds so runSessionAutosaveTick + // can honor the typing quiet period in release. + if event.type == .keyDown { + AppDelegate.shared?.recordTypingActivity() + } +#if DEBUG + defer { + if event.type == .keyDown { + let totalMs = (ProcessInfo.processInfo.systemUptime - phaseTotalStart) * 1000.0 + ProgramaTypingTiming.logBreakdown( + path: "window.sendEvent.phase", + totalMs: totalMs, + event: event, + thresholdMs: 1.0, + parts: [ + ("contextSetupMs", contextSetupMs), + ("focusRepairMs", focusRepairMs), + ("folderGuardMs", folderGuardMs), + ("originalDispatchMs", originalDispatchMs), + ], + extra: typingTimingExtra + ) + ProgramaTypingTiming.logDuration( + path: "window.sendEvent", + startedAt: typingTimingStart, + event: event, + extra: typingTimingExtra + ) + } + } + let contextSetupStart = event.type == .keyDown ? ProcessInfo.processInfo.systemUptime : 0 +#endif + let previousContextEvent = programaFirstResponderGuardCurrentEventContext + let previousContextHitView = programaFirstResponderGuardHitViewContext + let previousContextWindowNumber = programaFirstResponderGuardContextWindowNumber + programaFirstResponderGuardCurrentEventContext = event + programaFirstResponderGuardHitViewContext = Self.programaHitViewForEventDispatch(in: self, event: event) + programaFirstResponderGuardContextWindowNumber = self.windowNumber +#if DEBUG + if event.type == .keyDown { + contextSetupMs = (ProcessInfo.processInfo.systemUptime - contextSetupStart) * 1000.0 + } + let focusRepairStart = event.type == .keyDown ? ProcessInfo.processInfo.systemUptime : 0 +#endif + if event.type == .keyDown { + AppDelegate.shared?.repairFocusedTerminalKeyboardRoutingIfNeeded( + window: self, + event: event + ) + } +#if DEBUG + if event.type == .keyDown { + focusRepairMs = (ProcessInfo.processInfo.systemUptime - focusRepairStart) * 1000.0 + } + let folderGuardStart = event.type == .keyDown ? ProcessInfo.processInfo.systemUptime : 0 +#endif + defer { + programaFirstResponderGuardCurrentEventContext = previousContextEvent + programaFirstResponderGuardHitViewContext = previousContextHitView + programaFirstResponderGuardContextWindowNumber = previousContextWindowNumber + } + + guard shouldSuppressWindowMoveForFolderDrag(window: self, event: event), + let contentView = self.contentView else { +#if DEBUG + if event.type == .keyDown { + folderGuardMs = (ProcessInfo.processInfo.systemUptime - folderGuardStart) * 1000.0 + let originalDispatchStart = ProcessInfo.processInfo.systemUptime + programa_sendEvent(event) + originalDispatchMs = (ProcessInfo.processInfo.systemUptime - originalDispatchStart) * 1000.0 + return + } +#endif + programa_sendEvent(event) + return + } +#if DEBUG + if event.type == .keyDown { + folderGuardMs = (ProcessInfo.processInfo.systemUptime - folderGuardStart) * 1000.0 + } + let originalDispatchStart = event.type == .keyDown ? ProcessInfo.processInfo.systemUptime : 0 +#endif + + let contentPoint = contentView.convert(event.locationInWindow, from: nil) + let hitView = contentView.hitTest(contentPoint) + let previousMovableState = isMovable + if previousMovableState { + isMovable = false + } + + #if DEBUG + let hitDesc = hitView.map { String(describing: type(of: $0)) } ?? "nil" + dlog("window.sendEvent.folderDown suppress=1 hit=\(hitDesc) wasMovable=\(previousMovableState)") + #endif + + programa_sendEvent(event) +#if DEBUG + if event.type == .keyDown { + originalDispatchMs = (ProcessInfo.processInfo.systemUptime - originalDispatchStart) * 1000.0 + } +#endif + + if previousMovableState { + isMovable = previousMovableState + } + + #if DEBUG + dlog("window.sendEvent.folderDown restore nowMovable=\(isMovable)") + #endif + } + + @objc func programa_performKeyEquivalent(with event: NSEvent) -> Bool { +#if DEBUG + let typingTimingStart = ProgramaTypingTiming.start() + defer { + ProgramaTypingTiming.logDuration( + path: "window.performKeyEquivalent", + startedAt: typingTimingStart, + event: event + ) + } + let frType = self.firstResponder.map { String(describing: type(of: $0)) } ?? "nil" + dlog("performKeyEquiv: \(Self.keyDescription(event)) fr=\(frType)") +#endif + + // When the terminal surface is the first responder, prevent SwiftUI's + // hosting view from consuming key events via performKeyEquivalent. + // After a browser panel (WKWebView) has been in the responder chain, + // SwiftUI's internal focus system can get into a broken state where it + // intercepts key events in the content view hierarchy, returns true + // (claiming consumption), but never actually fires the action closure. + // + // For non-Command keys: bypass the view hierarchy entirely and send + // directly to the terminal so arrow keys, Ctrl+N/P, etc. reach keyDown. + // + // For Command keys: bypass the SwiftUI content view hierarchy and + // dispatch directly to the main menu. No SwiftUI view should be handling + // Command shortcuts when the terminal is focused — the local event monitor + // (handleCustomShortcut) already handles app-level shortcuts, and anything + // remaining should be menu items. + let firstResponderGhosttyView = cmuxOwningGhosttyView(for: self.firstResponder) + let firstResponderWebView = self.firstResponder.flatMap { + Self.programaOwningWebView(for: $0, in: self, event: event) + } + let firstResponderHasMarkedText = browserResponderHasMarkedText(self.firstResponder) + if let ghosttyView = firstResponderGhosttyView { + // If the IME is composing and the key has no Cmd modifier, don't intercept — + // let it flow through normal AppKit event dispatch so the input method can + // process it. Cmd-based shortcuts should still work during composition since + // Cmd is never part of IME input sequences. + if ghosttyView.hasMarkedText(), !event.modifierFlags.intersection(.deviceIndependentFlagsMask).contains(.command) { + return programa_performKeyEquivalent(with: event) + } + + let flags = event.modifierFlags.intersection(.deviceIndependentFlagsMask) + if !flags.contains(.command) { + let result = ghosttyView.performKeyEquivalent(with: event) +#if DEBUG + dlog(" → ghostty direct: \(result)") +#endif + return result + } + + // Preserve Ghostty's terminal font-size shortcuts (Cmd +/−/0) when + // the terminal is focused. Otherwise our browser menu shortcuts can + // consume the event even when no browser panel is focused. + if shouldRouteTerminalFontZoomShortcutToGhostty( + firstResponderIsGhostty: true, + flags: event.modifierFlags, + chars: event.charactersIgnoringModifiers ?? "", + keyCode: event.keyCode, + literalChars: event.characters + ) { + ghosttyView.keyDown(with: event) +#if DEBUG + dlog("zoom.shortcut stage=window.ghosttyKeyDownDirect event=\(Self.keyDescription(event)) handled=1") +#endif + return true + } + } + + // Web forms rely on Return/Enter flowing through keyDown. If the original + // NSWindow.performKeyEquivalent consumes Enter first, submission never reaches + // WebKit. Route Return/Enter directly to the current first responder and + // mark handled to avoid the AppKit alert sound path. + if shouldDispatchBrowserReturnViaFirstResponderKeyDown( + keyCode: event.keyCode, + firstResponderIsBrowser: firstResponderWebView != nil, + firstResponderHasMarkedText: firstResponderHasMarkedText, + flags: event.modifierFlags + ) { + // Forwarding keyDown can re-enter performKeyEquivalent in WebKit/AppKit internals. + // On re-entry, fall back to normal dispatch to avoid an infinite loop. + if programaBrowserReturnForwardingDepth > 0 { +#if DEBUG + dlog(" → browser Return/Enter reentry; using normal dispatch") +#endif + return false + } + programaBrowserReturnForwardingDepth += 1 + defer { programaBrowserReturnForwardingDepth = max(0, programaBrowserReturnForwardingDepth - 1) } +#if DEBUG + dlog(" → browser Return/Enter routed to firstResponder.keyDown") +#endif + self.firstResponder?.keyDown(with: event) + return true + } + + if let firstResponderWebView, + shouldRouteBrowserFindCommandEquivalentThroughWebContentFirst( + event, + responder: self.firstResponder, + owningWebView: firstResponderWebView + ) { + let result = firstResponderWebView.performKeyEquivalent(with: event) +#if DEBUG + if result { + dlog(" → browser find command resolved before window menu path") + } else { + dlog(" → browser find command preflight left unclaimed; suppressing replay") + } +#endif + // The focused web view has already received this Find-family shortcut once. + // Do not fall through into the original NSWindow.performKeyEquivalent path, + // or WebKit can observe the same key equivalent a second time before AppKit + // reaches keyDown/menu fallback. + return true + } + + if AppDelegate.shared?.handleBrowserSurfaceKeyEquivalent(event) == true { +#if DEBUG + dlog(" → consumed by handleBrowserSurfaceKeyEquivalent") +#endif + return true + } + + // When the terminal is focused, skip the full NSWindow.performKeyEquivalent + // (which walks the SwiftUI content view hierarchy) and dispatch Command-key + // events directly to the main menu. This avoids the broken SwiftUI focus path. + if firstResponderGhosttyView != nil, + shouldRouteCommandEquivalentDirectlyToMainMenu(event), + let mainMenu = NSApp.mainMenu { + let consumedByMenu = mainMenu.performKeyEquivalent(with: event) +#if DEBUG + if browserZoomShortcutTraceCandidate( + flags: event.modifierFlags, + chars: event.charactersIgnoringModifiers ?? "", + keyCode: event.keyCode, + literalChars: event.characters + ) { + dlog( + "zoom.shortcut stage=window.mainMenuBypass event=\(Self.keyDescription(event)) " + + "consumed=\(consumedByMenu ? 1 : 0) fr=GhosttyNSView" + ) + } +#endif + if !consumedByMenu { + // Fall through to the original performKeyEquivalent path below. + } else { +#if DEBUG + dlog(" → consumed by mainMenu (bypassed SwiftUI)") +#endif + return true + } + } + + let result = programa_performKeyEquivalent(with: event) +#if DEBUG + if result { dlog(" → consumed by original performKeyEquivalent") } +#endif + return result + } + + static func keyDescription(_ event: NSEvent) -> String { + var parts: [String] = [] + let flags = event.modifierFlags.intersection(.deviceIndependentFlagsMask) + if flags.contains(.command) { parts.append("Cmd") } + if flags.contains(.shift) { parts.append("Shift") } + if flags.contains(.option) { parts.append("Opt") } + if flags.contains(.control) { parts.append("Ctrl") } + let chars = event.charactersIgnoringModifiers ?? "?" + parts.append("'\(chars)'(\(event.keyCode))") + return parts.joined(separator: "+") + } + + private static func programaOwningWebView(for responder: NSResponder) -> ProgramaWebView? { + if let webView = responder as? ProgramaWebView { + return webView + } + + if let view = responder as? NSView, + let webView = programaOwningWebView(for: view) { + return webView + } + + // NSTextView.delegate is unsafe-unretained in AppKit. Reading it here while + // a responder chain is tearing down can trap with "unowned reference". + var current = responder.nextResponder + while let next = current { + if let webView = next as? ProgramaWebView { + return webView + } + if let view = next as? NSView, + let webView = programaOwningWebView(for: view) { + return webView + } + current = next.nextResponder + } + + return nil + } + + private static func programaOwningWebView( + for responder: NSResponder, + in window: NSWindow, + event: NSEvent? + ) -> ProgramaWebView? { + // Browser find runs in the portal slot alongside the hosted WKWebView. + // Treat its native field editor chain as browser chrome, not as web content, + // so Cmd+F can move first responder into the find field while web focus is suppressed. + if BrowserWindowPortalRegistry.searchOverlayPanelId(for: responder, in: window) != nil { + return nil + } + + if let webView = programaOwningWebView(for: responder) { + return webView + } + + guard let textView = responder as? NSTextView, textView.isFieldEditor else { + return nil + } + + if let event, + let hitWebView = programaPointerHitWebView(in: window, event: event) { + programaTrackFieldEditor(textView, owningWebView: hitWebView) + return hitWebView + } + + return programaTrackedOwningWebView(for: textView) + } + + private static func programaOwningWebView(for view: NSView) -> ProgramaWebView? { + if let webView = view as? ProgramaWebView { + return webView + } + + var current: NSView? = view.superview + while let candidate = current { + if let webView = candidate as? ProgramaWebView { + return webView + } + if String(describing: type(of: candidate)).contains("WindowBrowserSlotView"), + let portalWebView = programaUniqueBrowserWebView(in: candidate) { + // Portal-hosted browser chrome (for example the Cmd+F overlay) is a + // sibling of the hosted WKWebView inside WindowBrowserSlotView, not a + // descendant of it. Allow native text-entry controls in that slot to + // acquire first responder directly, but keep generic sibling views + // associated with the hosted web view so blocked browser focus policy + // still protects inspector/overlay chrome from stray focus changes. + if view === portalWebView || view.isDescendant(of: portalWebView) { + return portalWebView + } + if programaAllowsPortalSlotTextEntryFocus(view) { + return nil + } + return portalWebView + } + current = candidate.superview + } + + return nil + } + + private static func programaAllowsPortalSlotTextEntryFocus(_ view: NSView) -> Bool { + var current: NSView? = view + while let candidate = current { + if let textField = candidate as? NSTextField { + return textField.isEditable || textField.acceptsFirstResponder + } + if let textView = candidate as? NSTextView { + return textView.isEditable || textView.isSelectable || textView.isFieldEditor + } + current = candidate.superview + } + return false + } + + private static func programaUniqueBrowserWebView(in root: NSView) -> ProgramaWebView? { + var stack: [NSView] = [root] + var found: ProgramaWebView? + while let current = stack.popLast() { + if let webView = current as? ProgramaWebView { + if found == nil { + found = webView + } else if found !== webView { + return nil + } + } + stack.append(contentsOf: current.subviews) + } + return found + } + + private static func programaCurrentEvent(for window: NSWindow) -> NSEvent? { +#if DEBUG + if let override = programaFirstResponderGuardCurrentEventOverride { + return override + } +#endif + if programaFirstResponderGuardContextWindowNumber == window.windowNumber { + return programaFirstResponderGuardCurrentEventContext + } + return NSApp.currentEvent + } + + private static func programaHitViewInThemeFrame(in window: NSWindow, event: NSEvent) -> NSView? { + guard let contentView = window.contentView, + let themeFrame = contentView.superview else { + return nil + } + let pointInTheme = themeFrame.convert(event.locationInWindow, from: nil) + return themeFrame.hitTest(pointInTheme) + } + + private static func programaHitViewInContentView(in window: NSWindow, event: NSEvent) -> NSView? { + guard let contentView = window.contentView else { + return nil + } + let pointInContent = contentView.convert(event.locationInWindow, from: nil) + return contentView.hitTest(pointInContent) + } + + private static func programaTopHitViewForEvent(in window: NSWindow, event: NSEvent) -> NSView? { + if let hitInThemeFrame = programaHitViewInThemeFrame(in: window, event: event) { + return hitInThemeFrame + } + return programaHitViewInContentView(in: window, event: event) + } + + private static func programaHitViewForEventDispatch(in window: NSWindow, event: NSEvent) -> NSView? { + if event.windowNumber != 0, event.windowNumber != window.windowNumber { + return nil + } + if let eventWindow = event.window, eventWindow !== window { + return nil + } + return programaTopHitViewForEvent(in: window, event: event) + } + + private static func programaHitViewForCurrentEvent(in window: NSWindow, event: NSEvent) -> NSView? { +#if DEBUG + if let override = programaFirstResponderGuardHitViewOverride { + return override + } +#endif + if programaFirstResponderGuardContextWindowNumber == window.windowNumber, + let contextHitView = programaFirstResponderGuardHitViewContext { + return contextHitView + } + return programaTopHitViewForEvent(in: window, event: event) + } + + private static func programaTrackFieldEditor(_ fieldEditor: NSTextView, owningWebView webView: ProgramaWebView?) { + if let webView { + objc_setAssociatedObject( + fieldEditor, + &programaFieldEditorOwningWebViewAssociationKey, + ProgramaFieldEditorOwningWebViewBox(webView: webView), + .OBJC_ASSOCIATION_RETAIN_NONATOMIC + ) + } else { + objc_setAssociatedObject( + fieldEditor, + &programaFieldEditorOwningWebViewAssociationKey, + nil, + .OBJC_ASSOCIATION_RETAIN_NONATOMIC + ) + } + } + + private static func programaTrackedOwningWebView(for fieldEditor: NSTextView) -> ProgramaWebView? { + guard let box = objc_getAssociatedObject( + fieldEditor, + &programaFieldEditorOwningWebViewAssociationKey + ) as? ProgramaFieldEditorOwningWebViewBox else { + return nil + } + guard let webView = box.webView else { + programaTrackFieldEditor(fieldEditor, owningWebView: nil) + return nil + } + return webView + } + + private static func programaIsPointerDownEvent(_ event: NSEvent) -> Bool { + switch event.type { + case .leftMouseDown, .rightMouseDown, .otherMouseDown: + return true + default: + return false + } + } + + private static func programaPointerHitWebView(in window: NSWindow, event: NSEvent) -> ProgramaWebView? { + guard programaIsPointerDownEvent(event) else { return nil } + if event.windowNumber != 0, event.windowNumber != window.windowNumber { + return nil + } + if let eventWindow = event.window, eventWindow !== window { + return nil + } + if let portalWebView = BrowserWindowPortalRegistry.webViewAtWindowPoint( + event.locationInWindow, + in: window + ) as? ProgramaWebView { + return portalWebView + } + guard let hitView = programaHitViewForCurrentEvent(in: window, event: event) else { + return nil + } + return programaOwningWebView(for: hitView) + } + + private static func programaShouldAllowPointerInitiatedWebViewFocus( + window: NSWindow, + webView: ProgramaWebView, + event: NSEvent? + ) -> Bool { + guard let event, + let hitWebView = programaPointerHitWebView(in: window, event: event) else { + return false + } + return hitWebView === webView + } + +} diff --git a/Sources/WorkspaceShortcutMapper.swift b/Sources/WorkspaceShortcutMapper.swift new file mode 100644 index 00000000000..22ee7da2769 --- /dev/null +++ b/Sources/WorkspaceShortcutMapper.swift @@ -0,0 +1,37 @@ +import AppKit +import SwiftUI +import Bonsplit +import CoreServices +import UserNotifications +import WebKit +import Combine +import ObjectiveC.runtime +import Darwin + +enum WorkspaceShortcutMapper { + /// Maps numbered workspace shortcuts to a zero-based workspace index. + /// 1...8 target fixed indices; 9 always targets the last workspace. + static func workspaceIndex(forDigit digit: Int, workspaceCount: Int) -> Int? { + guard workspaceCount > 0 else { return nil } + guard (1...9).contains(digit) else { return nil } + + if digit == 9 { + return workspaceCount - 1 + } + + let index = digit - 1 + return index < workspaceCount ? index : nil + } + + /// Returns the primary digit badge to display for a workspace row. + /// Picks the lowest digit that maps to that row index. + static func digitForWorkspace(at index: Int, workspaceCount: Int) -> Int? { + guard index >= 0 && index < workspaceCount else { return nil } + for digit in 1...9 { + if workspaceIndex(forDigit: digit, workspaceCount: workspaceCount) == index { + return digit + } + } + return nil + } +} From fde4223b16060405a8df935723e90a1818d06993 Mon Sep 17 00:00:00 2001 From: arzafran Date: Thu, 9 Jul 2026 13:59:23 -0300 Subject: [PATCH 08/19] fix(workspace): unify remote-state reset between configure and disconnect MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit configureRemoteConnection and disconnectRemoteConnection each carried a drifted copy of the remote-state reset block. configureRemoteConnection never cleared the per-panel bookkeeping fields (activeRemoteTerminalSurfaceIds, activeRemoteTerminalSessionCount, pendingRemoteSurfaceTTYName/SurfaceId, pendingRemoteSurfacePortKickReason/SurfaceId) that disconnectRemoteConnection did, so reconfiguring an already-connected workspace to a new destination left stale per-panel remote-session bookkeeping behind — including causing seedInitialRemoteTerminalSessionIfNeeded to skip seeding the new session because activeRemoteTerminalSurfaceIds wasn't actually empty. Extract resetRemoteState(), the union of both blocks, and call it from both paths. configureRemoteConnection now captures pendingRemoteForegroundAuthToken before the reset (since the reset nulls it) and seeds the initial terminal session after the reset instead of before, so seeding sees a clean slate. Refs #83. --- Sources/Workspace.swift | 65 ++++++++++++++++++----------------------- 1 file changed, 29 insertions(+), 36 deletions(-) diff --git a/Sources/Workspace.swift b/Sources/Workspace.swift index 5c90353b45b..2b884479055 100644 --- a/Sources/Workspace.swift +++ b/Sources/Workspace.swift @@ -3314,37 +3314,21 @@ final class Workspace: Identifiable, ObservableObject { } func configureRemoteConnection(_ configuration: WorkspaceRemoteConfiguration, autoConnect: Bool = true) { - skipControlMasterCleanupAfterDetachedRemoteTransfer = false + // Capture before resetRemoteState() nulls pendingRemoteForegroundAuthToken. + let foregroundAuthToken = Self.normalizedForegroundAuthToken(configuration.foregroundAuthToken) + let shouldAutoConnect = + autoConnect + || (foregroundAuthToken != nil && foregroundAuthToken == pendingRemoteForegroundAuthToken) + remoteConfiguration = configuration + resetRemoteState() + // Seed after the reset so a reconfigure of an already-connected workspace doesn't see + // stale per-panel bookkeeping from the previous destination and skip seeding. Refs #83. seedInitialRemoteTerminalSessionIfNeeded(configuration: configuration) - clearRemoteDetectedSurfacePorts() - remoteDetectedPorts = [] - remoteForwardedPorts = [] - remotePortConflicts = [] - remoteProxyEndpoint = nil - remoteHeartbeatCount = 0 - remoteLastHeartbeatAt = nil - remoteConnectionDetail = nil - remoteDaemonStatus = WorkspaceRemoteDaemonStatus() - statusEntries.removeValue(forKey: Self.remoteErrorStatusKey) - statusEntries.removeValue(forKey: Self.remotePortConflictStatusKey) - remoteLastErrorFingerprint = nil - remoteLastDaemonErrorFingerprint = nil - remoteLastPortConflictFingerprint = nil recomputeListeningPorts() - - let previousController = remoteSessionController - activeRemoteSessionControllerID = nil - remoteSessionController = nil - previousController?.stop() applyRemoteProxyEndpointUpdate(nil) applyBrowserRemoteWorkspaceStatusToPanels() - let foregroundAuthToken = Self.normalizedForegroundAuthToken(configuration.foregroundAuthToken) - let shouldAutoConnect = - autoConnect - || (foregroundAuthToken != nil && foregroundAuthToken == pendingRemoteForegroundAuthToken) - pendingRemoteForegroundAuthToken = nil guard shouldAutoConnect else { remoteConnectionState = .disconnected applyBrowserRemoteWorkspaceStatusToPanels() @@ -3402,6 +3386,26 @@ final class Workspace: Identifiable, ObservableObject { && pendingDetachedSurfaces.isEmpty && !skipControlMasterCleanupAfterDetachedRemoteTransfer let configurationForCleanup = shouldCleanupControlMaster ? remoteConfiguration : nil + resetRemoteState() + remoteConnectionState = .disconnected + if clearConfiguration { + remoteConfiguration = nil + skipControlMasterCleanupAfterDetachedRemoteTransfer = false + } + applyRemoteProxyEndpointUpdate(nil) + applyBrowserRemoteWorkspaceStatusToPanels() + recomputeListeningPorts() + if let configurationForCleanup { + Self.requestSSHControlMasterCleanupIfNeeded(configuration: configurationForCleanup) + } + } + + /// Resets all per-connection and per-panel remote-session bookkeeping. This is the + /// complete union of what `configureRemoteConnection` and `disconnectRemoteConnection` + /// each need to clear before establishing (or tearing down) a remote connection, so a + /// reconfigure to a new destination can't leave stale state from the previous one. Refs #83. + private func resetRemoteState() { + skipControlMasterCleanupAfterDetachedRemoteTransfer = false let previousController = remoteSessionController activeRemoteSessionControllerID = nil remoteSessionController = nil @@ -3420,7 +3424,6 @@ final class Workspace: Identifiable, ObservableObject { remoteProxyEndpoint = nil remoteHeartbeatCount = 0 remoteLastHeartbeatAt = nil - remoteConnectionState = .disconnected remoteConnectionDetail = nil remoteDaemonStatus = WorkspaceRemoteDaemonStatus() statusEntries.removeValue(forKey: Self.remoteErrorStatusKey) @@ -3428,16 +3431,6 @@ final class Workspace: Identifiable, ObservableObject { remoteLastErrorFingerprint = nil remoteLastDaemonErrorFingerprint = nil remoteLastPortConflictFingerprint = nil - if clearConfiguration { - remoteConfiguration = nil - skipControlMasterCleanupAfterDetachedRemoteTransfer = false - } - applyRemoteProxyEndpointUpdate(nil) - applyBrowserRemoteWorkspaceStatusToPanels() - recomputeListeningPorts() - if let configurationForCleanup { - Self.requestSSHControlMasterCleanupIfNeeded(configuration: configurationForCleanup) - } } private func clearRemoteConfigurationIfWorkspaceBecameLocal() { From e8d4c2a9300ab1ccfc8dc2efb2b6a7f49a00240e Mon Sep 17 00:00:00 2001 From: arzafran Date: Thu, 9 Jul 2026 13:59:38 -0300 Subject: [PATCH 09/19] refactor(workspace-remote): dedup SSH quoting, connection policy, and scp upload MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three pieces of logic were copy-pasted across WorkspaceRemoteSession.swift, WorkspaceRemoteDaemon.swift, and TerminalSSHSessionDetector.swift: - shellSingleQuoted: byte-identical POSIX single-quoting helper, present 4 times (twice in WorkspaceRemoteDaemon.swift alone — one copy was entirely dead code with no call sites). Consolidated into RemoteSSHConnectionPolicy.shellSingleQuoted. - The SSH connection-policy flag set (ConnectTimeout/ServerAliveInterval/ ServerAliveCountMax keepalive triple, the StrictHostKeyChecking=accept-new default, the BatchMode=yes/ControlMaster=no pairing) and the underlying -o key=value option parsing (optionKey/hasOptionKey/normalizedOptions/ backgroundOptions/optionValue) were each defined 2-3 times with matching behavior. Consolidated into RemoteSSHConnectionPolicy; each call site still assembles its own full argument list (they legitimately differ in jump-host/ agent-forwarding/compression handling and port-flag spelling) but the identical fragments now have one definition, spliced in at the same position so argument order is unchanged. - The scp-upload-with-cancel-cleanup control flow (upload each file, checking for cancellation, cleaning up whatever was already uploaded and rethrowing on any failure) was independently implemented for the ad-hoc detected-SSH path (TerminalSSHSessionDetector.uploadDroppedFilesSync) and the daemon-relay path (WorkspaceRemoteSession.uploadDroppedFilesLocked). Extracted performSCPUploadWithCancelCleanup, parameterized by a per-item upload closure; each call site still decides whether to record a file's remote path before or after the transfer completes, since that differed between the two existing implementations and isn't part of this dedup. New files RemoteSSHConnectionPolicy.swift and RemoteSCPUpload.swift are registered in GhosttyTabs.xcodeproj/project.pbxproj (main app target only — these are only used by main-app-only files). Refs #92. --- GhosttyTabs.xcodeproj/project.pbxproj | 8 ++ Sources/RemoteSCPUpload.swift | 35 +++++++ Sources/RemoteSSHConnectionPolicy.swift | 89 ++++++++++++++++ Sources/TerminalSSHSessionDetector.swift | 91 ++++------------ Sources/WorkspaceRemoteDaemon.swift | 88 ++-------------- Sources/WorkspaceRemoteSession.swift | 126 ++++++----------------- 6 files changed, 193 insertions(+), 244 deletions(-) create mode 100644 Sources/RemoteSCPUpload.swift create mode 100644 Sources/RemoteSSHConnectionPolicy.swift diff --git a/GhosttyTabs.xcodeproj/project.pbxproj b/GhosttyTabs.xcodeproj/project.pbxproj index 5ba0f619fc7..4c301506252 100644 --- a/GhosttyTabs.xcodeproj/project.pbxproj +++ b/GhosttyTabs.xcodeproj/project.pbxproj @@ -54,6 +54,8 @@ A5001406 /* Workspace.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5001416 /* Workspace.swift */; }; A5FF0012 /* WorkspaceRemoteSession.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5FF0002 /* WorkspaceRemoteSession.swift */; }; A5FF0011 /* WorkspaceRemoteDaemon.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5FF0001 /* WorkspaceRemoteDaemon.swift */; }; + NRWS00000000000000000002 /* RemoteSSHConnectionPolicy.swift in Sources */ = {isa = PBXBuildFile; fileRef = NRWS00000000000000000001 /* RemoteSSHConnectionPolicy.swift */; }; + NRWS00000000000000000004 /* RemoteSCPUpload.swift in Sources */ = {isa = PBXBuildFile; fileRef = NRWS00000000000000000003 /* RemoteSCPUpload.swift */; }; A5001407 /* WorkspaceContentView.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5001417 /* WorkspaceContentView.swift */; }; A5001093 /* AppDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5001090 /* AppDelegate.swift */; }; A5FF0031 /* AppDelegate+UITestCmdClick.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5FF0021 /* AppDelegate+UITestCmdClick.swift */; }; @@ -278,6 +280,8 @@ A5001416 /* Workspace.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Workspace.swift; sourceTree = ""; }; A5FF0002 /* WorkspaceRemoteSession.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WorkspaceRemoteSession.swift; sourceTree = ""; }; A5FF0001 /* WorkspaceRemoteDaemon.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WorkspaceRemoteDaemon.swift; sourceTree = ""; }; + NRWS00000000000000000001 /* RemoteSSHConnectionPolicy.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RemoteSSHConnectionPolicy.swift; sourceTree = ""; }; + NRWS00000000000000000003 /* RemoteSCPUpload.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RemoteSCPUpload.swift; sourceTree = ""; }; A5001417 /* WorkspaceContentView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WorkspaceContentView.swift; sourceTree = ""; }; A5001090 /* AppDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppDelegate.swift; sourceTree = ""; }; A5FF0021 /* AppDelegate+UITestCmdClick.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "AppDelegate+UITestCmdClick.swift"; sourceTree = ""; }; @@ -519,6 +523,8 @@ A5001416 /* Workspace.swift */, A5FF0002 /* WorkspaceRemoteSession.swift */, A5FF0001 /* WorkspaceRemoteDaemon.swift */, + NRWS00000000000000000001 /* RemoteSSHConnectionPolicy.swift */, + NRWS00000000000000000003 /* RemoteSCPUpload.swift */, A5001417 /* WorkspaceContentView.swift */, A5001014 /* GhosttyConfig.swift */, A5001015 /* GhosttyTerminalView.swift */, @@ -849,6 +855,8 @@ A5001406 /* Workspace.swift in Sources */, A5FF0012 /* WorkspaceRemoteSession.swift in Sources */, A5FF0011 /* WorkspaceRemoteDaemon.swift in Sources */, + NRWS00000000000000000002 /* RemoteSSHConnectionPolicy.swift in Sources */, + NRWS00000000000000000004 /* RemoteSCPUpload.swift in Sources */, A5001407 /* WorkspaceContentView.swift in Sources */, A5001004 /* GhosttyConfig.swift in Sources */, A5001005 /* GhosttyTerminalView.swift in Sources */, diff --git a/Sources/RemoteSCPUpload.swift b/Sources/RemoteSCPUpload.swift new file mode 100644 index 00000000000..c99c69d95d7 --- /dev/null +++ b/Sources/RemoteSCPUpload.swift @@ -0,0 +1,35 @@ +import Foundation + +/// Uploads each item in `items` via `performUpload`, tracking which remote paths have +/// been recorded so far. If any upload throws (including cancellation surfaced by +/// `checkCancelled`), invokes `cleanup` with whatever remote paths were recorded before +/// the failure, then rethrows the original error. +/// +/// This is the shared control flow behind the two previously-independent +/// scp-upload-with-cancel-cleanup routines: the ad-hoc detected-SSH-session path +/// (`TerminalSSHSessionDetector.swift`) and the daemon-relay managed-workspace path +/// (`WorkspaceRemoteSession.swift`). The two differ in how a single file is transferred +/// (which executable/argument builder they use) and in whether they record a file's +/// remote destination before or after the transfer completes — both differences are +/// preserved by leaving them to `performUpload`, which decides when to call `record`. +/// Refs #92. +func performSCPUploadWithCancelCleanup( + items: [Item], + checkCancelled: () throws -> Void, + performUpload: (_ item: Item, _ record: (String) -> Void) throws -> Void, + cleanup: (_ uploadedRemotePaths: [String]) -> Void +) throws -> [String] { + guard !items.isEmpty else { return [] } + + var uploadedRemotePaths: [String] = [] + do { + for item in items { + try checkCancelled() + try performUpload(item) { uploadedRemotePaths.append($0) } + } + return uploadedRemotePaths + } catch { + cleanup(uploadedRemotePaths) + throw error + } +} diff --git a/Sources/RemoteSSHConnectionPolicy.swift b/Sources/RemoteSSHConnectionPolicy.swift new file mode 100644 index 00000000000..bedf20833f1 --- /dev/null +++ b/Sources/RemoteSSHConnectionPolicy.swift @@ -0,0 +1,89 @@ +import Foundation + +/// Shared building blocks for constructing `ssh`/`scp` argument lists. +/// +/// Consolidates the connection-policy flags (keepalive timeouts, the +/// `StrictHostKeyChecking` default, the `BatchMode`/`ControlMaster` pairing) and the +/// `-o key=value` option-parsing helpers that were previously copy-pasted across +/// `WorkspaceRemoteSession.swift`, `WorkspaceRemoteDaemon.swift`, and +/// `TerminalSSHSessionDetector.swift`. Each call site still assembles its own argument +/// list (they differ in scp/ssh flags, jump-host/proxy handling, and port-flag +/// spelling), but the identical policy fragments now have one definition. Refs #92. +enum RemoteSSHConnectionPolicy { + /// `-o ConnectTimeout=6 -o ServerAliveInterval=20 -o ServerAliveCountMax=2` + static let keepaliveArguments: [String] = [ + "-o", "ConnectTimeout=6", + "-o", "ServerAliveInterval=20", + "-o", "ServerAliveCountMax=2", + ] + + /// `-o BatchMode=yes -o ControlMaster=no`, for non-interactive/background invocations. + static let batchModeArguments: [String] = [ + "-o", "BatchMode=yes", + "-o", "ControlMaster=no", + ] + + /// `-o StrictHostKeyChecking=accept-new`, appended unless the caller's own `-o` + /// options already set `StrictHostKeyChecking` explicitly. + static func strictHostKeyCheckingArguments(unlessSetIn options: [String]) -> [String] { + hasOptionKey(options, key: "StrictHostKeyChecking") ? [] : ["-o", "StrictHostKeyChecking=accept-new"] + } + + static func hasOptionKey(_ options: [String], key: String) -> Bool { + let loweredKey = key.lowercased() + return options.contains { optionKey($0) == loweredKey } + } + + static func normalizedOptions(_ options: [String]) -> [String] { + options.compactMap { option in + let trimmed = option.trimmingCharacters(in: .whitespacesAndNewlines) + guard !trimmed.isEmpty else { return nil } + return trimmed + } + } + + private static let backgroundExcludedOptionKeys: Set = [ + "controlmaster", + "controlpersist", + ] + + /// Strips `ControlMaster`/`ControlPersist` so a batch invocation can't negotiate (or + /// collide with) an interactive control-master. + static func backgroundOptions(_ options: [String]) -> [String] { + normalizedOptions(options).filter { option in + guard let key = optionKey(option) else { return false } + return !backgroundExcludedOptionKeys.contains(key) + } + } + + /// Looks up the value of a named `-o key=value` option within a list of raw options. + static func optionValue(named key: String, in options: [String]) -> String? { + let loweredKey = key.lowercased() + for option in normalizedOptions(options) { + let parts = option.split( + maxSplits: 1, + omittingEmptySubsequences: true, + whereSeparator: { $0 == "=" || $0.isWhitespace } + ) + guard parts.count == 2, parts[0].lowercased() == loweredKey else { continue } + let value = parts[1].trimmingCharacters(in: .whitespacesAndNewlines) + if !value.isEmpty { return value } + } + return nil + } + + static func optionKey(_ option: String) -> String? { + let trimmed = option.trimmingCharacters(in: .whitespacesAndNewlines) + guard !trimmed.isEmpty else { return nil } + return trimmed + .split(whereSeparator: { $0 == "=" || $0.isWhitespace }) + .first + .map(String.init)? + .lowercased() + } + + /// POSIX single-quote a string for interpolation into a `sh -c '...'` command. + static func shellSingleQuoted(_ value: String) -> String { + "'" + value.replacingOccurrences(of: "'", with: "'\"'\"'") + "'" + } +} diff --git a/Sources/TerminalSSHSessionDetector.swift b/Sources/TerminalSSHSessionDetector.swift index 6bd75803fa1..ab10f864716 100644 --- a/Sources/TerminalSSHSessionDetector.swift +++ b/Sources/TerminalSSHSessionDetector.swift @@ -84,12 +84,10 @@ struct DetectedSSHSession: Equatable { _ fileURLs: [URL], operation: TerminalImageTransferOperation ) throws -> [String] { - guard !fileURLs.isEmpty else { return [] } - - var uploadedRemotePaths: [String] = [] - do { - for localURL in fileURLs { - try operation.throwIfCancelled() + try performSCPUploadWithCancelCleanup( + items: fileURLs, + checkCancelled: { try operation.throwIfCancelled() }, + performUpload: { localURL, record in let normalizedLocalURL = localURL.standardizedFileURL guard normalizedLocalURL.isFileURL else { throw NSError(domain: "programa.detected-ssh.drop", code: 1, userInfo: [ @@ -112,25 +110,16 @@ struct DetectedSSHSession: Equatable { ]) } - uploadedRemotePaths.append(remotePath) - } - - return uploadedRemotePaths - } catch { - cleanupUploadedRemotePaths(uploadedRemotePaths) - throw error - } + record(remotePath) + }, + cleanup: { cleanupUploadedRemotePaths($0) } + ) } private func scpArguments(localPath: String, remotePath: String) -> [String] { - var args: [String] = [ - "-q", - "-o", "ConnectTimeout=6", - "-o", "ServerAliveInterval=20", - "-o", "ServerAliveCountMax=2", - "-o", "BatchMode=yes", - "-o", "ControlMaster=no", - ] + var args: [String] = ["-q"] + + RemoteSSHConnectionPolicy.keepaliveArguments + + RemoteSSHConnectionPolicy.batchModeArguments if useIPv4 { args.append("-4") @@ -157,12 +146,10 @@ struct DetectedSSHSession: Equatable { } if let controlPath, !controlPath.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty, - !Self.hasSSHOptionKey(sshOptions, key: "ControlPath") { + !RemoteSSHConnectionPolicy.hasOptionKey(sshOptions, key: "ControlPath") { args += ["-o", "ControlPath=\(controlPath)"] } - if !Self.hasSSHOptionKey(sshOptions, key: "StrictHostKeyChecking") { - args += ["-o", "StrictHostKeyChecking=accept-new"] - } + args += RemoteSSHConnectionPolicy.strictHostKeyCheckingArguments(unlessSetIn: sshOptions) for option in sshOptions { args += ["-o", option] } @@ -172,14 +159,9 @@ struct DetectedSSHSession: Equatable { } private func sshArguments(command: String) -> [String] { - var args: [String] = [ - "-T", - "-o", "ConnectTimeout=6", - "-o", "ServerAliveInterval=20", - "-o", "ServerAliveCountMax=2", - "-o", "BatchMode=yes", - "-o", "ControlMaster=no", - ] + var args: [String] = ["-T"] + + RemoteSSHConnectionPolicy.keepaliveArguments + + RemoteSSHConnectionPolicy.batchModeArguments if useIPv4 { args.append("-4") @@ -206,12 +188,10 @@ struct DetectedSSHSession: Equatable { } if let controlPath, !controlPath.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty, - !Self.hasSSHOptionKey(sshOptions, key: "ControlPath") { + !RemoteSSHConnectionPolicy.hasOptionKey(sshOptions, key: "ControlPath") { args += ["-o", "ControlPath=\(controlPath)"] } - if !Self.hasSSHOptionKey(sshOptions, key: "StrictHostKeyChecking") { - args += ["-o", "StrictHostKeyChecking=accept-new"] - } + args += RemoteSSHConnectionPolicy.strictHostKeyCheckingArguments(unlessSetIn: sshOptions) for option in sshOptions { args += ["-o", option] } @@ -222,8 +202,8 @@ struct DetectedSSHSession: Equatable { private func cleanupUploadedRemotePaths(_ remotePaths: [String]) { guard !remotePaths.isEmpty else { return } - let cleanupScript = "rm -f -- " + remotePaths.map(Self.shellSingleQuoted).joined(separator: " ") - let cleanupCommand = "sh -c \(Self.shellSingleQuoted(cleanupScript))" + let cleanupScript = "rm -f -- " + remotePaths.map(RemoteSSHConnectionPolicy.shellSingleQuoted).joined(separator: " ") + let cleanupCommand = "sh -c \(RemoteSSHConnectionPolicy.shellSingleQuoted(cleanupScript))" _ = try? Self.runProcess( executable: "/usr/bin/ssh", arguments: sshArguments(command: cleanupCommand), @@ -331,21 +311,6 @@ struct DetectedSSHSession: Equatable { .first(where: { !$0.isEmpty }) } - private static func hasSSHOptionKey(_ options: [String], key: String) -> Bool { - let loweredKey = key.lowercased() - return options.contains { optionKey($0) == loweredKey } - } - - private static func optionKey(_ option: String) -> String? { - let trimmed = option.trimmingCharacters(in: .whitespacesAndNewlines) - guard !trimmed.isEmpty else { return nil } - return trimmed - .split(whereSeparator: { $0 == "=" || $0.isWhitespace }) - .first - .map(String.init)? - .lowercased() - } - private static func scpRemoteDestination(_ destination: String) -> String { let trimmedDestination = destination.trimmingCharacters(in: .whitespacesAndNewlines) guard !trimmedDestination.isEmpty else { return destination } @@ -380,10 +345,6 @@ struct DetectedSSHSession: Equatable { !trimmedHost.hasSuffix("]") } - private static func shellSingleQuoted(_ value: String) -> String { - "'" + value.replacingOccurrences(of: "'", with: "'\"'\"'") + "'" - } - #if DEBUG func scpArgumentsForTesting(localPath: String, remotePath: String) -> [String] { scpArguments(localPath: localPath, remotePath: remotePath) @@ -727,7 +688,7 @@ enum TerminalSSHSessionDetector { ) -> Bool { let trimmed = option.trimmingCharacters(in: .whitespacesAndNewlines) guard !trimmed.isEmpty else { return false } - let key = sshOptionKey(trimmed) + let key = RemoteSSHConnectionPolicy.optionKey(trimmed) let value = sshOptionValue(trimmed) switch key { @@ -780,16 +741,6 @@ enum TerminalSSHSessionDetector { return "\(loginName)@\(trimmedDestination)" } - private static func sshOptionKey(_ option: String) -> String? { - let trimmed = option.trimmingCharacters(in: .whitespacesAndNewlines) - guard !trimmed.isEmpty else { return nil } - return trimmed - .split(whereSeparator: { $0 == "=" || $0.isWhitespace }) - .first - .map(String.init)? - .lowercased() - } - private static func sshOptionValue(_ option: String) -> String? { let trimmed = option.trimmingCharacters(in: .whitespacesAndNewlines) guard !trimmed.isEmpty else { return nil } diff --git a/Sources/WorkspaceRemoteDaemon.swift b/Sources/WorkspaceRemoteDaemon.swift index 7e97c7e9701..5f44408cd75 100644 --- a/Sources/WorkspaceRemoteDaemon.swift +++ b/Sources/WorkspaceRemoteDaemon.swift @@ -105,17 +105,12 @@ final class WorkspaceRemoteDaemonPendingCallRegistry { } enum WorkspaceRemoteSSHBatchCommandBuilder { - private static let batchSSHControlOptionKeys: Set = [ - "controlmaster", - "controlpersist", - ] - static func daemonTransportArguments( configuration: WorkspaceRemoteConfiguration, remotePath: String ) -> [String] { - let script = "exec \(shellSingleQuoted(remotePath)) serve --stdio" - let command = "sh -c \(shellSingleQuoted(script))" + let script = "exec \(RemoteSSHConnectionPolicy.shellSingleQuoted(remotePath)) serve --stdio" + let command = "sh -c \(RemoteSSHConnectionPolicy.shellSingleQuoted(script))" return ["-T"] + batchArguments(configuration: configuration) + ["-o", "RequestTTY=no", configuration.destination, command] @@ -126,7 +121,7 @@ enum WorkspaceRemoteSSHBatchCommandBuilder { controlCommand: String, forwardSpec: String ) -> [String]? { - guard let controlPath = sshOptionValue(named: "ControlPath", in: configuration.sshOptions)? + guard let controlPath = RemoteSSHConnectionPolicy.optionValue(named: "ControlPath", in: configuration.sshOptions)? .trimmingCharacters(in: .whitespacesAndNewlines), !controlPath.isEmpty, controlPath.lowercased() != "none" else { @@ -139,18 +134,11 @@ enum WorkspaceRemoteSSHBatchCommandBuilder { } private static func batchArguments(configuration: WorkspaceRemoteConfiguration) -> [String] { - let effectiveSSHOptions = backgroundSSHOptions(configuration.sshOptions) - var args: [String] = [ - "-o", "ConnectTimeout=6", - "-o", "ServerAliveInterval=20", - "-o", "ServerAliveCountMax=2", - ] - if !hasSSHOptionKey(effectiveSSHOptions, key: "StrictHostKeyChecking") { - args += ["-o", "StrictHostKeyChecking=accept-new"] - } - args += ["-o", "BatchMode=yes"] + let effectiveSSHOptions = RemoteSSHConnectionPolicy.backgroundOptions(configuration.sshOptions) + var args = RemoteSSHConnectionPolicy.keepaliveArguments + args += RemoteSSHConnectionPolicy.strictHostKeyCheckingArguments(unlessSetIn: effectiveSSHOptions) // Batch helpers may reuse an existing ControlPath, but must not negotiate a new master. - args += ["-o", "ControlMaster=no"] + args += RemoteSSHConnectionPolicy.batchModeArguments if let port = configuration.port { args += ["-p", String(port)] } @@ -163,64 +151,6 @@ enum WorkspaceRemoteSSHBatchCommandBuilder { } return args } - - private static func hasSSHOptionKey(_ options: [String], key: String) -> Bool { - let loweredKey = key.lowercased() - for option in options { - if sshOptionKey(option) == loweredKey { - return true - } - } - return false - } - - private static func normalizedSSHOptions(_ options: [String]) -> [String] { - options.compactMap { option in - let trimmed = option.trimmingCharacters(in: .whitespacesAndNewlines) - guard !trimmed.isEmpty else { return nil } - return trimmed - } - } - - private static func backgroundSSHOptions(_ options: [String]) -> [String] { - normalizedSSHOptions(options).filter { option in - guard let key = sshOptionKey(option) else { return false } - return !batchSSHControlOptionKeys.contains(key) - } - } - - private static func sshOptionValue(named key: String, in options: [String]) -> String? { - let loweredKey = key.lowercased() - for option in normalizedSSHOptions(options) { - let parts = option.split( - maxSplits: 1, - omittingEmptySubsequences: true, - whereSeparator: { $0 == "=" || $0.isWhitespace } - ) - guard parts.count == 2, parts[0].lowercased() == loweredKey else { - continue - } - let value = parts[1].trimmingCharacters(in: .whitespacesAndNewlines) - if !value.isEmpty { - return value - } - } - return nil - } - - private static func sshOptionKey(_ option: String) -> String? { - let trimmed = option.trimmingCharacters(in: .whitespacesAndNewlines) - guard !trimmed.isEmpty else { return nil } - return trimmed - .split(whereSeparator: { $0 == "=" || $0.isWhitespace }) - .first - .map(String.init)? - .lowercased() - } - - private static func shellSingleQuoted(_ value: String) -> String { - "'" + value.replacingOccurrences(of: "'", with: "'\"'\"'") + "'" - } } final class WorkspaceRemoteDaemonRPCClient { @@ -674,10 +604,6 @@ final class WorkspaceRemoteDaemonRPCClient { ) } - private static func shellSingleQuoted(_ value: String) -> String { - "'" + value.replacingOccurrences(of: "'", with: "'\"'\"'") + "'" - } - private static func bestErrorLine(stderr: String) -> String? { let lines = stderr .split(separator: "\n") diff --git a/Sources/WorkspaceRemoteSession.swift b/Sources/WorkspaceRemoteSession.swift index 4444612cee4..bf1d1bd0c61 100644 --- a/Sources/WorkspaceRemoteSession.swift +++ b/Sources/WorkspaceRemoteSession.swift @@ -702,7 +702,7 @@ final class WorkspaceRemoteSessionController { bootstrapRemoteTTYFetchInFlight = true defer { bootstrapRemoteTTYFetchInFlight = false } - let command = "sh -c \(Self.shellSingleQuoted("tty_path=\"$HOME/.programa/relay/\(relayPort).tty\"; if [ -r \"$tty_path\" ]; then cat \"$tty_path\"; fi"))" + let command = "sh -c \(RemoteSSHConnectionPolicy.shellSingleQuoted("tty_path=\"$HOME/.programa/relay/\(relayPort).tty\"; if [ -r \"$tty_path\" ]; then cat \"$tty_path\"; fi"))" do { let result = try sshExec( arguments: sshCommonArguments(batchMode: true) + [configuration.destination, command], @@ -816,21 +816,14 @@ final class WorkspaceRemoteSessionController { private func sshCommonArguments(batchMode: Bool) -> [String] { let effectiveSSHOptions: [String] = { if batchMode { - return backgroundSSHOptions(configuration.sshOptions) + return RemoteSSHConnectionPolicy.backgroundOptions(configuration.sshOptions) } - return normalizedSSHOptions(configuration.sshOptions) + return RemoteSSHConnectionPolicy.normalizedOptions(configuration.sshOptions) }() - var args: [String] = [ - "-o", "ConnectTimeout=6", - "-o", "ServerAliveInterval=20", - "-o", "ServerAliveCountMax=2", - ] - if !hasSSHOptionKey(effectiveSSHOptions, key: "StrictHostKeyChecking") { - args += ["-o", "StrictHostKeyChecking=accept-new"] - } + var args = RemoteSSHConnectionPolicy.keepaliveArguments + args += RemoteSSHConnectionPolicy.strictHostKeyCheckingArguments(unlessSetIn: effectiveSSHOptions) if batchMode { - args += ["-o", "BatchMode=yes"] - args += ["-o", "ControlMaster=no"] + args += RemoteSSHConnectionPolicy.batchModeArguments } if let port = configuration.port { args += ["-p", String(port)] @@ -845,46 +838,6 @@ final class WorkspaceRemoteSessionController { return args } - private func hasSSHOptionKey(_ options: [String], key: String) -> Bool { - let loweredKey = key.lowercased() - for option in options { - let token = sshOptionKey(option) - if token == loweredKey { - return true - } - } - return false - } - - private func normalizedSSHOptions(_ options: [String]) -> [String] { - options.compactMap { option in - let trimmed = option.trimmingCharacters(in: .whitespacesAndNewlines) - guard !trimmed.isEmpty else { return nil } - return trimmed - } - } - - private func backgroundSSHOptions(_ options: [String]) -> [String] { - let batchSSHControlOptionKeys: Set = [ - "controlmaster", - "controlpersist", - ] - return normalizedSSHOptions(options).filter { option in - guard let key = sshOptionKey(option) else { return false } - return !batchSSHControlOptionKeys.contains(key) - } - } - - private func sshOptionKey(_ option: String) -> String? { - let trimmed = option.trimmingCharacters(in: .whitespacesAndNewlines) - guard !trimmed.isEmpty else { return nil } - return trimmed - .split(whereSeparator: { $0 == "=" || $0.isWhitespace }) - .first - .map(String.init)? - .lowercased() - } - private func sshExec(arguments: [String], stdin: Data? = nil, timeout: TimeInterval = 15) throws -> CommandResult { try runProcess( executable: "/usr/bin/ssh", @@ -1120,7 +1073,7 @@ final class WorkspaceRemoteSessionController { relayID: relayID, relayToken: relayToken ) - let command = "sh -c \(Self.shellSingleQuoted(script))" + let command = "sh -c \(RemoteSSHConnectionPolicy.shellSingleQuoted(script))" let result = try sshExec(arguments: sshCommonArguments(batchMode: true) + [configuration.destination, command], timeout: 8) guard result.status == 0 else { let detail = Self.bestErrorLine(stderr: result.stderr, stdout: result.stdout) ?? "ssh exited \(result.status)" @@ -1133,7 +1086,7 @@ final class WorkspaceRemoteSessionController { private func removeRemoteRelayMetadataLocked() { guard let relayPort = configuration.relayPort, relayPort > 0 else { return } let script = Self.remoteRelayMetadataCleanupScript(relayPort: relayPort) - let command = "sh -c \(Self.shellSingleQuoted(script))" + let command = "sh -c \(RemoteSSHConnectionPolicy.shellSingleQuoted(script))" do { _ = try sshExec(arguments: sshCommonArguments(batchMode: true) + [configuration.destination, command], timeout: 8) } catch { @@ -1175,7 +1128,7 @@ final class WorkspaceRemoteSessionController { printf '%sno\\n' '\(Self.remotePlatformProbeExistsMarker)' fi """ - let command = "sh -c \(Self.shellSingleQuoted(script))" + let command = "sh -c \(RemoteSSHConnectionPolicy.shellSingleQuoted(script))" let result = try sshExec(arguments: sshCommonArguments(batchMode: true) + [configuration.destination, command], timeout: 20) let lines = result.stdout @@ -1465,8 +1418,8 @@ final class WorkspaceRemoteSessionController { "remote.upload.begin local=\(localBinary.path) remoteTemp=\(remoteTempPath) remote=\(remotePath)" ) - let mkdirScript = "mkdir -p \(Self.shellSingleQuoted(remoteDirectory))" - let mkdirCommand = "sh -c \(Self.shellSingleQuoted(mkdirScript))" + let mkdirScript = "mkdir -p \(RemoteSSHConnectionPolicy.shellSingleQuoted(remoteDirectory))" + let mkdirCommand = "sh -c \(RemoteSSHConnectionPolicy.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)" @@ -1475,11 +1428,9 @@ final class WorkspaceRemoteSessionController { ]) } - let scpSSHOptions = backgroundSSHOptions(configuration.sshOptions) + let scpSSHOptions = RemoteSSHConnectionPolicy.backgroundOptions(configuration.sshOptions) var scpArgs: [String] = ["-q"] - if !hasSSHOptionKey(scpSSHOptions, key: "StrictHostKeyChecking") { - scpArgs += ["-o", "StrictHostKeyChecking=accept-new"] - } + scpArgs += RemoteSSHConnectionPolicy.strictHostKeyCheckingArguments(unlessSetIn: scpSSHOptions) scpArgs += ["-o", "ControlMaster=no"] if let port = configuration.port { scpArgs += ["-P", String(port)] @@ -1501,10 +1452,10 @@ final class WorkspaceRemoteSessionController { } let finalizeScript = """ - chmod 755 \(Self.shellSingleQuoted(remoteTempPath)) && \ - mv \(Self.shellSingleQuoted(remoteTempPath)) \(Self.shellSingleQuoted(remotePath)) + chmod 755 \(RemoteSSHConnectionPolicy.shellSingleQuoted(remoteTempPath)) && \ + mv \(RemoteSSHConnectionPolicy.shellSingleQuoted(remoteTempPath)) \(RemoteSSHConnectionPolicy.shellSingleQuoted(remotePath)) """ - let finalizeCommand = "sh -c \(Self.shellSingleQuoted(finalizeScript))" + let finalizeCommand = "sh -c \(RemoteSSHConnectionPolicy.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)" @@ -1518,24 +1469,20 @@ final class WorkspaceRemoteSessionController { _ fileURLs: [URL], operation: TerminalImageTransferOperation ) throws -> [String] { - guard !fileURLs.isEmpty else { return [] } - - let scpSSHOptions = backgroundSSHOptions(configuration.sshOptions) - var uploadedRemotePaths: [String] = [] - do { - for localURL in fileURLs { - try operation.throwIfCancelled() + let scpSSHOptions = RemoteSSHConnectionPolicy.backgroundOptions(configuration.sshOptions) + return try performSCPUploadWithCancelCleanup( + items: fileURLs, + checkCancelled: { try operation.throwIfCancelled() }, + performUpload: { localURL, record in let normalizedLocalURL = localURL.standardizedFileURL guard normalizedLocalURL.isFileURL else { throw RemoteDropUploadError.invalidFileURL } let remotePath = Self.remoteDropPath(for: normalizedLocalURL) - uploadedRemotePaths.append(remotePath) + record(remotePath) var scpArgs: [String] = ["-q", "-o", "ControlMaster=no"] - if !hasSSHOptionKey(scpSSHOptions, key: "StrictHostKeyChecking") { - scpArgs += ["-o", "StrictHostKeyChecking=accept-new"] - } + scpArgs += RemoteSSHConnectionPolicy.strictHostKeyCheckingArguments(unlessSetIn: scpSSHOptions) if let port = configuration.port { scpArgs += ["-P", String(port)] } @@ -1554,12 +1501,9 @@ final class WorkspaceRemoteSessionController { "scp exited \(scpResult.status)" throw RemoteDropUploadError.uploadFailed(detail) } - } - return uploadedRemotePaths - } catch { - cleanupUploadedRemotePaths(uploadedRemotePaths) - throw error - } + }, + cleanup: { cleanupUploadedRemotePaths($0) } + ) } static func remoteDropPath(for fileURL: URL, uuid: UUID = UUID()) -> String { @@ -1570,8 +1514,8 @@ final class WorkspaceRemoteSessionController { private func cleanupUploadedRemotePaths(_ remotePaths: [String]) { guard !remotePaths.isEmpty else { return } - let cleanupScript = "rm -f -- " + remotePaths.map(Self.shellSingleQuoted).joined(separator: " ") - let cleanupCommand = "sh -c \(Self.shellSingleQuoted(cleanupScript))" + let cleanupScript = "rm -f -- " + remotePaths.map(RemoteSSHConnectionPolicy.shellSingleQuoted).joined(separator: " ") + let cleanupCommand = "sh -c \(RemoteSSHConnectionPolicy.shellSingleQuoted(cleanupScript))" _ = try? sshExec( arguments: sshCommonArguments(batchMode: true) + [configuration.destination, cleanupCommand], timeout: 8 @@ -1580,8 +1524,8 @@ final class WorkspaceRemoteSessionController { 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 -c \(Self.shellSingleQuoted(script))" + let script = "printf '%s\\n' \(RemoteSSHConnectionPolicy.shellSingleQuoted(request)) | \(RemoteSSHConnectionPolicy.shellSingleQuoted(remotePath)) serve --stdio" + let command = "sh -c \(RemoteSSHConnectionPolicy.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)" @@ -1645,7 +1589,7 @@ final class WorkspaceRemoteSessionController { private func debugShellCommand(executable: String, arguments: [String]) -> String { ([URL(fileURLWithPath: executable).lastPathComponent] + arguments) - .map(Self.shellSingleQuoted) + .map(RemoteSSHConnectionPolicy.shellSingleQuoted) .joined(separator: " ") } @@ -1675,10 +1619,6 @@ final class WorkspaceRemoteSessionController { return String(normalized.prefix(limit)) + "..." } - private static func shellSingleQuoted(_ value: String) -> String { - "'" + value.replacingOccurrences(of: "'", with: "'\"'\"'") + "'" - } - static func remoteCLIWrapperScript() -> String { """ #!/bin/sh @@ -2293,7 +2233,7 @@ final class WorkspaceRemoteSessionController { let ttyNames = Array(Set(ttyNamesByPanel.values)).sorted() guard !ttyNames.isEmpty else { return [:] } - let command = "sh -c \(Self.shellSingleQuoted(Self.remotePortScanScript(ttyNames: ttyNames, excluding: excludedRemoteScanPorts())))" + let command = "sh -c \(RemoteSSHConnectionPolicy.shellSingleQuoted(Self.remotePortScanScript(ttyNames: ttyNames, excluding: excludedRemoteScanPorts())))" let result = try sshExec( arguments: sshCommonArguments(batchMode: true) + [configuration.destination, command], timeout: 8 @@ -2387,7 +2327,7 @@ final class WorkspaceRemoteSessionController { return } - let command = "sh -c \(Self.shellSingleQuoted(Self.remoteAllPortsScanScript(excluding: excludedRemoteScanPorts())))" + let command = "sh -c \(RemoteSSHConnectionPolicy.shellSingleQuoted(Self.remoteAllPortsScanScript(excluding: excludedRemoteScanPorts())))" do { let result = try sshExec( arguments: sshCommonArguments(batchMode: true) + [configuration.destination, command], From 32e8c204ee9e0d78922fdd69a7aeb15842c01e20 Mon Sep 17 00:00:00 2001 From: arzafran Date: Thu, 9 Jul 2026 14:01:56 -0300 Subject: [PATCH 10/19] refactor: extract handleCustomShortcut app-shortcut dispatch table handleCustomShortcut(event:) was a single 966-line function with ~55 sequential if-checks. Documents the discovered precedence in a comment (palette > browser/terminal pre-checks > app-shortcut) and extracts the lowest-precedence phase -- the flat table of ~55 matchConfiguredShortcut / numberedConfiguredShortcutDigit / matchConfiguredDirectionalShortcut / matchTabShortcut checks -- into handleConfiguredAppShortcutActions(event: commandPaletteTargetWindow:hasFocusedAddressBarInShortcutContext:). This is pure tail-call code motion: the extracted function is byte-identical to the original tail of handleCustomShortcut (same statements, same order, same early returns), so evaluation order and behavior are unchanged. The higher-precedence palette and browser/terminal pre-check phases are left grouped in place per the conservative fallback (group into sub-functions without changing evaluation order) rather than risking a full reorder of a typing-latency-sensitive, tightly state-coupled dispatch chain. Refs #95. --- Sources/AppDelegate.swift | 33 +++++++++++++++++++++++++++++++++ 1 file changed, 33 insertions(+) diff --git a/Sources/AppDelegate.swift b/Sources/AppDelegate.swift index 0f6bb677d90..de59729e1da 100644 --- a/Sources/AppDelegate.swift +++ b/Sources/AppDelegate.swift @@ -5981,6 +5981,23 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent return true } + // Precedence encoded by this function's evaluation order (each phase is checked + // strictly after the previous one; the first phase that returns wins). Refs #95. + // 1. Setup: chord-prefix bookkeeping, Ctrl+D debug probe, close-confirmation-alert + // passthrough, modal/sheet passthrough, command-palette window/state computation. + // 2. Palette (highest real precedence): Escape-key routing (palette dismiss / + // suppressed-escape grace window), palette selection-navigation (arrow keys), + // palette interactive Return/dismiss handling, stale browser-address-bar-focus + // clear, palette "effective" actions (open palette / go-to-workspace + their + // chord arming), shouldConsumeShortcutWhileCommandPaletteVisible catch-all. + // 3. Browser/terminal pre-checks: terminal IME marked-text passthrough, notifications + // popover escape/typing consumption, shortcut-routing-context sync guard, Ctrl+D + // terminal-focus reconcile bypass, browser omnibar Cmd/Ctrl+N/P and arrow-key + // selection, empty-flags fast-path passthrough, browser-address-bar Emacs-nav + // bypass. + // 4. App-shortcut (lowest precedence, only reached once nothing above claimed the + // event): the flat table of ~55 `matchConfiguredShortcut`/digit/directional/tab + // checks, extracted verbatim into handleConfiguredAppShortcutActions(event:...). private func handleCustomShortcut(event: NSEvent) -> Bool { // `charactersIgnoringModifiers` can be nil for some synthetic NSEvents and certain special keys. // Treat nil as "" and rely on keyCode/layout-aware fallback logic where needed. @@ -6389,6 +6406,22 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent return false } + return handleConfiguredAppShortcutActions( + event: event, + commandPaletteTargetWindow: commandPaletteTargetWindow, + hasFocusedAddressBarInShortcutContext: hasFocusedAddressBarInShortcutContext + ) + } + + // Extracted verbatim from the tail of handleCustomShortcut(event:) -- the flat table of + // ~55 app-level shortcut checks (lowest precedence phase; only reached once the palette + // and browser/terminal pre-checks above have declined the event). Evaluation order is + // preserved exactly: this is pure code motion, not a reordering. Refs #95. + private func handleConfiguredAppShortcutActions( + event: NSEvent, + commandPaletteTargetWindow: NSWindow?, + hasFocusedAddressBarInShortcutContext: Bool + ) -> Bool { if activeConfiguredShortcutChordPrefixForCurrentEvent == nil, armConfiguredShortcutChordIfNeeded(event: event) { return true From d161e9f5b945590fd689db024f8171be10089332 Mon Sep 17 00:00:00 2001 From: arzafran Date: Thu, 9 Jul 2026 14:03:37 -0300 Subject: [PATCH 11/19] fix(contentview): widen access for symbols split across the #94 file moves MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit TabItemView.swift, FileDropOverlayView.swift, and CommandPaletteSearchEngine.swift now live in their own files, so file-private symbols they depend on (or that depend on them) that remain in ContentView.swift need at least internal visibility: - SidebarDragAutoScrollController, SidebarTabItemSettingsSnapshot, SidebarTabDragPayload, BonsplitTabDragPayload, SidebarBonsplitTabDropDelegate, SidebarTabDropDelegate, MiddleClickCapture, MiddleClickCaptureView — used by the extracted TabItemView. - coloredCircleImage, debugCommandPaletteTextPreview, the Color(hex:) initializer — top-level helpers used by the extracted TabItemView. FileDropOverlayView.swift needs `import Bonsplit` for dlog() (per CLAUDE.md's dlog-needs-bonsplit-import note). No behavior changes — access-level widening only. Refs #94. --- Sources/ContentView.swift | 22 +++++++++++----------- Sources/FileDropOverlayView.swift | 1 + 2 files changed, 12 insertions(+), 11 deletions(-) diff --git a/Sources/ContentView.swift b/Sources/ContentView.swift index 656147c7d8c..4fb7fd19783 100644 --- a/Sources/ContentView.swift +++ b/Sources/ContentView.swift @@ -7,7 +7,7 @@ import ObjectiveC import UniformTypeIdentifiers import WebKit -private extension Color { +extension Color { init?(hex: String) { let hex = hex.trimmingCharacters(in: .init(charactersIn: "#")) guard hex.count == 6, let value = UInt64(hex, radix: 16) else { return nil } @@ -19,7 +19,7 @@ private extension Color { } } -private func coloredCircleImage(color: NSColor) -> NSImage { +func coloredCircleImage(color: NSColor) -> NSImage { let size = NSSize(width: 14, height: 14) let image = NSImage(size: size, flipped: false) { rect in color.setFill() @@ -536,7 +536,7 @@ private func debugCommandPaletteKeyEventSummary(_ event: NSEvent) -> String { "chars=\(chars) charsIgnoring=\(charsIgnoring)" } -private func debugCommandPaletteTextPreview(_ text: String, limit: Int = 120) -> String { +func debugCommandPaletteTextPreview(_ text: String, limit: Int = 120) -> String { let escaped = text .replacingOccurrences(of: "\\", with: "\\\\") .replacingOccurrences(of: "\n", with: "\\n") @@ -7262,7 +7262,7 @@ private struct SidebarResizerAccessibilityModifier: ViewModifier { } } -private struct SidebarTabItemSettingsSnapshot: Equatable { +struct SidebarTabItemSettingsSnapshot: Equatable { let sidebarShortcutHintXOffset: Double let sidebarShortcutHintYOffset: Double let alwaysShowShortcutHints: Bool @@ -8962,7 +8962,7 @@ enum SidebarDragAutoScrollPlanner { } @MainActor -private final class SidebarDragAutoScrollController: ObservableObject { +final class SidebarDragAutoScrollController: ObservableObject { private weak var scrollView: NSScrollView? private var timer: Timer? private var activePlan: SidebarAutoScrollPlan? @@ -9095,7 +9095,7 @@ private final class SidebarDragAutoScrollController: ObservableObject { } } -private enum SidebarTabDragPayload { +enum SidebarTabDragPayload { static let typeIdentifier = "com.darkroom.programa.sidebar-tab-reorder" static let dropContentType = UTType(exportedAs: typeIdentifier) static let dropContentTypes: [UTType] = [dropContentType] @@ -9112,7 +9112,7 @@ private enum SidebarTabDragPayload { } } -private enum BonsplitTabDragPayload { +enum BonsplitTabDragPayload { static let typeIdentifier = "com.splittabbar.tabtransfer" static let dropContentType = UTType(exportedAs: typeIdentifier) static let dropContentTypes: [UTType] = [dropContentType] @@ -9167,7 +9167,7 @@ private enum BonsplitTabDragPayload { } } -private struct SidebarBonsplitTabDropDelegate: DropDelegate { +struct SidebarBonsplitTabDropDelegate: DropDelegate { let targetWorkspaceId: UUID let tabManager: TabManager @Binding var selectedTabIds: Set @@ -9219,7 +9219,7 @@ private struct SidebarBonsplitTabDropDelegate: DropDelegate { } } -private struct SidebarTabDropDelegate: DropDelegate { +struct SidebarTabDropDelegate: DropDelegate { let targetTabId: UUID? let tabManager: TabManager @Binding var draggedTabId: UUID? @@ -9358,7 +9358,7 @@ private struct SidebarTabDropDelegate: DropDelegate { } } -private struct MiddleClickCapture: NSViewRepresentable { +struct MiddleClickCapture: NSViewRepresentable { let onMiddleClick: () -> Void func makeNSView(context: Context) -> MiddleClickCaptureView { @@ -9372,7 +9372,7 @@ private struct MiddleClickCapture: NSViewRepresentable { } } -private final class MiddleClickCaptureView: NSView { +final class MiddleClickCaptureView: NSView { var onMiddleClick: (() -> Void)? override func hitTest(_ point: NSPoint) -> NSView? { diff --git a/Sources/FileDropOverlayView.swift b/Sources/FileDropOverlayView.swift index 6d403707d14..dcff59c5369 100644 --- a/Sources/FileDropOverlayView.swift +++ b/Sources/FileDropOverlayView.swift @@ -2,6 +2,7 @@ // Pure move — behavior-identical relocation. import AppKit +import Bonsplit import WebKit From 33132b14df1f06202d15a2ffe028f6a908917dec Mon Sep 17 00:00:00 2001 From: arzafran Date: Thu, 9 Jul 2026 14:04:25 -0300 Subject: [PATCH 12/19] refactor(workspace-remote): split WorkspaceRemoteDaemon.swift by type MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit WorkspaceRemoteDaemon.swift (2226 lines) held nine top-level type declarations covering unrelated concerns: RPC pending-call bookkeeping, SSH batch-command argument building, the daemon RPC client, HTTP request/response rewriting for the loopback proxy alias host, the local proxy tunnel plus its broker, and the CLI relay server. Split into six files along those seams, moving each type verbatim (no code changes): - WorkspaceRemoteDaemonPendingCallRegistry.swift - WorkspaceRemoteSSHBatchCommandBuilder.swift - WorkspaceRemoteDaemonRPCClient.swift - WorkspaceRemoteLoopbackHTTPRewriting.swift (RemoteLoopbackHTTPRequestRewriter, RemoteLoopbackHTTPRequestStreamRewriter, RemoteLoopbackHTTPResponseRewriter — kept together, they're the cohesive HTTP-rewriting subsystem) - WorkspaceRemoteProxyBroker.swift (WorkspaceRemoteDaemonProxyTunnel + WorkspaceRemoteProxyBroker — kept together since the tunnel is declared private and is only used by the broker; splitting them would have required widening its access level, which this move-only pass avoids) - WorkspaceRemoteCLIRelayServer.swift No access-level changes were needed — every moved type was already internal by default, aside from the private WorkspaceRemoteDaemonProxyTunnel which stayed paired with its only caller. project.pbxproj updated: old WorkspaceRemoteDaemon.swift entries replaced with the six new files (main app target only). Refs #98. --- GhosttyTabs.xcodeproj/project.pbxproj | 28 +- Sources/WorkspaceRemoteCLIRelayServer.swift | 491 ++++ Sources/WorkspaceRemoteDaemon.swift | 2226 ----------------- ...spaceRemoteDaemonPendingCallRegistry.swift | 103 + Sources/WorkspaceRemoteDaemonRPCClient.swift | 485 ++++ ...WorkspaceRemoteLoopbackHTTPRewriting.swift | 258 ++ Sources/WorkspaceRemoteProxyBroker.swift | 881 +++++++ ...orkspaceRemoteSSHBatchCommandBuilder.swift | 60 + 8 files changed, 2302 insertions(+), 2230 deletions(-) create mode 100644 Sources/WorkspaceRemoteCLIRelayServer.swift delete mode 100644 Sources/WorkspaceRemoteDaemon.swift create mode 100644 Sources/WorkspaceRemoteDaemonPendingCallRegistry.swift create mode 100644 Sources/WorkspaceRemoteDaemonRPCClient.swift create mode 100644 Sources/WorkspaceRemoteLoopbackHTTPRewriting.swift create mode 100644 Sources/WorkspaceRemoteProxyBroker.swift create mode 100644 Sources/WorkspaceRemoteSSHBatchCommandBuilder.swift diff --git a/GhosttyTabs.xcodeproj/project.pbxproj b/GhosttyTabs.xcodeproj/project.pbxproj index 4c301506252..7e8fafe7427 100644 --- a/GhosttyTabs.xcodeproj/project.pbxproj +++ b/GhosttyTabs.xcodeproj/project.pbxproj @@ -53,7 +53,12 @@ A5001405 /* PanelContentView.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5001415 /* PanelContentView.swift */; }; A5001406 /* Workspace.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5001416 /* Workspace.swift */; }; A5FF0012 /* WorkspaceRemoteSession.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5FF0002 /* WorkspaceRemoteSession.swift */; }; - A5FF0011 /* WorkspaceRemoteDaemon.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5FF0001 /* WorkspaceRemoteDaemon.swift */; }; + NRWS00000000000000000006 /* WorkspaceRemoteDaemonPendingCallRegistry.swift in Sources */ = {isa = PBXBuildFile; fileRef = NRWS00000000000000000005 /* WorkspaceRemoteDaemonPendingCallRegistry.swift */; }; + NRWS00000000000000000008 /* WorkspaceRemoteSSHBatchCommandBuilder.swift in Sources */ = {isa = PBXBuildFile; fileRef = NRWS00000000000000000007 /* WorkspaceRemoteSSHBatchCommandBuilder.swift */; }; + NRWS00000000000000000010 /* WorkspaceRemoteDaemonRPCClient.swift in Sources */ = {isa = PBXBuildFile; fileRef = NRWS00000000000000000009 /* WorkspaceRemoteDaemonRPCClient.swift */; }; + NRWS00000000000000000012 /* WorkspaceRemoteLoopbackHTTPRewriting.swift in Sources */ = {isa = PBXBuildFile; fileRef = NRWS00000000000000000011 /* WorkspaceRemoteLoopbackHTTPRewriting.swift */; }; + NRWS00000000000000000014 /* WorkspaceRemoteProxyBroker.swift in Sources */ = {isa = PBXBuildFile; fileRef = NRWS00000000000000000013 /* WorkspaceRemoteProxyBroker.swift */; }; + NRWS00000000000000000016 /* WorkspaceRemoteCLIRelayServer.swift in Sources */ = {isa = PBXBuildFile; fileRef = NRWS00000000000000000015 /* WorkspaceRemoteCLIRelayServer.swift */; }; NRWS00000000000000000002 /* RemoteSSHConnectionPolicy.swift in Sources */ = {isa = PBXBuildFile; fileRef = NRWS00000000000000000001 /* RemoteSSHConnectionPolicy.swift */; }; NRWS00000000000000000004 /* RemoteSCPUpload.swift in Sources */ = {isa = PBXBuildFile; fileRef = NRWS00000000000000000003 /* RemoteSCPUpload.swift */; }; A5001407 /* WorkspaceContentView.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5001417 /* WorkspaceContentView.swift */; }; @@ -279,7 +284,12 @@ A5001419 /* MarkdownPanelView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Panels/MarkdownPanelView.swift; sourceTree = ""; }; A5001416 /* Workspace.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Workspace.swift; sourceTree = ""; }; A5FF0002 /* WorkspaceRemoteSession.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WorkspaceRemoteSession.swift; sourceTree = ""; }; - A5FF0001 /* WorkspaceRemoteDaemon.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WorkspaceRemoteDaemon.swift; sourceTree = ""; }; + NRWS00000000000000000005 /* WorkspaceRemoteDaemonPendingCallRegistry.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WorkspaceRemoteDaemonPendingCallRegistry.swift; sourceTree = ""; }; + NRWS00000000000000000007 /* WorkspaceRemoteSSHBatchCommandBuilder.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WorkspaceRemoteSSHBatchCommandBuilder.swift; sourceTree = ""; }; + NRWS00000000000000000009 /* WorkspaceRemoteDaemonRPCClient.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WorkspaceRemoteDaemonRPCClient.swift; sourceTree = ""; }; + NRWS00000000000000000011 /* WorkspaceRemoteLoopbackHTTPRewriting.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WorkspaceRemoteLoopbackHTTPRewriting.swift; sourceTree = ""; }; + NRWS00000000000000000013 /* WorkspaceRemoteProxyBroker.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WorkspaceRemoteProxyBroker.swift; sourceTree = ""; }; + NRWS00000000000000000015 /* WorkspaceRemoteCLIRelayServer.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WorkspaceRemoteCLIRelayServer.swift; sourceTree = ""; }; NRWS00000000000000000001 /* RemoteSSHConnectionPolicy.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RemoteSSHConnectionPolicy.swift; sourceTree = ""; }; NRWS00000000000000000003 /* RemoteSCPUpload.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RemoteSCPUpload.swift; sourceTree = ""; }; A5001417 /* WorkspaceContentView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WorkspaceContentView.swift; sourceTree = ""; }; @@ -522,7 +532,12 @@ A5001511 /* UITestRecorder.swift */, A5001416 /* Workspace.swift */, A5FF0002 /* WorkspaceRemoteSession.swift */, - A5FF0001 /* WorkspaceRemoteDaemon.swift */, + NRWS00000000000000000005 /* WorkspaceRemoteDaemonPendingCallRegistry.swift */, + NRWS00000000000000000007 /* WorkspaceRemoteSSHBatchCommandBuilder.swift */, + NRWS00000000000000000009 /* WorkspaceRemoteDaemonRPCClient.swift */, + NRWS00000000000000000011 /* WorkspaceRemoteLoopbackHTTPRewriting.swift */, + NRWS00000000000000000013 /* WorkspaceRemoteProxyBroker.swift */, + NRWS00000000000000000015 /* WorkspaceRemoteCLIRelayServer.swift */, NRWS00000000000000000001 /* RemoteSSHConnectionPolicy.swift */, NRWS00000000000000000003 /* RemoteSCPUpload.swift */, A5001417 /* WorkspaceContentView.swift */, @@ -854,7 +869,12 @@ A5001501 /* UITestRecorder.swift in Sources */, A5001406 /* Workspace.swift in Sources */, A5FF0012 /* WorkspaceRemoteSession.swift in Sources */, - A5FF0011 /* WorkspaceRemoteDaemon.swift in Sources */, + NRWS00000000000000000006 /* WorkspaceRemoteDaemonPendingCallRegistry.swift in Sources */, + NRWS00000000000000000008 /* WorkspaceRemoteSSHBatchCommandBuilder.swift in Sources */, + NRWS00000000000000000010 /* WorkspaceRemoteDaemonRPCClient.swift in Sources */, + NRWS00000000000000000012 /* WorkspaceRemoteLoopbackHTTPRewriting.swift in Sources */, + NRWS00000000000000000014 /* WorkspaceRemoteProxyBroker.swift in Sources */, + NRWS00000000000000000016 /* WorkspaceRemoteCLIRelayServer.swift in Sources */, NRWS00000000000000000002 /* RemoteSSHConnectionPolicy.swift in Sources */, NRWS00000000000000000004 /* RemoteSCPUpload.swift in Sources */, A5001407 /* WorkspaceContentView.swift in Sources */, diff --git a/Sources/WorkspaceRemoteCLIRelayServer.swift b/Sources/WorkspaceRemoteCLIRelayServer.swift new file mode 100644 index 00000000000..77e77d0c621 --- /dev/null +++ b/Sources/WorkspaceRemoteCLIRelayServer.swift @@ -0,0 +1,491 @@ +// Extracted from WorkspaceRemoteDaemon.swift (nuclear-review #98): the CLI relay server exposed to the remote daemon. + +import Foundation +import SwiftUI +import AppKit +import Bonsplit +import Combine +import CryptoKit +import Darwin +import Network +import CoreText + +final class WorkspaceRemoteCLIRelayServer { + private final class Session { + private enum Phase { + case awaitingAuth + case awaitingCommand + case forwarding + case closed + } + + private let connection: NWConnection + private let localSocketPath: String + private let relayID: String + private let relayToken: Data + private let queue: DispatchQueue + private let onClose: () -> Void + private let challengeProtocol = "programa-relay-auth" + private let challengeVersion = 1 + private let minimumFailureDelay: TimeInterval = 0.05 + private let maximumFrameBytes = 16 * 1024 + + private var buffer = Data() + private var phase: Phase = .awaitingAuth + private var challengeNonce = "" + private var challengeSentAt = Date() + private var isClosed = false + + init( + connection: NWConnection, + localSocketPath: String, + relayID: String, + relayToken: Data, + queue: DispatchQueue, + onClose: @escaping () -> Void + ) { + self.connection = connection + self.localSocketPath = localSocketPath + self.relayID = relayID + self.relayToken = relayToken + self.queue = queue + self.onClose = onClose + } + + func start() { + connection.stateUpdateHandler = { [weak self] state in + self?.queue.async { + self?.handleState(state) + } + } + connection.start(queue: queue) + } + + func stop() { + close() + } + + private func handleState(_ state: NWConnection.State) { + guard !isClosed else { return } + switch state { + case .ready: + sendChallenge() + receive() + case .failed, .cancelled: + close() + default: + break + } + } + + private func sendChallenge() { + challengeSentAt = Date() + challengeNonce = Self.randomHex(byteCount: 16) + let challenge: [String: Any] = [ + "protocol": challengeProtocol, + "version": challengeVersion, + "relay_id": relayID, + "nonce": challengeNonce, + ] + sendJSONLine(challenge) { _ in } + } + + private func receive() { + guard !isClosed else { return } + connection.receive(minimumIncompleteLength: 1, maximumLength: maximumFrameBytes) { [weak self] data, _, isComplete, error in + guard let self else { return } + self.queue.async { + if error != nil { + self.close() + return + } + if let data, !data.isEmpty { + self.buffer.append(data) + if self.buffer.count > self.maximumFrameBytes { + self.sendFailureAndClose() + return + } + self.processBufferedLines() + } + if isComplete { + self.close() + return + } + if !self.isClosed { + self.receive() + } + } + } + } + + private func processBufferedLines() { + while let newlineIndex = buffer.firstIndex(of: 0x0A), !isClosed { + let lineData = buffer.prefix(upTo: newlineIndex) + buffer.removeSubrange(...newlineIndex) + let line = String(data: lineData, encoding: .utf8)? + .trimmingCharacters(in: .whitespacesAndNewlines) ?? "" + switch phase { + case .awaitingAuth: + handleAuthLine(line) + case .awaitingCommand: + handleCommandLine(Data(lineData) + Data([0x0A])) + case .forwarding, .closed: + return + } + } + } + + private func handleAuthLine(_ line: String) { + guard let data = line.data(using: .utf8), + let object = try? JSONSerialization.jsonObject(with: data) as? [String: Any], + let receivedRelayID = object["relay_id"] as? String, + receivedRelayID == relayID, + let macHex = object["mac"] as? String, + let receivedMAC = Self.hexData(from: macHex) + else { + sendFailureAndClose() + return + } + + let message = Self.authMessage(relayID: relayID, nonce: challengeNonce, version: challengeVersion) + let expectedMAC = Self.authMAC(token: relayToken, message: message) + guard Self.constantTimeEqual(receivedMAC, expectedMAC) else { + sendFailureAndClose() + return + } + + phase = .awaitingCommand + sendJSONLine(["ok": true]) { [weak self] _ in + self?.queue.async { + self?.processBufferedLines() + } + } + } + + private func handleCommandLine(_ commandLine: Data) { + guard !commandLine.isEmpty else { + sendFailureAndClose() + return + } + phase = .forwarding + DispatchQueue.global(qos: .utility).async { [localSocketPath, commandLine, queue] in + let result = Result { try Self.roundTripUnixSocket(socketPath: localSocketPath, request: commandLine) } + queue.async { [weak self] in + guard let self else { return } + switch result { + case .success(let response): + self.connection.send(content: response, completion: .contentProcessed { [weak self] _ in + self?.queue.async { + self?.close() + } + }) + case .failure: + self.sendFailureAndClose() + } + } + } + } + + private func sendFailureAndClose() { + let elapsed = Date().timeIntervalSince(challengeSentAt) + let delay = max(0, minimumFailureDelay - elapsed) + phase = .closed + queue.asyncAfter(deadline: .now() + delay) { [weak self] in + self?.sendJSONLine(["ok": false]) { [weak self] _ in + self?.queue.async { + self?.close() + } + } + } + } + + private func sendJSONLine(_ object: [String: Any], completion: @escaping (NWError?) -> Void) { + guard !isClosed else { + completion(nil) + return + } + guard let payload = try? JSONSerialization.data(withJSONObject: object) else { + completion(nil) + return + } + connection.send(content: payload + Data([0x0A]), completion: .contentProcessed(completion)) + } + + private func close() { + guard !isClosed else { return } + isClosed = true + phase = .closed + connection.stateUpdateHandler = nil + connection.cancel() + onClose() + } + + private static func authMessage(relayID: String, nonce: String, version: Int) -> Data { + Data("relay_id=\(relayID)\nnonce=\(nonce)\nversion=\(version)".utf8) + } + + private static func authMAC(token: Data, message: Data) -> Data { + let key = SymmetricKey(data: token) + let code = HMAC.authenticationCode(for: message, using: key) + return Data(code) + } + + private static func constantTimeEqual(_ lhs: Data, _ rhs: Data) -> Bool { + guard lhs.count == rhs.count else { return false } + var diff: UInt8 = 0 + for index in lhs.indices { + diff |= lhs[index] ^ rhs[index] + } + return diff == 0 + } + + fileprivate static func hexData(from string: String) -> Data? { + let normalized = string.trimmingCharacters(in: .whitespacesAndNewlines) + guard normalized.count.isMultiple(of: 2), !normalized.isEmpty else { return nil } + var data = Data(capacity: normalized.count / 2) + var cursor = normalized.startIndex + while cursor < normalized.endIndex { + let next = normalized.index(cursor, offsetBy: 2) + guard let byte = UInt8(normalized[cursor.. String { + var bytes = [UInt8](repeating: 0, count: byteCount) + _ = SecRandomCopyBytes(kSecRandomDefault, bytes.count, &bytes) + return bytes.map { String(format: "%02x", $0) }.joined() + } + + private static func roundTripUnixSocket(socketPath: String, request: Data) throws -> Data { + let fd = socket(AF_UNIX, SOCK_STREAM, 0) + guard fd >= 0 else { + throw NSError(domain: "programa.remote.relay", code: 1, userInfo: [ + NSLocalizedDescriptionKey: "failed to create local relay socket", + ]) + } + defer { Darwin.close(fd) } + + var timeout = timeval(tv_sec: 15, tv_usec: 0) + withUnsafePointer(to: &timeout) { pointer in + _ = setsockopt(fd, SOL_SOCKET, SO_RCVTIMEO, pointer, socklen_t(MemoryLayout.size)) + _ = setsockopt(fd, SOL_SOCKET, SO_SNDTIMEO, pointer, socklen_t(MemoryLayout.size)) + } + + var address = sockaddr_un() + address.sun_family = sa_family_t(AF_UNIX) + let pathBytes = Array(socketPath.utf8CString) + guard pathBytes.count <= MemoryLayout.size(ofValue: address.sun_path) else { + throw NSError(domain: "programa.remote.relay", code: 2, userInfo: [ + NSLocalizedDescriptionKey: "local relay socket path is too long", + ]) + } + let sunPathOffset = MemoryLayout.offset(of: \.sun_path) ?? 0 + withUnsafeMutableBytes(of: &address) { rawBuffer in + let destination = rawBuffer.baseAddress!.advanced(by: sunPathOffset) + pathBytes.withUnsafeBytes { pathBuffer in + destination.copyMemory(from: pathBuffer.baseAddress!, byteCount: pathBytes.count) + } + } + + let addressLength = socklen_t(MemoryLayout.size(ofValue: address.sun_family) + pathBytes.count) + let connectResult = withUnsafePointer(to: &address) { + $0.withMemoryRebound(to: sockaddr.self, capacity: 1) { + Darwin.connect(fd, $0, addressLength) + } + } + guard connectResult == 0 else { + throw NSError(domain: "programa.remote.relay", code: 3, userInfo: [ + NSLocalizedDescriptionKey: "failed to connect to local cmux socket", + ]) + } + + try request.withUnsafeBytes { rawBuffer in + guard let baseAddress = rawBuffer.bindMemory(to: UInt8.self).baseAddress else { return } + var bytesRemaining = rawBuffer.count + var pointer = baseAddress + while bytesRemaining > 0 { + let written = Darwin.write(fd, pointer, bytesRemaining) + if written <= 0 { + throw NSError(domain: "programa.remote.relay", code: 4, userInfo: [ + NSLocalizedDescriptionKey: "failed to write relay request", + ]) + } + bytesRemaining -= written + pointer = pointer.advanced(by: written) + } + } + _ = shutdown(fd, SHUT_WR) + + var response = Data() + var scratch = [UInt8](repeating: 0, count: 4096) + while true { + let count = Darwin.read(fd, &scratch, scratch.count) + if count > 0 { + response.append(scratch, count: count) + continue + } + if count == 0 { + break + } + + if errno == EAGAIN || errno == EWOULDBLOCK { + if !response.isEmpty { + break + } + throw NSError(domain: "programa.remote.relay", code: 5, userInfo: [ + NSLocalizedDescriptionKey: "timed out waiting for local cmux response", + ]) + } + throw NSError(domain: "programa.remote.relay", code: 6, userInfo: [ + NSLocalizedDescriptionKey: "failed to read local cmux response", + ]) + } + return response + } + } + + private let localSocketPath: String + private let relayID: String + private let relayToken: Data + private let queue = DispatchQueue(label: "com.cmux.remote-ssh.cli-relay.\(UUID().uuidString)", qos: .utility) + + private var listener: NWListener? + private var sessions: [UUID: Session] = [:] + private var isStopped = false + private(set) var localPort: Int? + + init(localSocketPath: String, relayID: String, relayTokenHex: String) throws { + guard let relayToken = Session.hexData(from: relayTokenHex), !relayToken.isEmpty else { + throw NSError(domain: "programa.remote.relay", code: 7, userInfo: [ + NSLocalizedDescriptionKey: "invalid relay token", + ]) + } + self.localSocketPath = localSocketPath + self.relayID = relayID + self.relayToken = relayToken + } + + func start() throws -> Int { + if let existingPort = queue.sync(execute: { localPort }) { + return existingPort + } + + let listener = try Self.makeLoopbackListener() + let readySemaphore = DispatchSemaphore(value: 0) + let stateLock = NSLock() + var capturedError: Error? + var boundPort: Int? + + listener.newConnectionHandler = { [weak self] connection in + self?.queue.async { + self?.acceptConnectionLocked(connection) + } + } + listener.stateUpdateHandler = { listenerState in + switch listenerState { + case .ready: + stateLock.lock() + boundPort = listener.port.map { Int($0.rawValue) } + stateLock.unlock() + readySemaphore.signal() + case .failed(let error): + stateLock.lock() + capturedError = error + stateLock.unlock() + readySemaphore.signal() + default: + break + } + } + listener.start(queue: queue) + + let waitResult = readySemaphore.wait(timeout: .now() + 5.0) + stateLock.lock() + let startupError = capturedError + let startupPort = boundPort + stateLock.unlock() + + if waitResult != .success { + listener.newConnectionHandler = nil + listener.stateUpdateHandler = nil + listener.cancel() + throw NSError(domain: "programa.remote.relay", code: 8, userInfo: [ + NSLocalizedDescriptionKey: "timed out waiting for local relay listener", + ]) + } + if let startupError { + listener.newConnectionHandler = nil + listener.stateUpdateHandler = nil + listener.cancel() + throw startupError + } + guard let startupPort, startupPort > 0 else { + listener.newConnectionHandler = nil + listener.stateUpdateHandler = nil + listener.cancel() + throw NSError(domain: "programa.remote.relay", code: 8, userInfo: [ + NSLocalizedDescriptionKey: "failed to bind local relay listener", + ]) + } + + return queue.sync { + if let localPort { + listener.newConnectionHandler = nil + listener.stateUpdateHandler = nil + listener.cancel() + return localPort + } + self.listener = listener + self.localPort = startupPort + return startupPort + } + } + + func stop() { + queue.sync { + guard !isStopped else { return } + isStopped = true + listener?.newConnectionHandler = nil + listener?.stateUpdateHandler = nil + listener?.cancel() + listener = nil + localPort = nil + let activeSessions = sessions.values + sessions.removeAll() + for session in activeSessions { + session.stop() + } + } + } + + private func acceptConnectionLocked(_ connection: NWConnection) { + guard !isStopped else { + connection.cancel() + return + } + let sessionID = UUID() + let session = Session( + connection: connection, + localSocketPath: localSocketPath, + relayID: relayID, + relayToken: relayToken, + queue: queue + ) { [weak self] in + self?.sessions.removeValue(forKey: sessionID) + } + sessions[sessionID] = session + session.start() + } + + private static func makeLoopbackListener() throws -> NWListener { + let tcpOptions = NWProtocolTCP.Options() + tcpOptions.noDelay = true + let parameters = NWParameters(tls: nil, tcp: tcpOptions) + parameters.allowLocalEndpointReuse = true + parameters.requiredLocalEndpoint = .hostPort(host: NWEndpoint.Host("127.0.0.1"), port: .any) + return try NWListener(using: parameters) + } +} diff --git a/Sources/WorkspaceRemoteDaemon.swift b/Sources/WorkspaceRemoteDaemon.swift deleted file mode 100644 index 5f44408cd75..00000000000 --- a/Sources/WorkspaceRemoteDaemon.swift +++ /dev/null @@ -1,2226 +0,0 @@ -// Extracted from Workspace.swift (nuclear-review N5): the remote-session -// infrastructure below is self-contained — its only edge back to Workspace -// is the weak reference held by WorkspaceRemoteSessionController. - -import Foundation -import SwiftUI -import AppKit -import Bonsplit -import Combine -import CryptoKit -import Darwin -import Network -import CoreText - -final class WorkspaceRemoteDaemonPendingCallRegistry { - final class PendingCall { - let id: Int - fileprivate let semaphore = DispatchSemaphore(value: 0) - fileprivate var response: [String: Any]? - fileprivate var failureMessage: String? - - fileprivate init(id: Int) { - self.id = id - } - } - - enum WaitOutcome { - case response([String: Any]) - case failure(String) - case missing - case timedOut - } - - private let queue = DispatchQueue(label: "com.cmux.remote-ssh.daemon-rpc.pending.\(UUID().uuidString)") - private var nextRequestID = 1 - private var pendingCalls: [Int: PendingCall] = [:] - - func reset() { - queue.sync { - nextRequestID = 1 - pendingCalls.removeAll(keepingCapacity: false) - } - } - - func register() -> PendingCall { - queue.sync { - let call = PendingCall(id: nextRequestID) - nextRequestID += 1 - pendingCalls[call.id] = call - return call - } - } - - @discardableResult - func resolve(id: Int, payload: [String: Any]) -> Bool { - queue.sync { - guard let pendingCall = pendingCalls[id] else { return false } - pendingCall.response = payload - pendingCall.semaphore.signal() - return true - } - } - - func failAll(_ message: String) { - queue.sync { - let calls = Array(pendingCalls.values) - for call in calls { - guard call.response == nil, call.failureMessage == nil else { continue } - call.failureMessage = message - call.semaphore.signal() - } - } - } - - func remove(_ call: PendingCall) { - _ = queue.sync { - pendingCalls.removeValue(forKey: call.id) - } - } - - func wait(for call: PendingCall, timeout: TimeInterval) -> WaitOutcome { - if call.semaphore.wait(timeout: .now() + timeout) == .timedOut { - _ = queue.sync { - pendingCalls.removeValue(forKey: call.id) - } - // A response can win the race immediately before timeout cleanup removes the call. - // Drain any late signal so DispatchSemaphore is not deallocated with a positive count. - _ = call.semaphore.wait(timeout: .now()) - return .timedOut - } - - return queue.sync { - guard let pendingCall = pendingCalls.removeValue(forKey: call.id) else { - return .missing - } - if let failure = pendingCall.failureMessage { - return .failure(failure) - } - guard let response = pendingCall.response else { - return .missing - } - return .response(response) - } - } -} - -enum WorkspaceRemoteSSHBatchCommandBuilder { - static func daemonTransportArguments( - configuration: WorkspaceRemoteConfiguration, - remotePath: String - ) -> [String] { - let script = "exec \(RemoteSSHConnectionPolicy.shellSingleQuoted(remotePath)) serve --stdio" - let command = "sh -c \(RemoteSSHConnectionPolicy.shellSingleQuoted(script))" - return ["-T"] - + batchArguments(configuration: configuration) - + ["-o", "RequestTTY=no", configuration.destination, command] - } - - static func reverseRelayControlMasterArguments( - configuration: WorkspaceRemoteConfiguration, - controlCommand: String, - forwardSpec: String - ) -> [String]? { - guard let controlPath = RemoteSSHConnectionPolicy.optionValue(named: "ControlPath", in: configuration.sshOptions)? - .trimmingCharacters(in: .whitespacesAndNewlines), - !controlPath.isEmpty, - controlPath.lowercased() != "none" else { - return nil - } - - var args = batchArguments(configuration: configuration) - args += ["-O", controlCommand, "-R", forwardSpec, configuration.destination] - return args - } - - private static func batchArguments(configuration: WorkspaceRemoteConfiguration) -> [String] { - let effectiveSSHOptions = RemoteSSHConnectionPolicy.backgroundOptions(configuration.sshOptions) - var args = RemoteSSHConnectionPolicy.keepaliveArguments - args += RemoteSSHConnectionPolicy.strictHostKeyCheckingArguments(unlessSetIn: effectiveSSHOptions) - // Batch helpers may reuse an existing ControlPath, but must not negotiate a new master. - args += RemoteSSHConnectionPolicy.batchModeArguments - 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 effectiveSSHOptions { - args += ["-o", option] - } - return args - } -} - -final class WorkspaceRemoteDaemonRPCClient { - private static let maxStdoutBufferBytes = 256 * 1024 - static let requiredProxyStreamCapability = "proxy.stream.push" - - enum StreamEvent { - case data(Data) - case eof(Data) - case error(String) - } - - private struct StreamSubscription { - let queue: DispatchQueue - let handler: (StreamEvent) -> Void - } - - private let configuration: WorkspaceRemoteConfiguration - private let remotePath: String - private let onUnexpectedTermination: (String) -> Void - private let writeQueue = DispatchQueue(label: "com.cmux.remote-ssh.daemon-rpc.write.\(UUID().uuidString)") - private let stateQueue = DispatchQueue(label: "com.cmux.remote-ssh.daemon-rpc.state.\(UUID().uuidString)") - private let pendingCalls = WorkspaceRemoteDaemonPendingCallRegistry() - - private var process: Process? - private var stdinPipe: Pipe? - private var stdoutPipe: Pipe? - private var stderrPipe: Pipe? - private var stdinHandle: FileHandle? - private var stdoutHandle: FileHandle? - private var stderrHandle: FileHandle? - private var isClosed = true - private var shouldReportTermination = true - - private var stdoutBuffer = Data() - private var stderrBuffer = "" - private var streamSubscriptions: [String: StreamSubscription] = [:] - - init( - configuration: WorkspaceRemoteConfiguration, - remotePath: String, - onUnexpectedTermination: @escaping (String) -> Void - ) { - self.configuration = configuration - self.remotePath = remotePath - self.onUnexpectedTermination = onUnexpectedTermination - } - - func start() throws { - let process = Process() - let stdinPipe = Pipe() - let stdoutPipe = Pipe() - let stderrPipe = Pipe() - - stateQueue.sync { - self.stdinPipe = stdinPipe - self.stdoutPipe = stdoutPipe - self.stderrPipe = stderrPipe - } - - process.executableURL = URL(fileURLWithPath: "/usr/bin/ssh") - process.arguments = Self.daemonArguments(configuration: configuration, remotePath: remotePath) - process.standardInput = stdinPipe - process.standardOutput = stdoutPipe - process.standardError = stderrPipe - - stdoutPipe.fileHandleForReading.readabilityHandler = { [weak self] handle in - let data = handle.availableData - self?.stateQueue.async { - self?.consumeStdoutData(data) - } - } - stderrPipe.fileHandleForReading.readabilityHandler = { [weak self] handle in - let data = handle.availableData - self?.stateQueue.async { - self?.consumeStderrData(data) - } - } - process.terminationHandler = { [weak self] terminated in - self?.stateQueue.async { - self?.handleProcessTermination(terminated) - } - } - - do { - try process.run() - } catch { - throw NSError(domain: "programa.remote.daemon.rpc", code: 1, userInfo: [ - NSLocalizedDescriptionKey: "Failed to launch SSH daemon transport: \(error.localizedDescription)", - ]) - } - - stateQueue.sync { - self.process = process - self.stdinHandle = stdinPipe.fileHandleForWriting - self.stdoutHandle = stdoutPipe.fileHandleForReading - self.stderrHandle = stderrPipe.fileHandleForReading - self.isClosed = false - self.shouldReportTermination = true - self.stdoutBuffer = Data() - self.stderrBuffer = "" - self.streamSubscriptions.removeAll(keepingCapacity: false) - } - pendingCalls.reset() - - do { - let hello = try call(method: "hello", params: [:], timeout: 8.0) - let capabilities = (hello["capabilities"] as? [String]) ?? [] - guard capabilities.contains(Self.requiredProxyStreamCapability) else { - throw NSError(domain: "programa.remote.daemon.rpc", code: 2, userInfo: [ - NSLocalizedDescriptionKey: "remote daemon missing required capability \(Self.requiredProxyStreamCapability)", - ]) - } - } catch { - stop(suppressTerminationCallback: true) - throw error - } - } - - func stop() { - stop(suppressTerminationCallback: true) - } - - func openStream(host: String, port: Int, timeoutMs: Int = 10000) throws -> String { - let result = try call( - method: "proxy.open", - params: [ - "host": host, - "port": port, - "timeout_ms": timeoutMs, - ], - timeout: 12.0 - ) - let streamID = (result["stream_id"] as? String)?.trimmingCharacters(in: .whitespacesAndNewlines) ?? "" - guard !streamID.isEmpty else { - throw NSError(domain: "programa.remote.daemon.rpc", code: 3, userInfo: [ - NSLocalizedDescriptionKey: "proxy.open missing stream_id", - ]) - } - return streamID - } - - func writeStream(streamID: String, data: Data) throws { - _ = try call( - method: "proxy.write", - params: [ - "stream_id": streamID, - "data_base64": data.base64EncodedString(), - ], - timeout: 8.0 - ) - } - - func attachStream( - streamID: String, - queue: DispatchQueue, - onEvent: @escaping (StreamEvent) -> Void - ) throws { - let trimmedStreamID = streamID.trimmingCharacters(in: .whitespacesAndNewlines) - guard !trimmedStreamID.isEmpty else { - throw NSError(domain: "programa.remote.daemon.rpc", code: 17, userInfo: [ - NSLocalizedDescriptionKey: "proxy.stream.subscribe requires stream_id", - ]) - } - - stateQueue.sync { - streamSubscriptions[trimmedStreamID] = StreamSubscription(queue: queue, handler: onEvent) - } - - do { - _ = try call( - method: "proxy.stream.subscribe", - params: ["stream_id": trimmedStreamID], - timeout: 8.0 - ) - } catch { - unregisterStream(streamID: trimmedStreamID) - throw error - } - } - - func unregisterStream(streamID: String) { - let trimmedStreamID = streamID.trimmingCharacters(in: .whitespacesAndNewlines) - guard !trimmedStreamID.isEmpty else { return } - _ = stateQueue.sync { - streamSubscriptions.removeValue(forKey: trimmedStreamID) - } - } - - func closeStream(streamID: String) { - unregisterStream(streamID: streamID) - _ = try? call( - method: "proxy.close", - params: ["stream_id": streamID], - timeout: 4.0 - ) - } - - private func call(method: String, params: [String: Any], timeout: TimeInterval) throws -> [String: Any] { - let pendingCall = pendingCalls.register() - let requestID = pendingCall.id - - let payload: Data - do { - payload = try Self.encodeJSON([ - "id": requestID, - "method": method, - "params": params, - ]) - } catch { - pendingCalls.remove(pendingCall) - throw NSError(domain: "programa.remote.daemon.rpc", code: 10, userInfo: [ - NSLocalizedDescriptionKey: "failed to encode daemon RPC request \(method): \(error.localizedDescription)", - ]) - } - - do { - try writeQueue.sync { - try writePayload(payload) - } - } catch { - pendingCalls.remove(pendingCall) - throw error - } - - let response: [String: Any] - switch pendingCalls.wait(for: pendingCall, timeout: timeout) { - case .timedOut: - stop(suppressTerminationCallback: false) - throw NSError(domain: "programa.remote.daemon.rpc", code: 11, userInfo: [ - NSLocalizedDescriptionKey: "daemon RPC timeout waiting for \(method) response", - ]) - case .failure(let failure): - throw NSError(domain: "programa.remote.daemon.rpc", code: 12, userInfo: [ - NSLocalizedDescriptionKey: failure, - ]) - case .missing: - throw NSError(domain: "programa.remote.daemon.rpc", code: 13, userInfo: [ - NSLocalizedDescriptionKey: "daemon RPC \(method) returned empty response", - ]) - case .response(let pendingResponse): - response = pendingResponse - } - - let ok = (response["ok"] as? Bool) ?? false - if ok { - return (response["result"] as? [String: Any]) ?? [:] - } - - let errorObject = (response["error"] as? [String: Any]) ?? [:] - let code = (errorObject["code"] as? String)?.trimmingCharacters(in: .whitespacesAndNewlines) ?? "rpc_error" - let message = (errorObject["message"] as? String)?.trimmingCharacters(in: .whitespacesAndNewlines) ?? "daemon RPC call failed" - throw NSError(domain: "programa.remote.daemon.rpc", code: 14, userInfo: [ - NSLocalizedDescriptionKey: "\(method) failed (\(code)): \(message)", - ]) - } - - private func writePayload(_ payload: Data) throws { - let stdinHandle: FileHandle = stateQueue.sync { - self.stdinHandle ?? FileHandle.nullDevice - } - if stdinHandle === FileHandle.nullDevice { - throw NSError(domain: "programa.remote.daemon.rpc", code: 15, userInfo: [ - NSLocalizedDescriptionKey: "daemon transport is not connected", - ]) - } - do { - try stdinHandle.write(contentsOf: payload) - try stdinHandle.write(contentsOf: Data([0x0A])) - } catch { - stop(suppressTerminationCallback: false) - throw NSError(domain: "programa.remote.daemon.rpc", code: 16, userInfo: [ - NSLocalizedDescriptionKey: "failed writing daemon RPC request: \(error.localizedDescription)", - ]) - } - } - - private func consumeStdoutData(_ data: Data) { - guard !data.isEmpty else { - signalPendingFailureLocked("daemon transport closed stdout") - return - } - - stdoutBuffer.append(data) - if stdoutBuffer.count > Self.maxStdoutBufferBytes { - stdoutBuffer.removeAll(keepingCapacity: false) - signalPendingFailureLocked("daemon transport stdout exceeded \(Self.maxStdoutBufferBytes) bytes without message framing") - process?.terminate() - return - } - while let newlineIndex = stdoutBuffer.firstIndex(of: 0x0A) { - var lineData = Data(stdoutBuffer[.. 8192 { - stderrBuffer.removeFirst(stderrBuffer.count - 8192) - } - } - - private func consumeEventPayload(_ payload: [String: Any]) { - guard let eventName = (payload["event"] as? String)? - .trimmingCharacters(in: .whitespacesAndNewlines), - !eventName.isEmpty, - let streamID = (payload["stream_id"] as? String)? - .trimmingCharacters(in: .whitespacesAndNewlines), - !streamID.isEmpty else { - return - } - - let subscription: StreamSubscription? - let event: StreamEvent? - switch eventName { - case "proxy.stream.data": - subscription = streamSubscriptions[streamID] - event = .data(Self.decodeBase64Data(payload["data_base64"])) - - case "proxy.stream.eof": - subscription = streamSubscriptions.removeValue(forKey: streamID) - event = .eof(Self.decodeBase64Data(payload["data_base64"])) - - case "proxy.stream.error": - subscription = streamSubscriptions.removeValue(forKey: streamID) - let detail = ((payload["error"] as? String)? - .trimmingCharacters(in: .whitespacesAndNewlines)).flatMap { $0.isEmpty ? nil : $0 } - ?? "stream error" - event = .error(detail) - - default: - return - } - - guard let subscription, let event else { return } - subscription.queue.async { - subscription.handler(event) - } - } - - private func handleProcessTermination(_ process: Process) { - let shouldNotify: Bool = { - guard self.process === process else { return false } - return !isClosed && shouldReportTermination - }() - let detail = Self.bestErrorLine(stderr: stderrBuffer) ?? "daemon transport exited with status \(process.terminationStatus)" - - isClosed = true - self.process = nil - stdinPipe = nil - stdoutPipe = nil - stderrPipe = nil - stdinHandle = nil - stdoutHandle?.readabilityHandler = nil - stdoutHandle = nil - stderrHandle?.readabilityHandler = nil - stderrHandle = nil - streamSubscriptions.removeAll(keepingCapacity: false) - signalPendingFailureLocked(detail) - - guard shouldNotify else { return } - onUnexpectedTermination(detail) - } - - private func stop(suppressTerminationCallback: Bool) { - let captured: (Process?, FileHandle?, FileHandle?, FileHandle?, Bool, String) = stateQueue.sync { - let detail = Self.bestErrorLine(stderr: stderrBuffer) ?? "daemon transport stopped" - let shouldNotify = !suppressTerminationCallback && !isClosed - shouldReportTermination = !suppressTerminationCallback - if isClosed { - return (nil, nil, nil, nil, false, detail) - } - - isClosed = true - signalPendingFailureLocked("daemon transport stopped") - let capturedProcess = process - let capturedStdin = stdinHandle - let capturedStdout = stdoutHandle - let capturedStderr = stderrHandle - - process = nil - stdinPipe = nil - stdoutPipe = nil - stderrPipe = nil - stdinHandle = nil - stdoutHandle = nil - stderrHandle = nil - streamSubscriptions.removeAll(keepingCapacity: false) - return (capturedProcess, capturedStdin, capturedStdout, capturedStderr, shouldNotify, detail) - } - - captured.2?.readabilityHandler = nil - captured.3?.readabilityHandler = nil - try? captured.1?.close() - try? captured.2?.close() - try? captured.3?.close() - if let process = captured.0, process.isRunning { - process.terminate() - } - if captured.4 { - onUnexpectedTermination(captured.5) - } - } - - private func signalPendingFailureLocked(_ message: String) { - pendingCalls.failAll(message) - } - - private static func responseID(in payload: [String: Any]) -> Int? { - if let intValue = payload["id"] as? Int { - return intValue - } - if let numberValue = payload["id"] as? NSNumber { - return numberValue.intValue - } - return nil - } - - private static func decodeBase64Data(_ value: Any?) -> Data { - guard let encoded = value as? String, !encoded.isEmpty else { return Data() } - return Data(base64Encoded: encoded) ?? Data() - } - - private static func encodeJSON(_ object: [String: Any]) throws -> Data { - try JSONSerialization.data(withJSONObject: object, options: []) - } - - private static func daemonArguments(configuration: WorkspaceRemoteConfiguration, remotePath: String) -> [String] { - WorkspaceRemoteSSHBatchCommandBuilder.daemonTransportArguments( - configuration: configuration, - remotePath: remotePath - ) - } - - private static func bestErrorLine(stderr: String) -> String? { - let lines = stderr - .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 - } -} - -enum RemoteLoopbackHTTPRequestRewriter { - private static let headerDelimiter = Data([0x0d, 0x0a, 0x0d, 0x0a]) - private static let canonicalLoopbackHost = "localhost" - private static let requestLineMethods = ["GET", "POST", "PUT", "PATCH", "DELETE", "HEAD", "OPTIONS", "TRACE", "PRI"] - - static func rewriteIfNeeded(data: Data, aliasHost: String) -> Data { - rewriteIfNeeded(data: data, aliasHost: aliasHost, allowIncompleteHeadersAtEOF: false) - } - - static func rewriteIfNeeded(data: Data, aliasHost: String, allowIncompleteHeadersAtEOF: Bool) -> Data { - let headerData: Data - let remainder: Data - - if let headerRange = data.range(of: headerDelimiter) { - headerData = Data(data[.. Bool { - let trimmed = requestLine.trimmingCharacters(in: .whitespacesAndNewlines) - let method = trimmed.split(separator: " ", maxSplits: 1).first.map(String.init)?.uppercased() ?? "" - return requestLineMethods.contains(method) - } - - private static func rewriteRequestLine(_ requestLine: String, aliasHost: String) -> String { - let trimmed = requestLine.trimmingCharacters(in: .whitespacesAndNewlines) - let parts = trimmed.split(separator: " ", omittingEmptySubsequences: false) - guard parts.count >= 3 else { return requestLine } - - var components = URLComponents(string: String(parts[1])) - guard let host = components?.host, - BrowserInsecureHTTPSettings.normalizeHost(host) == BrowserInsecureHTTPSettings.normalizeHost(aliasHost) else { - return requestLine - } - components?.host = canonicalLoopbackHost - guard let rewrittenURL = components?.string else { return requestLine } - - var rewritten = parts - rewritten[1] = Substring(rewrittenURL) - let leadingTrivia = requestLine.prefix { $0.isWhitespace || $0.isNewline } - let trailingTrivia = String(requestLine.reversed().prefix { $0.isWhitespace || $0.isNewline }.reversed()) - return String(leadingTrivia) + rewritten.joined(separator: " ") + trailingTrivia - } - - private static func rewriteHeaderLine(_ line: String, aliasHost: String) -> String { - guard let colonIndex = line.firstIndex(of: ":") else { return line } - let name = line[.. String? { - let trimmed = value.trimmingCharacters(in: .whitespacesAndNewlines) - guard !trimmed.isEmpty else { return nil } - - if trimmed.hasPrefix("["), - let closing = trimmed.firstIndex(of: "]") { - let host = String(trimmed[trimmed.index(after: trimmed.startIndex).. String? { - var components = URLComponents(string: value) - guard let host = components?.host, - BrowserInsecureHTTPSettings.normalizeHost(host) == BrowserInsecureHTTPSettings.normalizeHost(aliasHost) else { - return nil - } - components?.host = canonicalLoopbackHost - return components?.string - } -} - -struct RemoteLoopbackHTTPRequestStreamRewriter { - private static let maxHeaderBytes = 64 * 1024 - private static let headerDelimiter = Data([0x0D, 0x0A, 0x0D, 0x0A]) - - private let aliasHost: String - private var pendingHeaderBytes = Data() - private var hasForwardedHeaders = false - - init(aliasHost: String) { - self.aliasHost = aliasHost - } - - mutating func rewriteNextChunk(_ data: Data, eof: Bool) -> Data { - guard !hasForwardedHeaders else { return data } - - pendingHeaderBytes.append(data) - if pendingHeaderBytes.count > Self.maxHeaderBytes { - hasForwardedHeaders = true - let payload = pendingHeaderBytes - pendingHeaderBytes = Data() - return RemoteLoopbackHTTPRequestRewriter.rewriteIfNeeded( - data: payload, - aliasHost: aliasHost, - allowIncompleteHeadersAtEOF: true - ) - } - - guard pendingHeaderBytes.range(of: Self.headerDelimiter) != nil else { - guard eof else { return Data() } - hasForwardedHeaders = true - let payload = pendingHeaderBytes - pendingHeaderBytes = Data() - return RemoteLoopbackHTTPRequestRewriter.rewriteIfNeeded( - data: payload, - aliasHost: aliasHost, - allowIncompleteHeadersAtEOF: true - ) - } - - hasForwardedHeaders = true - let payload = pendingHeaderBytes - pendingHeaderBytes = Data() - return RemoteLoopbackHTTPRequestRewriter.rewriteIfNeeded( - data: payload, - aliasHost: aliasHost - ) - } -} - -enum RemoteLoopbackHTTPResponseRewriter { - private static let headerDelimiter = Data([0x0d, 0x0a, 0x0d, 0x0a]) - private static let canonicalLoopbackHost = "localhost" - - static func rewriteIfNeeded(data: Data, aliasHost: String) -> Data { - guard let headerRange = data.range(of: headerDelimiter) else { return data } - let headerData = Data(data[.. String { - guard let colonIndex = line.firstIndex(of: ":") else { return line } - let name = line[.. String? { - var components = URLComponents(string: value) - guard let host = components?.host, - BrowserInsecureHTTPSettings.normalizeHost(host) == BrowserInsecureHTTPSettings.normalizeHost(canonicalLoopbackHost) else { - return nil - } - components?.host = aliasHost - return components?.string - } - - private static func rewriteCookieValue(_ value: String, aliasHost: String) -> String? { - let parts = value.split(separator: ";", omittingEmptySubsequences: false).map(String.init) - guard !parts.isEmpty else { return nil } - - var didRewrite = false - let rewrittenParts = parts.map { part -> String in - let trimmed = part.trimmingCharacters(in: .whitespacesAndNewlines) - guard trimmed.lowercased().hasPrefix("domain=") else { return part } - let domainValue = String(trimmed.dropFirst("domain=".count)) - guard BrowserInsecureHTTPSettings.normalizeHost(domainValue) == BrowserInsecureHTTPSettings.normalizeHost(canonicalLoopbackHost) else { - return part - } - didRewrite = true - let leadingWhitespace = part.prefix { $0.isWhitespace } - return "\(leadingWhitespace)Domain=\(aliasHost)" - } - - return didRewrite ? rewrittenParts.joined(separator: ";") : nil - } -} - -private final class WorkspaceRemoteDaemonProxyTunnel { - private final class ProxySession { - private static let maxHandshakeBytes = 64 * 1024 - private static let remoteLoopbackProxyAliasHost = "programa-loopback.localtest.me" - - private enum HandshakeProtocol { - case undecided - case socks5 - case connect - } - - private enum SocksStage { - case greeting - case request - } - - private struct SocksRequest { - let host: String - let port: Int - let command: UInt8 - let consumedBytes: Int - } - - let id = UUID() - - private let connection: NWConnection - private let rpcClient: WorkspaceRemoteDaemonRPCClient - private let queue: DispatchQueue - private let onClose: (UUID) -> Void - - private var isClosed = false - private var protocolKind: HandshakeProtocol = .undecided - private var socksStage: SocksStage = .greeting - private var handshakeBuffer = Data() - private var streamID: String? - private var localInputEOF = false - private var rewritesLoopbackHTTPHeaders = false - private var loopbackRequestHeaderRewriter: RemoteLoopbackHTTPRequestStreamRewriter? - private var pendingRemoteHTTPHeaderBytes = Data() - private var hasForwardedRemoteHTTPHeaders = false - - init( - connection: NWConnection, - rpcClient: WorkspaceRemoteDaemonRPCClient, - queue: DispatchQueue, - onClose: @escaping (UUID) -> Void - ) { - self.connection = connection - self.rpcClient = rpcClient - self.queue = queue - self.onClose = onClose - } - - func start() { - connection.stateUpdateHandler = { [weak self] state in - guard let self else { return } - switch state { - case .failed(let error): - self.close(reason: "proxy client connection failed: \(error)") - case .cancelled: - self.close(reason: nil) - default: - break - } - } - connection.start(queue: queue) - receiveNext() - } - - func stop() { - close(reason: nil) - } - - private func receiveNext() { - guard !isClosed else { return } - connection.receive(minimumIncompleteLength: 1, maximumLength: 32768) { [weak self] data, _, isComplete, error in - guard let self, !self.isClosed else { return } - - if let data, !data.isEmpty { - if self.streamID == nil { - if self.handshakeBuffer.count + data.count > Self.maxHandshakeBytes { - self.close(reason: "proxy handshake exceeded \(Self.maxHandshakeBytes) bytes") - return - } - self.handshakeBuffer.append(data) - self.processHandshakeBuffer() - } else { - self.forwardToRemote(data, eof: isComplete) - } - } - - if isComplete { - // Treat local EOF as a half-close: keep remote read loop alive so we can - // drain upstream response bytes (for example curl closing write-side after - // sending an HTTP request through SOCKS/CONNECT). - self.localInputEOF = true - if self.streamID != nil, data?.isEmpty ?? true { - self.forwardToRemote(Data(), eof: true, allowAfterEOF: true) - } - if self.streamID == nil { - self.close(reason: nil) - } - return - } - if let error { - self.close(reason: "proxy client receive error: \(error)") - return - } - - self.receiveNext() - } - } - - private func processHandshakeBuffer() { - guard !isClosed else { return } - while streamID == nil { - switch protocolKind { - case .undecided: - guard let first = handshakeBuffer.first else { return } - protocolKind = (first == 0x05) ? .socks5 : .connect - case .socks5: - if !processSocksHandshakeStep() { - return - } - case .connect: - if !processConnectHandshakeStep() { - return - } - } - } - } - - private func processSocksHandshakeStep() -> Bool { - switch socksStage { - case .greeting: - guard handshakeBuffer.count >= 2 else { return false } - let methodCount = Int(handshakeBuffer[1]) - let total = 2 + methodCount - guard handshakeBuffer.count >= total else { return false } - - let methods = [UInt8](handshakeBuffer[2.. request.consumedBytes - ? Data(handshakeBuffer[request.consumedBytes...]) - : Data() - handshakeBuffer = Data() - guard request.command == 0x01 else { - sendAndClose(Data([0x05, 0x07, 0x00, 0x01, 0, 0, 0, 0, 0, 0])) - return false - } - - openRemoteStream( - host: request.host, - port: request.port, - successResponse: Data([0x05, 0x00, 0x00, 0x01, 0, 0, 0, 0, 0, 0]), - failureResponse: Data([0x05, 0x05, 0x00, 0x01, 0, 0, 0, 0, 0, 0]), - pendingPayload: pending - ) - return false - } - } - - private func parseSocksRequest(from data: Data) throws -> SocksRequest? { - let bytes = [UInt8](data) - guard bytes.count >= 4 else { return nil } - guard bytes[0] == 0x05 else { - throw NSError(domain: "programa.remote.proxy", code: 1, userInfo: [NSLocalizedDescriptionKey: "invalid SOCKS version"]) - } - - let command = bytes[1] - let addressType = bytes[3] - var cursor = 4 - let host: String - - switch addressType { - case 0x01: - guard bytes.count >= cursor + 4 + 2 else { return nil } - let octets = bytes[cursor..<(cursor + 4)].map { String($0) } - host = octets.joined(separator: ".") - cursor += 4 - - case 0x03: - guard bytes.count >= cursor + 1 else { return nil } - let length = Int(bytes[cursor]) - cursor += 1 - guard bytes.count >= cursor + length + 2 else { return nil } - let hostData = Data(bytes[cursor..<(cursor + length)]) - host = String(data: hostData, encoding: .utf8) ?? "" - cursor += length - - case 0x04: - guard bytes.count >= cursor + 16 + 2 else { return nil } - var address = in6_addr() - withUnsafeMutableBytes(of: &address) { target in - for i in 0..<16 { - target[i] = bytes[cursor + i] - } - } - var text = [CChar](repeating: 0, count: Int(INET6_ADDRSTRLEN)) - let pointer = withUnsafePointer(to: &address) { - inet_ntop(AF_INET6, UnsafeRawPointer($0), &text, socklen_t(INET6_ADDRSTRLEN)) - } - host = pointer != nil ? String(cString: text) : "" - cursor += 16 - - default: - throw NSError(domain: "programa.remote.proxy", code: 2, userInfo: [NSLocalizedDescriptionKey: "invalid SOCKS address type"]) - } - - guard !host.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty else { - throw NSError(domain: "programa.remote.proxy", code: 3, userInfo: [NSLocalizedDescriptionKey: "empty SOCKS host"]) - } - guard bytes.count >= cursor + 2 else { return nil } - let port = Int(UInt16(bytes[cursor]) << 8 | UInt16(bytes[cursor + 1])) - cursor += 2 - - guard port > 0 && port <= 65535 else { - throw NSError(domain: "programa.remote.proxy", code: 4, userInfo: [NSLocalizedDescriptionKey: "invalid SOCKS port"]) - } - - return SocksRequest(host: host, port: port, command: command, consumedBytes: cursor) - } - - private func processConnectHandshakeStep() -> Bool { - let marker = Data([0x0D, 0x0A, 0x0D, 0x0A]) - guard let headerRange = handshakeBuffer.range(of: marker) else { return false } - - let headerData = Data(handshakeBuffer[..= 2, parts[0].uppercased() == "CONNECT" else { - sendAndClose(Self.httpResponse(status: "400 Bad Request")) - return false - } - - guard let (host, port) = Self.parseConnectAuthority(parts[1]) else { - sendAndClose(Self.httpResponse(status: "400 Bad Request")) - return false - } - - openRemoteStream( - host: host, - port: port, - successResponse: Self.httpResponse(status: "200 Connection Established", closeAfterResponse: false), - failureResponse: Self.httpResponse(status: "502 Bad Gateway", closeAfterResponse: true), - pendingPayload: pending - ) - return false - } - - private func openRemoteStream( - host: String, - port: Int, - successResponse: Data, - failureResponse: Data, - pendingPayload: Data - ) { - guard !isClosed else { return } - do { - rewritesLoopbackHTTPHeaders = - BrowserInsecureHTTPSettings.normalizeHost(host) - == BrowserInsecureHTTPSettings.normalizeHost(Self.remoteLoopbackProxyAliasHost) - loopbackRequestHeaderRewriter = rewritesLoopbackHTTPHeaders - ? RemoteLoopbackHTTPRequestStreamRewriter(aliasHost: Self.remoteLoopbackProxyAliasHost) - : nil - pendingRemoteHTTPHeaderBytes = Data() - hasForwardedRemoteHTTPHeaders = false - let targetHost = Self.normalizedProxyTargetHost(host) - let streamID = try rpcClient.openStream(host: targetHost, port: port) - self.streamID = streamID - try rpcClient.attachStream(streamID: streamID, queue: queue) { [weak self] event in - self?.handleRemoteStreamEvent(streamID: streamID, event: event) - } - connection.send(content: successResponse, completion: .contentProcessed { [weak self] error in - guard let self else { return } - if let error { - self.close(reason: "proxy client send error: \(error)") - return - } - if !pendingPayload.isEmpty { - self.forwardToRemote(pendingPayload, allowAfterEOF: true) - } - }) - } catch { - sendAndClose(failureResponse) - } - } - - private func forwardToRemote(_ data: Data, eof: Bool = false, allowAfterEOF: Bool = false) { - guard !isClosed else { return } - guard !localInputEOF || allowAfterEOF else { return } - guard let streamID else { return } - do { - let outgoingData: Data - if rewritesLoopbackHTTPHeaders { - outgoingData = loopbackRequestHeaderRewriter?.rewriteNextChunk(data, eof: eof) ?? data - } else { - outgoingData = data - } - guard !outgoingData.isEmpty else { return } - try rpcClient.writeStream(streamID: streamID, data: outgoingData) - } catch { - close(reason: "proxy.write failed: \(error.localizedDescription)") - } - } - - private func handleRemoteStreamEvent( - streamID: String, - event: WorkspaceRemoteDaemonRPCClient.StreamEvent - ) { - guard !isClosed else { return } - guard self.streamID == streamID else { return } - - switch event { - case .data(let data): - forwardRemotePayloadToLocal(data, eof: false) - - case .eof(let data): - forwardRemotePayloadToLocal(data, eof: true) - - case .error(let detail): - close(reason: "proxy.stream failed: \(detail)") - } - } - - private func forwardRemotePayloadToLocal(_ data: Data, eof: Bool) { - let localData = rewriteRemoteResponseIfNeeded(data, eof: eof) - if !localData.isEmpty { - connection.send(content: localData, completion: .contentProcessed { [weak self] error in - guard let self else { return } - if let error { - self.close(reason: "proxy client send error: \(error)") - return - } - if eof { - self.close(reason: nil) - } - }) - return - } - - if eof { - close(reason: nil) - } - } - - private func rewriteRemoteResponseIfNeeded(_ data: Data, eof: Bool) -> Data { - guard rewritesLoopbackHTTPHeaders else { return data } - guard !data.isEmpty else { return data } - guard !hasForwardedRemoteHTTPHeaders else { return data } - - pendingRemoteHTTPHeaderBytes.append(data) - let marker = Data([0x0D, 0x0A, 0x0D, 0x0A]) - guard pendingRemoteHTTPHeaderBytes.range(of: marker) != nil else { - guard eof else { return Data() } - hasForwardedRemoteHTTPHeaders = true - let payload = pendingRemoteHTTPHeaderBytes - pendingRemoteHTTPHeaderBytes = Data() - return payload - } - - hasForwardedRemoteHTTPHeaders = true - let payload = pendingRemoteHTTPHeaderBytes - pendingRemoteHTTPHeaderBytes = Data() - return RemoteLoopbackHTTPResponseRewriter.rewriteIfNeeded( - data: payload, - aliasHost: Self.remoteLoopbackProxyAliasHost - ) - } - - private func close(reason: String?) { - guard !isClosed else { return } - isClosed = true - - let streamID = self.streamID - self.streamID = nil - - if let streamID { - rpcClient.closeStream(streamID: streamID) - } - connection.cancel() - onClose(id) - } - - private func sendLocal(_ data: Data) { - guard !isClosed else { return } - connection.send(content: data, completion: .contentProcessed { [weak self] error in - guard let self else { return } - if let error { - self.close(reason: "proxy client send error: \(error)") - } - }) - } - - private func sendAndClose(_ data: Data) { - guard !isClosed else { return } - connection.send(content: data, completion: .contentProcessed { [weak self] _ in - self?.close(reason: nil) - }) - } - - private static func parseConnectAuthority(_ authority: String) -> (host: String, port: Int)? { - let trimmed = authority.trimmingCharacters(in: .whitespacesAndNewlines) - guard !trimmed.isEmpty else { return nil } - - if trimmed.hasPrefix("[") { - guard let closing = trimmed.firstIndex(of: "]") else { return nil } - let host = String(trimmed[trimmed.index(after: trimmed.startIndex).. 0, port <= 65535 else { return nil } - return (host, port) - } - - guard let colon = trimmed.lastIndex(of: ":") else { return nil } - let host = String(trimmed[.. 0, port <= 65535 else { return nil } - return (host, port) - } - - private static func normalizedProxyTargetHost(_ host: String) -> String { - let trimmed = host.trimmingCharacters(in: .whitespacesAndNewlines) - let normalized = trimmed - .trimmingCharacters(in: CharacterSet(charactersIn: ".")) - .lowercased() - // BrowserPanel rewrites loopback URLs to this alias so proxy routing works. - // Resolve it back to true loopback before dialing from the remote daemon. - if normalized == remoteLoopbackProxyAliasHost { - return "127.0.0.1" - } - return host - } - - private static func httpResponse(status: String, closeAfterResponse: Bool = true) -> Data { - var text = "HTTP/1.1 \(status)\r\nProxy-Agent: cmux\r\n" - if closeAfterResponse { - text += "Connection: close\r\n" - } - text += "\r\n" - return Data(text.utf8) - } - } - - private let configuration: WorkspaceRemoteConfiguration - private let remotePath: String - private let localPort: Int - private let onFatalError: (String) -> Void - private let queue = DispatchQueue(label: "com.cmux.remote-ssh.daemon-tunnel.\(UUID().uuidString)", qos: .utility) - - private var listener: NWListener? - private var rpcClient: WorkspaceRemoteDaemonRPCClient? - private var sessions: [UUID: ProxySession] = [:] - private var isStopped = false - - init( - configuration: WorkspaceRemoteConfiguration, - remotePath: String, - localPort: Int, - onFatalError: @escaping (String) -> Void - ) { - self.configuration = configuration - self.remotePath = remotePath - self.localPort = localPort - self.onFatalError = onFatalError - } - - func start() throws { - var capturedError: Error? - queue.sync { - guard !isStopped else { - capturedError = NSError(domain: "programa.remote.proxy", code: 20, userInfo: [ - NSLocalizedDescriptionKey: "proxy tunnel already stopped", - ]) - return - } - do { - let client = WorkspaceRemoteDaemonRPCClient( - configuration: configuration, - remotePath: remotePath - ) { [weak self] detail in - self?.queue.async { - self?.failLocked("Remote daemon transport failed: \(detail)") - } - } - try client.start() - - let listener = try Self.makeLoopbackListener(port: localPort) - listener.newConnectionHandler = { [weak self] connection in - self?.queue.async { - self?.acceptConnectionLocked(connection) - } - } - listener.stateUpdateHandler = { [weak self] state in - self?.queue.async { - self?.handleListenerStateLocked(state) - } - } - - self.rpcClient = client - self.listener = listener - listener.start(queue: queue) - } catch { - capturedError = error - stopLocked(notify: false) - } - } - if let capturedError { - throw capturedError - } - } - - func stop() { - queue.sync { - stopLocked(notify: false) - } - } - - private func handleListenerStateLocked(_ state: NWListener.State) { - guard !isStopped else { return } - switch state { - case .failed(let error): - failLocked("Local proxy listener failed: \(error)") - default: - break - } - } - - private func acceptConnectionLocked(_ connection: NWConnection) { - guard !isStopped else { - connection.cancel() - return - } - guard let rpcClient else { - connection.cancel() - return - } - - let session = ProxySession( - connection: connection, - rpcClient: rpcClient, - queue: queue - ) { [weak self] id in - self?.queue.async { - self?.sessions.removeValue(forKey: id) - } - } - sessions[session.id] = session - session.start() - } - - private func failLocked(_ detail: String) { - guard !isStopped else { return } - stopLocked(notify: false) - onFatalError(detail) - } - - private func stopLocked(notify: Bool) { - guard !isStopped else { return } - isStopped = true - - listener?.stateUpdateHandler = nil - listener?.newConnectionHandler = nil - listener?.cancel() - listener = nil - - let activeSessions = sessions.values - sessions.removeAll() - for session in activeSessions { - session.stop() - } - - rpcClient?.stop() - rpcClient = nil - } - - private static func makeLoopbackListener(port: Int) throws -> NWListener { - guard let localPort = NWEndpoint.Port(rawValue: UInt16(port)) else { - throw NSError(domain: "programa.remote.proxy", code: 21, userInfo: [ - NSLocalizedDescriptionKey: "invalid local proxy port \(port)", - ]) - } - let tcpOptions = NWProtocolTCP.Options() - tcpOptions.noDelay = true - let parameters = NWParameters(tls: nil, tcp: tcpOptions) - parameters.allowLocalEndpointReuse = true - parameters.requiredLocalEndpoint = .hostPort(host: NWEndpoint.Host("127.0.0.1"), port: localPort) - return try NWListener(using: parameters) - } -} - -final class WorkspaceRemoteProxyBroker { - enum Update { - case connecting - case ready(BrowserProxyEndpoint) - case error(String) - } - - final class Lease { - private let key: String - private let subscriberID: UUID - private weak var broker: WorkspaceRemoteProxyBroker? - private var isReleased = false - - fileprivate init(key: String, subscriberID: UUID, broker: WorkspaceRemoteProxyBroker) { - self.key = key - self.subscriberID = subscriberID - self.broker = broker - } - - func release() { - guard !isReleased else { return } - isReleased = true - broker?.release(key: key, subscriberID: subscriberID) - } - - deinit { - release() - } - } - - private final class Entry { - let configuration: WorkspaceRemoteConfiguration - var remotePath: String - var tunnel: WorkspaceRemoteDaemonProxyTunnel? - var endpoint: BrowserProxyEndpoint? - var restartWorkItem: DispatchWorkItem? - var restartRetryCount = 0 - var subscribers: [UUID: (Update) -> Void] = [:] - - init(configuration: WorkspaceRemoteConfiguration, remotePath: String) { - self.configuration = configuration - self.remotePath = remotePath - } - } - - static let shared = WorkspaceRemoteProxyBroker() - - private let queue = DispatchQueue(label: "com.cmux.remote-ssh.proxy-broker", qos: .utility) - private var entries: [String: Entry] = [:] - - func acquire( - configuration: WorkspaceRemoteConfiguration, - remotePath: String, - onUpdate: @escaping (Update) -> Void - ) -> Lease { - queue.sync { - let key = Self.transportKey(for: configuration) - let subscriberID = UUID() - let entry: Entry - if let existing = entries[key] { - entry = existing - if existing.remotePath != remotePath { - existing.remotePath = remotePath - existing.restartRetryCount = 0 - if existing.tunnel != nil { - stopEntryRuntimeLocked(existing) - notifyLocked(existing, update: .connecting) - } - } - } else { - entry = Entry(configuration: configuration, remotePath: remotePath) - entries[key] = entry - } - - entry.subscribers[subscriberID] = onUpdate - if let endpoint = entry.endpoint { - onUpdate(.ready(endpoint)) - } else { - onUpdate(.connecting) - } - - if entry.tunnel == nil, entry.restartWorkItem == nil { - startEntryLocked(key: key, entry: entry) - } - - return Lease(key: key, subscriberID: subscriberID, broker: self) - } - } - - private func release(key: String, subscriberID: UUID) { - queue.async { [weak self] in - guard let self, let entry = self.entries[key] else { return } - entry.subscribers.removeValue(forKey: subscriberID) - guard entry.subscribers.isEmpty else { return } - self.teardownEntryLocked(key: key, entry: entry) - } - } - - private func startEntryLocked(key: String, entry: Entry) { - entry.restartWorkItem?.cancel() - entry.restartWorkItem = nil - - let localPort: Int - if let forcedLocalPort = entry.configuration.localProxyPort { - // Internal deterministic test hook used by docker regressions to force bind conflicts. - localPort = forcedLocalPort - } else { - let retryDelay = Self.retryDelay(baseDelay: 3.0, retry: entry.restartRetryCount + 1) - guard let allocatedPort = Self.allocateLoopbackPort() else { - notifyLocked( - entry, - update: .error("Failed to allocate local proxy port\(Self.retrySuffix(delay: retryDelay))") - ) - scheduleRestartLocked(key: key, entry: entry, baseDelay: 3.0) - return - } - localPort = allocatedPort - } - - do { - let tunnel = WorkspaceRemoteDaemonProxyTunnel( - configuration: entry.configuration, - remotePath: entry.remotePath, - localPort: localPort - ) { [weak self] detail in - self?.queue.async { - self?.handleTunnelFailureLocked(key: key, detail: detail) - } - } - try tunnel.start() - entry.tunnel = tunnel - let endpoint = BrowserProxyEndpoint(host: "127.0.0.1", port: localPort) - entry.endpoint = endpoint - entry.restartRetryCount = 0 - notifyLocked(entry, update: .ready(endpoint)) - } catch { - stopEntryRuntimeLocked(entry) - let detail = "Failed to start local daemon proxy: \(error.localizedDescription)" - let retryDelay = Self.retryDelay(baseDelay: 3.0, retry: entry.restartRetryCount + 1) - notifyLocked(entry, update: .error("\(detail)\(Self.retrySuffix(delay: retryDelay))")) - scheduleRestartLocked(key: key, entry: entry, baseDelay: 3.0) - } - } - - private func handleTunnelFailureLocked(key: String, detail: String) { - guard let entry = entries[key], entry.tunnel != nil else { return } - stopEntryRuntimeLocked(entry) - let retryDelay = Self.retryDelay(baseDelay: 3.0, retry: entry.restartRetryCount + 1) - notifyLocked(entry, update: .error("\(detail)\(Self.retrySuffix(delay: retryDelay))")) - scheduleRestartLocked(key: key, entry: entry, baseDelay: 3.0) - } - - private func scheduleRestartLocked(key: String, entry: Entry, baseDelay: TimeInterval) { - guard !entry.subscribers.isEmpty else { - teardownEntryLocked(key: key, entry: entry) - return - } - guard entry.restartWorkItem == nil else { return } - entry.restartRetryCount += 1 - let retryDelay = Self.retryDelay(baseDelay: baseDelay, retry: entry.restartRetryCount) - - let workItem = DispatchWorkItem { [weak self] in - guard let self, let currentEntry = self.entries[key] else { return } - currentEntry.restartWorkItem = nil - guard !currentEntry.subscribers.isEmpty else { - self.teardownEntryLocked(key: key, entry: currentEntry) - return - } - self.notifyLocked(currentEntry, update: .connecting) - self.startEntryLocked(key: key, entry: currentEntry) - } - - entry.restartWorkItem = workItem - queue.asyncAfter(deadline: .now() + retryDelay, execute: workItem) - } - - private func teardownEntryLocked(key: String, entry: Entry) { - entry.restartWorkItem?.cancel() - entry.restartWorkItem = nil - stopEntryRuntimeLocked(entry) - entries.removeValue(forKey: key) - } - - private func stopEntryRuntimeLocked(_ entry: Entry) { - entry.tunnel?.stop() - entry.tunnel = nil - entry.endpoint = nil - } - - private func notifyLocked(_ entry: Entry, update: Update) { - for callback in entry.subscribers.values { - callback(update) - } - } - - private static func transportKey(for configuration: WorkspaceRemoteConfiguration) -> String { - configuration.proxyBrokerTransportKey - } - - private static func allocateLoopbackPort() -> Int? { - for _ in 0..<8 { - let fd = socket(AF_INET, SOCK_STREAM, 0) - guard fd >= 0 else { return nil } - 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(0) - 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)) - } - } - guard bindResult == 0 else { continue } - - var bound = sockaddr_in() - var len = socklen_t(MemoryLayout.size) - let nameResult = withUnsafeMutablePointer(to: &bound) { ptr in - ptr.withMemoryRebound(to: sockaddr.self, capacity: 1) { sockaddrPtr in - getsockname(fd, sockaddrPtr, &len) - } - } - guard nameResult == 0 else { continue } - - let port = Int(UInt16(bigEndian: bound.sin_port)) - if port > 0 && port <= 65535 { - return port - } - } - return nil - } - - private static func retrySuffix(delay: TimeInterval) -> String { - let seconds = max(1, Int(delay.rounded())) - return " (retry in \(seconds)s)" - } - - private static func retryDelay(baseDelay: TimeInterval, retry: Int) -> TimeInterval { - let exponent = Double(max(0, retry - 1)) - return min(baseDelay * pow(2.0, exponent), 60.0) - } -} - -final class WorkspaceRemoteCLIRelayServer { - private final class Session { - private enum Phase { - case awaitingAuth - case awaitingCommand - case forwarding - case closed - } - - private let connection: NWConnection - private let localSocketPath: String - private let relayID: String - private let relayToken: Data - private let queue: DispatchQueue - private let onClose: () -> Void - private let challengeProtocol = "programa-relay-auth" - private let challengeVersion = 1 - private let minimumFailureDelay: TimeInterval = 0.05 - private let maximumFrameBytes = 16 * 1024 - - private var buffer = Data() - private var phase: Phase = .awaitingAuth - private var challengeNonce = "" - private var challengeSentAt = Date() - private var isClosed = false - - init( - connection: NWConnection, - localSocketPath: String, - relayID: String, - relayToken: Data, - queue: DispatchQueue, - onClose: @escaping () -> Void - ) { - self.connection = connection - self.localSocketPath = localSocketPath - self.relayID = relayID - self.relayToken = relayToken - self.queue = queue - self.onClose = onClose - } - - func start() { - connection.stateUpdateHandler = { [weak self] state in - self?.queue.async { - self?.handleState(state) - } - } - connection.start(queue: queue) - } - - func stop() { - close() - } - - private func handleState(_ state: NWConnection.State) { - guard !isClosed else { return } - switch state { - case .ready: - sendChallenge() - receive() - case .failed, .cancelled: - close() - default: - break - } - } - - private func sendChallenge() { - challengeSentAt = Date() - challengeNonce = Self.randomHex(byteCount: 16) - let challenge: [String: Any] = [ - "protocol": challengeProtocol, - "version": challengeVersion, - "relay_id": relayID, - "nonce": challengeNonce, - ] - sendJSONLine(challenge) { _ in } - } - - private func receive() { - guard !isClosed else { return } - connection.receive(minimumIncompleteLength: 1, maximumLength: maximumFrameBytes) { [weak self] data, _, isComplete, error in - guard let self else { return } - self.queue.async { - if error != nil { - self.close() - return - } - if let data, !data.isEmpty { - self.buffer.append(data) - if self.buffer.count > self.maximumFrameBytes { - self.sendFailureAndClose() - return - } - self.processBufferedLines() - } - if isComplete { - self.close() - return - } - if !self.isClosed { - self.receive() - } - } - } - } - - private func processBufferedLines() { - while let newlineIndex = buffer.firstIndex(of: 0x0A), !isClosed { - let lineData = buffer.prefix(upTo: newlineIndex) - buffer.removeSubrange(...newlineIndex) - let line = String(data: lineData, encoding: .utf8)? - .trimmingCharacters(in: .whitespacesAndNewlines) ?? "" - switch phase { - case .awaitingAuth: - handleAuthLine(line) - case .awaitingCommand: - handleCommandLine(Data(lineData) + Data([0x0A])) - case .forwarding, .closed: - return - } - } - } - - private func handleAuthLine(_ line: String) { - guard let data = line.data(using: .utf8), - let object = try? JSONSerialization.jsonObject(with: data) as? [String: Any], - let receivedRelayID = object["relay_id"] as? String, - receivedRelayID == relayID, - let macHex = object["mac"] as? String, - let receivedMAC = Self.hexData(from: macHex) - else { - sendFailureAndClose() - return - } - - let message = Self.authMessage(relayID: relayID, nonce: challengeNonce, version: challengeVersion) - let expectedMAC = Self.authMAC(token: relayToken, message: message) - guard Self.constantTimeEqual(receivedMAC, expectedMAC) else { - sendFailureAndClose() - return - } - - phase = .awaitingCommand - sendJSONLine(["ok": true]) { [weak self] _ in - self?.queue.async { - self?.processBufferedLines() - } - } - } - - private func handleCommandLine(_ commandLine: Data) { - guard !commandLine.isEmpty else { - sendFailureAndClose() - return - } - phase = .forwarding - DispatchQueue.global(qos: .utility).async { [localSocketPath, commandLine, queue] in - let result = Result { try Self.roundTripUnixSocket(socketPath: localSocketPath, request: commandLine) } - queue.async { [weak self] in - guard let self else { return } - switch result { - case .success(let response): - self.connection.send(content: response, completion: .contentProcessed { [weak self] _ in - self?.queue.async { - self?.close() - } - }) - case .failure: - self.sendFailureAndClose() - } - } - } - } - - private func sendFailureAndClose() { - let elapsed = Date().timeIntervalSince(challengeSentAt) - let delay = max(0, minimumFailureDelay - elapsed) - phase = .closed - queue.asyncAfter(deadline: .now() + delay) { [weak self] in - self?.sendJSONLine(["ok": false]) { [weak self] _ in - self?.queue.async { - self?.close() - } - } - } - } - - private func sendJSONLine(_ object: [String: Any], completion: @escaping (NWError?) -> Void) { - guard !isClosed else { - completion(nil) - return - } - guard let payload = try? JSONSerialization.data(withJSONObject: object) else { - completion(nil) - return - } - connection.send(content: payload + Data([0x0A]), completion: .contentProcessed(completion)) - } - - private func close() { - guard !isClosed else { return } - isClosed = true - phase = .closed - connection.stateUpdateHandler = nil - connection.cancel() - onClose() - } - - private static func authMessage(relayID: String, nonce: String, version: Int) -> Data { - Data("relay_id=\(relayID)\nnonce=\(nonce)\nversion=\(version)".utf8) - } - - private static func authMAC(token: Data, message: Data) -> Data { - let key = SymmetricKey(data: token) - let code = HMAC.authenticationCode(for: message, using: key) - return Data(code) - } - - private static func constantTimeEqual(_ lhs: Data, _ rhs: Data) -> Bool { - guard lhs.count == rhs.count else { return false } - var diff: UInt8 = 0 - for index in lhs.indices { - diff |= lhs[index] ^ rhs[index] - } - return diff == 0 - } - - fileprivate static func hexData(from string: String) -> Data? { - let normalized = string.trimmingCharacters(in: .whitespacesAndNewlines) - guard normalized.count.isMultiple(of: 2), !normalized.isEmpty else { return nil } - var data = Data(capacity: normalized.count / 2) - var cursor = normalized.startIndex - while cursor < normalized.endIndex { - let next = normalized.index(cursor, offsetBy: 2) - guard let byte = UInt8(normalized[cursor.. String { - var bytes = [UInt8](repeating: 0, count: byteCount) - _ = SecRandomCopyBytes(kSecRandomDefault, bytes.count, &bytes) - return bytes.map { String(format: "%02x", $0) }.joined() - } - - private static func roundTripUnixSocket(socketPath: String, request: Data) throws -> Data { - let fd = socket(AF_UNIX, SOCK_STREAM, 0) - guard fd >= 0 else { - throw NSError(domain: "programa.remote.relay", code: 1, userInfo: [ - NSLocalizedDescriptionKey: "failed to create local relay socket", - ]) - } - defer { Darwin.close(fd) } - - var timeout = timeval(tv_sec: 15, tv_usec: 0) - withUnsafePointer(to: &timeout) { pointer in - _ = setsockopt(fd, SOL_SOCKET, SO_RCVTIMEO, pointer, socklen_t(MemoryLayout.size)) - _ = setsockopt(fd, SOL_SOCKET, SO_SNDTIMEO, pointer, socklen_t(MemoryLayout.size)) - } - - var address = sockaddr_un() - address.sun_family = sa_family_t(AF_UNIX) - let pathBytes = Array(socketPath.utf8CString) - guard pathBytes.count <= MemoryLayout.size(ofValue: address.sun_path) else { - throw NSError(domain: "programa.remote.relay", code: 2, userInfo: [ - NSLocalizedDescriptionKey: "local relay socket path is too long", - ]) - } - let sunPathOffset = MemoryLayout.offset(of: \.sun_path) ?? 0 - withUnsafeMutableBytes(of: &address) { rawBuffer in - let destination = rawBuffer.baseAddress!.advanced(by: sunPathOffset) - pathBytes.withUnsafeBytes { pathBuffer in - destination.copyMemory(from: pathBuffer.baseAddress!, byteCount: pathBytes.count) - } - } - - let addressLength = socklen_t(MemoryLayout.size(ofValue: address.sun_family) + pathBytes.count) - let connectResult = withUnsafePointer(to: &address) { - $0.withMemoryRebound(to: sockaddr.self, capacity: 1) { - Darwin.connect(fd, $0, addressLength) - } - } - guard connectResult == 0 else { - throw NSError(domain: "programa.remote.relay", code: 3, userInfo: [ - NSLocalizedDescriptionKey: "failed to connect to local cmux socket", - ]) - } - - try request.withUnsafeBytes { rawBuffer in - guard let baseAddress = rawBuffer.bindMemory(to: UInt8.self).baseAddress else { return } - var bytesRemaining = rawBuffer.count - var pointer = baseAddress - while bytesRemaining > 0 { - let written = Darwin.write(fd, pointer, bytesRemaining) - if written <= 0 { - throw NSError(domain: "programa.remote.relay", code: 4, userInfo: [ - NSLocalizedDescriptionKey: "failed to write relay request", - ]) - } - bytesRemaining -= written - pointer = pointer.advanced(by: written) - } - } - _ = shutdown(fd, SHUT_WR) - - var response = Data() - var scratch = [UInt8](repeating: 0, count: 4096) - while true { - let count = Darwin.read(fd, &scratch, scratch.count) - if count > 0 { - response.append(scratch, count: count) - continue - } - if count == 0 { - break - } - - if errno == EAGAIN || errno == EWOULDBLOCK { - if !response.isEmpty { - break - } - throw NSError(domain: "programa.remote.relay", code: 5, userInfo: [ - NSLocalizedDescriptionKey: "timed out waiting for local cmux response", - ]) - } - throw NSError(domain: "programa.remote.relay", code: 6, userInfo: [ - NSLocalizedDescriptionKey: "failed to read local cmux response", - ]) - } - return response - } - } - - private let localSocketPath: String - private let relayID: String - private let relayToken: Data - private let queue = DispatchQueue(label: "com.cmux.remote-ssh.cli-relay.\(UUID().uuidString)", qos: .utility) - - private var listener: NWListener? - private var sessions: [UUID: Session] = [:] - private var isStopped = false - private(set) var localPort: Int? - - init(localSocketPath: String, relayID: String, relayTokenHex: String) throws { - guard let relayToken = Session.hexData(from: relayTokenHex), !relayToken.isEmpty else { - throw NSError(domain: "programa.remote.relay", code: 7, userInfo: [ - NSLocalizedDescriptionKey: "invalid relay token", - ]) - } - self.localSocketPath = localSocketPath - self.relayID = relayID - self.relayToken = relayToken - } - - func start() throws -> Int { - if let existingPort = queue.sync(execute: { localPort }) { - return existingPort - } - - let listener = try Self.makeLoopbackListener() - let readySemaphore = DispatchSemaphore(value: 0) - let stateLock = NSLock() - var capturedError: Error? - var boundPort: Int? - - listener.newConnectionHandler = { [weak self] connection in - self?.queue.async { - self?.acceptConnectionLocked(connection) - } - } - listener.stateUpdateHandler = { listenerState in - switch listenerState { - case .ready: - stateLock.lock() - boundPort = listener.port.map { Int($0.rawValue) } - stateLock.unlock() - readySemaphore.signal() - case .failed(let error): - stateLock.lock() - capturedError = error - stateLock.unlock() - readySemaphore.signal() - default: - break - } - } - listener.start(queue: queue) - - let waitResult = readySemaphore.wait(timeout: .now() + 5.0) - stateLock.lock() - let startupError = capturedError - let startupPort = boundPort - stateLock.unlock() - - if waitResult != .success { - listener.newConnectionHandler = nil - listener.stateUpdateHandler = nil - listener.cancel() - throw NSError(domain: "programa.remote.relay", code: 8, userInfo: [ - NSLocalizedDescriptionKey: "timed out waiting for local relay listener", - ]) - } - if let startupError { - listener.newConnectionHandler = nil - listener.stateUpdateHandler = nil - listener.cancel() - throw startupError - } - guard let startupPort, startupPort > 0 else { - listener.newConnectionHandler = nil - listener.stateUpdateHandler = nil - listener.cancel() - throw NSError(domain: "programa.remote.relay", code: 8, userInfo: [ - NSLocalizedDescriptionKey: "failed to bind local relay listener", - ]) - } - - return queue.sync { - if let localPort { - listener.newConnectionHandler = nil - listener.stateUpdateHandler = nil - listener.cancel() - return localPort - } - self.listener = listener - self.localPort = startupPort - return startupPort - } - } - - func stop() { - queue.sync { - guard !isStopped else { return } - isStopped = true - listener?.newConnectionHandler = nil - listener?.stateUpdateHandler = nil - listener?.cancel() - listener = nil - localPort = nil - let activeSessions = sessions.values - sessions.removeAll() - for session in activeSessions { - session.stop() - } - } - } - - private func acceptConnectionLocked(_ connection: NWConnection) { - guard !isStopped else { - connection.cancel() - return - } - let sessionID = UUID() - let session = Session( - connection: connection, - localSocketPath: localSocketPath, - relayID: relayID, - relayToken: relayToken, - queue: queue - ) { [weak self] in - self?.sessions.removeValue(forKey: sessionID) - } - sessions[sessionID] = session - session.start() - } - - private static func makeLoopbackListener() throws -> NWListener { - let tcpOptions = NWProtocolTCP.Options() - tcpOptions.noDelay = true - let parameters = NWParameters(tls: nil, tcp: tcpOptions) - parameters.allowLocalEndpointReuse = true - parameters.requiredLocalEndpoint = .hostPort(host: NWEndpoint.Host("127.0.0.1"), port: .any) - return try NWListener(using: parameters) - } -} - diff --git a/Sources/WorkspaceRemoteDaemonPendingCallRegistry.swift b/Sources/WorkspaceRemoteDaemonPendingCallRegistry.swift new file mode 100644 index 00000000000..536c3eb3324 --- /dev/null +++ b/Sources/WorkspaceRemoteDaemonPendingCallRegistry.swift @@ -0,0 +1,103 @@ +// Extracted from WorkspaceRemoteDaemon.swift (nuclear-review #98): pending-RPC-call bookkeeping for the daemon transport. + +import Foundation +import SwiftUI +import AppKit +import Bonsplit +import Combine +import CryptoKit +import Darwin +import Network +import CoreText + +final class WorkspaceRemoteDaemonPendingCallRegistry { + final class PendingCall { + let id: Int + fileprivate let semaphore = DispatchSemaphore(value: 0) + fileprivate var response: [String: Any]? + fileprivate var failureMessage: String? + + fileprivate init(id: Int) { + self.id = id + } + } + + enum WaitOutcome { + case response([String: Any]) + case failure(String) + case missing + case timedOut + } + + private let queue = DispatchQueue(label: "com.cmux.remote-ssh.daemon-rpc.pending.\(UUID().uuidString)") + private var nextRequestID = 1 + private var pendingCalls: [Int: PendingCall] = [:] + + func reset() { + queue.sync { + nextRequestID = 1 + pendingCalls.removeAll(keepingCapacity: false) + } + } + + func register() -> PendingCall { + queue.sync { + let call = PendingCall(id: nextRequestID) + nextRequestID += 1 + pendingCalls[call.id] = call + return call + } + } + + @discardableResult + func resolve(id: Int, payload: [String: Any]) -> Bool { + queue.sync { + guard let pendingCall = pendingCalls[id] else { return false } + pendingCall.response = payload + pendingCall.semaphore.signal() + return true + } + } + + func failAll(_ message: String) { + queue.sync { + let calls = Array(pendingCalls.values) + for call in calls { + guard call.response == nil, call.failureMessage == nil else { continue } + call.failureMessage = message + call.semaphore.signal() + } + } + } + + func remove(_ call: PendingCall) { + _ = queue.sync { + pendingCalls.removeValue(forKey: call.id) + } + } + + func wait(for call: PendingCall, timeout: TimeInterval) -> WaitOutcome { + if call.semaphore.wait(timeout: .now() + timeout) == .timedOut { + _ = queue.sync { + pendingCalls.removeValue(forKey: call.id) + } + // A response can win the race immediately before timeout cleanup removes the call. + // Drain any late signal so DispatchSemaphore is not deallocated with a positive count. + _ = call.semaphore.wait(timeout: .now()) + return .timedOut + } + + return queue.sync { + guard let pendingCall = pendingCalls.removeValue(forKey: call.id) else { + return .missing + } + if let failure = pendingCall.failureMessage { + return .failure(failure) + } + guard let response = pendingCall.response else { + return .missing + } + return .response(response) + } + } +} diff --git a/Sources/WorkspaceRemoteDaemonRPCClient.swift b/Sources/WorkspaceRemoteDaemonRPCClient.swift new file mode 100644 index 00000000000..1688ec88f75 --- /dev/null +++ b/Sources/WorkspaceRemoteDaemonRPCClient.swift @@ -0,0 +1,485 @@ +// Extracted from WorkspaceRemoteDaemon.swift (nuclear-review #98): the JSON-RPC client that talks to programad-remote over ssh -T. + +import Foundation +import SwiftUI +import AppKit +import Bonsplit +import Combine +import CryptoKit +import Darwin +import Network +import CoreText + +final class WorkspaceRemoteDaemonRPCClient { + private static let maxStdoutBufferBytes = 256 * 1024 + static let requiredProxyStreamCapability = "proxy.stream.push" + + enum StreamEvent { + case data(Data) + case eof(Data) + case error(String) + } + + private struct StreamSubscription { + let queue: DispatchQueue + let handler: (StreamEvent) -> Void + } + + private let configuration: WorkspaceRemoteConfiguration + private let remotePath: String + private let onUnexpectedTermination: (String) -> Void + private let writeQueue = DispatchQueue(label: "com.cmux.remote-ssh.daemon-rpc.write.\(UUID().uuidString)") + private let stateQueue = DispatchQueue(label: "com.cmux.remote-ssh.daemon-rpc.state.\(UUID().uuidString)") + private let pendingCalls = WorkspaceRemoteDaemonPendingCallRegistry() + + private var process: Process? + private var stdinPipe: Pipe? + private var stdoutPipe: Pipe? + private var stderrPipe: Pipe? + private var stdinHandle: FileHandle? + private var stdoutHandle: FileHandle? + private var stderrHandle: FileHandle? + private var isClosed = true + private var shouldReportTermination = true + + private var stdoutBuffer = Data() + private var stderrBuffer = "" + private var streamSubscriptions: [String: StreamSubscription] = [:] + + init( + configuration: WorkspaceRemoteConfiguration, + remotePath: String, + onUnexpectedTermination: @escaping (String) -> Void + ) { + self.configuration = configuration + self.remotePath = remotePath + self.onUnexpectedTermination = onUnexpectedTermination + } + + func start() throws { + let process = Process() + let stdinPipe = Pipe() + let stdoutPipe = Pipe() + let stderrPipe = Pipe() + + stateQueue.sync { + self.stdinPipe = stdinPipe + self.stdoutPipe = stdoutPipe + self.stderrPipe = stderrPipe + } + + process.executableURL = URL(fileURLWithPath: "/usr/bin/ssh") + process.arguments = Self.daemonArguments(configuration: configuration, remotePath: remotePath) + process.standardInput = stdinPipe + process.standardOutput = stdoutPipe + process.standardError = stderrPipe + + stdoutPipe.fileHandleForReading.readabilityHandler = { [weak self] handle in + let data = handle.availableData + self?.stateQueue.async { + self?.consumeStdoutData(data) + } + } + stderrPipe.fileHandleForReading.readabilityHandler = { [weak self] handle in + let data = handle.availableData + self?.stateQueue.async { + self?.consumeStderrData(data) + } + } + process.terminationHandler = { [weak self] terminated in + self?.stateQueue.async { + self?.handleProcessTermination(terminated) + } + } + + do { + try process.run() + } catch { + throw NSError(domain: "programa.remote.daemon.rpc", code: 1, userInfo: [ + NSLocalizedDescriptionKey: "Failed to launch SSH daemon transport: \(error.localizedDescription)", + ]) + } + + stateQueue.sync { + self.process = process + self.stdinHandle = stdinPipe.fileHandleForWriting + self.stdoutHandle = stdoutPipe.fileHandleForReading + self.stderrHandle = stderrPipe.fileHandleForReading + self.isClosed = false + self.shouldReportTermination = true + self.stdoutBuffer = Data() + self.stderrBuffer = "" + self.streamSubscriptions.removeAll(keepingCapacity: false) + } + pendingCalls.reset() + + do { + let hello = try call(method: "hello", params: [:], timeout: 8.0) + let capabilities = (hello["capabilities"] as? [String]) ?? [] + guard capabilities.contains(Self.requiredProxyStreamCapability) else { + throw NSError(domain: "programa.remote.daemon.rpc", code: 2, userInfo: [ + NSLocalizedDescriptionKey: "remote daemon missing required capability \(Self.requiredProxyStreamCapability)", + ]) + } + } catch { + stop(suppressTerminationCallback: true) + throw error + } + } + + func stop() { + stop(suppressTerminationCallback: true) + } + + func openStream(host: String, port: Int, timeoutMs: Int = 10000) throws -> String { + let result = try call( + method: "proxy.open", + params: [ + "host": host, + "port": port, + "timeout_ms": timeoutMs, + ], + timeout: 12.0 + ) + let streamID = (result["stream_id"] as? String)?.trimmingCharacters(in: .whitespacesAndNewlines) ?? "" + guard !streamID.isEmpty else { + throw NSError(domain: "programa.remote.daemon.rpc", code: 3, userInfo: [ + NSLocalizedDescriptionKey: "proxy.open missing stream_id", + ]) + } + return streamID + } + + func writeStream(streamID: String, data: Data) throws { + _ = try call( + method: "proxy.write", + params: [ + "stream_id": streamID, + "data_base64": data.base64EncodedString(), + ], + timeout: 8.0 + ) + } + + func attachStream( + streamID: String, + queue: DispatchQueue, + onEvent: @escaping (StreamEvent) -> Void + ) throws { + let trimmedStreamID = streamID.trimmingCharacters(in: .whitespacesAndNewlines) + guard !trimmedStreamID.isEmpty else { + throw NSError(domain: "programa.remote.daemon.rpc", code: 17, userInfo: [ + NSLocalizedDescriptionKey: "proxy.stream.subscribe requires stream_id", + ]) + } + + stateQueue.sync { + streamSubscriptions[trimmedStreamID] = StreamSubscription(queue: queue, handler: onEvent) + } + + do { + _ = try call( + method: "proxy.stream.subscribe", + params: ["stream_id": trimmedStreamID], + timeout: 8.0 + ) + } catch { + unregisterStream(streamID: trimmedStreamID) + throw error + } + } + + func unregisterStream(streamID: String) { + let trimmedStreamID = streamID.trimmingCharacters(in: .whitespacesAndNewlines) + guard !trimmedStreamID.isEmpty else { return } + _ = stateQueue.sync { + streamSubscriptions.removeValue(forKey: trimmedStreamID) + } + } + + func closeStream(streamID: String) { + unregisterStream(streamID: streamID) + _ = try? call( + method: "proxy.close", + params: ["stream_id": streamID], + timeout: 4.0 + ) + } + + private func call(method: String, params: [String: Any], timeout: TimeInterval) throws -> [String: Any] { + let pendingCall = pendingCalls.register() + let requestID = pendingCall.id + + let payload: Data + do { + payload = try Self.encodeJSON([ + "id": requestID, + "method": method, + "params": params, + ]) + } catch { + pendingCalls.remove(pendingCall) + throw NSError(domain: "programa.remote.daemon.rpc", code: 10, userInfo: [ + NSLocalizedDescriptionKey: "failed to encode daemon RPC request \(method): \(error.localizedDescription)", + ]) + } + + do { + try writeQueue.sync { + try writePayload(payload) + } + } catch { + pendingCalls.remove(pendingCall) + throw error + } + + let response: [String: Any] + switch pendingCalls.wait(for: pendingCall, timeout: timeout) { + case .timedOut: + stop(suppressTerminationCallback: false) + throw NSError(domain: "programa.remote.daemon.rpc", code: 11, userInfo: [ + NSLocalizedDescriptionKey: "daemon RPC timeout waiting for \(method) response", + ]) + case .failure(let failure): + throw NSError(domain: "programa.remote.daemon.rpc", code: 12, userInfo: [ + NSLocalizedDescriptionKey: failure, + ]) + case .missing: + throw NSError(domain: "programa.remote.daemon.rpc", code: 13, userInfo: [ + NSLocalizedDescriptionKey: "daemon RPC \(method) returned empty response", + ]) + case .response(let pendingResponse): + response = pendingResponse + } + + let ok = (response["ok"] as? Bool) ?? false + if ok { + return (response["result"] as? [String: Any]) ?? [:] + } + + let errorObject = (response["error"] as? [String: Any]) ?? [:] + let code = (errorObject["code"] as? String)?.trimmingCharacters(in: .whitespacesAndNewlines) ?? "rpc_error" + let message = (errorObject["message"] as? String)?.trimmingCharacters(in: .whitespacesAndNewlines) ?? "daemon RPC call failed" + throw NSError(domain: "programa.remote.daemon.rpc", code: 14, userInfo: [ + NSLocalizedDescriptionKey: "\(method) failed (\(code)): \(message)", + ]) + } + + private func writePayload(_ payload: Data) throws { + let stdinHandle: FileHandle = stateQueue.sync { + self.stdinHandle ?? FileHandle.nullDevice + } + if stdinHandle === FileHandle.nullDevice { + throw NSError(domain: "programa.remote.daemon.rpc", code: 15, userInfo: [ + NSLocalizedDescriptionKey: "daemon transport is not connected", + ]) + } + do { + try stdinHandle.write(contentsOf: payload) + try stdinHandle.write(contentsOf: Data([0x0A])) + } catch { + stop(suppressTerminationCallback: false) + throw NSError(domain: "programa.remote.daemon.rpc", code: 16, userInfo: [ + NSLocalizedDescriptionKey: "failed writing daemon RPC request: \(error.localizedDescription)", + ]) + } + } + + private func consumeStdoutData(_ data: Data) { + guard !data.isEmpty else { + signalPendingFailureLocked("daemon transport closed stdout") + return + } + + stdoutBuffer.append(data) + if stdoutBuffer.count > Self.maxStdoutBufferBytes { + stdoutBuffer.removeAll(keepingCapacity: false) + signalPendingFailureLocked("daemon transport stdout exceeded \(Self.maxStdoutBufferBytes) bytes without message framing") + process?.terminate() + return + } + while let newlineIndex = stdoutBuffer.firstIndex(of: 0x0A) { + var lineData = Data(stdoutBuffer[.. 8192 { + stderrBuffer.removeFirst(stderrBuffer.count - 8192) + } + } + + private func consumeEventPayload(_ payload: [String: Any]) { + guard let eventName = (payload["event"] as? String)? + .trimmingCharacters(in: .whitespacesAndNewlines), + !eventName.isEmpty, + let streamID = (payload["stream_id"] as? String)? + .trimmingCharacters(in: .whitespacesAndNewlines), + !streamID.isEmpty else { + return + } + + let subscription: StreamSubscription? + let event: StreamEvent? + switch eventName { + case "proxy.stream.data": + subscription = streamSubscriptions[streamID] + event = .data(Self.decodeBase64Data(payload["data_base64"])) + + case "proxy.stream.eof": + subscription = streamSubscriptions.removeValue(forKey: streamID) + event = .eof(Self.decodeBase64Data(payload["data_base64"])) + + case "proxy.stream.error": + subscription = streamSubscriptions.removeValue(forKey: streamID) + let detail = ((payload["error"] as? String)? + .trimmingCharacters(in: .whitespacesAndNewlines)).flatMap { $0.isEmpty ? nil : $0 } + ?? "stream error" + event = .error(detail) + + default: + return + } + + guard let subscription, let event else { return } + subscription.queue.async { + subscription.handler(event) + } + } + + private func handleProcessTermination(_ process: Process) { + let shouldNotify: Bool = { + guard self.process === process else { return false } + return !isClosed && shouldReportTermination + }() + let detail = Self.bestErrorLine(stderr: stderrBuffer) ?? "daemon transport exited with status \(process.terminationStatus)" + + isClosed = true + self.process = nil + stdinPipe = nil + stdoutPipe = nil + stderrPipe = nil + stdinHandle = nil + stdoutHandle?.readabilityHandler = nil + stdoutHandle = nil + stderrHandle?.readabilityHandler = nil + stderrHandle = nil + streamSubscriptions.removeAll(keepingCapacity: false) + signalPendingFailureLocked(detail) + + guard shouldNotify else { return } + onUnexpectedTermination(detail) + } + + private func stop(suppressTerminationCallback: Bool) { + let captured: (Process?, FileHandle?, FileHandle?, FileHandle?, Bool, String) = stateQueue.sync { + let detail = Self.bestErrorLine(stderr: stderrBuffer) ?? "daemon transport stopped" + let shouldNotify = !suppressTerminationCallback && !isClosed + shouldReportTermination = !suppressTerminationCallback + if isClosed { + return (nil, nil, nil, nil, false, detail) + } + + isClosed = true + signalPendingFailureLocked("daemon transport stopped") + let capturedProcess = process + let capturedStdin = stdinHandle + let capturedStdout = stdoutHandle + let capturedStderr = stderrHandle + + process = nil + stdinPipe = nil + stdoutPipe = nil + stderrPipe = nil + stdinHandle = nil + stdoutHandle = nil + stderrHandle = nil + streamSubscriptions.removeAll(keepingCapacity: false) + return (capturedProcess, capturedStdin, capturedStdout, capturedStderr, shouldNotify, detail) + } + + captured.2?.readabilityHandler = nil + captured.3?.readabilityHandler = nil + try? captured.1?.close() + try? captured.2?.close() + try? captured.3?.close() + if let process = captured.0, process.isRunning { + process.terminate() + } + if captured.4 { + onUnexpectedTermination(captured.5) + } + } + + private func signalPendingFailureLocked(_ message: String) { + pendingCalls.failAll(message) + } + + private static func responseID(in payload: [String: Any]) -> Int? { + if let intValue = payload["id"] as? Int { + return intValue + } + if let numberValue = payload["id"] as? NSNumber { + return numberValue.intValue + } + return nil + } + + private static func decodeBase64Data(_ value: Any?) -> Data { + guard let encoded = value as? String, !encoded.isEmpty else { return Data() } + return Data(base64Encoded: encoded) ?? Data() + } + + private static func encodeJSON(_ object: [String: Any]) throws -> Data { + try JSONSerialization.data(withJSONObject: object, options: []) + } + + private static func daemonArguments(configuration: WorkspaceRemoteConfiguration, remotePath: String) -> [String] { + WorkspaceRemoteSSHBatchCommandBuilder.daemonTransportArguments( + configuration: configuration, + remotePath: remotePath + ) + } + + private static func bestErrorLine(stderr: String) -> String? { + let lines = stderr + .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 + } +} diff --git a/Sources/WorkspaceRemoteLoopbackHTTPRewriting.swift b/Sources/WorkspaceRemoteLoopbackHTTPRewriting.swift new file mode 100644 index 00000000000..b4f0ec17e67 --- /dev/null +++ b/Sources/WorkspaceRemoteLoopbackHTTPRewriting.swift @@ -0,0 +1,258 @@ +// Extracted from WorkspaceRemoteDaemon.swift (nuclear-review #98): HTTP request/response rewriting for the loopback proxy alias host. + +import Foundation +import SwiftUI +import AppKit +import Bonsplit +import Combine +import CryptoKit +import Darwin +import Network +import CoreText + +enum RemoteLoopbackHTTPRequestRewriter { + private static let headerDelimiter = Data([0x0d, 0x0a, 0x0d, 0x0a]) + private static let canonicalLoopbackHost = "localhost" + private static let requestLineMethods = ["GET", "POST", "PUT", "PATCH", "DELETE", "HEAD", "OPTIONS", "TRACE", "PRI"] + + static func rewriteIfNeeded(data: Data, aliasHost: String) -> Data { + rewriteIfNeeded(data: data, aliasHost: aliasHost, allowIncompleteHeadersAtEOF: false) + } + + static func rewriteIfNeeded(data: Data, aliasHost: String, allowIncompleteHeadersAtEOF: Bool) -> Data { + let headerData: Data + let remainder: Data + + if let headerRange = data.range(of: headerDelimiter) { + headerData = Data(data[.. Bool { + let trimmed = requestLine.trimmingCharacters(in: .whitespacesAndNewlines) + let method = trimmed.split(separator: " ", maxSplits: 1).first.map(String.init)?.uppercased() ?? "" + return requestLineMethods.contains(method) + } + + private static func rewriteRequestLine(_ requestLine: String, aliasHost: String) -> String { + let trimmed = requestLine.trimmingCharacters(in: .whitespacesAndNewlines) + let parts = trimmed.split(separator: " ", omittingEmptySubsequences: false) + guard parts.count >= 3 else { return requestLine } + + var components = URLComponents(string: String(parts[1])) + guard let host = components?.host, + BrowserInsecureHTTPSettings.normalizeHost(host) == BrowserInsecureHTTPSettings.normalizeHost(aliasHost) else { + return requestLine + } + components?.host = canonicalLoopbackHost + guard let rewrittenURL = components?.string else { return requestLine } + + var rewritten = parts + rewritten[1] = Substring(rewrittenURL) + let leadingTrivia = requestLine.prefix { $0.isWhitespace || $0.isNewline } + let trailingTrivia = String(requestLine.reversed().prefix { $0.isWhitespace || $0.isNewline }.reversed()) + return String(leadingTrivia) + rewritten.joined(separator: " ") + trailingTrivia + } + + private static func rewriteHeaderLine(_ line: String, aliasHost: String) -> String { + guard let colonIndex = line.firstIndex(of: ":") else { return line } + let name = line[.. String? { + let trimmed = value.trimmingCharacters(in: .whitespacesAndNewlines) + guard !trimmed.isEmpty else { return nil } + + if trimmed.hasPrefix("["), + let closing = trimmed.firstIndex(of: "]") { + let host = String(trimmed[trimmed.index(after: trimmed.startIndex).. String? { + var components = URLComponents(string: value) + guard let host = components?.host, + BrowserInsecureHTTPSettings.normalizeHost(host) == BrowserInsecureHTTPSettings.normalizeHost(aliasHost) else { + return nil + } + components?.host = canonicalLoopbackHost + return components?.string + } +} + +struct RemoteLoopbackHTTPRequestStreamRewriter { + private static let maxHeaderBytes = 64 * 1024 + private static let headerDelimiter = Data([0x0D, 0x0A, 0x0D, 0x0A]) + + private let aliasHost: String + private var pendingHeaderBytes = Data() + private var hasForwardedHeaders = false + + init(aliasHost: String) { + self.aliasHost = aliasHost + } + + mutating func rewriteNextChunk(_ data: Data, eof: Bool) -> Data { + guard !hasForwardedHeaders else { return data } + + pendingHeaderBytes.append(data) + if pendingHeaderBytes.count > Self.maxHeaderBytes { + hasForwardedHeaders = true + let payload = pendingHeaderBytes + pendingHeaderBytes = Data() + return RemoteLoopbackHTTPRequestRewriter.rewriteIfNeeded( + data: payload, + aliasHost: aliasHost, + allowIncompleteHeadersAtEOF: true + ) + } + + guard pendingHeaderBytes.range(of: Self.headerDelimiter) != nil else { + guard eof else { return Data() } + hasForwardedHeaders = true + let payload = pendingHeaderBytes + pendingHeaderBytes = Data() + return RemoteLoopbackHTTPRequestRewriter.rewriteIfNeeded( + data: payload, + aliasHost: aliasHost, + allowIncompleteHeadersAtEOF: true + ) + } + + hasForwardedHeaders = true + let payload = pendingHeaderBytes + pendingHeaderBytes = Data() + return RemoteLoopbackHTTPRequestRewriter.rewriteIfNeeded( + data: payload, + aliasHost: aliasHost + ) + } +} + +enum RemoteLoopbackHTTPResponseRewriter { + private static let headerDelimiter = Data([0x0d, 0x0a, 0x0d, 0x0a]) + private static let canonicalLoopbackHost = "localhost" + + static func rewriteIfNeeded(data: Data, aliasHost: String) -> Data { + guard let headerRange = data.range(of: headerDelimiter) else { return data } + let headerData = Data(data[.. String { + guard let colonIndex = line.firstIndex(of: ":") else { return line } + let name = line[.. String? { + var components = URLComponents(string: value) + guard let host = components?.host, + BrowserInsecureHTTPSettings.normalizeHost(host) == BrowserInsecureHTTPSettings.normalizeHost(canonicalLoopbackHost) else { + return nil + } + components?.host = aliasHost + return components?.string + } + + private static func rewriteCookieValue(_ value: String, aliasHost: String) -> String? { + let parts = value.split(separator: ";", omittingEmptySubsequences: false).map(String.init) + guard !parts.isEmpty else { return nil } + + var didRewrite = false + let rewrittenParts = parts.map { part -> String in + let trimmed = part.trimmingCharacters(in: .whitespacesAndNewlines) + guard trimmed.lowercased().hasPrefix("domain=") else { return part } + let domainValue = String(trimmed.dropFirst("domain=".count)) + guard BrowserInsecureHTTPSettings.normalizeHost(domainValue) == BrowserInsecureHTTPSettings.normalizeHost(canonicalLoopbackHost) else { + return part + } + didRewrite = true + let leadingWhitespace = part.prefix { $0.isWhitespace } + return "\(leadingWhitespace)Domain=\(aliasHost)" + } + + return didRewrite ? rewrittenParts.joined(separator: ";") : nil + } +} diff --git a/Sources/WorkspaceRemoteProxyBroker.swift b/Sources/WorkspaceRemoteProxyBroker.swift new file mode 100644 index 00000000000..3e70ea294e7 --- /dev/null +++ b/Sources/WorkspaceRemoteProxyBroker.swift @@ -0,0 +1,881 @@ +// Extracted from WorkspaceRemoteDaemon.swift (nuclear-review #98): the local<->remote proxy tunnel and its broker. + +import Foundation +import SwiftUI +import AppKit +import Bonsplit +import Combine +import CryptoKit +import Darwin +import Network +import CoreText + +private final class WorkspaceRemoteDaemonProxyTunnel { + private final class ProxySession { + private static let maxHandshakeBytes = 64 * 1024 + private static let remoteLoopbackProxyAliasHost = "programa-loopback.localtest.me" + + private enum HandshakeProtocol { + case undecided + case socks5 + case connect + } + + private enum SocksStage { + case greeting + case request + } + + private struct SocksRequest { + let host: String + let port: Int + let command: UInt8 + let consumedBytes: Int + } + + let id = UUID() + + private let connection: NWConnection + private let rpcClient: WorkspaceRemoteDaemonRPCClient + private let queue: DispatchQueue + private let onClose: (UUID) -> Void + + private var isClosed = false + private var protocolKind: HandshakeProtocol = .undecided + private var socksStage: SocksStage = .greeting + private var handshakeBuffer = Data() + private var streamID: String? + private var localInputEOF = false + private var rewritesLoopbackHTTPHeaders = false + private var loopbackRequestHeaderRewriter: RemoteLoopbackHTTPRequestStreamRewriter? + private var pendingRemoteHTTPHeaderBytes = Data() + private var hasForwardedRemoteHTTPHeaders = false + + init( + connection: NWConnection, + rpcClient: WorkspaceRemoteDaemonRPCClient, + queue: DispatchQueue, + onClose: @escaping (UUID) -> Void + ) { + self.connection = connection + self.rpcClient = rpcClient + self.queue = queue + self.onClose = onClose + } + + func start() { + connection.stateUpdateHandler = { [weak self] state in + guard let self else { return } + switch state { + case .failed(let error): + self.close(reason: "proxy client connection failed: \(error)") + case .cancelled: + self.close(reason: nil) + default: + break + } + } + connection.start(queue: queue) + receiveNext() + } + + func stop() { + close(reason: nil) + } + + private func receiveNext() { + guard !isClosed else { return } + connection.receive(minimumIncompleteLength: 1, maximumLength: 32768) { [weak self] data, _, isComplete, error in + guard let self, !self.isClosed else { return } + + if let data, !data.isEmpty { + if self.streamID == nil { + if self.handshakeBuffer.count + data.count > Self.maxHandshakeBytes { + self.close(reason: "proxy handshake exceeded \(Self.maxHandshakeBytes) bytes") + return + } + self.handshakeBuffer.append(data) + self.processHandshakeBuffer() + } else { + self.forwardToRemote(data, eof: isComplete) + } + } + + if isComplete { + // Treat local EOF as a half-close: keep remote read loop alive so we can + // drain upstream response bytes (for example curl closing write-side after + // sending an HTTP request through SOCKS/CONNECT). + self.localInputEOF = true + if self.streamID != nil, data?.isEmpty ?? true { + self.forwardToRemote(Data(), eof: true, allowAfterEOF: true) + } + if self.streamID == nil { + self.close(reason: nil) + } + return + } + if let error { + self.close(reason: "proxy client receive error: \(error)") + return + } + + self.receiveNext() + } + } + + private func processHandshakeBuffer() { + guard !isClosed else { return } + while streamID == nil { + switch protocolKind { + case .undecided: + guard let first = handshakeBuffer.first else { return } + protocolKind = (first == 0x05) ? .socks5 : .connect + case .socks5: + if !processSocksHandshakeStep() { + return + } + case .connect: + if !processConnectHandshakeStep() { + return + } + } + } + } + + private func processSocksHandshakeStep() -> Bool { + switch socksStage { + case .greeting: + guard handshakeBuffer.count >= 2 else { return false } + let methodCount = Int(handshakeBuffer[1]) + let total = 2 + methodCount + guard handshakeBuffer.count >= total else { return false } + + let methods = [UInt8](handshakeBuffer[2.. request.consumedBytes + ? Data(handshakeBuffer[request.consumedBytes...]) + : Data() + handshakeBuffer = Data() + guard request.command == 0x01 else { + sendAndClose(Data([0x05, 0x07, 0x00, 0x01, 0, 0, 0, 0, 0, 0])) + return false + } + + openRemoteStream( + host: request.host, + port: request.port, + successResponse: Data([0x05, 0x00, 0x00, 0x01, 0, 0, 0, 0, 0, 0]), + failureResponse: Data([0x05, 0x05, 0x00, 0x01, 0, 0, 0, 0, 0, 0]), + pendingPayload: pending + ) + return false + } + } + + private func parseSocksRequest(from data: Data) throws -> SocksRequest? { + let bytes = [UInt8](data) + guard bytes.count >= 4 else { return nil } + guard bytes[0] == 0x05 else { + throw NSError(domain: "programa.remote.proxy", code: 1, userInfo: [NSLocalizedDescriptionKey: "invalid SOCKS version"]) + } + + let command = bytes[1] + let addressType = bytes[3] + var cursor = 4 + let host: String + + switch addressType { + case 0x01: + guard bytes.count >= cursor + 4 + 2 else { return nil } + let octets = bytes[cursor..<(cursor + 4)].map { String($0) } + host = octets.joined(separator: ".") + cursor += 4 + + case 0x03: + guard bytes.count >= cursor + 1 else { return nil } + let length = Int(bytes[cursor]) + cursor += 1 + guard bytes.count >= cursor + length + 2 else { return nil } + let hostData = Data(bytes[cursor..<(cursor + length)]) + host = String(data: hostData, encoding: .utf8) ?? "" + cursor += length + + case 0x04: + guard bytes.count >= cursor + 16 + 2 else { return nil } + var address = in6_addr() + withUnsafeMutableBytes(of: &address) { target in + for i in 0..<16 { + target[i] = bytes[cursor + i] + } + } + var text = [CChar](repeating: 0, count: Int(INET6_ADDRSTRLEN)) + let pointer = withUnsafePointer(to: &address) { + inet_ntop(AF_INET6, UnsafeRawPointer($0), &text, socklen_t(INET6_ADDRSTRLEN)) + } + host = pointer != nil ? String(cString: text) : "" + cursor += 16 + + default: + throw NSError(domain: "programa.remote.proxy", code: 2, userInfo: [NSLocalizedDescriptionKey: "invalid SOCKS address type"]) + } + + guard !host.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty else { + throw NSError(domain: "programa.remote.proxy", code: 3, userInfo: [NSLocalizedDescriptionKey: "empty SOCKS host"]) + } + guard bytes.count >= cursor + 2 else { return nil } + let port = Int(UInt16(bytes[cursor]) << 8 | UInt16(bytes[cursor + 1])) + cursor += 2 + + guard port > 0 && port <= 65535 else { + throw NSError(domain: "programa.remote.proxy", code: 4, userInfo: [NSLocalizedDescriptionKey: "invalid SOCKS port"]) + } + + return SocksRequest(host: host, port: port, command: command, consumedBytes: cursor) + } + + private func processConnectHandshakeStep() -> Bool { + let marker = Data([0x0D, 0x0A, 0x0D, 0x0A]) + guard let headerRange = handshakeBuffer.range(of: marker) else { return false } + + let headerData = Data(handshakeBuffer[..= 2, parts[0].uppercased() == "CONNECT" else { + sendAndClose(Self.httpResponse(status: "400 Bad Request")) + return false + } + + guard let (host, port) = Self.parseConnectAuthority(parts[1]) else { + sendAndClose(Self.httpResponse(status: "400 Bad Request")) + return false + } + + openRemoteStream( + host: host, + port: port, + successResponse: Self.httpResponse(status: "200 Connection Established", closeAfterResponse: false), + failureResponse: Self.httpResponse(status: "502 Bad Gateway", closeAfterResponse: true), + pendingPayload: pending + ) + return false + } + + private func openRemoteStream( + host: String, + port: Int, + successResponse: Data, + failureResponse: Data, + pendingPayload: Data + ) { + guard !isClosed else { return } + do { + rewritesLoopbackHTTPHeaders = + BrowserInsecureHTTPSettings.normalizeHost(host) + == BrowserInsecureHTTPSettings.normalizeHost(Self.remoteLoopbackProxyAliasHost) + loopbackRequestHeaderRewriter = rewritesLoopbackHTTPHeaders + ? RemoteLoopbackHTTPRequestStreamRewriter(aliasHost: Self.remoteLoopbackProxyAliasHost) + : nil + pendingRemoteHTTPHeaderBytes = Data() + hasForwardedRemoteHTTPHeaders = false + let targetHost = Self.normalizedProxyTargetHost(host) + let streamID = try rpcClient.openStream(host: targetHost, port: port) + self.streamID = streamID + try rpcClient.attachStream(streamID: streamID, queue: queue) { [weak self] event in + self?.handleRemoteStreamEvent(streamID: streamID, event: event) + } + connection.send(content: successResponse, completion: .contentProcessed { [weak self] error in + guard let self else { return } + if let error { + self.close(reason: "proxy client send error: \(error)") + return + } + if !pendingPayload.isEmpty { + self.forwardToRemote(pendingPayload, allowAfterEOF: true) + } + }) + } catch { + sendAndClose(failureResponse) + } + } + + private func forwardToRemote(_ data: Data, eof: Bool = false, allowAfterEOF: Bool = false) { + guard !isClosed else { return } + guard !localInputEOF || allowAfterEOF else { return } + guard let streamID else { return } + do { + let outgoingData: Data + if rewritesLoopbackHTTPHeaders { + outgoingData = loopbackRequestHeaderRewriter?.rewriteNextChunk(data, eof: eof) ?? data + } else { + outgoingData = data + } + guard !outgoingData.isEmpty else { return } + try rpcClient.writeStream(streamID: streamID, data: outgoingData) + } catch { + close(reason: "proxy.write failed: \(error.localizedDescription)") + } + } + + private func handleRemoteStreamEvent( + streamID: String, + event: WorkspaceRemoteDaemonRPCClient.StreamEvent + ) { + guard !isClosed else { return } + guard self.streamID == streamID else { return } + + switch event { + case .data(let data): + forwardRemotePayloadToLocal(data, eof: false) + + case .eof(let data): + forwardRemotePayloadToLocal(data, eof: true) + + case .error(let detail): + close(reason: "proxy.stream failed: \(detail)") + } + } + + private func forwardRemotePayloadToLocal(_ data: Data, eof: Bool) { + let localData = rewriteRemoteResponseIfNeeded(data, eof: eof) + if !localData.isEmpty { + connection.send(content: localData, completion: .contentProcessed { [weak self] error in + guard let self else { return } + if let error { + self.close(reason: "proxy client send error: \(error)") + return + } + if eof { + self.close(reason: nil) + } + }) + return + } + + if eof { + close(reason: nil) + } + } + + private func rewriteRemoteResponseIfNeeded(_ data: Data, eof: Bool) -> Data { + guard rewritesLoopbackHTTPHeaders else { return data } + guard !data.isEmpty else { return data } + guard !hasForwardedRemoteHTTPHeaders else { return data } + + pendingRemoteHTTPHeaderBytes.append(data) + let marker = Data([0x0D, 0x0A, 0x0D, 0x0A]) + guard pendingRemoteHTTPHeaderBytes.range(of: marker) != nil else { + guard eof else { return Data() } + hasForwardedRemoteHTTPHeaders = true + let payload = pendingRemoteHTTPHeaderBytes + pendingRemoteHTTPHeaderBytes = Data() + return payload + } + + hasForwardedRemoteHTTPHeaders = true + let payload = pendingRemoteHTTPHeaderBytes + pendingRemoteHTTPHeaderBytes = Data() + return RemoteLoopbackHTTPResponseRewriter.rewriteIfNeeded( + data: payload, + aliasHost: Self.remoteLoopbackProxyAliasHost + ) + } + + private func close(reason: String?) { + guard !isClosed else { return } + isClosed = true + + let streamID = self.streamID + self.streamID = nil + + if let streamID { + rpcClient.closeStream(streamID: streamID) + } + connection.cancel() + onClose(id) + } + + private func sendLocal(_ data: Data) { + guard !isClosed else { return } + connection.send(content: data, completion: .contentProcessed { [weak self] error in + guard let self else { return } + if let error { + self.close(reason: "proxy client send error: \(error)") + } + }) + } + + private func sendAndClose(_ data: Data) { + guard !isClosed else { return } + connection.send(content: data, completion: .contentProcessed { [weak self] _ in + self?.close(reason: nil) + }) + } + + private static func parseConnectAuthority(_ authority: String) -> (host: String, port: Int)? { + let trimmed = authority.trimmingCharacters(in: .whitespacesAndNewlines) + guard !trimmed.isEmpty else { return nil } + + if trimmed.hasPrefix("[") { + guard let closing = trimmed.firstIndex(of: "]") else { return nil } + let host = String(trimmed[trimmed.index(after: trimmed.startIndex).. 0, port <= 65535 else { return nil } + return (host, port) + } + + guard let colon = trimmed.lastIndex(of: ":") else { return nil } + let host = String(trimmed[.. 0, port <= 65535 else { return nil } + return (host, port) + } + + private static func normalizedProxyTargetHost(_ host: String) -> String { + let trimmed = host.trimmingCharacters(in: .whitespacesAndNewlines) + let normalized = trimmed + .trimmingCharacters(in: CharacterSet(charactersIn: ".")) + .lowercased() + // BrowserPanel rewrites loopback URLs to this alias so proxy routing works. + // Resolve it back to true loopback before dialing from the remote daemon. + if normalized == remoteLoopbackProxyAliasHost { + return "127.0.0.1" + } + return host + } + + private static func httpResponse(status: String, closeAfterResponse: Bool = true) -> Data { + var text = "HTTP/1.1 \(status)\r\nProxy-Agent: cmux\r\n" + if closeAfterResponse { + text += "Connection: close\r\n" + } + text += "\r\n" + return Data(text.utf8) + } + } + + private let configuration: WorkspaceRemoteConfiguration + private let remotePath: String + private let localPort: Int + private let onFatalError: (String) -> Void + private let queue = DispatchQueue(label: "com.cmux.remote-ssh.daemon-tunnel.\(UUID().uuidString)", qos: .utility) + + private var listener: NWListener? + private var rpcClient: WorkspaceRemoteDaemonRPCClient? + private var sessions: [UUID: ProxySession] = [:] + private var isStopped = false + + init( + configuration: WorkspaceRemoteConfiguration, + remotePath: String, + localPort: Int, + onFatalError: @escaping (String) -> Void + ) { + self.configuration = configuration + self.remotePath = remotePath + self.localPort = localPort + self.onFatalError = onFatalError + } + + func start() throws { + var capturedError: Error? + queue.sync { + guard !isStopped else { + capturedError = NSError(domain: "programa.remote.proxy", code: 20, userInfo: [ + NSLocalizedDescriptionKey: "proxy tunnel already stopped", + ]) + return + } + do { + let client = WorkspaceRemoteDaemonRPCClient( + configuration: configuration, + remotePath: remotePath + ) { [weak self] detail in + self?.queue.async { + self?.failLocked("Remote daemon transport failed: \(detail)") + } + } + try client.start() + + let listener = try Self.makeLoopbackListener(port: localPort) + listener.newConnectionHandler = { [weak self] connection in + self?.queue.async { + self?.acceptConnectionLocked(connection) + } + } + listener.stateUpdateHandler = { [weak self] state in + self?.queue.async { + self?.handleListenerStateLocked(state) + } + } + + self.rpcClient = client + self.listener = listener + listener.start(queue: queue) + } catch { + capturedError = error + stopLocked(notify: false) + } + } + if let capturedError { + throw capturedError + } + } + + func stop() { + queue.sync { + stopLocked(notify: false) + } + } + + private func handleListenerStateLocked(_ state: NWListener.State) { + guard !isStopped else { return } + switch state { + case .failed(let error): + failLocked("Local proxy listener failed: \(error)") + default: + break + } + } + + private func acceptConnectionLocked(_ connection: NWConnection) { + guard !isStopped else { + connection.cancel() + return + } + guard let rpcClient else { + connection.cancel() + return + } + + let session = ProxySession( + connection: connection, + rpcClient: rpcClient, + queue: queue + ) { [weak self] id in + self?.queue.async { + self?.sessions.removeValue(forKey: id) + } + } + sessions[session.id] = session + session.start() + } + + private func failLocked(_ detail: String) { + guard !isStopped else { return } + stopLocked(notify: false) + onFatalError(detail) + } + + private func stopLocked(notify: Bool) { + guard !isStopped else { return } + isStopped = true + + listener?.stateUpdateHandler = nil + listener?.newConnectionHandler = nil + listener?.cancel() + listener = nil + + let activeSessions = sessions.values + sessions.removeAll() + for session in activeSessions { + session.stop() + } + + rpcClient?.stop() + rpcClient = nil + } + + private static func makeLoopbackListener(port: Int) throws -> NWListener { + guard let localPort = NWEndpoint.Port(rawValue: UInt16(port)) else { + throw NSError(domain: "programa.remote.proxy", code: 21, userInfo: [ + NSLocalizedDescriptionKey: "invalid local proxy port \(port)", + ]) + } + let tcpOptions = NWProtocolTCP.Options() + tcpOptions.noDelay = true + let parameters = NWParameters(tls: nil, tcp: tcpOptions) + parameters.allowLocalEndpointReuse = true + parameters.requiredLocalEndpoint = .hostPort(host: NWEndpoint.Host("127.0.0.1"), port: localPort) + return try NWListener(using: parameters) + } +} + +final class WorkspaceRemoteProxyBroker { + enum Update { + case connecting + case ready(BrowserProxyEndpoint) + case error(String) + } + + final class Lease { + private let key: String + private let subscriberID: UUID + private weak var broker: WorkspaceRemoteProxyBroker? + private var isReleased = false + + fileprivate init(key: String, subscriberID: UUID, broker: WorkspaceRemoteProxyBroker) { + self.key = key + self.subscriberID = subscriberID + self.broker = broker + } + + func release() { + guard !isReleased else { return } + isReleased = true + broker?.release(key: key, subscriberID: subscriberID) + } + + deinit { + release() + } + } + + private final class Entry { + let configuration: WorkspaceRemoteConfiguration + var remotePath: String + var tunnel: WorkspaceRemoteDaemonProxyTunnel? + var endpoint: BrowserProxyEndpoint? + var restartWorkItem: DispatchWorkItem? + var restartRetryCount = 0 + var subscribers: [UUID: (Update) -> Void] = [:] + + init(configuration: WorkspaceRemoteConfiguration, remotePath: String) { + self.configuration = configuration + self.remotePath = remotePath + } + } + + static let shared = WorkspaceRemoteProxyBroker() + + private let queue = DispatchQueue(label: "com.cmux.remote-ssh.proxy-broker", qos: .utility) + private var entries: [String: Entry] = [:] + + func acquire( + configuration: WorkspaceRemoteConfiguration, + remotePath: String, + onUpdate: @escaping (Update) -> Void + ) -> Lease { + queue.sync { + let key = Self.transportKey(for: configuration) + let subscriberID = UUID() + let entry: Entry + if let existing = entries[key] { + entry = existing + if existing.remotePath != remotePath { + existing.remotePath = remotePath + existing.restartRetryCount = 0 + if existing.tunnel != nil { + stopEntryRuntimeLocked(existing) + notifyLocked(existing, update: .connecting) + } + } + } else { + entry = Entry(configuration: configuration, remotePath: remotePath) + entries[key] = entry + } + + entry.subscribers[subscriberID] = onUpdate + if let endpoint = entry.endpoint { + onUpdate(.ready(endpoint)) + } else { + onUpdate(.connecting) + } + + if entry.tunnel == nil, entry.restartWorkItem == nil { + startEntryLocked(key: key, entry: entry) + } + + return Lease(key: key, subscriberID: subscriberID, broker: self) + } + } + + private func release(key: String, subscriberID: UUID) { + queue.async { [weak self] in + guard let self, let entry = self.entries[key] else { return } + entry.subscribers.removeValue(forKey: subscriberID) + guard entry.subscribers.isEmpty else { return } + self.teardownEntryLocked(key: key, entry: entry) + } + } + + private func startEntryLocked(key: String, entry: Entry) { + entry.restartWorkItem?.cancel() + entry.restartWorkItem = nil + + let localPort: Int + if let forcedLocalPort = entry.configuration.localProxyPort { + // Internal deterministic test hook used by docker regressions to force bind conflicts. + localPort = forcedLocalPort + } else { + let retryDelay = Self.retryDelay(baseDelay: 3.0, retry: entry.restartRetryCount + 1) + guard let allocatedPort = Self.allocateLoopbackPort() else { + notifyLocked( + entry, + update: .error("Failed to allocate local proxy port\(Self.retrySuffix(delay: retryDelay))") + ) + scheduleRestartLocked(key: key, entry: entry, baseDelay: 3.0) + return + } + localPort = allocatedPort + } + + do { + let tunnel = WorkspaceRemoteDaemonProxyTunnel( + configuration: entry.configuration, + remotePath: entry.remotePath, + localPort: localPort + ) { [weak self] detail in + self?.queue.async { + self?.handleTunnelFailureLocked(key: key, detail: detail) + } + } + try tunnel.start() + entry.tunnel = tunnel + let endpoint = BrowserProxyEndpoint(host: "127.0.0.1", port: localPort) + entry.endpoint = endpoint + entry.restartRetryCount = 0 + notifyLocked(entry, update: .ready(endpoint)) + } catch { + stopEntryRuntimeLocked(entry) + let detail = "Failed to start local daemon proxy: \(error.localizedDescription)" + let retryDelay = Self.retryDelay(baseDelay: 3.0, retry: entry.restartRetryCount + 1) + notifyLocked(entry, update: .error("\(detail)\(Self.retrySuffix(delay: retryDelay))")) + scheduleRestartLocked(key: key, entry: entry, baseDelay: 3.0) + } + } + + private func handleTunnelFailureLocked(key: String, detail: String) { + guard let entry = entries[key], entry.tunnel != nil else { return } + stopEntryRuntimeLocked(entry) + let retryDelay = Self.retryDelay(baseDelay: 3.0, retry: entry.restartRetryCount + 1) + notifyLocked(entry, update: .error("\(detail)\(Self.retrySuffix(delay: retryDelay))")) + scheduleRestartLocked(key: key, entry: entry, baseDelay: 3.0) + } + + private func scheduleRestartLocked(key: String, entry: Entry, baseDelay: TimeInterval) { + guard !entry.subscribers.isEmpty else { + teardownEntryLocked(key: key, entry: entry) + return + } + guard entry.restartWorkItem == nil else { return } + entry.restartRetryCount += 1 + let retryDelay = Self.retryDelay(baseDelay: baseDelay, retry: entry.restartRetryCount) + + let workItem = DispatchWorkItem { [weak self] in + guard let self, let currentEntry = self.entries[key] else { return } + currentEntry.restartWorkItem = nil + guard !currentEntry.subscribers.isEmpty else { + self.teardownEntryLocked(key: key, entry: currentEntry) + return + } + self.notifyLocked(currentEntry, update: .connecting) + self.startEntryLocked(key: key, entry: currentEntry) + } + + entry.restartWorkItem = workItem + queue.asyncAfter(deadline: .now() + retryDelay, execute: workItem) + } + + private func teardownEntryLocked(key: String, entry: Entry) { + entry.restartWorkItem?.cancel() + entry.restartWorkItem = nil + stopEntryRuntimeLocked(entry) + entries.removeValue(forKey: key) + } + + private func stopEntryRuntimeLocked(_ entry: Entry) { + entry.tunnel?.stop() + entry.tunnel = nil + entry.endpoint = nil + } + + private func notifyLocked(_ entry: Entry, update: Update) { + for callback in entry.subscribers.values { + callback(update) + } + } + + private static func transportKey(for configuration: WorkspaceRemoteConfiguration) -> String { + configuration.proxyBrokerTransportKey + } + + private static func allocateLoopbackPort() -> Int? { + for _ in 0..<8 { + let fd = socket(AF_INET, SOCK_STREAM, 0) + guard fd >= 0 else { return nil } + 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(0) + 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)) + } + } + guard bindResult == 0 else { continue } + + var bound = sockaddr_in() + var len = socklen_t(MemoryLayout.size) + let nameResult = withUnsafeMutablePointer(to: &bound) { ptr in + ptr.withMemoryRebound(to: sockaddr.self, capacity: 1) { sockaddrPtr in + getsockname(fd, sockaddrPtr, &len) + } + } + guard nameResult == 0 else { continue } + + let port = Int(UInt16(bigEndian: bound.sin_port)) + if port > 0 && port <= 65535 { + return port + } + } + return nil + } + + private static func retrySuffix(delay: TimeInterval) -> String { + let seconds = max(1, Int(delay.rounded())) + return " (retry in \(seconds)s)" + } + + private static func retryDelay(baseDelay: TimeInterval, retry: Int) -> TimeInterval { + let exponent = Double(max(0, retry - 1)) + return min(baseDelay * pow(2.0, exponent), 60.0) + } +} diff --git a/Sources/WorkspaceRemoteSSHBatchCommandBuilder.swift b/Sources/WorkspaceRemoteSSHBatchCommandBuilder.swift new file mode 100644 index 00000000000..867f77c3cd0 --- /dev/null +++ b/Sources/WorkspaceRemoteSSHBatchCommandBuilder.swift @@ -0,0 +1,60 @@ +// Extracted from WorkspaceRemoteDaemon.swift (nuclear-review #98): SSH argument builders for daemon-transport batch commands. + +import Foundation +import SwiftUI +import AppKit +import Bonsplit +import Combine +import CryptoKit +import Darwin +import Network +import CoreText + +enum WorkspaceRemoteSSHBatchCommandBuilder { + static func daemonTransportArguments( + configuration: WorkspaceRemoteConfiguration, + remotePath: String + ) -> [String] { + let script = "exec \(RemoteSSHConnectionPolicy.shellSingleQuoted(remotePath)) serve --stdio" + let command = "sh -c \(RemoteSSHConnectionPolicy.shellSingleQuoted(script))" + return ["-T"] + + batchArguments(configuration: configuration) + + ["-o", "RequestTTY=no", configuration.destination, command] + } + + static func reverseRelayControlMasterArguments( + configuration: WorkspaceRemoteConfiguration, + controlCommand: String, + forwardSpec: String + ) -> [String]? { + guard let controlPath = RemoteSSHConnectionPolicy.optionValue(named: "ControlPath", in: configuration.sshOptions)? + .trimmingCharacters(in: .whitespacesAndNewlines), + !controlPath.isEmpty, + controlPath.lowercased() != "none" else { + return nil + } + + var args = batchArguments(configuration: configuration) + args += ["-O", controlCommand, "-R", forwardSpec, configuration.destination] + return args + } + + private static func batchArguments(configuration: WorkspaceRemoteConfiguration) -> [String] { + let effectiveSSHOptions = RemoteSSHConnectionPolicy.backgroundOptions(configuration.sshOptions) + var args = RemoteSSHConnectionPolicy.keepaliveArguments + args += RemoteSSHConnectionPolicy.strictHostKeyCheckingArguments(unlessSetIn: effectiveSSHOptions) + // Batch helpers may reuse an existing ControlPath, but must not negotiate a new master. + args += RemoteSSHConnectionPolicy.batchModeArguments + 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 effectiveSSHOptions { + args += ["-o", option] + } + return args + } +} From 2140f5e1f21c29ce2826e596cefeaaad50c5de4c Mon Sep 17 00:00:00 2001 From: arzafran Date: Thu, 9 Jul 2026 14:04:38 -0300 Subject: [PATCH 13/19] refactor(terminal): split IME and SwiftUI wrapper out of GhosttyTerminalView.swift MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit GhosttyTerminalView.swift (9,490 lines) hosts 6+ subsystems; apply the existing +Extension convention (+Keyboard, +Mouse, +DragDrop, +Accessibility) to two more, clearly self-contained ones: - GhosttyTerminalView+IME.swift: the GhosttyNSView: NSTextInputClient conformance (marked-text/composition state, committed-text delivery, character-coordinate queries for system input methods). Moved verbatim. - GhosttyTerminalView+SwiftUIWrapper.swift: the GhosttyTerminalView NSViewRepresentable struct, its Coordinator, and the private HostContainerView it manages — the SwiftUI/AppKit bridge. Moved verbatim. Both are pure file moves; the only code change is widening GhosttyNSView.imePointOverrideForTesting from private to private(set) so the moved IME extension (a #if DEBUG testing hook, firstRect override) can still read it — it is written only inside GhosttyNSView's own declaration, which stays in GhosttyTerminalView.swift, so private(set) preserves the single-writer invariant. GhosttyTerminalView.swift drops from 9,490 to 8,592 lines. Registered both new files in project.pbxproj (NRGV0001-NRGV0004). Refs #97. --- GhosttyTabs.xcodeproj/project.pbxproj | 8 + Sources/GhosttyTerminalView+IME.swift | 475 +++++++++ .../GhosttyTerminalView+SwiftUIWrapper.swift | 482 +++++++++ Sources/GhosttyTerminalView.swift | 919 +----------------- 4 files changed, 968 insertions(+), 916 deletions(-) create mode 100644 Sources/GhosttyTerminalView+IME.swift create mode 100644 Sources/GhosttyTerminalView+SwiftUIWrapper.swift diff --git a/GhosttyTabs.xcodeproj/project.pbxproj b/GhosttyTabs.xcodeproj/project.pbxproj index e7a4fe5363d..afaef0e3f3b 100644 --- a/GhosttyTabs.xcodeproj/project.pbxproj +++ b/GhosttyTabs.xcodeproj/project.pbxproj @@ -24,6 +24,8 @@ A5FF0052 /* GhosttyTerminalView+Mouse.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5FF0042 /* GhosttyTerminalView+Mouse.swift */; }; A5FF0053 /* GhosttyTerminalView+DragDrop.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5FF0043 /* GhosttyTerminalView+DragDrop.swift */; }; A5FF0054 /* GhosttyTerminalView+Accessibility.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5FF0044 /* GhosttyTerminalView+Accessibility.swift */; }; + NRGV0001 /* GhosttyTerminalView+IME.swift in Sources */ = {isa = PBXBuildFile; fileRef = NRGV0002 /* GhosttyTerminalView+IME.swift */; }; + NRGV0003 /* GhosttyTerminalView+SwiftUIWrapper.swift in Sources */ = {isa = PBXBuildFile; fileRef = NRGV0004 /* GhosttyTerminalView+SwiftUIWrapper.swift */; }; A5001532 /* TerminalWindowPortal.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5001531 /* TerminalWindowPortal.swift */; }; A5001534 /* BrowserWindowPortal.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5001533 /* BrowserWindowPortal.swift */; }; A5FF0008 /* HostedViewPortalRegistry.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5FF0018 /* HostedViewPortalRegistry.swift */; }; @@ -247,6 +249,8 @@ A5FF0042 /* GhosttyTerminalView+Mouse.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "GhosttyTerminalView+Mouse.swift"; sourceTree = ""; }; A5FF0043 /* GhosttyTerminalView+DragDrop.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "GhosttyTerminalView+DragDrop.swift"; sourceTree = ""; }; A5FF0044 /* GhosttyTerminalView+Accessibility.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "GhosttyTerminalView+Accessibility.swift"; sourceTree = ""; }; + NRGV0002 /* GhosttyTerminalView+IME.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "GhosttyTerminalView+IME.swift"; sourceTree = ""; }; + NRGV0004 /* GhosttyTerminalView+SwiftUIWrapper.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "GhosttyTerminalView+SwiftUIWrapper.swift"; sourceTree = ""; }; A5001531 /* TerminalWindowPortal.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TerminalWindowPortal.swift; sourceTree = ""; }; A5001533 /* BrowserWindowPortal.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BrowserWindowPortal.swift; sourceTree = ""; }; A5FF0018 /* HostedViewPortalRegistry.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HostedViewPortalRegistry.swift; sourceTree = ""; }; @@ -524,6 +528,8 @@ A5FF0042 /* GhosttyTerminalView+Mouse.swift */, A5FF0043 /* GhosttyTerminalView+DragDrop.swift */, A5FF0044 /* GhosttyTerminalView+Accessibility.swift */, + NRGV0002 /* GhosttyTerminalView+IME.swift */, + NRGV0004 /* GhosttyTerminalView+SwiftUIWrapper.swift */, A5001531 /* TerminalWindowPortal.swift */, A5001533 /* BrowserWindowPortal.swift */, A5FF0018 /* HostedViewPortalRegistry.swift */, @@ -853,6 +859,8 @@ A5FF0052 /* GhosttyTerminalView+Mouse.swift in Sources */, A5FF0053 /* GhosttyTerminalView+DragDrop.swift in Sources */, A5FF0054 /* GhosttyTerminalView+Accessibility.swift in Sources */, + NRGV0001 /* GhosttyTerminalView+IME.swift in Sources */, + NRGV0003 /* GhosttyTerminalView+SwiftUIWrapper.swift in Sources */, A5001532 /* TerminalWindowPortal.swift in Sources */, A5001534 /* BrowserWindowPortal.swift in Sources */, A5FF0008 /* HostedViewPortalRegistry.swift in Sources */, diff --git a/Sources/GhosttyTerminalView+IME.swift b/Sources/GhosttyTerminalView+IME.swift new file mode 100644 index 00000000000..cd4cc99448d --- /dev/null +++ b/Sources/GhosttyTerminalView+IME.swift @@ -0,0 +1,475 @@ +import Foundation +import SwiftUI +import AppKit +import Metal +import QuartzCore +import Combine +import CoreText +import Darwin +import Carbon.HIToolbox +import Bonsplit +import IOSurface +import UniformTypeIdentifiers + +// MARK: - GhosttyNSView + IME (NSTextInputClient) +// +// Input Method Editor conformance for GhosttyNSView: marked-text/composition +// state, committed-text delivery, and character-coordinate queries used by +// system input methods (Japanese/Chinese/Korean IME, dead-key composition, +// emoji picker, etc.). +// +// Split out of GhosttyTerminalView.swift (Nuclear Review #97). Extracted +// verbatim as a same-type extension, so call-site behavior is unchanged. + +extension GhosttyNSView: NSTextInputClient { + /// Deliver committed text using typed-input semantics so shells and editors + /// keep their normal interactive behaviors (autosuggestions, Return + /// execution, etc.). Programmatic callers can preserve literal ESC bytes so + /// automation payloads remain byte-for-byte stable. + fileprivate func sendTextToSurface(_ chars: String, preserveLiteralEscape: Bool) { + guard let surface = surface else { return } +#if DEBUG + let typingTimingStart = ProgramaTypingTiming.start() +#endif +#if DEBUG + programaWriteChildExitProbe( + [ + "probeInsertTextCharsHex": programaScalarHex(chars), + "probeInsertTextSurfaceId": terminalSurface?.id.uuidString ?? "", + ], + increments: ["probeInsertTextCount": 1] + ) +#endif + + var bufferedText = "" + var previousWasCR = false + + func flushBufferedText() { + guard !bufferedText.isEmpty else { return } + var keyEvent = ghostty_input_key_s() + keyEvent.action = GHOSTTY_ACTION_PRESS + keyEvent.keycode = 0 + keyEvent.mods = GHOSTTY_MODS_NONE + keyEvent.consumed_mods = GHOSTTY_MODS_NONE + keyEvent.unshifted_codepoint = 0 + keyEvent.composing = false + bufferedText.withCString { ptr in + keyEvent.text = ptr + _ = sendGhosttyKey(surface, keyEvent) + } + bufferedText.removeAll(keepingCapacity: true) + } + + func sendControlKey(_ keycode: UInt32) { + var keyEvent = ghostty_input_key_s() + keyEvent.action = GHOSTTY_ACTION_PRESS + keyEvent.keycode = keycode + keyEvent.mods = GHOSTTY_MODS_NONE + keyEvent.consumed_mods = GHOSTTY_MODS_NONE + keyEvent.unshifted_codepoint = 0 + keyEvent.composing = false + keyEvent.text = nil + _ = sendGhosttyKey(surface, keyEvent) + } + + for scalar in chars.unicodeScalars { + switch scalar.value { + case 0x0A: + if !previousWasCR { + flushBufferedText() + sendControlKey(0x24) // kVK_Return + } + previousWasCR = false + case 0x0D: + flushBufferedText() + sendControlKey(0x24) // kVK_Return + previousWasCR = true + case 0x09: + flushBufferedText() + sendControlKey(0x30) // kVK_Tab + previousWasCR = false + case 0x1B: + if preserveLiteralEscape { + bufferedText.unicodeScalars.append(scalar) + } else { + flushBufferedText() + sendControlKey(0x35) // kVK_Escape + } + previousWasCR = false + default: + bufferedText.unicodeScalars.append(scalar) + previousWasCR = false + } + } + flushBufferedText() +#if DEBUG + ProgramaTypingTiming.logDuration( + path: "terminal.sendTextToSurface", + startedAt: typingTimingStart, + extra: "textBytes=\(chars.utf8.count)" + ) +#endif + } + + /// External accessibility/dictation tools should commit plain text, but + /// some inject a leading escape sequence first. Strip those bytes on the + /// committed-text path so they can't leak into the PTY as literals. + static func sanitizeExternalCommittedText(_ text: String) -> String { + let bytes = Array(text.utf8) + guard !bytes.isEmpty else { return text } + + var index = 0 + while index < bytes.count { + let byte = bytes[index] + if byte == 0x1B { + index = consumeLeadingEscapeSequence(in: bytes, from: index) + continue + } + + if byte == 0xC2 { + let next = index + 1 + if next < bytes.count, bytes[next] == 0x9B { + // U+009B (C1 CSI) is encoded as the UTF-8 byte pair C2 9B. + index = consumeLeadingCSISequence(in: bytes, from: next + 1) + continue + } + } + + break + } + + if index == 0 { + return text + } + + guard index < bytes.count else { return "" } + return String(decoding: bytes[index...], as: UTF8.self) + } + + private static func consumeLeadingEscapeSequence( + in bytes: [UInt8], + from start: Int + ) -> Int { + let next = start + 1 + guard next < bytes.count else { return bytes.count } + + switch bytes[next] { + case 0x5B: + // CSI: ESC [ ... final + return consumeLeadingCSISequence(in: bytes, from: next + 1) + case 0x4F: + // SS3: ESC O final + return min(bytes.count, next + 2) + case 0x50, 0x5D, 0x5E, 0x5F: + // DCS/OSC/PM/APC: consume until BEL/ST or EOF. + return consumeLeadingEscapedStringSequence(in: bytes, from: next + 1) + default: + // Single-character escape. + return min(bytes.count, next + 1) + } + } + + private static func consumeLeadingCSISequence( + in bytes: [UInt8], + from start: Int + ) -> Int { + var index = start + while index < bytes.count { + let byte = bytes[index] + if (0x20...0x3F).contains(byte) { + index += 1 + continue + } + + if (0x40...0x7E).contains(byte) { + return index + 1 + } + + break + } + + return index + } + + private static func consumeLeadingEscapedStringSequence( + in bytes: [UInt8], + from start: Int + ) -> Int { + var index = start + while index < bytes.count { + let byte = bytes[index] + if byte == 0x07 { + return index + 1 + } + + if byte == 0x1B { + let next = index + 1 + if next < bytes.count, bytes[next] == 0x5C { + return next + 1 + } + return index + } + + if byte < 0x20 || byte == 0x7F { + return index + 1 + } + + index += 1 + } + + return bytes.count + } + + func hasMarkedText() -> Bool { + return markedText.length > 0 + } + + func markedRange() -> NSRange { + guard markedText.length > 0 else { return NSRange(location: NSNotFound, length: 0) } + return NSRange(location: 0, length: markedText.length) + } + + func selectedRange() -> NSRange { + readSelectionSnapshot()?.range ?? NSRange(location: 0, length: 0) + } + + func setMarkedText(_ string: Any, selectedRange: NSRange, replacementRange: NSRange) { +#if DEBUG + let typingTimingStart = ProgramaTypingTiming.start() + defer { + ProgramaTypingTiming.logDuration( + path: "terminal.setMarkedText", + startedAt: typingTimingStart, + extra: "markedLength=\(markedText.length)" + ) + } +#endif + switch string { + case let v as NSAttributedString: + markedText = NSMutableAttributedString(attributedString: v) + case let v as String: + markedText = NSMutableAttributedString(string: v) + default: + break + } + + // If we're not in a keyDown event, sync preedit immediately. + // This can happen due to external events like changing keyboard layouts + // while composing. + if keyTextAccumulator == nil { + syncPreedit() + invalidateTextInputCoordinates(selectionChanged: true) + } + } + + func unmarkText() { +#if DEBUG + let hadMarkedText = markedText.length > 0 + let typingTimingStart = ProgramaTypingTiming.start() + defer { + ProgramaTypingTiming.logDuration( + path: "terminal.unmarkText", + startedAt: typingTimingStart, + extra: "hadMarkedText=\(hadMarkedText ? 1 : 0)" + ) + } +#endif + if markedText.length > 0 { + markedText.mutableString.setString("") + syncPreedit() + invalidateTextInputCoordinates(selectionChanged: true) + } + } + + /// Sync the preedit state based on the markedText value to libghostty. + /// This tells Ghostty about IME composition text so it can render the + /// preedit overlay (e.g. for Korean, Japanese, Chinese input). + func syncPreedit(clearIfNeeded: Bool = true) { +#if DEBUG + let typingTimingStart = ProgramaTypingTiming.start() + defer { + ProgramaTypingTiming.logDuration( + path: "terminal.syncPreedit", + startedAt: typingTimingStart, + extra: "markedLength=\(markedText.length) clearIfNeeded=\(clearIfNeeded ? 1 : 0)" + ) + } +#endif + guard let surface = surface else { return } + + if markedText.length > 0 { + let str = markedText.string + let len = str.utf8CString.count + if len > 0 { + str.withCString { ptr in + // Subtract 1 for the null terminator + ghostty_surface_preedit(surface, ptr, UInt(len - 1)) + } + } + } else if clearIfNeeded { + // If we had marked text before but don't now, we're no longer + // in a preedit state so we can clear it. + ghostty_surface_preedit(surface, nil, 0) + } + } + + func validAttributesForMarkedText() -> [NSAttributedString.Key] { + return [] + } + + func attributedSubstring(forProposedRange range: NSRange, actualRange: NSRangePointer?) -> NSAttributedString? { + guard range.length > 0, + let snapshot = readSelectionSnapshot() else { return nil } + actualRange?.pointee = snapshot.range + return NSAttributedString(string: snapshot.string) + } + + func characterIndex(for point: NSPoint) -> Int { + return selectedRange().location + } + + func firstRect(forCharacterRange range: NSRange, actualRange: NSRangePointer?) -> NSRect { + guard let window = self.window else { + return NSRect(x: frame.origin.x, y: frame.origin.y, width: 0, height: 0) + } + + // Use Ghostty's IME point API for accurate cursor position if available. + var x: Double = 0 + var y: Double = 0 + var w: Double = cellSize.width + var h: Double = cellSize.height +#if DEBUG + if range.length > 0, + range != selectedRange(), + let snapshot = readSelectionSnapshot() { + x = snapshot.topLeft.x - 2 + y = snapshot.topLeft.y + 2 + } else if let override = imePointOverrideForTesting { + x = override.x + y = override.y + w = override.width + h = override.height + } else if let surface = surface { + ghostty_surface_ime_point(surface, &x, &y, &w, &h) + } +#else + if range.length > 0, + range != selectedRange(), + let snapshot = readSelectionSnapshot() { + x = snapshot.topLeft.x - 2 + y = snapshot.topLeft.y + 2 + } else if let surface = surface { + ghostty_surface_ime_point(surface, &x, &y, &w, &h) + } +#endif + + if range.length == 0, w > 0 { + // Dictation expects a caret rect for insertion points rather than a box. + w = 0 + } + + // Ghostty coordinates are top-left origin; AppKit expects bottom-left. + let viewRect = NSRect( + x: x, + y: frame.size.height - y, + width: w, + height: max(h, cellSize.height) + ) + let winRect = convert(viewRect, to: nil) + return window.convertToScreen(winRect) + } + + func attributedString() -> NSAttributedString { + if markedText.length > 0 { + return NSAttributedString(attributedString: markedText) + } + if let snapshot = readSelectionSnapshot(), !snapshot.string.isEmpty { + return NSAttributedString(string: snapshot.string) + } + return NSAttributedString(string: "") + } + + func windowLevel() -> Int { + Int(window?.level.rawValue ?? NSWindow.Level.normal.rawValue) + } + + @available(macOS 14.0, *) + var unionRectInVisibleSelectedRange: NSRect { + firstRect(forCharacterRange: selectedRange(), actualRange: nil) + } + + @available(macOS 14.0, *) + var documentVisibleRect: NSRect { + visibleDocumentRectInScreenCoordinates() + } + + func insertText(_ string: Any, replacementRange: NSRange) { +#if DEBUG + let typingTimingStart = ProgramaTypingTiming.start() + defer { + ProgramaTypingTiming.logDuration( + path: "terminal.insertText", + startedAt: typingTimingStart, + event: NSApp.currentEvent, + extra: "replacementLocation=\(replacementRange.location) replacementLength=\(replacementRange.length)" + ) + } +#endif + // Get the string value + var chars = "" + switch string { + case let v as NSAttributedString: + chars = v.string + case let v as String: + chars = v + default: + return + } + + // Clear marked text since we're inserting + unmarkText() + + // Some IME/input-method paths call insertText with an empty payload to + // flush state. There is no terminal text to send in that case. + guard !chars.isEmpty else { return } + +#if DEBUG + if NSApp.currentEvent == nil { + dlog("ime.insertText.noEvent len=\(chars.count)") + } +#endif + + // If we have an accumulator, we're in a keyDown event - accumulate the text + if keyTextAccumulator != nil { + keyTextAccumulator?.append(chars) + return + } + + let isExternalCommittedText = externalCommittedTextDepth > 0 + let sanitizedChars = if isExternalCommittedText { + // Only sanitize explicit external committed-text paths used by + // AX/dictation integrations. Programmatic NSTextInputClient callers + // may intentionally start with ESC/CSI bytes. + Self.sanitizeExternalCommittedText(chars) + } else { + chars + } + +#if DEBUG + if sanitizedChars != chars { + dlog( + "ime.insertText.sanitized originalBytes=\(chars.utf8.count) " + + "sanitizedBytes=\(sanitizedChars.utf8.count)" + ) + } +#endif + + guard !sanitizedChars.isEmpty else { return } + + // Otherwise send directly to the terminal + sendTextToSurface( + sanitizedChars, + preserveLiteralEscape: !isExternalCommittedText + ) + } +} diff --git a/Sources/GhosttyTerminalView+SwiftUIWrapper.swift b/Sources/GhosttyTerminalView+SwiftUIWrapper.swift new file mode 100644 index 00000000000..75d9a955f50 --- /dev/null +++ b/Sources/GhosttyTerminalView+SwiftUIWrapper.swift @@ -0,0 +1,482 @@ +import Foundation +import SwiftUI +import AppKit +import Metal +import QuartzCore +import Combine +import CoreText +import Darwin +import Carbon.HIToolbox +import Bonsplit +import IOSurface +import UniformTypeIdentifiers + +// MARK: - GhosttyTerminalView (SwiftUI Wrapper) +// +// The NSViewRepresentable bridge between SwiftUI panel layout and the +// AppKit-hosted terminal portal (GhosttySurfaceScrollView/GhosttyNSView), +// plus its Coordinator and the private HostContainerView it manages. +// +// Split out of GhosttyTerminalView.swift (Nuclear Review #97). Extracted +// verbatim, so call-site behavior is unchanged. + +// MARK: - SwiftUI Wrapper + +struct GhosttyTerminalView: NSViewRepresentable { + @Environment(\.paneDropZone) var paneDropZone + + let terminalSurface: TerminalSurface + let paneId: PaneID + var isActive: Bool = true + var isVisibleInUI: Bool = true + var portalZPriority: Int = 0 + var showsInactiveOverlay: Bool = false + var showsUnreadNotificationRing: Bool = false + var inactiveOverlayColor: NSColor = .clear + var inactiveOverlayOpacity: Double = 0 + var searchState: TerminalSurface.SearchState? = nil + var reattachToken: UInt64 = 0 + var onFocus: ((UUID) -> Void)? = nil + var onTriggerFlash: (() -> Void)? = nil + + private final class HostContainerView: NSView { + private static var nextInstanceSerial: UInt64 = 0 + + var onDidMoveToWindow: (() -> Void)? + var onGeometryChanged: (() -> Void)? + let instanceSerial: UInt64 + private(set) var geometryRevision: UInt64 = 0 + private var lastReportedGeometryState: GeometryState? + + override init(frame frameRect: NSRect) { + Self.nextInstanceSerial &+= 1 + instanceSerial = Self.nextInstanceSerial + super.init(frame: frameRect) + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) not implemented") + } + + private struct GeometryState: Equatable { + let frame: CGRect + let bounds: CGRect + let windowNumber: Int? + let superviewID: ObjectIdentifier? + } + + private func currentGeometryState() -> GeometryState { + GeometryState( + frame: frame, + bounds: bounds, + windowNumber: window?.windowNumber, + superviewID: superview.map(ObjectIdentifier.init) + ) + } + + private func notifyGeometryChangedIfNeeded() { + let state = currentGeometryState() + guard state != lastReportedGeometryState else { return } + lastReportedGeometryState = state + geometryRevision &+= 1 + onGeometryChanged?() + } + + override func viewDidMoveToWindow() { + super.viewDidMoveToWindow() + onDidMoveToWindow?() + notifyGeometryChangedIfNeeded() + } + + override func viewDidMoveToSuperview() { + super.viewDidMoveToSuperview() + notifyGeometryChangedIfNeeded() + } + + override func layout() { + super.layout() + notifyGeometryChangedIfNeeded() + } + + override func setFrameOrigin(_ newOrigin: NSPoint) { + super.setFrameOrigin(newOrigin) + notifyGeometryChangedIfNeeded() + } + + override func setFrameSize(_ newSize: NSSize) { + super.setFrameSize(newSize) + notifyGeometryChangedIfNeeded() + } + } + + final class Coordinator { + var attachGeneration: Int = 0 + // Track the latest desired state so attach retries can re-apply focus after re-parenting. + var desiredIsActive: Bool = true + var desiredIsVisibleInUI: Bool = true + var desiredShowsUnreadNotificationRing: Bool = false + var desiredPortalZPriority: Int = 0 + var lastBoundHostId: ObjectIdentifier? + var lastPaneDropZone: DropZone? + var lastSynchronizedHostGeometryRevision: UInt64 = 0 + weak var hostedView: GhosttySurfaceScrollView? + } + + func makeCoordinator() -> Coordinator { + Coordinator() + } + + static func shouldApplyImmediateHostedStateUpdate( + hostedViewHasSuperview: Bool, + isBoundToCurrentHost: Bool + ) -> Bool { + // If this update originates from a stale/replaced host while the hosted view is + // already attached elsewhere, do not mutate visibility/active state here. + if isBoundToCurrentHost { return true } + return !hostedViewHasSuperview + } + + static func shouldSynchronizePortalGeometryImmediately( + hostInLiveResize: Bool, + windowInLiveResize: Bool, + interactiveGeometryResizeActive: Bool + ) -> Bool { + hostInLiveResize || windowInLiveResize || interactiveGeometryResizeActive + } + + private static func synchronizePortalGeometry( + for host: HostContainerView, + coordinator: Coordinator + ) { + let geometryRevision = host.geometryRevision + guard coordinator.lastSynchronizedHostGeometryRevision != geometryRevision else { return } + coordinator.lastSynchronizedHostGeometryRevision = geometryRevision + let window = host.window + if shouldSynchronizePortalGeometryImmediately( + hostInLiveResize: host.inLiveResize, + windowInLiveResize: window?.inLiveResize == true, + interactiveGeometryResizeActive: TerminalWindowPortalRegistry.isInteractiveGeometryResizeActive + ) { + TerminalWindowPortalRegistry.synchronizeForAnchor(host) + return + } + // Avoid synchronizing the terminal portal while AppKit is still inside + // the current layout turn. Re-entrant syncs here can wedge window resize + // handling and leave the app spinning on the wait cursor. + guard let window else { return } + TerminalWindowPortalRegistry.scheduleExternalGeometrySynchronize(for: window) + } + + func makeNSView(context: Context) -> NSView { + let container = HostContainerView(frame: .zero) + container.wantsLayer = false + // The actual terminal surface lives in the AppKit portal layer above SwiftUI. + // This empty placeholder should not be walked by the accessibility subsystem. + container.setAccessibilityRole(.none) + container.setAccessibilityElement(false) + return container + } + + func updateNSView(_ nsView: NSView, context: Context) { + let hostedView = terminalSurface.hostedView + let coordinator = context.coordinator + let previousDesiredIsActive = coordinator.desiredIsActive + let previousDesiredIsVisibleInUI = coordinator.desiredIsVisibleInUI + let previousDesiredShowsUnreadNotificationRing = coordinator.desiredShowsUnreadNotificationRing + let previousDesiredPortalZPriority = coordinator.desiredPortalZPriority + let desiredStateChanged = + previousDesiredIsActive != isActive || + previousDesiredIsVisibleInUI != isVisibleInUI || + previousDesiredPortalZPriority != portalZPriority + coordinator.desiredIsActive = isActive + coordinator.desiredIsVisibleInUI = isVisibleInUI + coordinator.desiredShowsUnreadNotificationRing = showsUnreadNotificationRing + coordinator.desiredPortalZPriority = portalZPriority + coordinator.hostedView = hostedView +#if DEBUG + if desiredStateChanged { + if let snapshot = AppDelegate.shared?.tabManager?.debugCurrentWorkspaceSwitchSnapshot() { + let dtMs = (CACurrentMediaTime() - snapshot.startedAt) * 1000 + dlog( + "ws.swiftui.update id=\(snapshot.id) dt=\(String(format: "%.2fms", dtMs)) " + + "surface=\(terminalSurface.id.uuidString.prefix(5)) visible=\(isVisibleInUI ? 1 : 0) " + + "active=\(isActive ? 1 : 0) z=\(portalZPriority) " + + "hostWindow=\(nsView.window != nil ? 1 : 0) hostedWindow=\(hostedView.window != nil ? 1 : 0) " + + "hostedSuperview=\(hostedView.superview != nil ? 1 : 0)" + ) + } else { + dlog( + "ws.swiftui.update id=none surface=\(terminalSurface.id.uuidString.prefix(5)) " + + "visible=\(isVisibleInUI ? 1 : 0) active=\(isActive ? 1 : 0) z=\(portalZPriority) " + + "hostWindow=\(nsView.window != nil ? 1 : 0) hostedWindow=\(hostedView.window != nil ? 1 : 0) " + + "hostedSuperview=\(hostedView.superview != nil ? 1 : 0)" + ) + } + } +#endif + + let hostContainer = nsView as? HostContainerView + let hostOwnsPortalNow = hostContainer.map { host in + terminalSurface.claimPortalHost( + hostId: ObjectIdentifier(host), + paneId: paneId, + instanceSerial: host.instanceSerial, + inWindow: host.window != nil, + bounds: host.bounds, + reason: "update" + ) + } ?? true + + // Keep the surface lifecycle and handlers updated even if we defer re-parenting. + hostedView.attachSurface(terminalSurface) + hostedView.setFocusHandler { onFocus?(terminalSurface.id) } + hostedView.setTriggerFlashHandler(onTriggerFlash) + if hostOwnsPortalNow { + hostedView.setInactiveOverlay( + color: inactiveOverlayColor, + opacity: CGFloat(inactiveOverlayOpacity), + visible: showsInactiveOverlay + ) + hostedView.setNotificationRing(visible: showsUnreadNotificationRing) + hostedView.setSearchOverlay(searchState: searchState) + hostedView.syncKeyStateIndicator(text: terminalSurface.currentKeyStateIndicatorText) + } + 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 { + let oldZone = coordinator.lastPaneDropZone.map { String(describing: $0) } ?? "none" + let newZone = paneDropZone.map { String(describing: $0) } ?? "none" + dlog( + "terminal.paneDropZone surface=\(terminalSurface.id.uuidString.prefix(5)) " + + "old=\(oldZone) new=\(newZone) " + + "active=\(isActive ? 1 : 0) visible=\(isVisibleInUI ? 1 : 0) " + + "inWindow=\(hostedView.window != nil ? 1 : 0)" + ) + coordinator.lastPaneDropZone = paneDropZone + } + if paneDropZone != nil, !isVisibleInUI { + dlog( + "terminal.paneDropZone.suppress surface=\(terminalSurface.id.uuidString.prefix(5)) " + + "requested=\(String(describing: paneDropZone!)) visible=0 active=\(isActive ? 1 : 0)" + ) + } +#endif + if hostOwnsPortalNow { + hostedView.setDropZoneOverlay(zone: forwardedDropZone) + } + + coordinator.attachGeneration += 1 + let generation = coordinator.attachGeneration + + if let host = hostContainer { + host.onDidMoveToWindow = { [weak host, weak hostedView, weak coordinator] in + guard let host, let hostedView, let coordinator else { return } + guard coordinator.attachGeneration == generation else { return } + guard terminalSurface.claimPortalHost( + hostId: ObjectIdentifier(host), + paneId: paneId, + instanceSerial: host.instanceSerial, + inWindow: host.window != nil, + bounds: host.bounds, + reason: "didMoveToWindow" + ) else { return } + guard host.window != nil else { return } + guard portalBindingStillLive() else { return } + TerminalWindowPortalRegistry.bind( + hostedView: hostedView, + to: host, + visibleInUI: coordinator.desiredIsVisibleInUI, + zPriority: coordinator.desiredPortalZPriority, + expectedSurfaceId: portalExpectedSurfaceId, + expectedGeneration: portalExpectedGeneration + ) + coordinator.lastBoundHostId = ObjectIdentifier(host) + coordinator.lastSynchronizedHostGeometryRevision = host.geometryRevision + hostedView.setVisibleInUI(coordinator.desiredIsVisibleInUI) + hostedView.setActive(coordinator.desiredIsActive) + hostedView.setNotificationRing(visible: coordinator.desiredShowsUnreadNotificationRing) + } + host.onGeometryChanged = { [weak host, weak hostedView, weak coordinator] in + guard let host, let hostedView, let coordinator else { return } + guard coordinator.attachGeneration == generation else { return } + guard terminalSurface.claimPortalHost( + hostId: ObjectIdentifier(host), + paneId: paneId, + instanceSerial: host.instanceSerial, + inWindow: host.window != nil, + bounds: host.bounds, + reason: "geometryChanged" + ) else { return } + guard portalBindingStillLive() else { return } + let hostId = ObjectIdentifier(host) + if host.window != nil, + (coordinator.lastBoundHostId != hostId || + !TerminalWindowPortalRegistry.isHostedView(hostedView, boundTo: host)) { +#if DEBUG + dlog( + "ws.hostState.rebindOnGeometry surface=\(terminalSurface.id.uuidString.prefix(5)) " + + "reason=portalEntryMissing visible=\(coordinator.desiredIsVisibleInUI ? 1 : 0) " + + "active=\(coordinator.desiredIsActive ? 1 : 0) z=\(coordinator.desiredPortalZPriority)" + ) +#endif + TerminalWindowPortalRegistry.bind( + hostedView: hostedView, + to: host, + visibleInUI: coordinator.desiredIsVisibleInUI, + zPriority: coordinator.desiredPortalZPriority, + expectedSurfaceId: portalExpectedSurfaceId, + expectedGeneration: portalExpectedGeneration + ) + coordinator.lastBoundHostId = hostId + hostedView.setVisibleInUI(coordinator.desiredIsVisibleInUI) + hostedView.setActive(coordinator.desiredIsActive) + hostedView.setNotificationRing(visible: coordinator.desiredShowsUnreadNotificationRing) + } + Self.synchronizePortalGeometry( + for: host, + coordinator: coordinator + ) + } + + if host.window != nil, hostOwnsPortalNow { + let portalBindingLive = portalBindingStillLive() + let hostId = ObjectIdentifier(host) + let geometryRevision = host.geometryRevision + let portalEntryMissing = !TerminalWindowPortalRegistry.isHostedView(hostedView, boundTo: host) + let shouldBindNow = + coordinator.lastBoundHostId != hostId || + hostedView.superview == nil || + portalEntryMissing || + previousDesiredIsVisibleInUI != isVisibleInUI || + previousDesiredShowsUnreadNotificationRing != showsUnreadNotificationRing || + previousDesiredPortalZPriority != portalZPriority + if portalBindingLive && shouldBindNow { +#if DEBUG + if portalEntryMissing { + dlog( + "ws.hostState.rebindOnUpdate surface=\(terminalSurface.id.uuidString.prefix(5)) " + + "reason=portalEntryMissing visible=\(coordinator.desiredIsVisibleInUI ? 1 : 0) " + + "active=\(coordinator.desiredIsActive ? 1 : 0) z=\(coordinator.desiredPortalZPriority)" + ) + } +#endif + TerminalWindowPortalRegistry.bind( + hostedView: hostedView, + to: host, + visibleInUI: coordinator.desiredIsVisibleInUI, + zPriority: coordinator.desiredPortalZPriority, + expectedSurfaceId: portalExpectedSurfaceId, + expectedGeneration: portalExpectedGeneration + ) + coordinator.lastBoundHostId = hostId + coordinator.lastSynchronizedHostGeometryRevision = geometryRevision + } else if portalBindingLive && coordinator.lastSynchronizedHostGeometryRevision != geometryRevision { + Self.synchronizePortalGeometry( + for: host, + coordinator: coordinator + ) + } + } 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. +#if DEBUG + if desiredStateChanged { + dlog( + "ws.hostState.deferBind surface=\(terminalSurface.id.uuidString.prefix(5)) " + + "reason=hostNoWindow visible=\(coordinator.desiredIsVisibleInUI ? 1 : 0) " + + "active=\(coordinator.desiredIsActive ? 1 : 0) z=\(coordinator.desiredPortalZPriority) " + + "hostedWindow=\(hostedView.window != nil ? 1 : 0) hostedSuperview=\(hostedView.superview != nil ? 1 : 0)" + ) + } +#endif + TerminalWindowPortalRegistry.updateEntryVisibility( + for: hostedView, + visibleInUI: coordinator.desiredIsVisibleInUI + ) + } + } + + let hostWindowAttached = hostContainer?.window != nil + let isBoundToCurrentHost = hostContainer.map { host in + TerminalWindowPortalRegistry.isHostedView(hostedView, boundTo: host) + } ?? true + let shouldApplyImmediateHostedState = hostOwnsPortalNow && Self.shouldApplyImmediateHostedStateUpdate( + hostedViewHasSuperview: hostedView.superview != nil, + isBoundToCurrentHost: isBoundToCurrentHost + ) + + if portalBindingStillLive() && shouldApplyImmediateHostedState { + hostedView.setVisibleInUI(isVisibleInUI) + hostedView.setActive(isActive) + } else { + // Preserve portal entry visibility while a stale host is still receiving SwiftUI updates. + // The currently bound host remains authoritative for immediate visible/active state. +#if DEBUG + if desiredStateChanged { + dlog( + "ws.hostState.deferApply surface=\(terminalSurface.id.uuidString.prefix(5)) " + + "reason=\(hostOwnsPortalNow ? "staleHostBinding" : "hostOwnershipRejected") " + + "hostWindow=\(hostWindowAttached ? 1 : 0) " + + "boundToCurrent=\(isBoundToCurrentHost ? 1 : 0) hostedSuperview=\(hostedView.superview != nil ? 1 : 0) " + + "visible=\(isVisibleInUI ? 1 : 0) active=\(isActive ? 1 : 0)" + ) + } +#endif + } + } + + static func dismantleNSView(_ nsView: NSView, coordinator: Coordinator) { + coordinator.attachGeneration += 1 + coordinator.desiredIsActive = false + coordinator.desiredIsVisibleInUI = false + coordinator.desiredShowsUnreadNotificationRing = false + coordinator.desiredPortalZPriority = 0 + coordinator.lastBoundHostId = nil + let hostedView = coordinator.hostedView +#if DEBUG + if let hostedView { + if let snapshot = AppDelegate.shared?.tabManager?.debugCurrentWorkspaceSwitchSnapshot() { + let dtMs = (CACurrentMediaTime() - snapshot.startedAt) * 1000 + dlog( + "ws.swiftui.dismantle id=\(snapshot.id) dt=\(String(format: "%.2fms", dtMs)) " + + "surface=\(hostedView.debugSurfaceId?.uuidString.prefix(5) ?? "nil") " + + "inWindow=\(hostedView.window != nil ? 1 : 0)" + ) + } else { + dlog( + "ws.swiftui.dismantle id=none surface=\(hostedView.debugSurfaceId?.uuidString.prefix(5) ?? "nil") " + + "inWindow=\(hostedView.window != nil ? 1 : 0)" + ) + } + } +#endif + + if let host = nsView as? HostContainerView { + host.onDidMoveToWindow = nil + host.onGeometryChanged = nil + hostedView?.prepareOwnedPortalHostForTransientReattach( + hostId: ObjectIdentifier(host), + reason: "dismantle" + ) + } + + // SwiftUI can transiently dismantle/rebuild NSViewRepresentable instances during split + // tree updates. Do not drop the portal lease or force visible/active false here; that + // causes avoidable blackouts when the same hosted view is rebound moments later. + hostedView?.setFocusHandler(nil) + hostedView?.setTriggerFlashHandler(nil) + hostedView?.setDropZoneOverlay(zone: nil) + coordinator.hostedView = nil + + nsView.subviews.forEach { $0.removeFromSuperview() } + } +} diff --git a/Sources/GhosttyTerminalView.swift b/Sources/GhosttyTerminalView.swift index 872d3f7fa0c..36d78d9d976 100644 --- a/Sources/GhosttyTerminalView.swift +++ b/Sources/GhosttyTerminalView.swift @@ -5587,7 +5587,9 @@ class GhosttyNSView: NSView, NSUserInterfaceValidations { } // Test-only IME point override so firstRect behavior can be regression tested. - private var imePointOverrideForTesting: (x: Double, y: Double, width: Double, height: Double)? + // private(set): read from the NSTextInputClient conformance in + // GhosttyTerminalView+IME.swift (Nuclear Review #97 split), written only here. + private(set) var imePointOverrideForTesting: (x: Double, y: Double, width: Double, height: Double)? func setIMEPointForTesting(x: Double, y: Double, width: Double, height: Double) { imePointOverrideForTesting = (x, y, width, height) @@ -8588,918 +8590,3 @@ final class GhosttySurfaceScrollView: NSView { } } -// MARK: - NSTextInputClient - -extension GhosttyNSView: NSTextInputClient { - /// Deliver committed text using typed-input semantics so shells and editors - /// keep their normal interactive behaviors (autosuggestions, Return - /// execution, etc.). Programmatic callers can preserve literal ESC bytes so - /// automation payloads remain byte-for-byte stable. - fileprivate func sendTextToSurface(_ chars: String, preserveLiteralEscape: Bool) { - guard let surface = surface else { return } -#if DEBUG - let typingTimingStart = ProgramaTypingTiming.start() -#endif -#if DEBUG - programaWriteChildExitProbe( - [ - "probeInsertTextCharsHex": programaScalarHex(chars), - "probeInsertTextSurfaceId": terminalSurface?.id.uuidString ?? "", - ], - increments: ["probeInsertTextCount": 1] - ) -#endif - - var bufferedText = "" - var previousWasCR = false - - func flushBufferedText() { - guard !bufferedText.isEmpty else { return } - var keyEvent = ghostty_input_key_s() - keyEvent.action = GHOSTTY_ACTION_PRESS - keyEvent.keycode = 0 - keyEvent.mods = GHOSTTY_MODS_NONE - keyEvent.consumed_mods = GHOSTTY_MODS_NONE - keyEvent.unshifted_codepoint = 0 - keyEvent.composing = false - bufferedText.withCString { ptr in - keyEvent.text = ptr - _ = sendGhosttyKey(surface, keyEvent) - } - bufferedText.removeAll(keepingCapacity: true) - } - - func sendControlKey(_ keycode: UInt32) { - var keyEvent = ghostty_input_key_s() - keyEvent.action = GHOSTTY_ACTION_PRESS - keyEvent.keycode = keycode - keyEvent.mods = GHOSTTY_MODS_NONE - keyEvent.consumed_mods = GHOSTTY_MODS_NONE - keyEvent.unshifted_codepoint = 0 - keyEvent.composing = false - keyEvent.text = nil - _ = sendGhosttyKey(surface, keyEvent) - } - - for scalar in chars.unicodeScalars { - switch scalar.value { - case 0x0A: - if !previousWasCR { - flushBufferedText() - sendControlKey(0x24) // kVK_Return - } - previousWasCR = false - case 0x0D: - flushBufferedText() - sendControlKey(0x24) // kVK_Return - previousWasCR = true - case 0x09: - flushBufferedText() - sendControlKey(0x30) // kVK_Tab - previousWasCR = false - case 0x1B: - if preserveLiteralEscape { - bufferedText.unicodeScalars.append(scalar) - } else { - flushBufferedText() - sendControlKey(0x35) // kVK_Escape - } - previousWasCR = false - default: - bufferedText.unicodeScalars.append(scalar) - previousWasCR = false - } - } - flushBufferedText() -#if DEBUG - ProgramaTypingTiming.logDuration( - path: "terminal.sendTextToSurface", - startedAt: typingTimingStart, - extra: "textBytes=\(chars.utf8.count)" - ) -#endif - } - - /// External accessibility/dictation tools should commit plain text, but - /// some inject a leading escape sequence first. Strip those bytes on the - /// committed-text path so they can't leak into the PTY as literals. - static func sanitizeExternalCommittedText(_ text: String) -> String { - let bytes = Array(text.utf8) - guard !bytes.isEmpty else { return text } - - var index = 0 - while index < bytes.count { - let byte = bytes[index] - if byte == 0x1B { - index = consumeLeadingEscapeSequence(in: bytes, from: index) - continue - } - - if byte == 0xC2 { - let next = index + 1 - if next < bytes.count, bytes[next] == 0x9B { - // U+009B (C1 CSI) is encoded as the UTF-8 byte pair C2 9B. - index = consumeLeadingCSISequence(in: bytes, from: next + 1) - continue - } - } - - break - } - - if index == 0 { - return text - } - - guard index < bytes.count else { return "" } - return String(decoding: bytes[index...], as: UTF8.self) - } - - private static func consumeLeadingEscapeSequence( - in bytes: [UInt8], - from start: Int - ) -> Int { - let next = start + 1 - guard next < bytes.count else { return bytes.count } - - switch bytes[next] { - case 0x5B: - // CSI: ESC [ ... final - return consumeLeadingCSISequence(in: bytes, from: next + 1) - case 0x4F: - // SS3: ESC O final - return min(bytes.count, next + 2) - case 0x50, 0x5D, 0x5E, 0x5F: - // DCS/OSC/PM/APC: consume until BEL/ST or EOF. - return consumeLeadingEscapedStringSequence(in: bytes, from: next + 1) - default: - // Single-character escape. - return min(bytes.count, next + 1) - } - } - - private static func consumeLeadingCSISequence( - in bytes: [UInt8], - from start: Int - ) -> Int { - var index = start - while index < bytes.count { - let byte = bytes[index] - if (0x20...0x3F).contains(byte) { - index += 1 - continue - } - - if (0x40...0x7E).contains(byte) { - return index + 1 - } - - break - } - - return index - } - - private static func consumeLeadingEscapedStringSequence( - in bytes: [UInt8], - from start: Int - ) -> Int { - var index = start - while index < bytes.count { - let byte = bytes[index] - if byte == 0x07 { - return index + 1 - } - - if byte == 0x1B { - let next = index + 1 - if next < bytes.count, bytes[next] == 0x5C { - return next + 1 - } - return index - } - - if byte < 0x20 || byte == 0x7F { - return index + 1 - } - - index += 1 - } - - return bytes.count - } - - func hasMarkedText() -> Bool { - return markedText.length > 0 - } - - func markedRange() -> NSRange { - guard markedText.length > 0 else { return NSRange(location: NSNotFound, length: 0) } - return NSRange(location: 0, length: markedText.length) - } - - func selectedRange() -> NSRange { - readSelectionSnapshot()?.range ?? NSRange(location: 0, length: 0) - } - - func setMarkedText(_ string: Any, selectedRange: NSRange, replacementRange: NSRange) { -#if DEBUG - let typingTimingStart = ProgramaTypingTiming.start() - defer { - ProgramaTypingTiming.logDuration( - path: "terminal.setMarkedText", - startedAt: typingTimingStart, - extra: "markedLength=\(markedText.length)" - ) - } -#endif - switch string { - case let v as NSAttributedString: - markedText = NSMutableAttributedString(attributedString: v) - case let v as String: - markedText = NSMutableAttributedString(string: v) - default: - break - } - - // If we're not in a keyDown event, sync preedit immediately. - // This can happen due to external events like changing keyboard layouts - // while composing. - if keyTextAccumulator == nil { - syncPreedit() - invalidateTextInputCoordinates(selectionChanged: true) - } - } - - func unmarkText() { -#if DEBUG - let hadMarkedText = markedText.length > 0 - let typingTimingStart = ProgramaTypingTiming.start() - defer { - ProgramaTypingTiming.logDuration( - path: "terminal.unmarkText", - startedAt: typingTimingStart, - extra: "hadMarkedText=\(hadMarkedText ? 1 : 0)" - ) - } -#endif - if markedText.length > 0 { - markedText.mutableString.setString("") - syncPreedit() - invalidateTextInputCoordinates(selectionChanged: true) - } - } - - /// Sync the preedit state based on the markedText value to libghostty. - /// This tells Ghostty about IME composition text so it can render the - /// preedit overlay (e.g. for Korean, Japanese, Chinese input). - func syncPreedit(clearIfNeeded: Bool = true) { -#if DEBUG - let typingTimingStart = ProgramaTypingTiming.start() - defer { - ProgramaTypingTiming.logDuration( - path: "terminal.syncPreedit", - startedAt: typingTimingStart, - extra: "markedLength=\(markedText.length) clearIfNeeded=\(clearIfNeeded ? 1 : 0)" - ) - } -#endif - guard let surface = surface else { return } - - if markedText.length > 0 { - let str = markedText.string - let len = str.utf8CString.count - if len > 0 { - str.withCString { ptr in - // Subtract 1 for the null terminator - ghostty_surface_preedit(surface, ptr, UInt(len - 1)) - } - } - } else if clearIfNeeded { - // If we had marked text before but don't now, we're no longer - // in a preedit state so we can clear it. - ghostty_surface_preedit(surface, nil, 0) - } - } - - func validAttributesForMarkedText() -> [NSAttributedString.Key] { - return [] - } - - func attributedSubstring(forProposedRange range: NSRange, actualRange: NSRangePointer?) -> NSAttributedString? { - guard range.length > 0, - let snapshot = readSelectionSnapshot() else { return nil } - actualRange?.pointee = snapshot.range - return NSAttributedString(string: snapshot.string) - } - - func characterIndex(for point: NSPoint) -> Int { - return selectedRange().location - } - - func firstRect(forCharacterRange range: NSRange, actualRange: NSRangePointer?) -> NSRect { - guard let window = self.window else { - return NSRect(x: frame.origin.x, y: frame.origin.y, width: 0, height: 0) - } - - // Use Ghostty's IME point API for accurate cursor position if available. - var x: Double = 0 - var y: Double = 0 - var w: Double = cellSize.width - var h: Double = cellSize.height -#if DEBUG - if range.length > 0, - range != selectedRange(), - let snapshot = readSelectionSnapshot() { - x = snapshot.topLeft.x - 2 - y = snapshot.topLeft.y + 2 - } else if let override = imePointOverrideForTesting { - x = override.x - y = override.y - w = override.width - h = override.height - } else if let surface = surface { - ghostty_surface_ime_point(surface, &x, &y, &w, &h) - } -#else - if range.length > 0, - range != selectedRange(), - let snapshot = readSelectionSnapshot() { - x = snapshot.topLeft.x - 2 - y = snapshot.topLeft.y + 2 - } else if let surface = surface { - ghostty_surface_ime_point(surface, &x, &y, &w, &h) - } -#endif - - if range.length == 0, w > 0 { - // Dictation expects a caret rect for insertion points rather than a box. - w = 0 - } - - // Ghostty coordinates are top-left origin; AppKit expects bottom-left. - let viewRect = NSRect( - x: x, - y: frame.size.height - y, - width: w, - height: max(h, cellSize.height) - ) - let winRect = convert(viewRect, to: nil) - return window.convertToScreen(winRect) - } - - func attributedString() -> NSAttributedString { - if markedText.length > 0 { - return NSAttributedString(attributedString: markedText) - } - if let snapshot = readSelectionSnapshot(), !snapshot.string.isEmpty { - return NSAttributedString(string: snapshot.string) - } - return NSAttributedString(string: "") - } - - func windowLevel() -> Int { - Int(window?.level.rawValue ?? NSWindow.Level.normal.rawValue) - } - - @available(macOS 14.0, *) - var unionRectInVisibleSelectedRange: NSRect { - firstRect(forCharacterRange: selectedRange(), actualRange: nil) - } - - @available(macOS 14.0, *) - var documentVisibleRect: NSRect { - visibleDocumentRectInScreenCoordinates() - } - - func insertText(_ string: Any, replacementRange: NSRange) { -#if DEBUG - let typingTimingStart = ProgramaTypingTiming.start() - defer { - ProgramaTypingTiming.logDuration( - path: "terminal.insertText", - startedAt: typingTimingStart, - event: NSApp.currentEvent, - extra: "replacementLocation=\(replacementRange.location) replacementLength=\(replacementRange.length)" - ) - } -#endif - // Get the string value - var chars = "" - switch string { - case let v as NSAttributedString: - chars = v.string - case let v as String: - chars = v - default: - return - } - - // Clear marked text since we're inserting - unmarkText() - - // Some IME/input-method paths call insertText with an empty payload to - // flush state. There is no terminal text to send in that case. - guard !chars.isEmpty else { return } - -#if DEBUG - if NSApp.currentEvent == nil { - dlog("ime.insertText.noEvent len=\(chars.count)") - } -#endif - - // If we have an accumulator, we're in a keyDown event - accumulate the text - if keyTextAccumulator != nil { - keyTextAccumulator?.append(chars) - return - } - - let isExternalCommittedText = externalCommittedTextDepth > 0 - let sanitizedChars = if isExternalCommittedText { - // Only sanitize explicit external committed-text paths used by - // AX/dictation integrations. Programmatic NSTextInputClient callers - // may intentionally start with ESC/CSI bytes. - Self.sanitizeExternalCommittedText(chars) - } else { - chars - } - -#if DEBUG - if sanitizedChars != chars { - dlog( - "ime.insertText.sanitized originalBytes=\(chars.utf8.count) " + - "sanitizedBytes=\(sanitizedChars.utf8.count)" - ) - } -#endif - - guard !sanitizedChars.isEmpty else { return } - - // Otherwise send directly to the terminal - sendTextToSurface( - sanitizedChars, - preserveLiteralEscape: !isExternalCommittedText - ) - } -} - -// MARK: - SwiftUI Wrapper - -struct GhosttyTerminalView: NSViewRepresentable { - @Environment(\.paneDropZone) var paneDropZone - - let terminalSurface: TerminalSurface - let paneId: PaneID - var isActive: Bool = true - var isVisibleInUI: Bool = true - var portalZPriority: Int = 0 - var showsInactiveOverlay: Bool = false - var showsUnreadNotificationRing: Bool = false - var inactiveOverlayColor: NSColor = .clear - var inactiveOverlayOpacity: Double = 0 - var searchState: TerminalSurface.SearchState? = nil - var reattachToken: UInt64 = 0 - var onFocus: ((UUID) -> Void)? = nil - var onTriggerFlash: (() -> Void)? = nil - - private final class HostContainerView: NSView { - private static var nextInstanceSerial: UInt64 = 0 - - var onDidMoveToWindow: (() -> Void)? - var onGeometryChanged: (() -> Void)? - let instanceSerial: UInt64 - private(set) var geometryRevision: UInt64 = 0 - private var lastReportedGeometryState: GeometryState? - - override init(frame frameRect: NSRect) { - Self.nextInstanceSerial &+= 1 - instanceSerial = Self.nextInstanceSerial - super.init(frame: frameRect) - } - - required init?(coder: NSCoder) { - fatalError("init(coder:) not implemented") - } - - private struct GeometryState: Equatable { - let frame: CGRect - let bounds: CGRect - let windowNumber: Int? - let superviewID: ObjectIdentifier? - } - - private func currentGeometryState() -> GeometryState { - GeometryState( - frame: frame, - bounds: bounds, - windowNumber: window?.windowNumber, - superviewID: superview.map(ObjectIdentifier.init) - ) - } - - private func notifyGeometryChangedIfNeeded() { - let state = currentGeometryState() - guard state != lastReportedGeometryState else { return } - lastReportedGeometryState = state - geometryRevision &+= 1 - onGeometryChanged?() - } - - override func viewDidMoveToWindow() { - super.viewDidMoveToWindow() - onDidMoveToWindow?() - notifyGeometryChangedIfNeeded() - } - - override func viewDidMoveToSuperview() { - super.viewDidMoveToSuperview() - notifyGeometryChangedIfNeeded() - } - - override func layout() { - super.layout() - notifyGeometryChangedIfNeeded() - } - - override func setFrameOrigin(_ newOrigin: NSPoint) { - super.setFrameOrigin(newOrigin) - notifyGeometryChangedIfNeeded() - } - - override func setFrameSize(_ newSize: NSSize) { - super.setFrameSize(newSize) - notifyGeometryChangedIfNeeded() - } - } - - final class Coordinator { - var attachGeneration: Int = 0 - // Track the latest desired state so attach retries can re-apply focus after re-parenting. - var desiredIsActive: Bool = true - var desiredIsVisibleInUI: Bool = true - var desiredShowsUnreadNotificationRing: Bool = false - var desiredPortalZPriority: Int = 0 - var lastBoundHostId: ObjectIdentifier? - var lastPaneDropZone: DropZone? - var lastSynchronizedHostGeometryRevision: UInt64 = 0 - weak var hostedView: GhosttySurfaceScrollView? - } - - func makeCoordinator() -> Coordinator { - Coordinator() - } - - static func shouldApplyImmediateHostedStateUpdate( - hostedViewHasSuperview: Bool, - isBoundToCurrentHost: Bool - ) -> Bool { - // If this update originates from a stale/replaced host while the hosted view is - // already attached elsewhere, do not mutate visibility/active state here. - if isBoundToCurrentHost { return true } - return !hostedViewHasSuperview - } - - static func shouldSynchronizePortalGeometryImmediately( - hostInLiveResize: Bool, - windowInLiveResize: Bool, - interactiveGeometryResizeActive: Bool - ) -> Bool { - hostInLiveResize || windowInLiveResize || interactiveGeometryResizeActive - } - - private static func synchronizePortalGeometry( - for host: HostContainerView, - coordinator: Coordinator - ) { - let geometryRevision = host.geometryRevision - guard coordinator.lastSynchronizedHostGeometryRevision != geometryRevision else { return } - coordinator.lastSynchronizedHostGeometryRevision = geometryRevision - let window = host.window - if shouldSynchronizePortalGeometryImmediately( - hostInLiveResize: host.inLiveResize, - windowInLiveResize: window?.inLiveResize == true, - interactiveGeometryResizeActive: TerminalWindowPortalRegistry.isInteractiveGeometryResizeActive - ) { - TerminalWindowPortalRegistry.synchronizeForAnchor(host) - return - } - // Avoid synchronizing the terminal portal while AppKit is still inside - // the current layout turn. Re-entrant syncs here can wedge window resize - // handling and leave the app spinning on the wait cursor. - guard let window else { return } - TerminalWindowPortalRegistry.scheduleExternalGeometrySynchronize(for: window) - } - - func makeNSView(context: Context) -> NSView { - let container = HostContainerView(frame: .zero) - container.wantsLayer = false - // The actual terminal surface lives in the AppKit portal layer above SwiftUI. - // This empty placeholder should not be walked by the accessibility subsystem. - container.setAccessibilityRole(.none) - container.setAccessibilityElement(false) - return container - } - - func updateNSView(_ nsView: NSView, context: Context) { - let hostedView = terminalSurface.hostedView - let coordinator = context.coordinator - let previousDesiredIsActive = coordinator.desiredIsActive - let previousDesiredIsVisibleInUI = coordinator.desiredIsVisibleInUI - let previousDesiredShowsUnreadNotificationRing = coordinator.desiredShowsUnreadNotificationRing - let previousDesiredPortalZPriority = coordinator.desiredPortalZPriority - let desiredStateChanged = - previousDesiredIsActive != isActive || - previousDesiredIsVisibleInUI != isVisibleInUI || - previousDesiredPortalZPriority != portalZPriority - coordinator.desiredIsActive = isActive - coordinator.desiredIsVisibleInUI = isVisibleInUI - coordinator.desiredShowsUnreadNotificationRing = showsUnreadNotificationRing - coordinator.desiredPortalZPriority = portalZPriority - coordinator.hostedView = hostedView -#if DEBUG - if desiredStateChanged { - if let snapshot = AppDelegate.shared?.tabManager?.debugCurrentWorkspaceSwitchSnapshot() { - let dtMs = (CACurrentMediaTime() - snapshot.startedAt) * 1000 - dlog( - "ws.swiftui.update id=\(snapshot.id) dt=\(String(format: "%.2fms", dtMs)) " + - "surface=\(terminalSurface.id.uuidString.prefix(5)) visible=\(isVisibleInUI ? 1 : 0) " + - "active=\(isActive ? 1 : 0) z=\(portalZPriority) " + - "hostWindow=\(nsView.window != nil ? 1 : 0) hostedWindow=\(hostedView.window != nil ? 1 : 0) " + - "hostedSuperview=\(hostedView.superview != nil ? 1 : 0)" - ) - } else { - dlog( - "ws.swiftui.update id=none surface=\(terminalSurface.id.uuidString.prefix(5)) " + - "visible=\(isVisibleInUI ? 1 : 0) active=\(isActive ? 1 : 0) z=\(portalZPriority) " + - "hostWindow=\(nsView.window != nil ? 1 : 0) hostedWindow=\(hostedView.window != nil ? 1 : 0) " + - "hostedSuperview=\(hostedView.superview != nil ? 1 : 0)" - ) - } - } -#endif - - let hostContainer = nsView as? HostContainerView - let hostOwnsPortalNow = hostContainer.map { host in - terminalSurface.claimPortalHost( - hostId: ObjectIdentifier(host), - paneId: paneId, - instanceSerial: host.instanceSerial, - inWindow: host.window != nil, - bounds: host.bounds, - reason: "update" - ) - } ?? true - - // Keep the surface lifecycle and handlers updated even if we defer re-parenting. - hostedView.attachSurface(terminalSurface) - hostedView.setFocusHandler { onFocus?(terminalSurface.id) } - hostedView.setTriggerFlashHandler(onTriggerFlash) - if hostOwnsPortalNow { - hostedView.setInactiveOverlay( - color: inactiveOverlayColor, - opacity: CGFloat(inactiveOverlayOpacity), - visible: showsInactiveOverlay - ) - hostedView.setNotificationRing(visible: showsUnreadNotificationRing) - hostedView.setSearchOverlay(searchState: searchState) - hostedView.syncKeyStateIndicator(text: terminalSurface.currentKeyStateIndicatorText) - } - 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 { - let oldZone = coordinator.lastPaneDropZone.map { String(describing: $0) } ?? "none" - let newZone = paneDropZone.map { String(describing: $0) } ?? "none" - dlog( - "terminal.paneDropZone surface=\(terminalSurface.id.uuidString.prefix(5)) " + - "old=\(oldZone) new=\(newZone) " + - "active=\(isActive ? 1 : 0) visible=\(isVisibleInUI ? 1 : 0) " + - "inWindow=\(hostedView.window != nil ? 1 : 0)" - ) - coordinator.lastPaneDropZone = paneDropZone - } - if paneDropZone != nil, !isVisibleInUI { - dlog( - "terminal.paneDropZone.suppress surface=\(terminalSurface.id.uuidString.prefix(5)) " + - "requested=\(String(describing: paneDropZone!)) visible=0 active=\(isActive ? 1 : 0)" - ) - } -#endif - if hostOwnsPortalNow { - hostedView.setDropZoneOverlay(zone: forwardedDropZone) - } - - coordinator.attachGeneration += 1 - let generation = coordinator.attachGeneration - - if let host = hostContainer { - host.onDidMoveToWindow = { [weak host, weak hostedView, weak coordinator] in - guard let host, let hostedView, let coordinator else { return } - guard coordinator.attachGeneration == generation else { return } - guard terminalSurface.claimPortalHost( - hostId: ObjectIdentifier(host), - paneId: paneId, - instanceSerial: host.instanceSerial, - inWindow: host.window != nil, - bounds: host.bounds, - reason: "didMoveToWindow" - ) else { return } - guard host.window != nil else { return } - guard portalBindingStillLive() else { return } - TerminalWindowPortalRegistry.bind( - hostedView: hostedView, - to: host, - visibleInUI: coordinator.desiredIsVisibleInUI, - zPriority: coordinator.desiredPortalZPriority, - expectedSurfaceId: portalExpectedSurfaceId, - expectedGeneration: portalExpectedGeneration - ) - coordinator.lastBoundHostId = ObjectIdentifier(host) - coordinator.lastSynchronizedHostGeometryRevision = host.geometryRevision - hostedView.setVisibleInUI(coordinator.desiredIsVisibleInUI) - hostedView.setActive(coordinator.desiredIsActive) - hostedView.setNotificationRing(visible: coordinator.desiredShowsUnreadNotificationRing) - } - host.onGeometryChanged = { [weak host, weak hostedView, weak coordinator] in - guard let host, let hostedView, let coordinator else { return } - guard coordinator.attachGeneration == generation else { return } - guard terminalSurface.claimPortalHost( - hostId: ObjectIdentifier(host), - paneId: paneId, - instanceSerial: host.instanceSerial, - inWindow: host.window != nil, - bounds: host.bounds, - reason: "geometryChanged" - ) else { return } - guard portalBindingStillLive() else { return } - let hostId = ObjectIdentifier(host) - if host.window != nil, - (coordinator.lastBoundHostId != hostId || - !TerminalWindowPortalRegistry.isHostedView(hostedView, boundTo: host)) { -#if DEBUG - dlog( - "ws.hostState.rebindOnGeometry surface=\(terminalSurface.id.uuidString.prefix(5)) " + - "reason=portalEntryMissing visible=\(coordinator.desiredIsVisibleInUI ? 1 : 0) " + - "active=\(coordinator.desiredIsActive ? 1 : 0) z=\(coordinator.desiredPortalZPriority)" - ) -#endif - TerminalWindowPortalRegistry.bind( - hostedView: hostedView, - to: host, - visibleInUI: coordinator.desiredIsVisibleInUI, - zPriority: coordinator.desiredPortalZPriority, - expectedSurfaceId: portalExpectedSurfaceId, - expectedGeneration: portalExpectedGeneration - ) - coordinator.lastBoundHostId = hostId - hostedView.setVisibleInUI(coordinator.desiredIsVisibleInUI) - hostedView.setActive(coordinator.desiredIsActive) - hostedView.setNotificationRing(visible: coordinator.desiredShowsUnreadNotificationRing) - } - Self.synchronizePortalGeometry( - for: host, - coordinator: coordinator - ) - } - - if host.window != nil, hostOwnsPortalNow { - let portalBindingLive = portalBindingStillLive() - let hostId = ObjectIdentifier(host) - let geometryRevision = host.geometryRevision - let portalEntryMissing = !TerminalWindowPortalRegistry.isHostedView(hostedView, boundTo: host) - let shouldBindNow = - coordinator.lastBoundHostId != hostId || - hostedView.superview == nil || - portalEntryMissing || - previousDesiredIsVisibleInUI != isVisibleInUI || - previousDesiredShowsUnreadNotificationRing != showsUnreadNotificationRing || - previousDesiredPortalZPriority != portalZPriority - if portalBindingLive && shouldBindNow { -#if DEBUG - if portalEntryMissing { - dlog( - "ws.hostState.rebindOnUpdate surface=\(terminalSurface.id.uuidString.prefix(5)) " + - "reason=portalEntryMissing visible=\(coordinator.desiredIsVisibleInUI ? 1 : 0) " + - "active=\(coordinator.desiredIsActive ? 1 : 0) z=\(coordinator.desiredPortalZPriority)" - ) - } -#endif - TerminalWindowPortalRegistry.bind( - hostedView: hostedView, - to: host, - visibleInUI: coordinator.desiredIsVisibleInUI, - zPriority: coordinator.desiredPortalZPriority, - expectedSurfaceId: portalExpectedSurfaceId, - expectedGeneration: portalExpectedGeneration - ) - coordinator.lastBoundHostId = hostId - coordinator.lastSynchronizedHostGeometryRevision = geometryRevision - } else if portalBindingLive && coordinator.lastSynchronizedHostGeometryRevision != geometryRevision { - Self.synchronizePortalGeometry( - for: host, - coordinator: coordinator - ) - } - } 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. -#if DEBUG - if desiredStateChanged { - dlog( - "ws.hostState.deferBind surface=\(terminalSurface.id.uuidString.prefix(5)) " + - "reason=hostNoWindow visible=\(coordinator.desiredIsVisibleInUI ? 1 : 0) " + - "active=\(coordinator.desiredIsActive ? 1 : 0) z=\(coordinator.desiredPortalZPriority) " + - "hostedWindow=\(hostedView.window != nil ? 1 : 0) hostedSuperview=\(hostedView.superview != nil ? 1 : 0)" - ) - } -#endif - TerminalWindowPortalRegistry.updateEntryVisibility( - for: hostedView, - visibleInUI: coordinator.desiredIsVisibleInUI - ) - } - } - - let hostWindowAttached = hostContainer?.window != nil - let isBoundToCurrentHost = hostContainer.map { host in - TerminalWindowPortalRegistry.isHostedView(hostedView, boundTo: host) - } ?? true - let shouldApplyImmediateHostedState = hostOwnsPortalNow && Self.shouldApplyImmediateHostedStateUpdate( - hostedViewHasSuperview: hostedView.superview != nil, - isBoundToCurrentHost: isBoundToCurrentHost - ) - - if portalBindingStillLive() && shouldApplyImmediateHostedState { - hostedView.setVisibleInUI(isVisibleInUI) - hostedView.setActive(isActive) - } else { - // Preserve portal entry visibility while a stale host is still receiving SwiftUI updates. - // The currently bound host remains authoritative for immediate visible/active state. -#if DEBUG - if desiredStateChanged { - dlog( - "ws.hostState.deferApply surface=\(terminalSurface.id.uuidString.prefix(5)) " + - "reason=\(hostOwnsPortalNow ? "staleHostBinding" : "hostOwnershipRejected") " + - "hostWindow=\(hostWindowAttached ? 1 : 0) " + - "boundToCurrent=\(isBoundToCurrentHost ? 1 : 0) hostedSuperview=\(hostedView.superview != nil ? 1 : 0) " + - "visible=\(isVisibleInUI ? 1 : 0) active=\(isActive ? 1 : 0)" - ) - } -#endif - } - } - - static func dismantleNSView(_ nsView: NSView, coordinator: Coordinator) { - coordinator.attachGeneration += 1 - coordinator.desiredIsActive = false - coordinator.desiredIsVisibleInUI = false - coordinator.desiredShowsUnreadNotificationRing = false - coordinator.desiredPortalZPriority = 0 - coordinator.lastBoundHostId = nil - let hostedView = coordinator.hostedView -#if DEBUG - if let hostedView { - if let snapshot = AppDelegate.shared?.tabManager?.debugCurrentWorkspaceSwitchSnapshot() { - let dtMs = (CACurrentMediaTime() - snapshot.startedAt) * 1000 - dlog( - "ws.swiftui.dismantle id=\(snapshot.id) dt=\(String(format: "%.2fms", dtMs)) " + - "surface=\(hostedView.debugSurfaceId?.uuidString.prefix(5) ?? "nil") " + - "inWindow=\(hostedView.window != nil ? 1 : 0)" - ) - } else { - dlog( - "ws.swiftui.dismantle id=none surface=\(hostedView.debugSurfaceId?.uuidString.prefix(5) ?? "nil") " + - "inWindow=\(hostedView.window != nil ? 1 : 0)" - ) - } - } -#endif - - if let host = nsView as? HostContainerView { - host.onDidMoveToWindow = nil - host.onGeometryChanged = nil - hostedView?.prepareOwnedPortalHostForTransientReattach( - hostId: ObjectIdentifier(host), - reason: "dismantle" - ) - } - - // SwiftUI can transiently dismantle/rebuild NSViewRepresentable instances during split - // tree updates. Do not drop the portal lease or force visible/active false here; that - // causes avoidable blackouts when the same hosted view is rebound moments later. - hostedView?.setFocusHandler(nil) - hostedView?.setTriggerFlashHandler(nil) - hostedView?.setDropZoneOverlay(zone: nil) - coordinator.hostedView = nil - - nsView.subviews.forEach { $0.removeFromSuperview() } - } -} From 5294e0c89708d8d50c3fc35f57e0ce2bac26414d Mon Sep 17 00:00:00 2001 From: arzafran Date: Thu, 9 Jul 2026 14:06:00 -0300 Subject: [PATCH 14/19] refactor: consolidate command-palette per-window state into one dictionary Replace 7 parallel [UUID: _] dictionaries (visibility, pendingOpen, recentRequestAt, escapeSuppression set, escapeSuppressionStartedAt, selection, snapshot) with a single CommandPaletteWindowState struct stored in one commandPaletteStateByWindowId: [UUID: CommandPaletteWindowState] dictionary. Adds three small helpers (commandPaletteState(for:), updateCommandPaletteState(for:_:), teardownCommandPaletteState(for:)) so the two previously-duplicated 7-line teardown blocks (window context discard, window close) collapse to a single teardownCommandPaletteState(for:) call each. Every read/write site is a mechanical per-field translation with no behavior change (removeValue(forKey:) on a field-specific dictionary maps to setting that field back to its default; Set.contains maps to an Optional Bool comparison, etc). Refs #95. --- Sources/AppDelegate.swift | 148 ++++++++++++++++++++++---------------- 1 file changed, 86 insertions(+), 62 deletions(-) diff --git a/Sources/AppDelegate.swift b/Sources/AppDelegate.swift index de59729e1da..779cc7268c3 100644 --- a/Sources/AppDelegate.swift +++ b/Sources/AppDelegate.swift @@ -336,6 +336,20 @@ struct CommandPaletteDebugSnapshot { static let empty = CommandPaletteDebugSnapshot(query: "", mode: "commands", results: []) } +/// Per-window command-palette state. Consolidates what used to be 7 parallel +/// `[UUID: _]` dictionaries (one dictionary keyed by main-window UUID instead), +/// so window teardown is a single `removeValue(forKey:)` instead of 7 duplicated +/// call sites. Refs #95. +struct CommandPaletteWindowState { + var isVisible = false + var pendingOpen = false + var recentRequestAt: TimeInterval? + var escapeSuppressed = false + var escapeSuppressionStartedAt: TimeInterval? + var selectionIndex = 0 + var snapshot: CommandPaletteDebugSnapshot = .empty +} + func browserZoomShortcutAction( flags: NSEvent.ModifierFlags, chars: String, @@ -969,13 +983,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent private var isQuitWarningConfirmed = false private var didInstallLifecycleSnapshotObservers = false private var didDisableSuddenTermination = false - private var commandPaletteVisibilityByWindowId: [UUID: Bool] = [:] - private var commandPalettePendingOpenByWindowId: [UUID: Bool] = [:] - private var commandPaletteRecentRequestAtByWindowId: [UUID: TimeInterval] = [:] - private var commandPaletteEscapeSuppressionByWindowId: Set = [] - private var commandPaletteEscapeSuppressionStartedAtByWindowId: [UUID: TimeInterval] = [:] - private var commandPaletteSelectionByWindowId: [UUID: Int] = [:] - private var commandPaletteSnapshotByWindowId: [UUID: CommandPaletteDebugSnapshot] = [:] + private var commandPaletteStateByWindowId: [UUID: CommandPaletteWindowState] = [:] private static let commandPaletteRequestGraceInterval: TimeInterval = 1.25 private static let commandPalettePendingOpenMaxAge: TimeInterval = 8.0 private static let sessionAutosaveTypingQuietPeriod: TimeInterval = 0.65 @@ -2584,9 +2592,11 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent self.unregisterMainWindow(closing) } } - commandPaletteVisibilityByWindowId[windowId] = false - commandPaletteSelectionByWindowId[windowId] = 0 - commandPaletteSnapshotByWindowId[windowId] = .empty + updateCommandPaletteState(for: windowId) { state in + state.isVisible = false + state.selectionIndex = 0 + state.snapshot = .empty + } #if DEBUG dlog( @@ -3150,11 +3160,27 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent return workspace.id } + private func commandPaletteState(for windowId: UUID) -> CommandPaletteWindowState { + commandPaletteStateByWindowId[windowId] ?? CommandPaletteWindowState() + } + + private func updateCommandPaletteState(for windowId: UUID, _ mutate: (inout CommandPaletteWindowState) -> Void) { + var state = commandPaletteState(for: windowId) + mutate(&state) + commandPaletteStateByWindowId[windowId] = state + } + + private func teardownCommandPaletteState(for windowId: UUID) { + commandPaletteStateByWindowId.removeValue(forKey: windowId) + } + private func markCommandPaletteOpenRequested(for window: NSWindow?) { guard let window, let windowId = mainWindowId(for: window) else { return } - commandPalettePendingOpenByWindowId[windowId] = true - commandPaletteRecentRequestAtByWindowId[windowId] = ProcessInfo.processInfo.systemUptime + updateCommandPaletteState(for: windowId) { state in + state.pendingOpen = true + state.recentRequestAt = ProcessInfo.processInfo.systemUptime + } } private func postCommandPaletteRequest( @@ -3231,17 +3257,19 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent private func clearCommandPalettePendingOpen(for window: NSWindow?) { guard let window, let windowId = mainWindowId(for: window) else { return } - commandPalettePendingOpenByWindowId.removeValue(forKey: windowId) - commandPaletteRecentRequestAtByWindowId.removeValue(forKey: windowId) + updateCommandPaletteState(for: windowId) { state in + state.pendingOpen = false + state.recentRequestAt = nil + } } private func pruneExpiredCommandPalettePendingOpenStates( now: TimeInterval = ProcessInfo.processInfo.systemUptime ) { - for windowId in Array(commandPalettePendingOpenByWindowId.keys) { - guard commandPalettePendingOpenByWindowId[windowId] == true else { continue } - guard let requestedAt = commandPaletteRecentRequestAtByWindowId[windowId] else { - commandPalettePendingOpenByWindowId.removeValue(forKey: windowId) + for windowId in Array(commandPaletteStateByWindowId.keys) { + guard commandPaletteStateByWindowId[windowId]?.pendingOpen == true else { continue } + guard let requestedAt = commandPaletteStateByWindowId[windowId]?.recentRequestAt else { + commandPaletteStateByWindowId[windowId]?.pendingOpen = false #if DEBUG dlog("shortcut.palette.pendingPrune windowId=\(windowId.uuidString.prefix(8)) reason=missingTimestamp") #endif @@ -3249,8 +3277,8 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent } let age = now - requestedAt guard age > Self.commandPalettePendingOpenMaxAge else { continue } - commandPalettePendingOpenByWindowId.removeValue(forKey: windowId) - commandPaletteRecentRequestAtByWindowId.removeValue(forKey: windowId) + commandPaletteStateByWindowId[windowId]?.pendingOpen = false + commandPaletteStateByWindowId[windowId]?.recentRequestAt = nil #if DEBUG dlog( "shortcut.palette.pendingPrune windowId=\(windowId.uuidString.prefix(8)) " + @@ -3263,30 +3291,34 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent private func isCommandPalettePendingOpen(for window: NSWindow) -> Bool { guard let windowId = mainWindowId(for: window) else { return false } pruneExpiredCommandPalettePendingOpenStates() - return commandPalettePendingOpenByWindowId[windowId] == true + return commandPaletteStateByWindowId[windowId]?.pendingOpen == true } private func beginCommandPaletteEscapeSuppression(for window: NSWindow?) { guard let window, let windowId = mainWindowId(for: window) else { return } - commandPaletteEscapeSuppressionByWindowId.insert(windowId) - commandPaletteEscapeSuppressionStartedAtByWindowId[windowId] = ProcessInfo.processInfo.systemUptime + updateCommandPaletteState(for: windowId) { state in + state.escapeSuppressed = true + state.escapeSuppressionStartedAt = ProcessInfo.processInfo.systemUptime + } } private func endCommandPaletteEscapeSuppression(for window: NSWindow?) { guard let window, let windowId = mainWindowId(for: window) else { return } - commandPaletteEscapeSuppressionByWindowId.remove(windowId) - commandPaletteEscapeSuppressionStartedAtByWindowId.removeValue(forKey: windowId) + updateCommandPaletteState(for: windowId) { state in + state.escapeSuppressed = false + state.escapeSuppressionStartedAt = nil + } } private func shouldConsumeSuppressedEscape(event: NSEvent, window: NSWindow?) -> Bool { guard let window, let windowId = mainWindowId(for: window), - commandPaletteEscapeSuppressionByWindowId.contains(windowId) else { + commandPaletteStateByWindowId[windowId]?.escapeSuppressed == true else { return false } - let startedAt = commandPaletteEscapeSuppressionStartedAtByWindowId[windowId] ?? 0 + let startedAt = commandPaletteStateByWindowId[windowId]?.escapeSuppressionStartedAt ?? 0 if ProcessInfo.processInfo.systemUptime - startedAt <= 0.35 { return true } @@ -3302,12 +3334,12 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent } let now = ProcessInfo.processInfo.systemUptime pruneExpiredCommandPalettePendingOpenStates(now: now) - guard commandPalettePendingOpenByWindowId[windowId] == true else { - commandPaletteRecentRequestAtByWindowId.removeValue(forKey: windowId) + guard commandPaletteStateByWindowId[windowId]?.pendingOpen == true else { + commandPaletteStateByWindowId[windowId]?.recentRequestAt = nil return nil } - guard let startedAt = commandPaletteRecentRequestAtByWindowId[windowId] else { - commandPalettePendingOpenByWindowId.removeValue(forKey: windowId) + guard let startedAt = commandPaletteStateByWindowId[windowId]?.recentRequestAt else { + commandPaletteStateByWindowId[windowId]?.pendingOpen = false return nil } let age = now - startedAt @@ -3336,8 +3368,10 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent #endif return didConsume } - commandPaletteEscapeSuppressionByWindowId.removeAll() - commandPaletteEscapeSuppressionStartedAtByWindowId.removeAll() + for windowId in commandPaletteStateByWindowId.keys { + commandPaletteStateByWindowId[windowId]?.escapeSuppressed = false + commandPaletteStateByWindowId[windowId]?.escapeSuppressionStartedAt = nil + } #if DEBUG dlog("shortcut.escape suppressionClear target={nil} clearedAll=1 keyUpConsumed=\(didConsume ? 1 : 0)") #endif @@ -3346,19 +3380,19 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent func setCommandPaletteVisible(_ visible: Bool, for window: NSWindow) { guard let windowId = mainWindowId(for: window) else { return } - let wasVisible = commandPaletteVisibilityByWindowId[windowId] ?? false - commandPaletteVisibilityByWindowId[windowId] = visible + let wasVisible = commandPaletteState(for: windowId).isVisible + updateCommandPaletteState(for: windowId) { $0.isVisible = visible } // Opening (false -> true) always resolves pending-open. // Closing (true -> false) also clears stale pending state. // Ignore repeated false updates so a stale sync cannot erase an in-flight open request. if visible || wasVisible { - commandPalettePendingOpenByWindowId.removeValue(forKey: windowId) - commandPaletteRecentRequestAtByWindowId.removeValue(forKey: windowId) + commandPaletteStateByWindowId[windowId]?.pendingOpen = false + commandPaletteStateByWindowId[windowId]?.recentRequestAt = nil } #if DEBUG if !visible, !wasVisible, - commandPalettePendingOpenByWindowId[windowId] == true { + commandPaletteStateByWindowId[windowId]?.pendingOpen == true { dlog( "palette.visibility.retainPending " + "window={\(debugWindowToken(window))} visible=0 wasVisible=0 pending=1" @@ -3368,30 +3402,30 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent } func isCommandPaletteVisible(windowId: UUID) -> Bool { - commandPaletteVisibilityByWindowId[windowId] ?? false + commandPaletteStateByWindowId[windowId]?.isVisible ?? false } func setCommandPaletteSelectionIndex(_ index: Int, for window: NSWindow) { guard let windowId = mainWindowId(for: window) else { return } - commandPaletteSelectionByWindowId[windowId] = max(0, index) + updateCommandPaletteState(for: windowId) { $0.selectionIndex = max(0, index) } } func commandPaletteSelectionIndex(windowId: UUID) -> Int { - commandPaletteSelectionByWindowId[windowId] ?? 0 + commandPaletteStateByWindowId[windowId]?.selectionIndex ?? 0 } func setCommandPaletteSnapshot(_ snapshot: CommandPaletteDebugSnapshot, for window: NSWindow) { guard let windowId = mainWindowId(for: window) else { return } - commandPaletteSnapshotByWindowId[windowId] = snapshot + updateCommandPaletteState(for: windowId) { $0.snapshot = snapshot } } func commandPaletteSnapshot(windowId: UUID) -> CommandPaletteDebugSnapshot { - commandPaletteSnapshotByWindowId[windowId] ?? .empty + commandPaletteStateByWindowId[windowId]?.snapshot ?? .empty } func isCommandPaletteVisible(for window: NSWindow) -> Bool { guard let windowId = mainWindowId(for: window) else { return false } - return commandPaletteVisibilityByWindowId[windowId] ?? false + return commandPaletteStateByWindowId[windowId]?.isVisible ?? false } func isCommandPaletteEffectivelyVisible(for window: NSWindow) -> Bool { @@ -3901,13 +3935,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent } notifyMainWindowContextsDidChange() - commandPaletteVisibilityByWindowId.removeValue(forKey: context.windowId) - commandPalettePendingOpenByWindowId.removeValue(forKey: context.windowId) - commandPaletteRecentRequestAtByWindowId.removeValue(forKey: context.windowId) - commandPaletteEscapeSuppressionByWindowId.remove(context.windowId) - commandPaletteEscapeSuppressionStartedAtByWindowId.removeValue(forKey: context.windowId) - commandPaletteSelectionByWindowId.removeValue(forKey: context.windowId) - commandPaletteSnapshotByWindowId.removeValue(forKey: context.windowId) + teardownCommandPaletteState(for: context.windowId) if tabManager === context.tabManager { if let nextContext = mainWindowContexts.values.first(where: { resolvedWindow(for: $0) != nil }) { @@ -4018,10 +4046,10 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent }) { return orderedWindow } - if let visibleWindowId = commandPaletteVisibilityByWindowId.first(where: { $0.value })?.key { + if let visibleWindowId = commandPaletteStateByWindowId.first(where: { $0.value.isVisible })?.key { return windowForMainWindowId(visibleWindowId) } - if let pendingWindowId = commandPalettePendingOpenByWindowId.first(where: { $0.value })?.key { + if let pendingWindowId = commandPaletteStateByWindowId.first(where: { $0.value.pendingOpen })?.key { return windowForMainWindowId(pendingWindowId) } return nil @@ -7648,8 +7676,10 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent @discardableResult func debugSetCommandPalettePendingOpenAge(window: NSWindow, age: TimeInterval) -> Bool { guard let windowId = mainWindowId(for: window) else { return false } - commandPalettePendingOpenByWindowId[windowId] = true - commandPaletteRecentRequestAtByWindowId[windowId] = ProcessInfo.processInfo.systemUptime - max(age, 0) + updateCommandPaletteState(for: windowId) { state in + state.pendingOpen = true + state.recentRequestAt = ProcessInfo.processInfo.systemUptime - max(age, 0) + } return true } @@ -8449,13 +8479,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent // is removed when the last window closes. persistWindowGeometry(from: window) guard let removed = unregisterMainWindowContext(for: window) else { return } - commandPaletteVisibilityByWindowId.removeValue(forKey: removed.windowId) - commandPalettePendingOpenByWindowId.removeValue(forKey: removed.windowId) - commandPaletteRecentRequestAtByWindowId.removeValue(forKey: removed.windowId) - commandPaletteEscapeSuppressionByWindowId.remove(removed.windowId) - commandPaletteEscapeSuppressionStartedAtByWindowId.removeValue(forKey: removed.windowId) - commandPaletteSelectionByWindowId.removeValue(forKey: removed.windowId) - commandPaletteSnapshotByWindowId.removeValue(forKey: removed.windowId) + teardownCommandPaletteState(for: removed.windowId) // Avoid stale notifications that can no longer be opened once the owning window is gone. if let store = notificationStore { From 5f44a87a26030f505d54be48e38a11ab80be674d Mon Sep 17 00:00:00 2001 From: arzafran Date: Thu, 9 Jul 2026 14:09:03 -0300 Subject: [PATCH 15/19] refactor(contentview): extract SidebarDragDrop.swift and SidebarVisuals.swift MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Consolidate the drag-and-drop domain (DragOverlayRoutingPolicy, drag payload enums, drop delegates, drop edge/indicator/planner, auto-scroll planner, drag lifecycle/failsafe types) and the sidebar visual chrome (footer/help-menu/ scrim/blur/material/tint/preset enums, visual-effect/backdrop views, NSColor extension), both previously fragmented across several non-contiguous locations in ContentView.swift, into two new files. Done together in one pass since both extractions needed the same verification cycle. Access-level widening (dropped `private`, no other behavior change): - SidebarDragFailsafeMonitor, SidebarExternalDropOverlay — constructed from VerticalTabsSidebar / ContentView, which stay in ContentView.swift. - SidebarFooter, SidebarTopScrim, SidebarScrollViewResolver, SidebarScrollViewResolverView, SidebarEmptyArea, ClearScrollBackground, DraggableFolderIcon, TitlebarLeadingInsetReader, SidebarTrailingBorder, SidebarBackdrop — same reason. The fileDropOverlayKey/commandPaletteWindowOverlayKey/ tmuxWorkspacePaneWindowOverlayKey associated-object keys and their overlay container identifiers stay in ContentView.swift (they back WindowCommandPaletteOverlayController / WindowTmuxWorkspacePaneOverlayController, not the drag-drop domain) — they only transited SidebarDragDrop.swift briefly during the mechanical move and were moved back. Refs #94. --- GhosttyTabs.xcodeproj/project.pbxproj | 8 + Sources/ContentView.swift | 2427 ------------------------- Sources/SidebarDragDrop.swift | 926 ++++++++++ Sources/SidebarVisuals.swift | 1533 ++++++++++++++++ 4 files changed, 2467 insertions(+), 2427 deletions(-) create mode 100644 Sources/SidebarDragDrop.swift create mode 100644 Sources/SidebarVisuals.swift diff --git a/GhosttyTabs.xcodeproj/project.pbxproj b/GhosttyTabs.xcodeproj/project.pbxproj index 009e2b37251..0ac220775fa 100644 --- a/GhosttyTabs.xcodeproj/project.pbxproj +++ b/GhosttyTabs.xcodeproj/project.pbxproj @@ -14,6 +14,8 @@ NRCV00000000000000000005 /* TabItemView.swift in Sources */ = {isa = PBXBuildFile; fileRef = NRCV00000000000000000006 /* TabItemView.swift */; }; NRCV00000000000000000003 /* FileDropOverlayView.swift in Sources */ = {isa = PBXBuildFile; fileRef = NRCV00000000000000000004 /* FileDropOverlayView.swift */; }; NRCV00000000000000000001 /* CommandPaletteSearchEngine.swift in Sources */ = {isa = PBXBuildFile; fileRef = NRCV00000000000000000002 /* CommandPaletteSearchEngine.swift */; }; + NRCV00000000000000000007 /* SidebarDragDrop.swift in Sources */ = {isa = PBXBuildFile; fileRef = NRCV00000000000000000008 /* SidebarDragDrop.swift */; }; + NRCV00000000000000000009 /* SidebarVisuals.swift in Sources */ = {isa = PBXBuildFile; fileRef = NRCV00000000000000000010 /* SidebarVisuals.swift */; }; E62155868BB29FEB5DAAAF25 /* SidebarSelectionState.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9AD52285508B1D6A9875E7B3 /* SidebarSelectionState.swift */; }; B9000018A1B2C3D4E5F60719 /* WindowDragHandleView.swift in Sources */ = {isa = PBXBuildFile; fileRef = B9000017A1B2C3D4E5F60719 /* WindowDragHandleView.swift */; }; A5001003 /* TabManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5001013 /* TabManager.swift */; }; @@ -241,6 +243,8 @@ NRCV00000000000000000006 /* TabItemView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TabItemView.swift; sourceTree = ""; }; NRCV00000000000000000004 /* FileDropOverlayView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FileDropOverlayView.swift; sourceTree = ""; }; NRCV00000000000000000002 /* CommandPaletteSearchEngine.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CommandPaletteSearchEngine.swift; sourceTree = ""; }; + NRCV00000000000000000008 /* SidebarDragDrop.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SidebarDragDrop.swift; sourceTree = ""; }; + NRCV00000000000000000010 /* SidebarVisuals.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SidebarVisuals.swift; sourceTree = ""; }; 9AD52285508B1D6A9875E7B3 /* SidebarSelectionState.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SidebarSelectionState.swift; sourceTree = ""; }; B9000017A1B2C3D4E5F60719 /* WindowDragHandleView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WindowDragHandleView.swift; sourceTree = ""; }; A5001013 /* TabManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TabManager.swift; sourceTree = ""; }; @@ -513,6 +517,8 @@ NRCV00000000000000000006 /* TabItemView.swift */, NRCV00000000000000000004 /* FileDropOverlayView.swift */, NRCV00000000000000000002 /* CommandPaletteSearchEngine.swift */, + NRCV00000000000000000008 /* SidebarDragDrop.swift */, + NRCV00000000000000000010 /* SidebarVisuals.swift */, 9AD52285508B1D6A9875E7B3 /* SidebarSelectionState.swift */, B9000017A1B2C3D4E5F60719 /* WindowDragHandleView.swift */, A50012F0 /* Backport.swift */, @@ -846,6 +852,8 @@ NRCV00000000000000000005 /* TabItemView.swift in Sources */, NRCV00000000000000000003 /* FileDropOverlayView.swift in Sources */, NRCV00000000000000000001 /* CommandPaletteSearchEngine.swift in Sources */, + NRCV00000000000000000007 /* SidebarDragDrop.swift in Sources */, + NRCV00000000000000000009 /* SidebarVisuals.swift in Sources */, E62155868BB29FEB5DAAAF25 /* SidebarSelectionState.swift in Sources */, B9000018A1B2C3D4E5F60719 /* WindowDragHandleView.swift in Sources */, A50012F1 /* Backport.swift in Sources */, diff --git a/Sources/ContentView.swift b/Sources/ContentView.swift index 4fb7fd19783..87ca62e5b4a 100644 --- a/Sources/ContentView.swift +++ b/Sources/ContentView.swift @@ -370,107 +370,6 @@ enum SidebarResizeInteraction { } } -// MARK: - File Drop Overlay - -enum DragOverlayRoutingPolicy { - static let bonsplitTabTransferType = NSPasteboard.PasteboardType("com.splittabbar.tabtransfer") - static let sidebarTabReorderType = NSPasteboard.PasteboardType(SidebarTabDragPayload.typeIdentifier) - - static func hasBonsplitTabTransfer(_ pasteboardTypes: [NSPasteboard.PasteboardType]?) -> Bool { - guard let pasteboardTypes else { return false } - return pasteboardTypes.contains(bonsplitTabTransferType) - } - - static func hasSidebarTabReorder(_ pasteboardTypes: [NSPasteboard.PasteboardType]?) -> Bool { - guard let pasteboardTypes else { return false } - return pasteboardTypes.contains(sidebarTabReorderType) - } - - static func hasFileURL(_ pasteboardTypes: [NSPasteboard.PasteboardType]?) -> Bool { - guard let pasteboardTypes else { return false } - return pasteboardTypes.contains(.fileURL) - } - - static func shouldCaptureFileDropDestination( - pasteboardTypes: [NSPasteboard.PasteboardType]?, - hasLocalDraggingSource: Bool - ) -> Bool { - // Local file drags (e.g. in-app draggable folder views) are valid drop - // inputs; rely on explicit non-file drag types below to avoid hijacking - // Bonsplit/sidebar drags. - _ = hasLocalDraggingSource - guard hasFileURL(pasteboardTypes) else { return false } - - // Prefer explicit non-file drag types so stale fileURL entries cannot hijack - // Bonsplit tab drags or sidebar tab reorder drags. - if hasBonsplitTabTransfer(pasteboardTypes) { return false } - if hasSidebarTabReorder(pasteboardTypes) { return false } - return true - } - - static func shouldCaptureFileDropDestination( - pasteboardTypes: [NSPasteboard.PasteboardType]? - ) -> Bool { - shouldCaptureFileDropDestination( - pasteboardTypes: pasteboardTypes, - hasLocalDraggingSource: false - ) - } - - static func shouldCaptureFileDropOverlay( - pasteboardTypes: [NSPasteboard.PasteboardType]?, - eventType: NSEvent.EventType? - ) -> Bool { - guard shouldCaptureFileDropDestination(pasteboardTypes: pasteboardTypes) else { return false } - guard isDragMouseEvent(eventType) else { return false } - return true - } - - static func shouldCaptureSidebarExternalOverlay( - hasSidebarDragState: Bool, - pasteboardTypes: [NSPasteboard.PasteboardType]? - ) -> Bool { - guard hasSidebarDragState else { return false } - return hasSidebarTabReorder(pasteboardTypes) - } - - static func shouldCaptureSidebarExternalOverlay( - draggedTabId: UUID?, - pasteboardTypes: [NSPasteboard.PasteboardType]? - ) -> Bool { - shouldCaptureSidebarExternalOverlay( - hasSidebarDragState: draggedTabId != nil, - pasteboardTypes: pasteboardTypes - ) - } - - static func shouldPassThroughPortalHitTesting( - pasteboardTypes: [NSPasteboard.PasteboardType]?, - eventType: NSEvent.EventType? - ) -> Bool { - guard isPortalDragEvent(eventType) else { return false } - return hasBonsplitTabTransfer(pasteboardTypes) || hasSidebarTabReorder(pasteboardTypes) - } - - private static func isDragMouseEvent(_ eventType: NSEvent.EventType?) -> Bool { - eventType == .leftMouseDragged - || eventType == .rightMouseDragged - || eventType == .otherMouseDragged - } - - private static func isPortalDragEvent(_ eventType: NSEvent.EventType?) -> Bool { - // Restrict portal pass-through to explicit drag-motion events so stale - // NSPasteboard(name: .drag) types cannot hijack normal pointer input. - guard let eventType else { return false } - switch eventType { - case .leftMouseDragged, .rightMouseDragged, .otherMouseDragged: - return true - default: - return false - } - } -} - var fileDropOverlayKey: UInt8 = 0 private var commandPaletteWindowOverlayKey: UInt8 = 0 private var tmuxWorkspacePaneWindowOverlayKey: UInt8 = 0 @@ -7699,244 +7598,6 @@ enum DevBuildBannerDebugSettings { } } -enum SidebarDragLifecycleNotification { - static let stateDidChange = Notification.Name("programa.sidebarDragStateDidChange") - static let requestClear = Notification.Name("programa.sidebarDragRequestClear") - static let tabIdKey = "tabId" - static let reasonKey = "reason" - - static func postStateDidChange(tabId: UUID?, reason: String) { - var userInfo: [AnyHashable: Any] = [reasonKey: reason] - if let tabId { - userInfo[tabIdKey] = tabId - } - NotificationCenter.default.post( - name: stateDidChange, - object: nil, - userInfo: userInfo - ) - } - - static func postClearRequest(reason: String) { - NotificationCenter.default.post( - name: requestClear, - object: nil, - userInfo: [reasonKey: reason] - ) - } - - static func tabId(from notification: Notification) -> UUID? { - notification.userInfo?[tabIdKey] as? UUID - } - - static func reason(from notification: Notification) -> String { - notification.userInfo?[reasonKey] as? String ?? "unknown" - } -} - -enum SidebarOutsideDropResetPolicy { - static func shouldResetDrag(draggedTabId: UUID?, hasSidebarDragPayload: Bool) -> Bool { - draggedTabId != nil && hasSidebarDragPayload - } -} - -enum SidebarDragFailsafePolicy { - static let clearDelay: TimeInterval = 0.15 - - static func shouldRequestClear(isDragActive: Bool, isLeftMouseButtonDown: Bool) -> Bool { - isDragActive && !isLeftMouseButtonDown - } - - static func shouldRequestClearWhenMonitoringStarts(isLeftMouseButtonDown: Bool) -> Bool { - shouldRequestClear( - isDragActive: true, - isLeftMouseButtonDown: isLeftMouseButtonDown - ) - } - - static func shouldRequestClear(forMouseEventType eventType: NSEvent.EventType) -> Bool { - eventType == .leftMouseUp - } -} - -@MainActor -private final class SidebarDragFailsafeMonitor: ObservableObject { - private static let escapeKeyCode: UInt16 = 53 - private var pendingClearWorkItem: DispatchWorkItem? - private var appResignObserver: NSObjectProtocol? - private var keyDownMonitor: Any? - private var localMouseMonitor: Any? - private var globalMouseMonitor: Any? - private var onRequestClear: ((String) -> Void)? - - func start(onRequestClear: @escaping (String) -> Void) { - self.onRequestClear = onRequestClear - if SidebarDragFailsafePolicy.shouldRequestClearWhenMonitoringStarts( - isLeftMouseButtonDown: CGEventSource.buttonState( - .combinedSessionState, - button: .left - ) - ) { - requestClearSoon(reason: "mouse_up_failsafe") - } - if appResignObserver == nil { - appResignObserver = NotificationCenter.default.addObserver( - forName: NSApplication.didResignActiveNotification, - object: nil, - queue: .main - ) { [weak self] _ in - Task { @MainActor [weak self] in - self?.requestClearSoon(reason: "app_resign_active") - } - } - } - if keyDownMonitor == nil { - keyDownMonitor = NSEvent.addLocalMonitorForEvents(matching: .keyDown) { [weak self] event in - if event.keyCode == Self.escapeKeyCode { - self?.requestClearSoon(reason: "escape_cancel") - } - return event - } - } - if localMouseMonitor == nil { - localMouseMonitor = NSEvent.addLocalMonitorForEvents(matching: .leftMouseUp) { [weak self] event in - if SidebarDragFailsafePolicy.shouldRequestClear(forMouseEventType: event.type) { - self?.requestClearSoon(reason: "mouse_up_failsafe") - } - return event - } - } - if globalMouseMonitor == nil { - globalMouseMonitor = NSEvent.addGlobalMonitorForEvents(matching: .leftMouseUp) { [weak self] event in - guard SidebarDragFailsafePolicy.shouldRequestClear(forMouseEventType: event.type) else { return } - Task { @MainActor [weak self] in - self?.requestClearSoon(reason: "mouse_up_failsafe") - } - } - } - } - - func stop() { - pendingClearWorkItem?.cancel() - pendingClearWorkItem = nil - if let appResignObserver { - NotificationCenter.default.removeObserver(appResignObserver) - self.appResignObserver = nil - } - if let keyDownMonitor { - NSEvent.removeMonitor(keyDownMonitor) - self.keyDownMonitor = nil - } - if let localMouseMonitor { - NSEvent.removeMonitor(localMouseMonitor) - self.localMouseMonitor = nil - } - if let globalMouseMonitor { - NSEvent.removeMonitor(globalMouseMonitor) - self.globalMouseMonitor = nil - } - onRequestClear = nil - } - - private func requestClearSoon(reason: String) { - guard pendingClearWorkItem == nil else { return } -#if DEBUG - dlog("sidebar.dragFailsafe.schedule reason=\(reason)") -#endif - let workItem = DispatchWorkItem { [weak self] in -#if DEBUG - dlog("sidebar.dragFailsafe.fire reason=\(reason)") -#endif - self?.pendingClearWorkItem = nil - self?.onRequestClear?(reason) - } - pendingClearWorkItem = workItem - DispatchQueue.main.asyncAfter(deadline: .now() + SidebarDragFailsafePolicy.clearDelay, execute: workItem) - } -} - -private struct SidebarExternalDropOverlay: View { - let draggedTabId: UUID? - - var body: some View { - let dragPasteboardTypes = NSPasteboard(name: .drag).types - let shouldCapture = DragOverlayRoutingPolicy.shouldCaptureSidebarExternalOverlay( - draggedTabId: draggedTabId, - pasteboardTypes: dragPasteboardTypes - ) - Group { - if shouldCapture { - Color.clear - .contentShape(Rectangle()) - .allowsHitTesting(true) - .onDrop( - of: SidebarTabDragPayload.dropContentTypes, - delegate: SidebarExternalDropDelegate(draggedTabId: draggedTabId) - ) - } else { - Color.clear - .contentShape(Rectangle()) - .allowsHitTesting(false) - } - } - } -} - -private struct SidebarExternalDropDelegate: DropDelegate { - let draggedTabId: UUID? - - func validateDrop(info: DropInfo) -> Bool { - let hasSidebarPayload = info.hasItemsConforming(to: [SidebarTabDragPayload.typeIdentifier]) - let shouldReset = SidebarOutsideDropResetPolicy.shouldResetDrag( - draggedTabId: draggedTabId, - hasSidebarDragPayload: hasSidebarPayload - ) -#if DEBUG - dlog( - "sidebar.dropOutside.validate tab=\(debugShortSidebarTabId(draggedTabId)) " + - "hasType=\(hasSidebarPayload) allowed=\(shouldReset)" - ) -#endif - return shouldReset - } - - func dropEntered(info: DropInfo) { -#if DEBUG - dlog("sidebar.dropOutside.entered tab=\(debugShortSidebarTabId(draggedTabId))") -#endif - } - - func dropExited(info: DropInfo) { -#if DEBUG - dlog("sidebar.dropOutside.exited tab=\(debugShortSidebarTabId(draggedTabId))") -#endif - } - - func dropUpdated(info: DropInfo) -> DropProposal? { - guard validateDrop(info: info) else { return nil } -#if DEBUG - dlog("sidebar.dropOutside.updated tab=\(debugShortSidebarTabId(draggedTabId)) op=move") -#endif - // Explicit move proposal avoids AppKit showing a copy (+) cursor. - return DropProposal(operation: .move) - } - - func performDrop(info: DropInfo) -> Bool { - guard validateDrop(info: info) else { return false } -#if DEBUG - dlog("sidebar.dropOutside.perform tab=\(debugShortSidebarTabId(draggedTabId))") -#endif - SidebarDragLifecycleNotification.postClearRequest(reason: "outside_sidebar_drop") - return true - } - - private func debugShortSidebarTabId(_ id: UUID?) -> String { - guard let id else { return "nil" } - return String(id.uuidString.prefix(5)) - } -} - -@MainActor private final class SidebarShortcutHintModifierMonitor: ObservableObject { @Published private(set) var isModifierPressed = false @@ -8096,2091 +7757,3 @@ private final class SidebarShortcutHintModifierMonitor: ObservableObject { } } -private struct SidebarFooter: View { - @ObservedObject var updateViewModel: UpdateViewModel - let onSendFeedback: () -> Void - - var body: some View { -#if DEBUG - SidebarDevFooter(updateViewModel: updateViewModel, onSendFeedback: onSendFeedback) -#else - SidebarFooterButtons(updateViewModel: updateViewModel, onSendFeedback: onSendFeedback) - .padding(.leading, 6) - .padding(.trailing, 10) - .padding(.bottom, 6) -#endif - } -} - -private struct SidebarFooterButtons: View { - @ObservedObject var updateViewModel: UpdateViewModel - let onSendFeedback: () -> Void - - var body: some View { - HStack(spacing: 4) { - SidebarHelpMenuButton(onSendFeedback: onSendFeedback) - UpdatePill(model: updateViewModel) - } - .frame(maxWidth: .infinity, alignment: .leading) - } -} - -private enum SidebarHelpMenuAction { - case importBrowserData - case keyboardShortcuts - case docs - case changelog - case github - case githubIssues - case discord - case checkForUpdates - case sendFeedback - case welcome -} - -private struct SidebarHelpMenuButton: View { - private let docsURL = URL(string: "https://github.com/darkroomengineering/programa/tree/main/docs") - private let changelogURL = URL(string: "https://github.com/darkroomengineering/programa/blob/main/CHANGELOG.md") - private let githubURL = URL(string: "https://github.com/darkroomengineering/programa") - private let githubIssuesURL = URL(string: "https://github.com/darkroomengineering/programa/issues") - private let discordURL = URL(string: "https://discord.gg/xsgFEVrWCZ") - private let helpTitle = String(localized: "sidebar.help.button", defaultValue: "Help") - private let buttonSize: CGFloat = 22 - private let iconSize: CGFloat = 11 - @ObservedObject private var keyboardShortcutSettingsObserver = KeyboardShortcutSettingsObserver.shared - - let onSendFeedback: () -> Void - - @State private var isPopoverPresented = false - - private var sendFeedbackShortcutHint: String { - let _ = keyboardShortcutSettingsObserver.revision - return KeyboardShortcutSettings.shortcut(for: .sendFeedback).displayString - } - - var body: some View { - Button { - isPopoverPresented.toggle() - } label: { - Image(systemName: "questionmark.circle") - .symbolRenderingMode(.monochrome) - .font(.system(size: iconSize, weight: .medium)) - .foregroundStyle(Color(nsColor: .secondaryLabelColor)) - .frame(width: buttonSize, height: buttonSize, alignment: .center) - } - .buttonStyle(SidebarFooterIconButtonStyle()) - .frame(width: buttonSize, height: buttonSize, alignment: .center) - .background(ArrowlessPopoverAnchor( - isPresented: $isPopoverPresented, - preferredEdge: .maxY, - detachedGap: 4 - ) { - helpPopover - }) - .accessibilityElement(children: .ignore) - .safeHelp(helpTitle) - .accessibilityLabel(helpTitle) - .accessibilityIdentifier("SidebarHelpMenuButton") - } - - private var helpPopover: some View { - VStack(alignment: .leading, spacing: 2) { - helpOptionButton( - title: String(localized: "sidebar.help.welcome", defaultValue: "Welcome to Programa!"), - action: .welcome, - accessibilityIdentifier: "SidebarHelpMenuOptionWelcome", - isExternalLink: false - ) - helpOptionButton( - title: String(localized: "sidebar.help.sendFeedback", defaultValue: "Send Feedback"), - action: .sendFeedback, - accessibilityIdentifier: "SidebarHelpMenuOptionSendFeedback", - isExternalLink: false, - shortcutHint: sendFeedbackShortcutHint, - trailingSystemImage: "bubble.left.and.text.bubble.right" - ) - helpOptionButton( - title: String(localized: "settings.section.keyboardShortcuts", defaultValue: "Keyboard Shortcuts"), - action: .keyboardShortcuts, - accessibilityIdentifier: "SidebarHelpMenuOptionKeyboardShortcuts", - isExternalLink: false - ) - helpOptionButton( - title: String(localized: "menu.view.importFromBrowser", defaultValue: "Import Browser Data…"), - action: .importBrowserData, - accessibilityIdentifier: "SidebarHelpMenuOptionImportBrowserData", - isExternalLink: false - ) - if docsURL != nil { - helpOptionButton( - title: String(localized: "about.docs", defaultValue: "Docs"), - action: .docs, - accessibilityIdentifier: "SidebarHelpMenuOptionDocs", - isExternalLink: true - ) - } - if changelogURL != nil { - helpOptionButton( - title: String(localized: "sidebar.help.changelog", defaultValue: "Changelog"), - action: .changelog, - accessibilityIdentifier: "SidebarHelpMenuOptionChangelog", - isExternalLink: true - ) - } - if githubURL != nil { - helpOptionButton( - title: String(localized: "about.github", defaultValue: "GitHub"), - action: .github, - accessibilityIdentifier: "SidebarHelpMenuOptionGitHub", - isExternalLink: true - ) - } - if githubIssuesURL != nil { - helpOptionButton( - title: String(localized: "sidebar.help.githubIssues", defaultValue: "GitHub Issues"), - action: .githubIssues, - accessibilityIdentifier: "SidebarHelpMenuOptionGitHubIssues", - isExternalLink: true - ) - } - if discordURL != nil { - helpOptionButton( - title: String(localized: "sidebar.help.discord", defaultValue: "Discord"), - action: .discord, - accessibilityIdentifier: "SidebarHelpMenuOptionDiscord", - isExternalLink: true - ) - } - helpOptionButton( - title: String(localized: "command.checkForUpdates.title", defaultValue: "Check for Updates"), - action: .checkForUpdates, - accessibilityIdentifier: "SidebarHelpMenuOptionCheckForUpdates", - isExternalLink: false - ) - } - .padding(8) - .frame(minWidth: 200) - } - - private func helpOptionButton( - title: String, - action: SidebarHelpMenuAction, - accessibilityIdentifier: String, - isExternalLink: Bool, - shortcutHint: String? = nil, - trailingSystemImage: String? = nil - ) -> some View { - Button { - isPopoverPresented = false - perform(action) - } label: { - HStack(spacing: 8) { - Text(title) - .font(.system(size: 12)) - Spacer(minLength: 0) - if let shortcutHint { - helpOptionShortcutHint(text: shortcutHint) - } - if let trailingSystemImage { - helpOptionTrailingIcon(systemName: trailingSystemImage) - } - if isExternalLink { - helpOptionTrailingIcon(systemName: "arrow.up.right", size: 8) - } - } - .padding(.horizontal, 8) - .frame(height: 24) - .contentShape(Rectangle()) - } - .buttonStyle(.plain) - .accessibilityIdentifier(accessibilityIdentifier) - } - - private func helpOptionShortcutHint(text: String) -> some View { - Text(text) - .lineLimit(1) - .fixedSize(horizontal: true, vertical: false) - .font(.system(size: 10, weight: .regular, design: .rounded)) - .monospacedDigit() - .foregroundStyle(Color(nsColor: .secondaryLabelColor)) - } - - private func helpOptionTrailingIcon(systemName: String, size: CGFloat = 13) -> some View { - Image(systemName: systemName) - .resizable() - .scaledToFit() - .frame(width: size, height: size) - .foregroundStyle(Color(nsColor: .secondaryLabelColor)) - } - - private func perform(_ action: SidebarHelpMenuAction) { - switch action { - case .importBrowserData: - isPopoverPresented = false - DispatchQueue.main.async { - BrowserDataImportCoordinator.shared.presentImportDialog() - } - case .keyboardShortcuts: - isPopoverPresented = false - DispatchQueue.main.asyncAfter(deadline: .now() + 0.12) { - Task { @MainActor in - if let appDelegate = AppDelegate.shared { - appDelegate.openPreferencesWindow( - debugSource: "sidebarHelpMenu.keyboardShortcuts", - navigationTarget: .keyboardShortcuts - ) - } else { - AppDelegate.presentPreferencesWindow(navigationTarget: .keyboardShortcuts) - } - } - } - case .docs: - guard let docsURL else { return } - NSWorkspace.shared.open(docsURL) - case .changelog: - guard let changelogURL else { return } - NSWorkspace.shared.open(changelogURL) - case .github: - guard let githubURL else { return } - NSWorkspace.shared.open(githubURL) - case .githubIssues: - guard let githubIssuesURL else { return } - NSWorkspace.shared.open(githubIssuesURL) - case .discord: - guard let discordURL else { return } - NSWorkspace.shared.open(discordURL) - case .checkForUpdates: - Task { @MainActor in - AppDelegate.shared?.checkForUpdates(nil) - } - case .sendFeedback: - isPopoverPresented = false - onSendFeedback() - case .welcome: - isPopoverPresented = false - Task { @MainActor in - if let appDelegate = AppDelegate.shared { - appDelegate.openWelcomeWorkspace() - } - } - } - } - -} - -private struct ArrowlessPopoverAnchor: NSViewRepresentable { - @Binding var isPresented: Bool - let preferredEdge: NSRectEdge - let detachedGap: CGFloat - @ViewBuilder let content: () -> PopoverContent - - func makeNSView(context: Context) -> NSView { - let view = NSView() - context.coordinator.anchorView = view - return view - } - - func updateNSView(_ nsView: NSView, context: Context) { - context.coordinator.anchorView = nsView - context.coordinator.updateRootView(AnyView(content())) - - if isPresented { - context.coordinator.present( - preferredEdge: preferredEdge, - detachedGap: detachedGap - ) - } else { - context.coordinator.dismiss() - } - } - - func makeCoordinator() -> Coordinator { - Coordinator(isPresented: $isPresented) - } - - final class Coordinator: NSObject, NSPopoverDelegate { - @Binding var isPresented: Bool - - weak var anchorView: NSView? - private let hostingController = NSHostingController(rootView: AnyView(EmptyView())) - private var popover: NSPopover? - - init(isPresented: Binding) { - _isPresented = isPresented - } - - func updateRootView(_ rootView: AnyView) { - hostingController.rootView = AnyView(rootView.fixedSize()) - hostingController.view.invalidateIntrinsicContentSize() - hostingController.view.layoutSubtreeIfNeeded() - } - - func present(preferredEdge: NSRectEdge, detachedGap: CGFloat) { - guard let anchorView else { - isPresented = false - dismiss() - return - } - - let popover = popover ?? makePopover() - if popover.isShown { - return - } - - hostingController.view.invalidateIntrinsicContentSize() - hostingController.view.layoutSubtreeIfNeeded() - let fittingSize = hostingController.view.fittingSize - if fittingSize.width > 0, fittingSize.height > 0 { - popover.contentSize = NSSize( - width: ceil(fittingSize.width), - height: ceil(fittingSize.height) - ) - } - - popover.show( - relativeTo: positioningRect( - for: anchorView.bounds, - preferredEdge: preferredEdge, - detachedGap: detachedGap - ), - of: anchorView, - preferredEdge: preferredEdge - ) - } - - func dismiss() { - popover?.performClose(nil) - popover = nil - } - - func popoverDidClose(_ notification: Notification) { - popover = nil - if isPresented { - isPresented = false - } - } - - private func makePopover() -> NSPopover { - let popover = NSPopover() - popover.behavior = .semitransient - popover.animates = true - popover.setValue(true, forKeyPath: "shouldHideAnchor") - popover.contentViewController = hostingController - popover.delegate = self - self.popover = popover - return popover - } - - private func positioningRect( - for bounds: CGRect, - preferredEdge: NSRectEdge, - detachedGap: CGFloat - ) -> CGRect { - let hiddenArrowInset: CGFloat = 13 - let compensation = max(hiddenArrowInset - detachedGap, 0) - - switch preferredEdge { - case .maxY: - return NSRect( - x: bounds.minX, - y: bounds.maxY - compensation, - width: bounds.width, - height: compensation - ) - case .minY: - return NSRect( - x: bounds.minX, - y: bounds.minY, - width: bounds.width, - height: compensation - ) - case .maxX: - return NSRect( - x: bounds.maxX - compensation, - y: bounds.minY, - width: compensation, - height: bounds.height - ) - case .minX: - return NSRect( - x: bounds.minX, - y: bounds.minY, - width: compensation, - height: bounds.height - ) - @unknown default: - return bounds - } - } - } -} - -private struct SidebarFooterIconButtonStyle: ButtonStyle { - func makeBody(configuration: Configuration) -> some View { - SidebarFooterIconButtonStyleBody(configuration: configuration) - } -} - -private struct SidebarFooterIconButtonStyleBody: View { - let configuration: SidebarFooterIconButtonStyle.Configuration - - @Environment(\.isEnabled) private var isEnabled - @State private var isHovered = false - - private var backgroundOpacity: Double { - guard isEnabled else { return 0.0 } - if configuration.isPressed { return 0.16 } - if isHovered { return 0.08 } - return 0.0 - } - - var body: some View { - configuration.label - .background( - RoundedRectangle(cornerRadius: 8, style: .continuous) - .fill(Color.primary.opacity(backgroundOpacity)) - ) - .onHover { hovering in - isHovered = hovering - } - .animation(.easeOut(duration: 0.12), value: isHovered) - .animation(.easeOut(duration: 0.08), value: configuration.isPressed) - } -} - -#if DEBUG -private struct SidebarDevFooter: View { - @ObservedObject var updateViewModel: UpdateViewModel - let onSendFeedback: () -> Void - @AppStorage(DevBuildBannerDebugSettings.sidebarBannerVisibleKey) - private var showSidebarDevBuildBanner = DevBuildBannerDebugSettings.defaultShowSidebarBanner - - var body: some View { - VStack(alignment: .leading, spacing: 6) { - SidebarFooterButtons(updateViewModel: updateViewModel, onSendFeedback: onSendFeedback) - if showSidebarDevBuildBanner { - Text(String(localized: "debug.devBuildBanner.title", defaultValue: "THIS IS A DEV BUILD")) - .font(.system(size: 11, weight: .semibold)) - .foregroundColor(.red) - } - } - .padding(.leading, 6) - .padding(.trailing, 10) - .padding(.bottom, 6) - } -} -#endif - -private struct SidebarTopScrim: View { - let height: CGFloat - - var body: some View { - SidebarTopBlurEffect() - .frame(height: height) - .mask( - LinearGradient( - colors: [ - Color.black.opacity(0.95), - Color.black.opacity(0.75), - Color.black.opacity(0.35), - Color.clear - ], - startPoint: .top, - endPoint: .bottom - ) - ) - } -} - -private struct SidebarTopBlurEffect: NSViewRepresentable { - func makeNSView(context: Context) -> NSVisualEffectView { - let view = NSVisualEffectView() - view.blendingMode = .withinWindow - view.material = .underWindowBackground - view.state = .active - view.isEmphasized = false - return view - } - - func updateNSView(_ nsView: NSVisualEffectView, context: Context) {} -} - -private struct SidebarScrollViewResolver: NSViewRepresentable { - let onResolve: (NSScrollView?) -> Void - - func makeNSView(context: Context) -> SidebarScrollViewResolverView { - let view = SidebarScrollViewResolverView() - view.onResolve = onResolve - return view - } - - func updateNSView(_ nsView: SidebarScrollViewResolverView, context: Context) { - nsView.onResolve = onResolve - nsView.resolveScrollView() - } -} - -private final class SidebarScrollViewResolverView: NSView { - var onResolve: ((NSScrollView?) -> Void)? - - override func viewDidMoveToSuperview() { - super.viewDidMoveToSuperview() - resolveScrollView() - } - - override func viewDidMoveToWindow() { - super.viewDidMoveToWindow() - resolveScrollView() - } - - func resolveScrollView() { - DispatchQueue.main.async { [weak self] in - guard let self else { return } - onResolve?(self.enclosingScrollView) - } - } -} - -private struct SidebarEmptyArea: View { - @EnvironmentObject var tabManager: TabManager - let rowSpacing: CGFloat - @Binding var selection: SidebarSelection - @Binding var selectedTabIds: Set - @Binding var lastSidebarSelectionIndex: Int? - let dragAutoScrollController: SidebarDragAutoScrollController - @Binding var draggedTabId: UUID? - @Binding var dropIndicator: SidebarDropIndicator? - - var body: some View { - Color.clear - .contentShape(Rectangle()) - .frame(maxWidth: .infinity, maxHeight: .infinity) - .onTapGesture(count: 2) { - tabManager.addWorkspace(placementOverride: .end) - if let selectedId = tabManager.selectedTabId { - selectedTabIds = [selectedId] - lastSidebarSelectionIndex = tabManager.tabs.firstIndex { $0.id == selectedId } - } - selection = .tabs - } - .onDrop(of: SidebarTabDragPayload.dropContentTypes, delegate: SidebarTabDropDelegate( - targetTabId: nil, - tabManager: tabManager, - draggedTabId: $draggedTabId, - selectedTabIds: $selectedTabIds, - lastSidebarSelectionIndex: $lastSidebarSelectionIndex, - targetRowHeight: nil, - dragAutoScrollController: dragAutoScrollController, - dropIndicator: $dropIndicator - )) - .overlay(alignment: .top) { - if shouldShowTopDropIndicator { - Rectangle() - .fill(programaAccentColor()) - .frame(height: 2) - .padding(.horizontal, 8) - .offset(y: -(rowSpacing / 2)) - } - } - } - - private var shouldShowTopDropIndicator: Bool { - guard draggedTabId != nil, let indicator = dropIndicator else { return false } - if indicator.tabId == nil { - return true - } - guard indicator.edge == .bottom, let lastTabId = tabManager.tabs.last?.id else { return false } - return indicator.tabId == lastTabId - } -} - -enum SidebarPathFormatter { - static let homeDirectoryPath: String = FileManager.default.homeDirectoryForCurrentUser.path - - static func shortenedPath( - _ path: String, - homeDirectoryPath: String = Self.homeDirectoryPath - ) -> String { - let trimmed = path.trimmingCharacters(in: .whitespacesAndNewlines) - guard !trimmed.isEmpty else { return path } - if trimmed == homeDirectoryPath { - return "~" - } - if trimmed.hasPrefix(homeDirectoryPath + "/") { - return "~" + trimmed.dropFirst(homeDirectoryPath.count) - } - return trimmed - } -} - -enum SidebarWorkspaceShortcutHintMetrics { - private static let measurementFont = NSFont.systemFont(ofSize: 10, weight: .semibold) - private static let minimumSlotWidth: CGFloat = 28 - private static let horizontalPadding: CGFloat = 12 - private static let lock = NSLock() - private static var cachedHintWidths: [String: CGFloat] = [:] - #if DEBUG - private static var measurementCount = 0 - #endif - - static func slotWidth(label: String?, debugXOffset: Double) -> CGFloat { - guard let label else { return minimumSlotWidth } - let positiveDebugInset = max(0, CGFloat(ShortcutHintDebugSettings.clamped(debugXOffset))) + 2 - return max(minimumSlotWidth, hintWidth(for: label) + positiveDebugInset) - } - - static func hintWidth(for label: String) -> CGFloat { - lock.lock() - if let cached = cachedHintWidths[label] { - lock.unlock() - return cached - } - lock.unlock() - - let textWidth = (label as NSString).size(withAttributes: [.font: measurementFont]).width - let measuredWidth = ceil(textWidth) + horizontalPadding - - lock.lock() - cachedHintWidths[label] = measuredWidth - #if DEBUG - measurementCount += 1 - #endif - lock.unlock() - return measuredWidth - } - - #if DEBUG - static func resetCacheForTesting() { - lock.lock() - cachedHintWidths.removeAll() - measurementCount = 0 - lock.unlock() - } - - static func measurementCountForTesting() -> Int { - lock.lock() - let count = measurementCount - lock.unlock() - return count - } - #endif -} - -enum SidebarTrailingAccessoryWidthPolicy { - static let closeButtonWidth: CGFloat = 16 - - static func width( - canCloseWorkspace: Bool, - showsWorkspaceShortcutHint: Bool, - workspaceShortcutLabel: String?, - debugXOffset: Double - ) -> CGFloat { - if showsWorkspaceShortcutHint, let workspaceShortcutLabel { - return SidebarWorkspaceShortcutHintMetrics.slotWidth( - label: workspaceShortcutLabel, - debugXOffset: debugXOffset - ) - } - - return canCloseWorkspace ? closeButtonWidth : 0 - } -} - -enum SidebarDropEdge: Equatable { - case top - case bottom -} - -struct SidebarDropIndicator: Equatable { - let tabId: UUID? - let edge: SidebarDropEdge -} - -enum SidebarDropPlanner { - static func indicator( - draggedTabId: UUID?, - targetTabId: UUID?, - tabIds: [UUID], - pinnedTabIds: Set, - pointerY: CGFloat? = nil, - targetHeight: CGFloat? = nil - ) -> SidebarDropIndicator? { - guard tabIds.count > 1, let draggedTabId else { return nil } - guard let fromIndex = tabIds.firstIndex(of: draggedTabId) else { return nil } - - let insertionPosition: Int - if let targetTabId { - guard let targetTabIndex = tabIds.firstIndex(of: targetTabId) else { return nil } - let edge: SidebarDropEdge - if let pointerY, let targetHeight { - edge = edgeForPointer(locationY: pointerY, targetHeight: targetHeight) - } else { - edge = preferredEdge(fromIndex: fromIndex, targetTabId: targetTabId, tabIds: tabIds) - } - insertionPosition = (edge == .bottom) ? targetTabIndex + 1 : targetTabIndex - } else { - insertionPosition = tabIds.count - } - - let legalInsertionPosition = legalInsertionPosition( - draggedTabId: draggedTabId, - proposedInsertionPosition: insertionPosition, - tabIds: tabIds, - pinnedTabIds: pinnedTabIds - ) - let legalTargetIndex = resolvedTargetIndex( - from: fromIndex, - insertionPosition: legalInsertionPosition, - totalCount: tabIds.count - ) - guard legalTargetIndex != fromIndex else { return nil } - return indicatorForInsertionPosition(legalInsertionPosition, tabIds: tabIds) - } - - static func targetIndex( - draggedTabId: UUID, - targetTabId: UUID?, - indicator: SidebarDropIndicator?, - tabIds: [UUID], - pinnedTabIds: Set - ) -> Int? { - guard let fromIndex = tabIds.firstIndex(of: draggedTabId) else { return nil } - - let insertionPosition: Int - if let indicator, let indicatorInsertion = insertionPositionForIndicator(indicator, tabIds: tabIds) { - insertionPosition = indicatorInsertion - } else if let targetTabId { - guard let targetTabIndex = tabIds.firstIndex(of: targetTabId) else { return nil } - let edge = (indicator?.tabId == targetTabId) - ? (indicator?.edge ?? preferredEdge(fromIndex: fromIndex, targetTabId: targetTabId, tabIds: tabIds)) - : preferredEdge(fromIndex: fromIndex, targetTabId: targetTabId, tabIds: tabIds) - insertionPosition = (edge == .bottom) ? targetTabIndex + 1 : targetTabIndex - } else { - insertionPosition = tabIds.count - } - - let legalInsertionPosition = legalInsertionPosition( - draggedTabId: draggedTabId, - proposedInsertionPosition: insertionPosition, - tabIds: tabIds, - pinnedTabIds: pinnedTabIds - ) - return resolvedTargetIndex(from: fromIndex, insertionPosition: legalInsertionPosition, totalCount: tabIds.count) - } - - private static func indicatorForInsertionPosition(_ insertionPosition: Int, tabIds: [UUID]) -> SidebarDropIndicator { - let clampedInsertion = max(0, min(insertionPosition, tabIds.count)) - if clampedInsertion >= tabIds.count { - return SidebarDropIndicator(tabId: nil, edge: .bottom) - } - return SidebarDropIndicator(tabId: tabIds[clampedInsertion], edge: .top) - } - - private static func insertionPositionForIndicator(_ indicator: SidebarDropIndicator, tabIds: [UUID]) -> Int? { - if let tabId = indicator.tabId { - guard let targetTabIndex = tabIds.firstIndex(of: tabId) else { return nil } - return indicator.edge == .bottom ? targetTabIndex + 1 : targetTabIndex - } - return tabIds.count - } - - private static func preferredEdge(fromIndex: Int, targetTabId: UUID, tabIds: [UUID]) -> SidebarDropEdge { - guard let targetIndex = tabIds.firstIndex(of: targetTabId) else { return .top } - return fromIndex < targetIndex ? .bottom : .top - } - - private static func legalInsertionPosition( - draggedTabId: UUID, - proposedInsertionPosition: Int, - tabIds: [UUID], - pinnedTabIds: Set - ) -> Int { - let clampedInsertion = max(0, min(proposedInsertionPosition, tabIds.count)) - guard !pinnedTabIds.isEmpty else { return clampedInsertion } - - let pinnedCount = tabIds.reduce(into: 0) { count, tabId in - if pinnedTabIds.contains(tabId) { - count += 1 - } - } - guard pinnedCount > 0 else { return clampedInsertion } - - if pinnedTabIds.contains(draggedTabId) { - return min(clampedInsertion, pinnedCount) - } - return max(clampedInsertion, pinnedCount) - } - - static func edgeForPointer(locationY: CGFloat, targetHeight: CGFloat) -> SidebarDropEdge { - guard targetHeight > 0 else { return .top } - let clampedY = min(max(locationY, 0), targetHeight) - return clampedY < (targetHeight / 2) ? .top : .bottom - } - - private static func resolvedTargetIndex(from sourceIndex: Int, insertionPosition: Int, totalCount: Int) -> Int { - let clampedInsertion = max(0, min(insertionPosition, totalCount)) - let adjusted = clampedInsertion > sourceIndex ? clampedInsertion - 1 : clampedInsertion - return max(0, min(adjusted, max(0, totalCount - 1))) - } -} - -enum SidebarAutoScrollDirection: Equatable { - case up - case down -} - -struct SidebarAutoScrollPlan: Equatable { - let direction: SidebarAutoScrollDirection - let pointsPerTick: CGFloat -} - -enum SidebarDragAutoScrollPlanner { - static let edgeInset: CGFloat = 44 - static let minStep: CGFloat = 2 - static let maxStep: CGFloat = 12 - - static func plan( - distanceToTop: CGFloat, - distanceToBottom: CGFloat, - edgeInset: CGFloat = SidebarDragAutoScrollPlanner.edgeInset, - minStep: CGFloat = SidebarDragAutoScrollPlanner.minStep, - maxStep: CGFloat = SidebarDragAutoScrollPlanner.maxStep - ) -> SidebarAutoScrollPlan? { - guard edgeInset > 0, maxStep >= minStep else { return nil } - if distanceToTop <= edgeInset { - let normalized = max(0, min(1, (edgeInset - distanceToTop) / edgeInset)) - let step = minStep + ((maxStep - minStep) * normalized) - return SidebarAutoScrollPlan(direction: .up, pointsPerTick: step) - } - if distanceToBottom <= edgeInset { - let normalized = max(0, min(1, (edgeInset - distanceToBottom) / edgeInset)) - let step = minStep + ((maxStep - minStep) * normalized) - return SidebarAutoScrollPlan(direction: .down, pointsPerTick: step) - } - return nil - } -} - -@MainActor -final class SidebarDragAutoScrollController: ObservableObject { - private weak var scrollView: NSScrollView? - private var timer: Timer? - private var activePlan: SidebarAutoScrollPlan? - - func attach(scrollView: NSScrollView?) { - self.scrollView = scrollView - } - - func updateFromDragLocation() { - guard let scrollView else { - stop() - return - } - guard let plan = plan(for: scrollView) else { - stop() - return - } - activePlan = plan - startTimerIfNeeded() - } - - func stop() { - timer?.invalidate() - timer = nil - activePlan = nil - } - - private func startTimerIfNeeded() { - guard timer == nil else { return } - let timer = Timer.scheduledTimer(withTimeInterval: 1.0 / 60.0, repeats: true) { [weak self] _ in - Task { @MainActor [weak self] in - self?.tick() - } - } - self.timer = timer - RunLoop.main.add(timer, forMode: .eventTracking) - } - - private func tick() { - guard NSEvent.pressedMouseButtons != 0 else { - stop() - return - } - guard let scrollView else { - stop() - return - } - - // AppKit drag/drop autoscroll guidance recommends autoscroll(with:) - // when periodic drag updates are available; use it first. - if applyNativeAutoscroll(to: scrollView) { - activePlan = plan(for: scrollView) - if activePlan == nil { - stop() - } - return - } - - activePlan = self.plan(for: scrollView) - guard let plan = activePlan else { - stop() - return - } - _ = apply(plan: plan, to: scrollView) - } - - private func applyNativeAutoscroll(to scrollView: NSScrollView) -> Bool { - guard let event = NSApp.currentEvent else { return false } - switch event.type { - case .leftMouseDragged, .rightMouseDragged, .otherMouseDragged: - break - default: - return false - } - - let clipView = scrollView.contentView - let didScroll = clipView.autoscroll(with: event) - if didScroll { - scrollView.reflectScrolledClipView(clipView) - } - return didScroll - } - - private func distancesToEdges(mousePoint: CGPoint, viewportHeight: CGFloat, isFlipped: Bool) -> (top: CGFloat, bottom: CGFloat) { - if isFlipped { - return (top: mousePoint.y, bottom: viewportHeight - mousePoint.y) - } - return (top: viewportHeight - mousePoint.y, bottom: mousePoint.y) - } - - private func planForMousePoint(_ mousePoint: CGPoint, in clipView: NSClipView) -> SidebarAutoScrollPlan? { - let viewportHeight = clipView.bounds.height - guard viewportHeight > 0 else { return nil } - - let distances = distancesToEdges(mousePoint: mousePoint, viewportHeight: viewportHeight, isFlipped: clipView.isFlipped) - return SidebarDragAutoScrollPlanner.plan(distanceToTop: distances.top, distanceToBottom: distances.bottom) - } - - private func mousePoint(in clipView: NSClipView) -> CGPoint { - let mouseInWindow = clipView.window?.convertPoint(fromScreen: NSEvent.mouseLocation) ?? .zero - return clipView.convert(mouseInWindow, from: nil) - } - - private func currentPlan(for scrollView: NSScrollView) -> SidebarAutoScrollPlan? { - let clipView = scrollView.contentView - let mouse = mousePoint(in: clipView) - return planForMousePoint(mouse, in: clipView) - } - - private func plan(for scrollView: NSScrollView) -> SidebarAutoScrollPlan? { - currentPlan(for: scrollView) - } - - private func apply(plan: SidebarAutoScrollPlan, to scrollView: NSScrollView) -> Bool { - guard let documentView = scrollView.documentView else { return false } - let clipView = scrollView.contentView - let maxOriginY = max(0, documentView.bounds.height - clipView.bounds.height) - guard maxOriginY > 0 else { return false } - - let directionMultiplier: CGFloat = (plan.direction == .down) ? 1 : -1 - let flippedMultiplier: CGFloat = documentView.isFlipped ? 1 : -1 - let delta = directionMultiplier * flippedMultiplier * plan.pointsPerTick - let currentY = clipView.bounds.origin.y - let targetY = min(max(currentY + delta, 0), maxOriginY) - guard abs(targetY - currentY) > 0.01 else { return false } - - clipView.scroll(to: CGPoint(x: clipView.bounds.origin.x, y: targetY)) - scrollView.reflectScrolledClipView(clipView) - return true - } -} - -enum SidebarTabDragPayload { - static let typeIdentifier = "com.darkroom.programa.sidebar-tab-reorder" - static let dropContentType = UTType(exportedAs: typeIdentifier) - static let dropContentTypes: [UTType] = [dropContentType] - private static let prefix = "programa.sidebar-tab." - - static func provider(for tabId: UUID) -> NSItemProvider { - let provider = NSItemProvider() - let payload = "\(prefix)\(tabId.uuidString)" - provider.registerDataRepresentation(forTypeIdentifier: typeIdentifier, visibility: .ownProcess) { completion in - completion(payload.data(using: .utf8), nil) - return nil - } - return provider - } -} - -enum BonsplitTabDragPayload { - static let typeIdentifier = "com.splittabbar.tabtransfer" - static let dropContentType = UTType(exportedAs: typeIdentifier) - static let dropContentTypes: [UTType] = [dropContentType] - private static let currentProcessId = Int32(ProcessInfo.processInfo.processIdentifier) - - struct Transfer: Decodable { - struct TabInfo: Decodable { - let id: UUID - } - - let tab: TabInfo - let sourcePaneId: UUID - let sourceProcessId: Int32 - - private enum CodingKeys: String, CodingKey { - case tab - case sourcePaneId - case sourceProcessId - } - - init(from decoder: Decoder) throws { - let container = try decoder.container(keyedBy: CodingKeys.self) - self.tab = try container.decode(TabInfo.self, forKey: .tab) - self.sourcePaneId = try container.decode(UUID.self, forKey: .sourcePaneId) - // Legacy payloads won't include this field. Treat as foreign process. - self.sourceProcessId = try container.decodeIfPresent(Int32.self, forKey: .sourceProcessId) ?? -1 - } - } - - private static func isCurrentProcessTransfer(_ transfer: Transfer) -> Bool { - transfer.sourceProcessId == currentProcessId - } - - static func currentTransfer() -> Transfer? { - let pasteboard = NSPasteboard(name: .drag) - let type = NSPasteboard.PasteboardType(typeIdentifier) - - if let data = pasteboard.data(forType: type), - let transfer = try? JSONDecoder().decode(Transfer.self, from: data), - isCurrentProcessTransfer(transfer) { - return transfer - } - - if let raw = pasteboard.string(forType: type), - let data = raw.data(using: .utf8), - let transfer = try? JSONDecoder().decode(Transfer.self, from: data), - isCurrentProcessTransfer(transfer) { - return transfer - } - - return nil - } -} - -struct SidebarBonsplitTabDropDelegate: DropDelegate { - let targetWorkspaceId: UUID - let tabManager: TabManager - @Binding var selectedTabIds: Set - @Binding var lastSidebarSelectionIndex: Int? - - func validateDrop(info: DropInfo) -> Bool { - guard info.hasItemsConforming(to: [BonsplitTabDragPayload.typeIdentifier]) else { return false } - return BonsplitTabDragPayload.currentTransfer() != nil - } - - func dropUpdated(info: DropInfo) -> DropProposal? { - guard validateDrop(info: info) else { return nil } - return DropProposal(operation: .move) - } - - func performDrop(info: DropInfo) -> Bool { - guard validateDrop(info: info), - let transfer = BonsplitTabDragPayload.currentTransfer(), - let app = AppDelegate.shared else { - return false - } - - if let source = app.locateBonsplitSurface(tabId: transfer.tab.id), - source.workspaceId == targetWorkspaceId { - syncSidebarSelection() - return true - } - - guard app.moveBonsplitTab( - tabId: transfer.tab.id, - toWorkspace: targetWorkspaceId, - focus: true, - focusWindow: true - ) else { - return false - } - - selectedTabIds = [targetWorkspaceId] - syncSidebarSelection() - return true - } - - private func syncSidebarSelection() { - if let selectedId = tabManager.selectedTabId { - lastSidebarSelectionIndex = tabManager.tabs.firstIndex { $0.id == selectedId } - } else { - lastSidebarSelectionIndex = nil - } - } -} - -struct SidebarTabDropDelegate: DropDelegate { - let targetTabId: UUID? - let tabManager: TabManager - @Binding var draggedTabId: UUID? - @Binding var selectedTabIds: Set - @Binding var lastSidebarSelectionIndex: Int? - let targetRowHeight: CGFloat? - let dragAutoScrollController: SidebarDragAutoScrollController - @Binding var dropIndicator: SidebarDropIndicator? - - func validateDrop(info: DropInfo) -> Bool { - let hasType = info.hasItemsConforming(to: [SidebarTabDragPayload.typeIdentifier]) - let hasDrag = draggedTabId != nil - #if DEBUG - dlog("sidebar.validateDrop target=\(targetTabId?.uuidString.prefix(5) ?? "end") hasType=\(hasType) hasDrag=\(hasDrag)") - #endif - return hasType && hasDrag - } - - func dropEntered(info: DropInfo) { - #if DEBUG - dlog("sidebar.dropEntered target=\(targetTabId?.uuidString.prefix(5) ?? "end")") - #endif - dragAutoScrollController.updateFromDragLocation() - updateDropIndicator(for: info) - } - - func dropExited(info: DropInfo) { -#if DEBUG - dlog("sidebar.dropExited target=\(targetTabId?.uuidString.prefix(5) ?? "end")") -#endif - if dropIndicator?.tabId == targetTabId { - dropIndicator = nil - } - } - - func dropUpdated(info: DropInfo) -> DropProposal? { - dragAutoScrollController.updateFromDragLocation() - updateDropIndicator(for: info) -#if DEBUG - dlog( - "sidebar.dropUpdated target=\(targetTabId?.uuidString.prefix(5) ?? "end") " + - "indicator=\(debugIndicator(dropIndicator))" - ) -#endif - return DropProposal(operation: .move) - } - - func performDrop(info: DropInfo) -> Bool { - defer { - draggedTabId = nil - dropIndicator = nil - dragAutoScrollController.stop() - } - #if DEBUG - dlog("sidebar.drop target=\(targetTabId?.uuidString.prefix(5) ?? "end")") - #endif - guard let draggedTabId else { -#if DEBUG - dlog("sidebar.drop.abort reason=missingDraggedTab") -#endif - return false - } - guard let fromIndex = tabManager.tabs.firstIndex(where: { $0.id == draggedTabId }) else { -#if DEBUG - dlog("sidebar.drop.abort reason=draggedTabMissing tab=\(draggedTabId.uuidString.prefix(5))") -#endif - return false - } - let tabIds = tabManager.tabs.map(\.id) - guard let targetIndex = SidebarDropPlanner.targetIndex( - draggedTabId: draggedTabId, - targetTabId: targetTabId, - indicator: dropIndicator, - tabIds: tabIds, - pinnedTabIds: Set(tabManager.tabs.filter(\.isPinned).map(\.id)) - ) else { -#if DEBUG - dlog( - "sidebar.drop.abort reason=noTargetIndex tab=\(draggedTabId.uuidString.prefix(5)) " + - "target=\(targetTabId?.uuidString.prefix(5) ?? "end") indicator=\(debugIndicator(dropIndicator))" - ) -#endif - return false - } - - guard fromIndex != targetIndex else { -#if DEBUG - dlog("sidebar.drop.noop from=\(fromIndex) to=\(targetIndex)") -#endif - syncSidebarSelection() - return true - } - -#if DEBUG - dlog("sidebar.drop.commit tab=\(draggedTabId.uuidString.prefix(5)) from=\(fromIndex) to=\(targetIndex)") -#endif - _ = tabManager.reorderWorkspace(tabId: draggedTabId, toIndex: targetIndex) - if let selectedId = tabManager.selectedTabId { - selectedTabIds = [selectedId] - syncSidebarSelection(preferredSelectedTabId: selectedId) - } else { - selectedTabIds = [] - syncSidebarSelection() - } - return true - } - - private func updateDropIndicator(for info: DropInfo) { - let tabIds = tabManager.tabs.map(\.id) - let pinnedTabIds = Set(tabManager.tabs.filter(\.isPinned).map(\.id)) - let nextIndicator = SidebarDropPlanner.indicator( - draggedTabId: draggedTabId, - targetTabId: targetTabId, - tabIds: tabIds, - pinnedTabIds: pinnedTabIds, - pointerY: targetTabId == nil ? nil : info.location.y, - targetHeight: targetRowHeight - ) - guard dropIndicator != nextIndicator else { return } - dropIndicator = nextIndicator - } - - private func syncSidebarSelection(preferredSelectedTabId: UUID? = nil) { - let selectedId = preferredSelectedTabId ?? tabManager.selectedTabId - if let selectedId { - lastSidebarSelectionIndex = tabManager.tabs.firstIndex { $0.id == selectedId } - } else { - lastSidebarSelectionIndex = nil - } - } - - private func debugIndicator(_ indicator: SidebarDropIndicator?) -> String { - guard let indicator else { return "nil" } - let tabText = indicator.tabId.map { String($0.uuidString.prefix(5)) } ?? "end" - return "\(tabText):\(indicator.edge == .top ? "top" : "bottom")" - } -} - -struct MiddleClickCapture: NSViewRepresentable { - let onMiddleClick: () -> Void - - func makeNSView(context: Context) -> MiddleClickCaptureView { - let view = MiddleClickCaptureView() - view.onMiddleClick = onMiddleClick - return view - } - - func updateNSView(_ nsView: MiddleClickCaptureView, context: Context) { - nsView.onMiddleClick = onMiddleClick - } -} - -final class MiddleClickCaptureView: NSView { - var onMiddleClick: (() -> Void)? - - override func hitTest(_ point: NSPoint) -> NSView? { - // Only intercept middle-click so left-click selection and right-click context menus - // continue to hit-test through to SwiftUI/AppKit normally. - guard let event = NSApp.currentEvent, - event.type == .otherMouseDown, - event.buttonNumber == 2 else { - return nil - } - return self - } - - override func otherMouseDown(with event: NSEvent) { - guard event.buttonNumber == 2 else { - super.otherMouseDown(with: event) - return - } - onMiddleClick?() - } -} - -enum SidebarSelection { - case tabs - case notifications -} - -private struct ClearScrollBackground: ViewModifier { - func body(content: Content) -> some View { - content - .scrollContentBackground(.hidden) - .background(ScrollBackgroundClearer()) - } -} - -private struct ScrollBackgroundClearer: NSViewRepresentable { - func makeNSView(context: Context) -> NSView { - NSView() - } - - func updateNSView(_ nsView: NSView, context: Context) { - DispatchQueue.main.async { - guard let scrollView = findScrollView(startingAt: nsView) else { return } - // Clear all backgrounds and mark as non-opaque for transparency - scrollView.drawsBackground = false - scrollView.backgroundColor = .clear - scrollView.wantsLayer = true - scrollView.layer?.backgroundColor = NSColor.clear.cgColor - scrollView.layer?.isOpaque = false - - scrollView.contentView.drawsBackground = false - scrollView.contentView.backgroundColor = .clear - scrollView.contentView.wantsLayer = true - scrollView.contentView.layer?.backgroundColor = NSColor.clear.cgColor - scrollView.contentView.layer?.isOpaque = false - - if let docView = scrollView.documentView { - docView.wantsLayer = true - docView.layer?.backgroundColor = NSColor.clear.cgColor - docView.layer?.isOpaque = false - } - } - } - - private func findScrollView(startingAt view: NSView) -> NSScrollView? { - var current: NSView? = view - while let candidate = current { - if let scrollView = candidate as? NSScrollView { - return scrollView - } - current = candidate.superview - } - return nil - } -} - -private struct DraggableFolderIcon: View { - let directory: String - - var body: some View { - DraggableFolderIconRepresentable(directory: directory) - .frame(width: 16, height: 16) - .safeHelp(String(localized: "sidebar.folderIcon.dragHint", defaultValue: "Drag to open in Finder or another app")) - .onTapGesture(count: 2) { - NSWorkspace.shared.selectFile(nil, inFileViewerRootedAtPath: directory) - } - } -} - -private struct DraggableFolderIconRepresentable: NSViewRepresentable { - let directory: String - - func makeNSView(context: Context) -> DraggableFolderNSView { - DraggableFolderNSView(directory: directory) - } - - func updateNSView(_ nsView: DraggableFolderNSView, context: Context) { - nsView.directory = directory - nsView.updateIcon() - } -} - -final class DraggableFolderNSView: NSView, NSDraggingSource { - private final class FolderIconImageView: NSImageView { - override var mouseDownCanMoveWindow: Bool { false } - } - - var directory: String - private var imageView: FolderIconImageView! - private var previousWindowMovableState: Bool? - private weak var suppressedWindow: NSWindow? - private var hasActiveDragSession = false - private var didArmWindowDragSuppression = false - - private func formatPoint(_ point: NSPoint) -> String { - String(format: "(%.1f,%.1f)", point.x, point.y) - } - - init(directory: String) { - self.directory = directory - super.init(frame: .zero) - setupImageView() - } - - required init?(coder: NSCoder) { - fatalError("init(coder:) has not been implemented") - } - - override var intrinsicContentSize: NSSize { - NSSize(width: 16, height: 16) - } - - override var mouseDownCanMoveWindow: Bool { false } - - private func setupImageView() { - imageView = FolderIconImageView() - imageView.imageScaling = .scaleProportionallyDown - imageView.translatesAutoresizingMaskIntoConstraints = false - addSubview(imageView) - NSLayoutConstraint.activate([ - imageView.leadingAnchor.constraint(equalTo: leadingAnchor), - imageView.trailingAnchor.constraint(equalTo: trailingAnchor), - imageView.topAnchor.constraint(equalTo: topAnchor), - imageView.bottomAnchor.constraint(equalTo: bottomAnchor), - imageView.widthAnchor.constraint(equalToConstant: 16), - imageView.heightAnchor.constraint(equalToConstant: 16), - ]) - updateIcon() - } - - func updateIcon() { - let icon = NSWorkspace.shared.icon(forFile: directory) - icon.size = NSSize(width: 16, height: 16) - imageView.image = icon - } - - func draggingSession(_ session: NSDraggingSession, sourceOperationMaskFor context: NSDraggingContext) -> NSDragOperation { - return context == .outsideApplication ? [.copy, .link] : .copy - } - - func draggingSession(_ session: NSDraggingSession, endedAt screenPoint: NSPoint, operation: NSDragOperation) { - hasActiveDragSession = false - restoreWindowMovableStateIfNeeded() - #if DEBUG - let nowMovable = window.map { String($0.isMovable) } ?? "nil" - let windowOrigin = window.map { formatPoint($0.frame.origin) } ?? "nil" - dlog("folder.dragEnd dir=\(directory) operation=\(operation.rawValue) screen=\(formatPoint(screenPoint)) nowMovable=\(nowMovable) windowOrigin=\(windowOrigin)") - #endif - } - - override func hitTest(_ point: NSPoint) -> NSView? { - guard bounds.contains(point) else { return nil } - let hit = super.hitTest(point) - #if DEBUG - let hitDesc = hit.map { String(describing: type(of: $0)) } ?? "nil" - let imageHit = (hit === imageView) - let wasMovable = previousWindowMovableState.map(String.init) ?? "nil" - let nowMovable = window.map { String($0.isMovable) } ?? "nil" - dlog("folder.hitTest point=\(formatPoint(point)) hit=\(hitDesc) imageViewHit=\(imageHit) returning=DraggableFolderNSView wasMovable=\(wasMovable) nowMovable=\(nowMovable)") - #endif - return self - } - - override func mouseDown(with event: NSEvent) { - maybeDisableWindowDraggingEarly(trigger: "mouseDown") - hasActiveDragSession = false - #if DEBUG - let localPoint = convert(event.locationInWindow, from: nil) - let responderDesc = window?.firstResponder.map { String(describing: type(of: $0)) } ?? "nil" - let wasMovable = previousWindowMovableState.map(String.init) ?? "nil" - let nowMovable = window.map { String($0.isMovable) } ?? "nil" - let windowOrigin = window.map { formatPoint($0.frame.origin) } ?? "nil" - dlog("folder.mouseDown dir=\(directory) point=\(formatPoint(localPoint)) firstResponder=\(responderDesc) wasMovable=\(wasMovable) nowMovable=\(nowMovable) windowOrigin=\(windowOrigin)") - #endif - let fileURL = URL(fileURLWithPath: directory) - let draggingItem = NSDraggingItem(pasteboardWriter: fileURL as NSURL) - - let iconImage = NSWorkspace.shared.icon(forFile: directory) - iconImage.size = NSSize(width: 32, height: 32) - draggingItem.setDraggingFrame(bounds, contents: iconImage) - - let session = beginDraggingSession(with: [draggingItem], event: event, source: self) - hasActiveDragSession = true - #if DEBUG - let itemCount = session.draggingPasteboard.pasteboardItems?.count ?? 0 - dlog("folder.dragStart dir=\(directory) pasteboardItems=\(itemCount)") - #endif - } - - override func mouseUp(with event: NSEvent) { - super.mouseUp(with: event) - // Always restore suppression on mouse-up; drag-session callbacks can be - // skipped for non-started drags, which would otherwise leave suppression stuck. - restoreWindowMovableStateIfNeeded() - } - - override func rightMouseDown(with event: NSEvent) { - let menu = buildPathMenu() - // Pop up menu at bottom-left of icon (like native proxy icon) - let menuLocation = NSPoint(x: 0, y: bounds.height) - menu.popUp(positioning: nil, at: menuLocation, in: self) - } - - private func buildPathMenu() -> NSMenu { - let menu = NSMenu() - let url = URL(fileURLWithPath: directory).standardized - var pathComponents: [URL] = [] - - // Build path from current directory up to root - var current = url - while current.path != "/" { - pathComponents.append(current) - current = current.deletingLastPathComponent() - } - pathComponents.append(URL(fileURLWithPath: "/")) - - // Add path components (current dir at top, root at bottom - matches native macOS) - for pathURL in pathComponents { - let icon = NSWorkspace.shared.icon(forFile: pathURL.path) - icon.size = NSSize(width: 16, height: 16) - - let displayName: String - if pathURL.path == "/" { - // Use the volume name for root - if let volumeName = try? URL(fileURLWithPath: "/").resourceValues(forKeys: [.volumeNameKey]).volumeName { - displayName = volumeName - } else { - displayName = String(localized: "sidebar.pathMenu.macintoshHD", defaultValue: "Macintosh HD") - } - } else { - displayName = FileManager.default.displayName(atPath: pathURL.path) - } - - let item = NSMenuItem(title: displayName, action: #selector(openPathComponent(_:)), keyEquivalent: "") - item.target = self - item.image = icon - item.representedObject = pathURL - menu.addItem(item) - } - - // Add computer name at the bottom (like native proxy icon) - let computerName = Host.current().localizedName ?? ProcessInfo.processInfo.hostName - let computerIcon = NSImage(named: NSImage.computerName) ?? NSImage() - computerIcon.size = NSSize(width: 16, height: 16) - - let computerItem = NSMenuItem(title: computerName, action: #selector(openComputer(_:)), keyEquivalent: "") - computerItem.target = self - computerItem.image = computerIcon - menu.addItem(computerItem) - - return menu - } - - @objc private func openPathComponent(_ sender: NSMenuItem) { - guard let url = sender.representedObject as? URL else { return } - NSWorkspace.shared.selectFile(nil, inFileViewerRootedAtPath: url.path) - } - - @objc private func openComputer(_ sender: NSMenuItem) { - // Open "Computer" view in Finder (shows all volumes) - NSWorkspace.shared.open(URL(fileURLWithPath: "/", isDirectory: true)) - } - - private func restoreWindowMovableStateIfNeeded() { - guard didArmWindowDragSuppression || previousWindowMovableState != nil else { return } - let targetWindow = suppressedWindow ?? window - let depthAfter = endWindowDragSuppression(window: targetWindow) - restoreWindowDragging(window: targetWindow, previousMovableState: previousWindowMovableState) - self.previousWindowMovableState = nil - self.suppressedWindow = nil - self.didArmWindowDragSuppression = false - #if DEBUG - let nowMovable = targetWindow.map { String($0.isMovable) } ?? "nil" - dlog("folder.dragSuppression restore depth=\(depthAfter) nowMovable=\(nowMovable)") - #endif - } - - private func maybeDisableWindowDraggingEarly(trigger: String) { - guard !didArmWindowDragSuppression else { return } - guard let eventType = NSApp.currentEvent?.type, - eventType == .leftMouseDown || eventType == .leftMouseDragged else { - return - } - guard let currentWindow = window else { return } - - didArmWindowDragSuppression = true - suppressedWindow = currentWindow - let suppressionDepth = beginWindowDragSuppression(window: currentWindow) ?? 0 - if currentWindow.isMovable { - previousWindowMovableState = temporarilyDisableWindowDragging(window: currentWindow) - } else { - previousWindowMovableState = nil - } - #if DEBUG - let wasMovable = previousWindowMovableState.map(String.init) ?? "nil" - let nowMovable = String(currentWindow.isMovable) - dlog( - "folder.dragSuppression trigger=\(trigger) event=\(eventType) depth=\(suppressionDepth) wasMovable=\(wasMovable) nowMovable=\(nowMovable)" - ) - #endif - } -} - -func temporarilyDisableWindowDragging(window: NSWindow?) -> Bool? { - guard let window else { return nil } - let wasMovable = window.isMovable - if wasMovable { - window.isMovable = false - } - return wasMovable -} - -func restoreWindowDragging(window: NSWindow?, previousMovableState: Bool?) { - guard let window, let previousMovableState else { return } - window.isMovable = previousMovableState -} - -/// Wrapper view that tries NSGlassEffectView (macOS 26+) when available or requested -private struct SidebarVisualEffectBackground: NSViewRepresentable { - let material: NSVisualEffectView.Material - let blendingMode: NSVisualEffectView.BlendingMode - let state: NSVisualEffectView.State - let opacity: Double - let tintColor: NSColor? - let cornerRadius: CGFloat - let preferLiquidGlass: Bool - - init( - material: NSVisualEffectView.Material = .hudWindow, - blendingMode: NSVisualEffectView.BlendingMode = .behindWindow, - state: NSVisualEffectView.State = .active, - opacity: Double = 1.0, - tintColor: NSColor? = nil, - cornerRadius: CGFloat = 0, - preferLiquidGlass: Bool = false - ) { - self.material = material - self.blendingMode = blendingMode - self.state = state - self.opacity = opacity - self.tintColor = tintColor - self.cornerRadius = cornerRadius - self.preferLiquidGlass = preferLiquidGlass - } - - static var liquidGlassAvailable: Bool { - NSClassFromString("NSGlassEffectView") != nil - } - - func makeNSView(context: Context) -> NSView { - // Try NSGlassEffectView if preferred or if we want to test availability - if preferLiquidGlass, let glassClass = NSClassFromString("NSGlassEffectView") as? NSView.Type { - let glass = glassClass.init(frame: .zero) - glass.autoresizingMask = [.width, .height] - glass.wantsLayer = true - return glass - } - - // Use NSVisualEffectView - let view = NSVisualEffectView() - view.autoresizingMask = [.width, .height] - view.wantsLayer = true - view.layerContentsRedrawPolicy = .onSetNeedsDisplay - return view - } - - func updateNSView(_ nsView: NSView, context: Context) { - // Configure based on view type - if nsView.className == "NSGlassEffectView" { - // NSGlassEffectView configuration via private API - nsView.alphaValue = max(0.0, min(1.0, opacity)) - nsView.layer?.cornerRadius = cornerRadius - nsView.layer?.masksToBounds = cornerRadius > 0 - - // Try to set tint color via private selector - if let color = tintColor { - let selector = NSSelectorFromString("setTintColor:") - if nsView.responds(to: selector) { - nsView.perform(selector, with: color) - } - } - } else if let visualEffect = nsView as? NSVisualEffectView { - // NSVisualEffectView configuration - visualEffect.material = material - visualEffect.blendingMode = blendingMode - visualEffect.state = state - visualEffect.alphaValue = max(0.0, min(1.0, opacity)) - visualEffect.layer?.cornerRadius = cornerRadius - visualEffect.layer?.masksToBounds = cornerRadius > 0 - visualEffect.needsDisplay = true - } - } -} - -/// Reads the leading inset required to clear traffic lights + left titlebar accessories. -final class TitlebarLeadingInsetPassthroughView: NSView { - override var mouseDownCanMoveWindow: Bool { false } - override func hitTest(_ point: NSPoint) -> NSView? { nil } -} - -private struct TitlebarLeadingInsetReader: NSViewRepresentable { - @Binding var inset: CGFloat - - func makeNSView(context: Context) -> NSView { - let view = TitlebarLeadingInsetPassthroughView() - view.setFrameSize(.zero) - return view - } - - func updateNSView(_ nsView: NSView, context: Context) { - DispatchQueue.main.async { - guard let window = nsView.window else { return } - // Start past the traffic lights - var leading: CGFloat = 78 - // Add width of all left-aligned titlebar accessories - for accessory in window.titlebarAccessoryViewControllers - where accessory.layoutAttribute == .leading || accessory.layoutAttribute == .left { - leading += accessory.view.frame.width - } - leading += 0 - if leading != inset { - inset = leading - } - } - } -} - -/// 1px trailing border on the sidebar, derived from the terminal chrome background -/// using the same logic as bonsplit's TabBarColors.nsColorSeparator: -/// dark bg → lighten RGB by 0.16 at 0.36 alpha; light bg → darken by 0.12 at 0.26 alpha. -private struct SidebarTrailingBorder: View { - @AppStorage("sidebarMatchTerminalBackground") private var matchTerminalBackground = false - @State private var separatorColor: NSColor = chromeSeparatorColor() - - var body: some View { - if matchTerminalBackground { - Rectangle() - .fill(Color(nsColor: separatorColor)) - .frame(width: 1) - .ignoresSafeArea() - .onAppear { - separatorColor = Self.chromeSeparatorColor() - } - .onReceive(NotificationCenter.default.publisher(for: .ghosttyDefaultBackgroundDidChange)) { _ in - separatorColor = Self.chromeSeparatorColor() - } - } - } - - /// Replicates bonsplit TabBarColors.nsColorSeparator derivation from chrome background. - private static func chromeSeparatorColor() -> NSColor { - let chrome = GhosttyBackgroundTheme.currentColor() - let srgb = chrome.usingColorSpace(.sRGB) ?? chrome - var r: CGFloat = 0, g: CGFloat = 0, b: CGFloat = 0, a: CGFloat = 0 - srgb.getRed(&r, green: &g, blue: &b, alpha: &a) - let luminance = 0.299 * r + 0.587 * g + 0.114 * b - let isLight = luminance > 0.5 - let amount: CGFloat = isLight ? -0.12 : 0.16 - let alpha: CGFloat = isLight ? 0.26 : 0.36 - return NSColor( - red: min(1.0, max(0.0, r + amount)), - green: min(1.0, max(0.0, g + amount)), - blue: min(1.0, max(0.0, b + amount)), - alpha: alpha - ) - } -} - -/// Sidebar background that uses the same technique as TitlebarLayerBackground: -/// fully opaque layer color + layer-level opacity. This matches how the terminal's -/// Metal surface composites its background. -private struct SidebarTerminalBackgroundView: NSViewRepresentable { - let backgroundColor: NSColor - let opacity: CGFloat - - func makeNSView(context: Context) -> NSView { - let view = NSView() - view.wantsLayer = true - view.layer?.backgroundColor = backgroundColor.withAlphaComponent(1.0).cgColor - view.layer?.opacity = Float(opacity) - return view - } - - func updateNSView(_ nsView: NSView, context: Context) { - nsView.layer?.backgroundColor = backgroundColor.withAlphaComponent(1.0).cgColor - nsView.layer?.opacity = Float(opacity) - } -} - -private struct SidebarBackdrop: View { - @AppStorage("sidebarMatchTerminalBackground") private var matchTerminalBackground = false - @AppStorage("sidebarTintOpacity") private var sidebarTintOpacity = SidebarTintDefaults.opacity - @AppStorage("sidebarTintHex") private var sidebarTintHex = SidebarTintDefaults.hex - @AppStorage("sidebarTintHexLight") private var sidebarTintHexLight: String? - @AppStorage("sidebarTintHexDark") private var sidebarTintHexDark: String? - @AppStorage("sidebarMaterial") private var sidebarMaterial = SidebarMaterialOption.sidebar.rawValue - @AppStorage("sidebarBlendMode") private var sidebarBlendMode = SidebarBlendModeOption.withinWindow.rawValue - @AppStorage("sidebarState") private var sidebarState = SidebarStateOption.followWindow.rawValue - @AppStorage("sidebarCornerRadius") private var sidebarCornerRadius = 0.0 - @AppStorage("sidebarBlurOpacity") private var sidebarBlurOpacity = 1.0 - @Environment(\.colorScheme) private var colorScheme - @State private var terminalBackgroundColor: NSColor = GhosttyBackgroundTheme.currentColor() - - var body: some View { - let cornerRadius = CGFloat(max(0, sidebarCornerRadius)) - - if matchTerminalBackground { - // The terminal background is provided by a single CALayer, so - // the sidebar uses the configured opacity directly. - let alpha = CGFloat(GhosttyApp.shared.defaultBackgroundOpacity) - return AnyView( - SidebarTerminalBackgroundView( - backgroundColor: GhosttyApp.shared.defaultBackgroundColor, - opacity: alpha - ) - .clipShape(RoundedRectangle(cornerRadius: cornerRadius, style: .continuous)) - .onReceive(NotificationCenter.default.publisher(for: .ghosttyDefaultBackgroundDidChange)) { _ in - terminalBackgroundColor = GhosttyBackgroundTheme.currentColor() - } - ) - } - - let materialOption = SidebarMaterialOption(rawValue: sidebarMaterial) - let blendingMode = SidebarBlendModeOption(rawValue: sidebarBlendMode)?.mode ?? .behindWindow - let state = SidebarStateOption(rawValue: sidebarState)?.state ?? .active - let resolvedHex: String = { - if colorScheme == .dark, let dark = sidebarTintHexDark { - return dark - } else if colorScheme == .light, let light = sidebarTintHexLight { - return light - } - return sidebarTintHex - }() - let tintColor = (NSColor(hex: resolvedHex) ?? NSColor(hex: sidebarTintHex) ?? .black).withAlphaComponent(sidebarTintOpacity) - let useLiquidGlass = materialOption?.usesLiquidGlass ?? false - let useWindowLevelGlass = useLiquidGlass && blendingMode == .behindWindow - - return AnyView( - ZStack { - if let material = materialOption?.material { - // When using liquidGlass + behindWindow, window handles glass + tint - // Sidebar is fully transparent - if !useWindowLevelGlass { - SidebarVisualEffectBackground( - material: material, - blendingMode: blendingMode, - state: state, - opacity: sidebarBlurOpacity, - tintColor: tintColor, - cornerRadius: cornerRadius, - preferLiquidGlass: useLiquidGlass - ) - // Tint overlay for NSVisualEffectView fallback - if !useLiquidGlass { - Color(nsColor: tintColor) - } - } - } - // When material is none or useWindowLevelGlass, render nothing - } - .clipShape(RoundedRectangle(cornerRadius: cornerRadius, style: .continuous)) - ) - } -} - -enum SidebarMaterialOption: String, CaseIterable, Identifiable { - case none - case liquidGlass // macOS 26+ NSGlassEffectView - case sidebar - case hudWindow - case menu - case popover - case underWindowBackground - case windowBackground - case contentBackground - case fullScreenUI - case sheet - case headerView - case toolTip - - var id: String { rawValue } - - var title: String { - switch self { - case .none: return String(localized: "settings.material.none", defaultValue: "None") - case .liquidGlass: return String(localized: "settings.material.liquidGlass", defaultValue: "Liquid Glass (macOS 26+)") - case .sidebar: return String(localized: "settings.material.sidebar", defaultValue: "Sidebar") - case .hudWindow: return String(localized: "settings.material.hudWindow", defaultValue: "HUD Window") - case .menu: return String(localized: "settings.material.menu", defaultValue: "Menu") - case .popover: return String(localized: "settings.material.popover", defaultValue: "Popover") - case .underWindowBackground: return String(localized: "settings.material.underWindow", defaultValue: "Under Window") - case .windowBackground: return String(localized: "settings.material.windowBackground", defaultValue: "Window Background") - case .contentBackground: return String(localized: "settings.material.contentBackground", defaultValue: "Content Background") - case .fullScreenUI: return String(localized: "settings.material.fullScreenUI", defaultValue: "Full Screen UI") - case .sheet: return String(localized: "settings.material.sheet", defaultValue: "Sheet") - case .headerView: return String(localized: "settings.material.headerView", defaultValue: "Header View") - case .toolTip: return String(localized: "settings.material.toolTip", defaultValue: "Tool Tip") - } - } - - /// Returns true if this option should use NSGlassEffectView (macOS 26+) - var usesLiquidGlass: Bool { - self == .liquidGlass - } - - var material: NSVisualEffectView.Material? { - switch self { - case .none: return nil - case .liquidGlass: return .underWindowBackground // Fallback material - case .sidebar: return .sidebar - case .hudWindow: return .hudWindow - case .menu: return .menu - case .popover: return .popover - case .underWindowBackground: return .underWindowBackground - case .windowBackground: return .windowBackground - case .contentBackground: return .contentBackground - case .fullScreenUI: return .fullScreenUI - case .sheet: return .sheet - case .headerView: return .headerView - case .toolTip: return .toolTip - } - } -} - -enum SidebarBlendModeOption: String, CaseIterable, Identifiable { - case behindWindow - case withinWindow - - var id: String { rawValue } - - var title: String { - switch self { - case .behindWindow: return String(localized: "settings.blendMode.behindWindow", defaultValue: "Behind Window") - case .withinWindow: return String(localized: "settings.blendMode.withinWindow", defaultValue: "Within Window") - } - } - - var mode: NSVisualEffectView.BlendingMode { - switch self { - case .behindWindow: return .behindWindow - case .withinWindow: return .withinWindow - } - } -} - -enum SidebarStateOption: String, CaseIterable, Identifiable { - case active - case inactive - case followWindow - - var id: String { rawValue } - - var title: String { - switch self { - case .active: return String(localized: "settings.state.active", defaultValue: "Active") - case .inactive: return String(localized: "settings.state.inactive", defaultValue: "Inactive") - case .followWindow: return String(localized: "settings.state.followWindow", defaultValue: "Follow Window") - } - } - - var state: NSVisualEffectView.State { - switch self { - case .active: return .active - case .inactive: return .inactive - case .followWindow: return .followsWindowActiveState - } - } -} - -enum SidebarTintDefaults { - static let hex = "#000000" - static let opacity = 0.18 -} - -enum SidebarPresetOption: String, CaseIterable, Identifiable { - case nativeSidebar - case glassBehind - case softBlur - case popoverGlass - case hudGlass - case underWindow - - var id: String { rawValue } - - var title: String { - switch self { - case .nativeSidebar: return String(localized: "settings.preset.nativeSidebar", defaultValue: "Native Sidebar") - case .glassBehind: return String(localized: "settings.preset.raycastGray", defaultValue: "Raycast Gray") - case .softBlur: return String(localized: "settings.preset.softBlur", defaultValue: "Soft Blur") - case .popoverGlass: return String(localized: "settings.preset.popoverGlass", defaultValue: "Popover Glass") - case .hudGlass: return String(localized: "settings.preset.hudGlass", defaultValue: "HUD Glass") - case .underWindow: return String(localized: "settings.preset.underWindow", defaultValue: "Under Window") - } - } - - var material: SidebarMaterialOption { - switch self { - case .nativeSidebar: return .sidebar - case .glassBehind: return .sidebar - case .softBlur: return .sidebar - case .popoverGlass: return .popover - case .hudGlass: return .hudWindow - case .underWindow: return .underWindowBackground - } - } - - var blendMode: SidebarBlendModeOption { - switch self { - case .nativeSidebar: return .withinWindow - case .glassBehind: return .behindWindow - case .softBlur: return .behindWindow - case .popoverGlass: return .behindWindow - case .hudGlass: return .withinWindow - case .underWindow: return .withinWindow - } - } - - var state: SidebarStateOption { - switch self { - case .nativeSidebar: return .followWindow - case .glassBehind: return .active - case .softBlur: return .active - case .popoverGlass: return .active - case .hudGlass: return .active - case .underWindow: return .followWindow - } - } - - var tintHex: String { - switch self { - case .nativeSidebar: return "#000000" - case .glassBehind: return "#000000" - case .softBlur: return "#000000" - case .popoverGlass: return "#000000" - case .hudGlass: return "#000000" - case .underWindow: return "#000000" - } - } - - var tintOpacity: Double { - switch self { - case .nativeSidebar: return 0.18 - case .glassBehind: return 0.36 - case .softBlur: return 0.28 - case .popoverGlass: return 0.10 - case .hudGlass: return 0.62 - case .underWindow: return 0.14 - } - } - - var cornerRadius: Double { - switch self { - case .nativeSidebar: return 0.0 - case .glassBehind: return 0.0 - case .softBlur: return 0.0 - case .popoverGlass: return 10.0 - case .hudGlass: return 10.0 - case .underWindow: return 6.0 - } - } - - var blurOpacity: Double { - switch self { - case .nativeSidebar: return 1.0 - case .glassBehind: return 0.6 - case .softBlur: return 0.45 - case .popoverGlass: return 0.9 - case .hudGlass: return 0.98 - case .underWindow: return 0.9 - } - } -} - -extension NSColor { - func hexString(includeAlpha: Bool = false) -> String { - let color = usingColorSpace(.sRGB) ?? self - var red: CGFloat = 0 - var green: CGFloat = 0 - var blue: CGFloat = 0 - var alpha: CGFloat = 0 - color.getRed(&red, green: &green, blue: &blue, alpha: &alpha) - let redByte = min(255, max(0, Int((red * 255).rounded()))) - let greenByte = min(255, max(0, Int((green * 255).rounded()))) - let blueByte = min(255, max(0, Int((blue * 255).rounded()))) - if includeAlpha { - let alphaByte = min(255, max(0, Int((alpha * 255).rounded()))) - return String(format: "#%02X%02X%02X%02X", redByte, greenByte, blueByte, alphaByte) - } - return String(format: "#%02X%02X%02X", redByte, greenByte, blueByte) - } -} diff --git a/Sources/SidebarDragDrop.swift b/Sources/SidebarDragDrop.swift new file mode 100644 index 00000000000..5413e57ab83 --- /dev/null +++ b/Sources/SidebarDragDrop.swift @@ -0,0 +1,926 @@ +// Sidebar drag-and-drop domain, extracted from ContentView.swift (nuclear-review #94.4). +// Pure move: DragOverlayRoutingPolicy, drag payload enums, drop delegates, +// drop edge/indicator/planner, auto-scroll planner, and drag lifecycle/failsafe +// types, consolidated from several non-contiguous locations in ContentView.swift. +// +// Access-level widening: SidebarDragFailsafeMonitor and SidebarExternalDropOverlay +// were file-private to the old ContentView.swift; widened to internal (dropped +// the `private` modifier) because VerticalTabsSidebar and ContentView, which stay +// in ContentView.swift, still construct/reference them. No other behavior change. + +import AppKit +import Bonsplit +import Combine +import SwiftUI +import UniformTypeIdentifiers + +enum DragOverlayRoutingPolicy { + static let bonsplitTabTransferType = NSPasteboard.PasteboardType("com.splittabbar.tabtransfer") + static let sidebarTabReorderType = NSPasteboard.PasteboardType(SidebarTabDragPayload.typeIdentifier) + + static func hasBonsplitTabTransfer(_ pasteboardTypes: [NSPasteboard.PasteboardType]?) -> Bool { + guard let pasteboardTypes else { return false } + return pasteboardTypes.contains(bonsplitTabTransferType) + } + + static func hasSidebarTabReorder(_ pasteboardTypes: [NSPasteboard.PasteboardType]?) -> Bool { + guard let pasteboardTypes else { return false } + return pasteboardTypes.contains(sidebarTabReorderType) + } + + static func hasFileURL(_ pasteboardTypes: [NSPasteboard.PasteboardType]?) -> Bool { + guard let pasteboardTypes else { return false } + return pasteboardTypes.contains(.fileURL) + } + + static func shouldCaptureFileDropDestination( + pasteboardTypes: [NSPasteboard.PasteboardType]?, + hasLocalDraggingSource: Bool + ) -> Bool { + // Local file drags (e.g. in-app draggable folder views) are valid drop + // inputs; rely on explicit non-file drag types below to avoid hijacking + // Bonsplit/sidebar drags. + _ = hasLocalDraggingSource + guard hasFileURL(pasteboardTypes) else { return false } + + // Prefer explicit non-file drag types so stale fileURL entries cannot hijack + // Bonsplit tab drags or sidebar tab reorder drags. + if hasBonsplitTabTransfer(pasteboardTypes) { return false } + if hasSidebarTabReorder(pasteboardTypes) { return false } + return true + } + + static func shouldCaptureFileDropDestination( + pasteboardTypes: [NSPasteboard.PasteboardType]? + ) -> Bool { + shouldCaptureFileDropDestination( + pasteboardTypes: pasteboardTypes, + hasLocalDraggingSource: false + ) + } + + static func shouldCaptureFileDropOverlay( + pasteboardTypes: [NSPasteboard.PasteboardType]?, + eventType: NSEvent.EventType? + ) -> Bool { + guard shouldCaptureFileDropDestination(pasteboardTypes: pasteboardTypes) else { return false } + guard isDragMouseEvent(eventType) else { return false } + return true + } + + static func shouldCaptureSidebarExternalOverlay( + hasSidebarDragState: Bool, + pasteboardTypes: [NSPasteboard.PasteboardType]? + ) -> Bool { + guard hasSidebarDragState else { return false } + return hasSidebarTabReorder(pasteboardTypes) + } + + static func shouldCaptureSidebarExternalOverlay( + draggedTabId: UUID?, + pasteboardTypes: [NSPasteboard.PasteboardType]? + ) -> Bool { + shouldCaptureSidebarExternalOverlay( + hasSidebarDragState: draggedTabId != nil, + pasteboardTypes: pasteboardTypes + ) + } + + static func shouldPassThroughPortalHitTesting( + pasteboardTypes: [NSPasteboard.PasteboardType]?, + eventType: NSEvent.EventType? + ) -> Bool { + guard isPortalDragEvent(eventType) else { return false } + return hasBonsplitTabTransfer(pasteboardTypes) || hasSidebarTabReorder(pasteboardTypes) + } + + private static func isDragMouseEvent(_ eventType: NSEvent.EventType?) -> Bool { + eventType == .leftMouseDragged + || eventType == .rightMouseDragged + || eventType == .otherMouseDragged + } + + private static func isPortalDragEvent(_ eventType: NSEvent.EventType?) -> Bool { + // Restrict portal pass-through to explicit drag-motion events so stale + // NSPasteboard(name: .drag) types cannot hijack normal pointer input. + guard let eventType else { return false } + switch eventType { + case .leftMouseDragged, .rightMouseDragged, .otherMouseDragged: + return true + default: + return false + } + } +} + +enum SidebarDragLifecycleNotification { + static let stateDidChange = Notification.Name("programa.sidebarDragStateDidChange") + static let requestClear = Notification.Name("programa.sidebarDragRequestClear") + static let tabIdKey = "tabId" + static let reasonKey = "reason" + + static func postStateDidChange(tabId: UUID?, reason: String) { + var userInfo: [AnyHashable: Any] = [reasonKey: reason] + if let tabId { + userInfo[tabIdKey] = tabId + } + NotificationCenter.default.post( + name: stateDidChange, + object: nil, + userInfo: userInfo + ) + } + + static func postClearRequest(reason: String) { + NotificationCenter.default.post( + name: requestClear, + object: nil, + userInfo: [reasonKey: reason] + ) + } + + static func tabId(from notification: Notification) -> UUID? { + notification.userInfo?[tabIdKey] as? UUID + } + + static func reason(from notification: Notification) -> String { + notification.userInfo?[reasonKey] as? String ?? "unknown" + } +} + +enum SidebarOutsideDropResetPolicy { + static func shouldResetDrag(draggedTabId: UUID?, hasSidebarDragPayload: Bool) -> Bool { + draggedTabId != nil && hasSidebarDragPayload + } +} + +enum SidebarDragFailsafePolicy { + static let clearDelay: TimeInterval = 0.15 + + static func shouldRequestClear(isDragActive: Bool, isLeftMouseButtonDown: Bool) -> Bool { + isDragActive && !isLeftMouseButtonDown + } + + static func shouldRequestClearWhenMonitoringStarts(isLeftMouseButtonDown: Bool) -> Bool { + shouldRequestClear( + isDragActive: true, + isLeftMouseButtonDown: isLeftMouseButtonDown + ) + } + + static func shouldRequestClear(forMouseEventType eventType: NSEvent.EventType) -> Bool { + eventType == .leftMouseUp + } +} + +@MainActor +final class SidebarDragFailsafeMonitor: ObservableObject { + private static let escapeKeyCode: UInt16 = 53 + private var pendingClearWorkItem: DispatchWorkItem? + private var appResignObserver: NSObjectProtocol? + private var keyDownMonitor: Any? + private var localMouseMonitor: Any? + private var globalMouseMonitor: Any? + private var onRequestClear: ((String) -> Void)? + + func start(onRequestClear: @escaping (String) -> Void) { + self.onRequestClear = onRequestClear + if SidebarDragFailsafePolicy.shouldRequestClearWhenMonitoringStarts( + isLeftMouseButtonDown: CGEventSource.buttonState( + .combinedSessionState, + button: .left + ) + ) { + requestClearSoon(reason: "mouse_up_failsafe") + } + if appResignObserver == nil { + appResignObserver = NotificationCenter.default.addObserver( + forName: NSApplication.didResignActiveNotification, + object: nil, + queue: .main + ) { [weak self] _ in + Task { @MainActor [weak self] in + self?.requestClearSoon(reason: "app_resign_active") + } + } + } + if keyDownMonitor == nil { + keyDownMonitor = NSEvent.addLocalMonitorForEvents(matching: .keyDown) { [weak self] event in + if event.keyCode == Self.escapeKeyCode { + self?.requestClearSoon(reason: "escape_cancel") + } + return event + } + } + if localMouseMonitor == nil { + localMouseMonitor = NSEvent.addLocalMonitorForEvents(matching: .leftMouseUp) { [weak self] event in + if SidebarDragFailsafePolicy.shouldRequestClear(forMouseEventType: event.type) { + self?.requestClearSoon(reason: "mouse_up_failsafe") + } + return event + } + } + if globalMouseMonitor == nil { + globalMouseMonitor = NSEvent.addGlobalMonitorForEvents(matching: .leftMouseUp) { [weak self] event in + guard SidebarDragFailsafePolicy.shouldRequestClear(forMouseEventType: event.type) else { return } + Task { @MainActor [weak self] in + self?.requestClearSoon(reason: "mouse_up_failsafe") + } + } + } + } + + func stop() { + pendingClearWorkItem?.cancel() + pendingClearWorkItem = nil + if let appResignObserver { + NotificationCenter.default.removeObserver(appResignObserver) + self.appResignObserver = nil + } + if let keyDownMonitor { + NSEvent.removeMonitor(keyDownMonitor) + self.keyDownMonitor = nil + } + if let localMouseMonitor { + NSEvent.removeMonitor(localMouseMonitor) + self.localMouseMonitor = nil + } + if let globalMouseMonitor { + NSEvent.removeMonitor(globalMouseMonitor) + self.globalMouseMonitor = nil + } + onRequestClear = nil + } + + private func requestClearSoon(reason: String) { + guard pendingClearWorkItem == nil else { return } +#if DEBUG + dlog("sidebar.dragFailsafe.schedule reason=\(reason)") +#endif + let workItem = DispatchWorkItem { [weak self] in +#if DEBUG + dlog("sidebar.dragFailsafe.fire reason=\(reason)") +#endif + self?.pendingClearWorkItem = nil + self?.onRequestClear?(reason) + } + pendingClearWorkItem = workItem + DispatchQueue.main.asyncAfter(deadline: .now() + SidebarDragFailsafePolicy.clearDelay, execute: workItem) + } +} + +struct SidebarExternalDropOverlay: View { + let draggedTabId: UUID? + + var body: some View { + let dragPasteboardTypes = NSPasteboard(name: .drag).types + let shouldCapture = DragOverlayRoutingPolicy.shouldCaptureSidebarExternalOverlay( + draggedTabId: draggedTabId, + pasteboardTypes: dragPasteboardTypes + ) + Group { + if shouldCapture { + Color.clear + .contentShape(Rectangle()) + .allowsHitTesting(true) + .onDrop( + of: SidebarTabDragPayload.dropContentTypes, + delegate: SidebarExternalDropDelegate(draggedTabId: draggedTabId) + ) + } else { + Color.clear + .contentShape(Rectangle()) + .allowsHitTesting(false) + } + } + } +} + +private struct SidebarExternalDropDelegate: DropDelegate { + let draggedTabId: UUID? + + func validateDrop(info: DropInfo) -> Bool { + let hasSidebarPayload = info.hasItemsConforming(to: [SidebarTabDragPayload.typeIdentifier]) + let shouldReset = SidebarOutsideDropResetPolicy.shouldResetDrag( + draggedTabId: draggedTabId, + hasSidebarDragPayload: hasSidebarPayload + ) +#if DEBUG + dlog( + "sidebar.dropOutside.validate tab=\(debugShortSidebarTabId(draggedTabId)) " + + "hasType=\(hasSidebarPayload) allowed=\(shouldReset)" + ) +#endif + return shouldReset + } + + func dropEntered(info: DropInfo) { +#if DEBUG + dlog("sidebar.dropOutside.entered tab=\(debugShortSidebarTabId(draggedTabId))") +#endif + } + + func dropExited(info: DropInfo) { +#if DEBUG + dlog("sidebar.dropOutside.exited tab=\(debugShortSidebarTabId(draggedTabId))") +#endif + } + + func dropUpdated(info: DropInfo) -> DropProposal? { + guard validateDrop(info: info) else { return nil } +#if DEBUG + dlog("sidebar.dropOutside.updated tab=\(debugShortSidebarTabId(draggedTabId)) op=move") +#endif + // Explicit move proposal avoids AppKit showing a copy (+) cursor. + return DropProposal(operation: .move) + } + + func performDrop(info: DropInfo) -> Bool { + guard validateDrop(info: info) else { return false } +#if DEBUG + dlog("sidebar.dropOutside.perform tab=\(debugShortSidebarTabId(draggedTabId))") +#endif + SidebarDragLifecycleNotification.postClearRequest(reason: "outside_sidebar_drop") + return true + } + + private func debugShortSidebarTabId(_ id: UUID?) -> String { + guard let id else { return "nil" } + return String(id.uuidString.prefix(5)) + } +} + +@MainActor + +enum SidebarDropEdge: Equatable { + case top + case bottom +} + +struct SidebarDropIndicator: Equatable { + let tabId: UUID? + let edge: SidebarDropEdge +} + +enum SidebarDropPlanner { + static func indicator( + draggedTabId: UUID?, + targetTabId: UUID?, + tabIds: [UUID], + pinnedTabIds: Set, + pointerY: CGFloat? = nil, + targetHeight: CGFloat? = nil + ) -> SidebarDropIndicator? { + guard tabIds.count > 1, let draggedTabId else { return nil } + guard let fromIndex = tabIds.firstIndex(of: draggedTabId) else { return nil } + + let insertionPosition: Int + if let targetTabId { + guard let targetTabIndex = tabIds.firstIndex(of: targetTabId) else { return nil } + let edge: SidebarDropEdge + if let pointerY, let targetHeight { + edge = edgeForPointer(locationY: pointerY, targetHeight: targetHeight) + } else { + edge = preferredEdge(fromIndex: fromIndex, targetTabId: targetTabId, tabIds: tabIds) + } + insertionPosition = (edge == .bottom) ? targetTabIndex + 1 : targetTabIndex + } else { + insertionPosition = tabIds.count + } + + let legalInsertionPosition = legalInsertionPosition( + draggedTabId: draggedTabId, + proposedInsertionPosition: insertionPosition, + tabIds: tabIds, + pinnedTabIds: pinnedTabIds + ) + let legalTargetIndex = resolvedTargetIndex( + from: fromIndex, + insertionPosition: legalInsertionPosition, + totalCount: tabIds.count + ) + guard legalTargetIndex != fromIndex else { return nil } + return indicatorForInsertionPosition(legalInsertionPosition, tabIds: tabIds) + } + + static func targetIndex( + draggedTabId: UUID, + targetTabId: UUID?, + indicator: SidebarDropIndicator?, + tabIds: [UUID], + pinnedTabIds: Set + ) -> Int? { + guard let fromIndex = tabIds.firstIndex(of: draggedTabId) else { return nil } + + let insertionPosition: Int + if let indicator, let indicatorInsertion = insertionPositionForIndicator(indicator, tabIds: tabIds) { + insertionPosition = indicatorInsertion + } else if let targetTabId { + guard let targetTabIndex = tabIds.firstIndex(of: targetTabId) else { return nil } + let edge = (indicator?.tabId == targetTabId) + ? (indicator?.edge ?? preferredEdge(fromIndex: fromIndex, targetTabId: targetTabId, tabIds: tabIds)) + : preferredEdge(fromIndex: fromIndex, targetTabId: targetTabId, tabIds: tabIds) + insertionPosition = (edge == .bottom) ? targetTabIndex + 1 : targetTabIndex + } else { + insertionPosition = tabIds.count + } + + let legalInsertionPosition = legalInsertionPosition( + draggedTabId: draggedTabId, + proposedInsertionPosition: insertionPosition, + tabIds: tabIds, + pinnedTabIds: pinnedTabIds + ) + return resolvedTargetIndex(from: fromIndex, insertionPosition: legalInsertionPosition, totalCount: tabIds.count) + } + + private static func indicatorForInsertionPosition(_ insertionPosition: Int, tabIds: [UUID]) -> SidebarDropIndicator { + let clampedInsertion = max(0, min(insertionPosition, tabIds.count)) + if clampedInsertion >= tabIds.count { + return SidebarDropIndicator(tabId: nil, edge: .bottom) + } + return SidebarDropIndicator(tabId: tabIds[clampedInsertion], edge: .top) + } + + private static func insertionPositionForIndicator(_ indicator: SidebarDropIndicator, tabIds: [UUID]) -> Int? { + if let tabId = indicator.tabId { + guard let targetTabIndex = tabIds.firstIndex(of: tabId) else { return nil } + return indicator.edge == .bottom ? targetTabIndex + 1 : targetTabIndex + } + return tabIds.count + } + + private static func preferredEdge(fromIndex: Int, targetTabId: UUID, tabIds: [UUID]) -> SidebarDropEdge { + guard let targetIndex = tabIds.firstIndex(of: targetTabId) else { return .top } + return fromIndex < targetIndex ? .bottom : .top + } + + private static func legalInsertionPosition( + draggedTabId: UUID, + proposedInsertionPosition: Int, + tabIds: [UUID], + pinnedTabIds: Set + ) -> Int { + let clampedInsertion = max(0, min(proposedInsertionPosition, tabIds.count)) + guard !pinnedTabIds.isEmpty else { return clampedInsertion } + + let pinnedCount = tabIds.reduce(into: 0) { count, tabId in + if pinnedTabIds.contains(tabId) { + count += 1 + } + } + guard pinnedCount > 0 else { return clampedInsertion } + + if pinnedTabIds.contains(draggedTabId) { + return min(clampedInsertion, pinnedCount) + } + return max(clampedInsertion, pinnedCount) + } + + static func edgeForPointer(locationY: CGFloat, targetHeight: CGFloat) -> SidebarDropEdge { + guard targetHeight > 0 else { return .top } + let clampedY = min(max(locationY, 0), targetHeight) + return clampedY < (targetHeight / 2) ? .top : .bottom + } + + private static func resolvedTargetIndex(from sourceIndex: Int, insertionPosition: Int, totalCount: Int) -> Int { + let clampedInsertion = max(0, min(insertionPosition, totalCount)) + let adjusted = clampedInsertion > sourceIndex ? clampedInsertion - 1 : clampedInsertion + return max(0, min(adjusted, max(0, totalCount - 1))) + } +} + +enum SidebarAutoScrollDirection: Equatable { + case up + case down +} + +struct SidebarAutoScrollPlan: Equatable { + let direction: SidebarAutoScrollDirection + let pointsPerTick: CGFloat +} + +enum SidebarDragAutoScrollPlanner { + static let edgeInset: CGFloat = 44 + static let minStep: CGFloat = 2 + static let maxStep: CGFloat = 12 + + static func plan( + distanceToTop: CGFloat, + distanceToBottom: CGFloat, + edgeInset: CGFloat = SidebarDragAutoScrollPlanner.edgeInset, + minStep: CGFloat = SidebarDragAutoScrollPlanner.minStep, + maxStep: CGFloat = SidebarDragAutoScrollPlanner.maxStep + ) -> SidebarAutoScrollPlan? { + guard edgeInset > 0, maxStep >= minStep else { return nil } + if distanceToTop <= edgeInset { + let normalized = max(0, min(1, (edgeInset - distanceToTop) / edgeInset)) + let step = minStep + ((maxStep - minStep) * normalized) + return SidebarAutoScrollPlan(direction: .up, pointsPerTick: step) + } + if distanceToBottom <= edgeInset { + let normalized = max(0, min(1, (edgeInset - distanceToBottom) / edgeInset)) + let step = minStep + ((maxStep - minStep) * normalized) + return SidebarAutoScrollPlan(direction: .down, pointsPerTick: step) + } + return nil + } +} + +@MainActor +final class SidebarDragAutoScrollController: ObservableObject { + private weak var scrollView: NSScrollView? + private var timer: Timer? + private var activePlan: SidebarAutoScrollPlan? + + func attach(scrollView: NSScrollView?) { + self.scrollView = scrollView + } + + func updateFromDragLocation() { + guard let scrollView else { + stop() + return + } + guard let plan = plan(for: scrollView) else { + stop() + return + } + activePlan = plan + startTimerIfNeeded() + } + + func stop() { + timer?.invalidate() + timer = nil + activePlan = nil + } + + private func startTimerIfNeeded() { + guard timer == nil else { return } + let timer = Timer.scheduledTimer(withTimeInterval: 1.0 / 60.0, repeats: true) { [weak self] _ in + Task { @MainActor [weak self] in + self?.tick() + } + } + self.timer = timer + RunLoop.main.add(timer, forMode: .eventTracking) + } + + private func tick() { + guard NSEvent.pressedMouseButtons != 0 else { + stop() + return + } + guard let scrollView else { + stop() + return + } + + // AppKit drag/drop autoscroll guidance recommends autoscroll(with:) + // when periodic drag updates are available; use it first. + if applyNativeAutoscroll(to: scrollView) { + activePlan = plan(for: scrollView) + if activePlan == nil { + stop() + } + return + } + + activePlan = self.plan(for: scrollView) + guard let plan = activePlan else { + stop() + return + } + _ = apply(plan: plan, to: scrollView) + } + + private func applyNativeAutoscroll(to scrollView: NSScrollView) -> Bool { + guard let event = NSApp.currentEvent else { return false } + switch event.type { + case .leftMouseDragged, .rightMouseDragged, .otherMouseDragged: + break + default: + return false + } + + let clipView = scrollView.contentView + let didScroll = clipView.autoscroll(with: event) + if didScroll { + scrollView.reflectScrolledClipView(clipView) + } + return didScroll + } + + private func distancesToEdges(mousePoint: CGPoint, viewportHeight: CGFloat, isFlipped: Bool) -> (top: CGFloat, bottom: CGFloat) { + if isFlipped { + return (top: mousePoint.y, bottom: viewportHeight - mousePoint.y) + } + return (top: viewportHeight - mousePoint.y, bottom: mousePoint.y) + } + + private func planForMousePoint(_ mousePoint: CGPoint, in clipView: NSClipView) -> SidebarAutoScrollPlan? { + let viewportHeight = clipView.bounds.height + guard viewportHeight > 0 else { return nil } + + let distances = distancesToEdges(mousePoint: mousePoint, viewportHeight: viewportHeight, isFlipped: clipView.isFlipped) + return SidebarDragAutoScrollPlanner.plan(distanceToTop: distances.top, distanceToBottom: distances.bottom) + } + + private func mousePoint(in clipView: NSClipView) -> CGPoint { + let mouseInWindow = clipView.window?.convertPoint(fromScreen: NSEvent.mouseLocation) ?? .zero + return clipView.convert(mouseInWindow, from: nil) + } + + private func currentPlan(for scrollView: NSScrollView) -> SidebarAutoScrollPlan? { + let clipView = scrollView.contentView + let mouse = mousePoint(in: clipView) + return planForMousePoint(mouse, in: clipView) + } + + private func plan(for scrollView: NSScrollView) -> SidebarAutoScrollPlan? { + currentPlan(for: scrollView) + } + + private func apply(plan: SidebarAutoScrollPlan, to scrollView: NSScrollView) -> Bool { + guard let documentView = scrollView.documentView else { return false } + let clipView = scrollView.contentView + let maxOriginY = max(0, documentView.bounds.height - clipView.bounds.height) + guard maxOriginY > 0 else { return false } + + let directionMultiplier: CGFloat = (plan.direction == .down) ? 1 : -1 + let flippedMultiplier: CGFloat = documentView.isFlipped ? 1 : -1 + let delta = directionMultiplier * flippedMultiplier * plan.pointsPerTick + let currentY = clipView.bounds.origin.y + let targetY = min(max(currentY + delta, 0), maxOriginY) + guard abs(targetY - currentY) > 0.01 else { return false } + + clipView.scroll(to: CGPoint(x: clipView.bounds.origin.x, y: targetY)) + scrollView.reflectScrolledClipView(clipView) + return true + } +} + +enum SidebarTabDragPayload { + static let typeIdentifier = "com.darkroom.programa.sidebar-tab-reorder" + static let dropContentType = UTType(exportedAs: typeIdentifier) + static let dropContentTypes: [UTType] = [dropContentType] + private static let prefix = "programa.sidebar-tab." + + static func provider(for tabId: UUID) -> NSItemProvider { + let provider = NSItemProvider() + let payload = "\(prefix)\(tabId.uuidString)" + provider.registerDataRepresentation(forTypeIdentifier: typeIdentifier, visibility: .ownProcess) { completion in + completion(payload.data(using: .utf8), nil) + return nil + } + return provider + } +} + +enum BonsplitTabDragPayload { + static let typeIdentifier = "com.splittabbar.tabtransfer" + static let dropContentType = UTType(exportedAs: typeIdentifier) + static let dropContentTypes: [UTType] = [dropContentType] + private static let currentProcessId = Int32(ProcessInfo.processInfo.processIdentifier) + + struct Transfer: Decodable { + struct TabInfo: Decodable { + let id: UUID + } + + let tab: TabInfo + let sourcePaneId: UUID + let sourceProcessId: Int32 + + private enum CodingKeys: String, CodingKey { + case tab + case sourcePaneId + case sourceProcessId + } + + init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + self.tab = try container.decode(TabInfo.self, forKey: .tab) + self.sourcePaneId = try container.decode(UUID.self, forKey: .sourcePaneId) + // Legacy payloads won't include this field. Treat as foreign process. + self.sourceProcessId = try container.decodeIfPresent(Int32.self, forKey: .sourceProcessId) ?? -1 + } + } + + private static func isCurrentProcessTransfer(_ transfer: Transfer) -> Bool { + transfer.sourceProcessId == currentProcessId + } + + static func currentTransfer() -> Transfer? { + let pasteboard = NSPasteboard(name: .drag) + let type = NSPasteboard.PasteboardType(typeIdentifier) + + if let data = pasteboard.data(forType: type), + let transfer = try? JSONDecoder().decode(Transfer.self, from: data), + isCurrentProcessTransfer(transfer) { + return transfer + } + + if let raw = pasteboard.string(forType: type), + let data = raw.data(using: .utf8), + let transfer = try? JSONDecoder().decode(Transfer.self, from: data), + isCurrentProcessTransfer(transfer) { + return transfer + } + + return nil + } +} + +struct SidebarBonsplitTabDropDelegate: DropDelegate { + let targetWorkspaceId: UUID + let tabManager: TabManager + @Binding var selectedTabIds: Set + @Binding var lastSidebarSelectionIndex: Int? + + func validateDrop(info: DropInfo) -> Bool { + guard info.hasItemsConforming(to: [BonsplitTabDragPayload.typeIdentifier]) else { return false } + return BonsplitTabDragPayload.currentTransfer() != nil + } + + func dropUpdated(info: DropInfo) -> DropProposal? { + guard validateDrop(info: info) else { return nil } + return DropProposal(operation: .move) + } + + func performDrop(info: DropInfo) -> Bool { + guard validateDrop(info: info), + let transfer = BonsplitTabDragPayload.currentTransfer(), + let app = AppDelegate.shared else { + return false + } + + if let source = app.locateBonsplitSurface(tabId: transfer.tab.id), + source.workspaceId == targetWorkspaceId { + syncSidebarSelection() + return true + } + + guard app.moveBonsplitTab( + tabId: transfer.tab.id, + toWorkspace: targetWorkspaceId, + focus: true, + focusWindow: true + ) else { + return false + } + + selectedTabIds = [targetWorkspaceId] + syncSidebarSelection() + return true + } + + private func syncSidebarSelection() { + if let selectedId = tabManager.selectedTabId { + lastSidebarSelectionIndex = tabManager.tabs.firstIndex { $0.id == selectedId } + } else { + lastSidebarSelectionIndex = nil + } + } +} + +struct SidebarTabDropDelegate: DropDelegate { + let targetTabId: UUID? + let tabManager: TabManager + @Binding var draggedTabId: UUID? + @Binding var selectedTabIds: Set + @Binding var lastSidebarSelectionIndex: Int? + let targetRowHeight: CGFloat? + let dragAutoScrollController: SidebarDragAutoScrollController + @Binding var dropIndicator: SidebarDropIndicator? + + func validateDrop(info: DropInfo) -> Bool { + let hasType = info.hasItemsConforming(to: [SidebarTabDragPayload.typeIdentifier]) + let hasDrag = draggedTabId != nil + #if DEBUG + dlog("sidebar.validateDrop target=\(targetTabId?.uuidString.prefix(5) ?? "end") hasType=\(hasType) hasDrag=\(hasDrag)") + #endif + return hasType && hasDrag + } + + func dropEntered(info: DropInfo) { + #if DEBUG + dlog("sidebar.dropEntered target=\(targetTabId?.uuidString.prefix(5) ?? "end")") + #endif + dragAutoScrollController.updateFromDragLocation() + updateDropIndicator(for: info) + } + + func dropExited(info: DropInfo) { +#if DEBUG + dlog("sidebar.dropExited target=\(targetTabId?.uuidString.prefix(5) ?? "end")") +#endif + if dropIndicator?.tabId == targetTabId { + dropIndicator = nil + } + } + + func dropUpdated(info: DropInfo) -> DropProposal? { + dragAutoScrollController.updateFromDragLocation() + updateDropIndicator(for: info) +#if DEBUG + dlog( + "sidebar.dropUpdated target=\(targetTabId?.uuidString.prefix(5) ?? "end") " + + "indicator=\(debugIndicator(dropIndicator))" + ) +#endif + return DropProposal(operation: .move) + } + + func performDrop(info: DropInfo) -> Bool { + defer { + draggedTabId = nil + dropIndicator = nil + dragAutoScrollController.stop() + } + #if DEBUG + dlog("sidebar.drop target=\(targetTabId?.uuidString.prefix(5) ?? "end")") + #endif + guard let draggedTabId else { +#if DEBUG + dlog("sidebar.drop.abort reason=missingDraggedTab") +#endif + return false + } + guard let fromIndex = tabManager.tabs.firstIndex(where: { $0.id == draggedTabId }) else { +#if DEBUG + dlog("sidebar.drop.abort reason=draggedTabMissing tab=\(draggedTabId.uuidString.prefix(5))") +#endif + return false + } + let tabIds = tabManager.tabs.map(\.id) + guard let targetIndex = SidebarDropPlanner.targetIndex( + draggedTabId: draggedTabId, + targetTabId: targetTabId, + indicator: dropIndicator, + tabIds: tabIds, + pinnedTabIds: Set(tabManager.tabs.filter(\.isPinned).map(\.id)) + ) else { +#if DEBUG + dlog( + "sidebar.drop.abort reason=noTargetIndex tab=\(draggedTabId.uuidString.prefix(5)) " + + "target=\(targetTabId?.uuidString.prefix(5) ?? "end") indicator=\(debugIndicator(dropIndicator))" + ) +#endif + return false + } + + guard fromIndex != targetIndex else { +#if DEBUG + dlog("sidebar.drop.noop from=\(fromIndex) to=\(targetIndex)") +#endif + syncSidebarSelection() + return true + } + +#if DEBUG + dlog("sidebar.drop.commit tab=\(draggedTabId.uuidString.prefix(5)) from=\(fromIndex) to=\(targetIndex)") +#endif + _ = tabManager.reorderWorkspace(tabId: draggedTabId, toIndex: targetIndex) + if let selectedId = tabManager.selectedTabId { + selectedTabIds = [selectedId] + syncSidebarSelection(preferredSelectedTabId: selectedId) + } else { + selectedTabIds = [] + syncSidebarSelection() + } + return true + } + + private func updateDropIndicator(for info: DropInfo) { + let tabIds = tabManager.tabs.map(\.id) + let pinnedTabIds = Set(tabManager.tabs.filter(\.isPinned).map(\.id)) + let nextIndicator = SidebarDropPlanner.indicator( + draggedTabId: draggedTabId, + targetTabId: targetTabId, + tabIds: tabIds, + pinnedTabIds: pinnedTabIds, + pointerY: targetTabId == nil ? nil : info.location.y, + targetHeight: targetRowHeight + ) + guard dropIndicator != nextIndicator else { return } + dropIndicator = nextIndicator + } + + private func syncSidebarSelection(preferredSelectedTabId: UUID? = nil) { + let selectedId = preferredSelectedTabId ?? tabManager.selectedTabId + if let selectedId { + lastSidebarSelectionIndex = tabManager.tabs.firstIndex { $0.id == selectedId } + } else { + lastSidebarSelectionIndex = nil + } + } + + private func debugIndicator(_ indicator: SidebarDropIndicator?) -> String { + guard let indicator else { return "nil" } + let tabText = indicator.tabId.map { String($0.uuidString.prefix(5)) } ?? "end" + return "\(tabText):\(indicator.edge == .top ? "top" : "bottom")" + } +} + diff --git a/Sources/SidebarVisuals.swift b/Sources/SidebarVisuals.swift new file mode 100644 index 00000000000..7ce7bd54a65 --- /dev/null +++ b/Sources/SidebarVisuals.swift @@ -0,0 +1,1533 @@ +// Sidebar visual chrome, extracted from ContentView.swift (nuclear-review #94.5). +// Pure move: sidebar footer/help-menu/scrim/blur/material/tint/preset enums, +// visual-effect/backdrop views, and the NSColor extension, consolidated from two +// non-contiguous locations in ContentView.swift. +// +// Access-level widening: SidebarFooter, SidebarTopScrim, SidebarScrollViewResolver, +// SidebarEmptyArea, ClearScrollBackground, DraggableFolderIcon, +// TitlebarLeadingInsetReader, SidebarTrailingBorder, and SidebarBackdrop were +// file-private to the old ContentView.swift; widened to internal (dropped the +// `private` modifier) because VerticalTabsSidebar and ContentView, which stay in +// ContentView.swift, still construct/reference them. No other behavior change. + +import AppKit +import Bonsplit +import SwiftUI + +struct SidebarFooter: View { + @ObservedObject var updateViewModel: UpdateViewModel + let onSendFeedback: () -> Void + + var body: some View { +#if DEBUG + SidebarDevFooter(updateViewModel: updateViewModel, onSendFeedback: onSendFeedback) +#else + SidebarFooterButtons(updateViewModel: updateViewModel, onSendFeedback: onSendFeedback) + .padding(.leading, 6) + .padding(.trailing, 10) + .padding(.bottom, 6) +#endif + } +} + +private struct SidebarFooterButtons: View { + @ObservedObject var updateViewModel: UpdateViewModel + let onSendFeedback: () -> Void + + var body: some View { + HStack(spacing: 4) { + SidebarHelpMenuButton(onSendFeedback: onSendFeedback) + UpdatePill(model: updateViewModel) + } + .frame(maxWidth: .infinity, alignment: .leading) + } +} + +private enum SidebarHelpMenuAction { + case importBrowserData + case keyboardShortcuts + case docs + case changelog + case github + case githubIssues + case discord + case checkForUpdates + case sendFeedback + case welcome +} + +private struct SidebarHelpMenuButton: View { + private let docsURL = URL(string: "https://github.com/darkroomengineering/programa/tree/main/docs") + private let changelogURL = URL(string: "https://github.com/darkroomengineering/programa/blob/main/CHANGELOG.md") + private let githubURL = URL(string: "https://github.com/darkroomengineering/programa") + private let githubIssuesURL = URL(string: "https://github.com/darkroomengineering/programa/issues") + private let discordURL = URL(string: "https://discord.gg/xsgFEVrWCZ") + private let helpTitle = String(localized: "sidebar.help.button", defaultValue: "Help") + private let buttonSize: CGFloat = 22 + private let iconSize: CGFloat = 11 + @ObservedObject private var keyboardShortcutSettingsObserver = KeyboardShortcutSettingsObserver.shared + + let onSendFeedback: () -> Void + + @State private var isPopoverPresented = false + + private var sendFeedbackShortcutHint: String { + let _ = keyboardShortcutSettingsObserver.revision + return KeyboardShortcutSettings.shortcut(for: .sendFeedback).displayString + } + + var body: some View { + Button { + isPopoverPresented.toggle() + } label: { + Image(systemName: "questionmark.circle") + .symbolRenderingMode(.monochrome) + .font(.system(size: iconSize, weight: .medium)) + .foregroundStyle(Color(nsColor: .secondaryLabelColor)) + .frame(width: buttonSize, height: buttonSize, alignment: .center) + } + .buttonStyle(SidebarFooterIconButtonStyle()) + .frame(width: buttonSize, height: buttonSize, alignment: .center) + .background(ArrowlessPopoverAnchor( + isPresented: $isPopoverPresented, + preferredEdge: .maxY, + detachedGap: 4 + ) { + helpPopover + }) + .accessibilityElement(children: .ignore) + .safeHelp(helpTitle) + .accessibilityLabel(helpTitle) + .accessibilityIdentifier("SidebarHelpMenuButton") + } + + private var helpPopover: some View { + VStack(alignment: .leading, spacing: 2) { + helpOptionButton( + title: String(localized: "sidebar.help.welcome", defaultValue: "Welcome to Programa!"), + action: .welcome, + accessibilityIdentifier: "SidebarHelpMenuOptionWelcome", + isExternalLink: false + ) + helpOptionButton( + title: String(localized: "sidebar.help.sendFeedback", defaultValue: "Send Feedback"), + action: .sendFeedback, + accessibilityIdentifier: "SidebarHelpMenuOptionSendFeedback", + isExternalLink: false, + shortcutHint: sendFeedbackShortcutHint, + trailingSystemImage: "bubble.left.and.text.bubble.right" + ) + helpOptionButton( + title: String(localized: "settings.section.keyboardShortcuts", defaultValue: "Keyboard Shortcuts"), + action: .keyboardShortcuts, + accessibilityIdentifier: "SidebarHelpMenuOptionKeyboardShortcuts", + isExternalLink: false + ) + helpOptionButton( + title: String(localized: "menu.view.importFromBrowser", defaultValue: "Import Browser Data…"), + action: .importBrowserData, + accessibilityIdentifier: "SidebarHelpMenuOptionImportBrowserData", + isExternalLink: false + ) + if docsURL != nil { + helpOptionButton( + title: String(localized: "about.docs", defaultValue: "Docs"), + action: .docs, + accessibilityIdentifier: "SidebarHelpMenuOptionDocs", + isExternalLink: true + ) + } + if changelogURL != nil { + helpOptionButton( + title: String(localized: "sidebar.help.changelog", defaultValue: "Changelog"), + action: .changelog, + accessibilityIdentifier: "SidebarHelpMenuOptionChangelog", + isExternalLink: true + ) + } + if githubURL != nil { + helpOptionButton( + title: String(localized: "about.github", defaultValue: "GitHub"), + action: .github, + accessibilityIdentifier: "SidebarHelpMenuOptionGitHub", + isExternalLink: true + ) + } + if githubIssuesURL != nil { + helpOptionButton( + title: String(localized: "sidebar.help.githubIssues", defaultValue: "GitHub Issues"), + action: .githubIssues, + accessibilityIdentifier: "SidebarHelpMenuOptionGitHubIssues", + isExternalLink: true + ) + } + if discordURL != nil { + helpOptionButton( + title: String(localized: "sidebar.help.discord", defaultValue: "Discord"), + action: .discord, + accessibilityIdentifier: "SidebarHelpMenuOptionDiscord", + isExternalLink: true + ) + } + helpOptionButton( + title: String(localized: "command.checkForUpdates.title", defaultValue: "Check for Updates"), + action: .checkForUpdates, + accessibilityIdentifier: "SidebarHelpMenuOptionCheckForUpdates", + isExternalLink: false + ) + } + .padding(8) + .frame(minWidth: 200) + } + + private func helpOptionButton( + title: String, + action: SidebarHelpMenuAction, + accessibilityIdentifier: String, + isExternalLink: Bool, + shortcutHint: String? = nil, + trailingSystemImage: String? = nil + ) -> some View { + Button { + isPopoverPresented = false + perform(action) + } label: { + HStack(spacing: 8) { + Text(title) + .font(.system(size: 12)) + Spacer(minLength: 0) + if let shortcutHint { + helpOptionShortcutHint(text: shortcutHint) + } + if let trailingSystemImage { + helpOptionTrailingIcon(systemName: trailingSystemImage) + } + if isExternalLink { + helpOptionTrailingIcon(systemName: "arrow.up.right", size: 8) + } + } + .padding(.horizontal, 8) + .frame(height: 24) + .contentShape(Rectangle()) + } + .buttonStyle(.plain) + .accessibilityIdentifier(accessibilityIdentifier) + } + + private func helpOptionShortcutHint(text: String) -> some View { + Text(text) + .lineLimit(1) + .fixedSize(horizontal: true, vertical: false) + .font(.system(size: 10, weight: .regular, design: .rounded)) + .monospacedDigit() + .foregroundStyle(Color(nsColor: .secondaryLabelColor)) + } + + private func helpOptionTrailingIcon(systemName: String, size: CGFloat = 13) -> some View { + Image(systemName: systemName) + .resizable() + .scaledToFit() + .frame(width: size, height: size) + .foregroundStyle(Color(nsColor: .secondaryLabelColor)) + } + + private func perform(_ action: SidebarHelpMenuAction) { + switch action { + case .importBrowserData: + isPopoverPresented = false + DispatchQueue.main.async { + BrowserDataImportCoordinator.shared.presentImportDialog() + } + case .keyboardShortcuts: + isPopoverPresented = false + DispatchQueue.main.asyncAfter(deadline: .now() + 0.12) { + Task { @MainActor in + if let appDelegate = AppDelegate.shared { + appDelegate.openPreferencesWindow( + debugSource: "sidebarHelpMenu.keyboardShortcuts", + navigationTarget: .keyboardShortcuts + ) + } else { + AppDelegate.presentPreferencesWindow(navigationTarget: .keyboardShortcuts) + } + } + } + case .docs: + guard let docsURL else { return } + NSWorkspace.shared.open(docsURL) + case .changelog: + guard let changelogURL else { return } + NSWorkspace.shared.open(changelogURL) + case .github: + guard let githubURL else { return } + NSWorkspace.shared.open(githubURL) + case .githubIssues: + guard let githubIssuesURL else { return } + NSWorkspace.shared.open(githubIssuesURL) + case .discord: + guard let discordURL else { return } + NSWorkspace.shared.open(discordURL) + case .checkForUpdates: + Task { @MainActor in + AppDelegate.shared?.checkForUpdates(nil) + } + case .sendFeedback: + isPopoverPresented = false + onSendFeedback() + case .welcome: + isPopoverPresented = false + Task { @MainActor in + if let appDelegate = AppDelegate.shared { + appDelegate.openWelcomeWorkspace() + } + } + } + } + +} + +private struct ArrowlessPopoverAnchor: NSViewRepresentable { + @Binding var isPresented: Bool + let preferredEdge: NSRectEdge + let detachedGap: CGFloat + @ViewBuilder let content: () -> PopoverContent + + func makeNSView(context: Context) -> NSView { + let view = NSView() + context.coordinator.anchorView = view + return view + } + + func updateNSView(_ nsView: NSView, context: Context) { + context.coordinator.anchorView = nsView + context.coordinator.updateRootView(AnyView(content())) + + if isPresented { + context.coordinator.present( + preferredEdge: preferredEdge, + detachedGap: detachedGap + ) + } else { + context.coordinator.dismiss() + } + } + + func makeCoordinator() -> Coordinator { + Coordinator(isPresented: $isPresented) + } + + final class Coordinator: NSObject, NSPopoverDelegate { + @Binding var isPresented: Bool + + weak var anchorView: NSView? + private let hostingController = NSHostingController(rootView: AnyView(EmptyView())) + private var popover: NSPopover? + + init(isPresented: Binding) { + _isPresented = isPresented + } + + func updateRootView(_ rootView: AnyView) { + hostingController.rootView = AnyView(rootView.fixedSize()) + hostingController.view.invalidateIntrinsicContentSize() + hostingController.view.layoutSubtreeIfNeeded() + } + + func present(preferredEdge: NSRectEdge, detachedGap: CGFloat) { + guard let anchorView else { + isPresented = false + dismiss() + return + } + + let popover = popover ?? makePopover() + if popover.isShown { + return + } + + hostingController.view.invalidateIntrinsicContentSize() + hostingController.view.layoutSubtreeIfNeeded() + let fittingSize = hostingController.view.fittingSize + if fittingSize.width > 0, fittingSize.height > 0 { + popover.contentSize = NSSize( + width: ceil(fittingSize.width), + height: ceil(fittingSize.height) + ) + } + + popover.show( + relativeTo: positioningRect( + for: anchorView.bounds, + preferredEdge: preferredEdge, + detachedGap: detachedGap + ), + of: anchorView, + preferredEdge: preferredEdge + ) + } + + func dismiss() { + popover?.performClose(nil) + popover = nil + } + + func popoverDidClose(_ notification: Notification) { + popover = nil + if isPresented { + isPresented = false + } + } + + private func makePopover() -> NSPopover { + let popover = NSPopover() + popover.behavior = .semitransient + popover.animates = true + popover.setValue(true, forKeyPath: "shouldHideAnchor") + popover.contentViewController = hostingController + popover.delegate = self + self.popover = popover + return popover + } + + private func positioningRect( + for bounds: CGRect, + preferredEdge: NSRectEdge, + detachedGap: CGFloat + ) -> CGRect { + let hiddenArrowInset: CGFloat = 13 + let compensation = max(hiddenArrowInset - detachedGap, 0) + + switch preferredEdge { + case .maxY: + return NSRect( + x: bounds.minX, + y: bounds.maxY - compensation, + width: bounds.width, + height: compensation + ) + case .minY: + return NSRect( + x: bounds.minX, + y: bounds.minY, + width: bounds.width, + height: compensation + ) + case .maxX: + return NSRect( + x: bounds.maxX - compensation, + y: bounds.minY, + width: compensation, + height: bounds.height + ) + case .minX: + return NSRect( + x: bounds.minX, + y: bounds.minY, + width: compensation, + height: bounds.height + ) + @unknown default: + return bounds + } + } + } +} + +private struct SidebarFooterIconButtonStyle: ButtonStyle { + func makeBody(configuration: Configuration) -> some View { + SidebarFooterIconButtonStyleBody(configuration: configuration) + } +} + +private struct SidebarFooterIconButtonStyleBody: View { + let configuration: SidebarFooterIconButtonStyle.Configuration + + @Environment(\.isEnabled) private var isEnabled + @State private var isHovered = false + + private var backgroundOpacity: Double { + guard isEnabled else { return 0.0 } + if configuration.isPressed { return 0.16 } + if isHovered { return 0.08 } + return 0.0 + } + + var body: some View { + configuration.label + .background( + RoundedRectangle(cornerRadius: 8, style: .continuous) + .fill(Color.primary.opacity(backgroundOpacity)) + ) + .onHover { hovering in + isHovered = hovering + } + .animation(.easeOut(duration: 0.12), value: isHovered) + .animation(.easeOut(duration: 0.08), value: configuration.isPressed) + } +} + +#if DEBUG +private struct SidebarDevFooter: View { + @ObservedObject var updateViewModel: UpdateViewModel + let onSendFeedback: () -> Void + @AppStorage(DevBuildBannerDebugSettings.sidebarBannerVisibleKey) + private var showSidebarDevBuildBanner = DevBuildBannerDebugSettings.defaultShowSidebarBanner + + var body: some View { + VStack(alignment: .leading, spacing: 6) { + SidebarFooterButtons(updateViewModel: updateViewModel, onSendFeedback: onSendFeedback) + if showSidebarDevBuildBanner { + Text(String(localized: "debug.devBuildBanner.title", defaultValue: "THIS IS A DEV BUILD")) + .font(.system(size: 11, weight: .semibold)) + .foregroundColor(.red) + } + } + .padding(.leading, 6) + .padding(.trailing, 10) + .padding(.bottom, 6) + } +} +#endif + +struct SidebarTopScrim: View { + let height: CGFloat + + var body: some View { + SidebarTopBlurEffect() + .frame(height: height) + .mask( + LinearGradient( + colors: [ + Color.black.opacity(0.95), + Color.black.opacity(0.75), + Color.black.opacity(0.35), + Color.clear + ], + startPoint: .top, + endPoint: .bottom + ) + ) + } +} + +private struct SidebarTopBlurEffect: NSViewRepresentable { + func makeNSView(context: Context) -> NSVisualEffectView { + let view = NSVisualEffectView() + view.blendingMode = .withinWindow + view.material = .underWindowBackground + view.state = .active + view.isEmphasized = false + return view + } + + func updateNSView(_ nsView: NSVisualEffectView, context: Context) {} +} + +struct SidebarScrollViewResolver: NSViewRepresentable { + let onResolve: (NSScrollView?) -> Void + + func makeNSView(context: Context) -> SidebarScrollViewResolverView { + let view = SidebarScrollViewResolverView() + view.onResolve = onResolve + return view + } + + func updateNSView(_ nsView: SidebarScrollViewResolverView, context: Context) { + nsView.onResolve = onResolve + nsView.resolveScrollView() + } +} + +final class SidebarScrollViewResolverView: NSView { + var onResolve: ((NSScrollView?) -> Void)? + + override func viewDidMoveToSuperview() { + super.viewDidMoveToSuperview() + resolveScrollView() + } + + override func viewDidMoveToWindow() { + super.viewDidMoveToWindow() + resolveScrollView() + } + + func resolveScrollView() { + DispatchQueue.main.async { [weak self] in + guard let self else { return } + onResolve?(self.enclosingScrollView) + } + } +} + +struct SidebarEmptyArea: View { + @EnvironmentObject var tabManager: TabManager + let rowSpacing: CGFloat + @Binding var selection: SidebarSelection + @Binding var selectedTabIds: Set + @Binding var lastSidebarSelectionIndex: Int? + let dragAutoScrollController: SidebarDragAutoScrollController + @Binding var draggedTabId: UUID? + @Binding var dropIndicator: SidebarDropIndicator? + + var body: some View { + Color.clear + .contentShape(Rectangle()) + .frame(maxWidth: .infinity, maxHeight: .infinity) + .onTapGesture(count: 2) { + tabManager.addWorkspace(placementOverride: .end) + if let selectedId = tabManager.selectedTabId { + selectedTabIds = [selectedId] + lastSidebarSelectionIndex = tabManager.tabs.firstIndex { $0.id == selectedId } + } + selection = .tabs + } + .onDrop(of: SidebarTabDragPayload.dropContentTypes, delegate: SidebarTabDropDelegate( + targetTabId: nil, + tabManager: tabManager, + draggedTabId: $draggedTabId, + selectedTabIds: $selectedTabIds, + lastSidebarSelectionIndex: $lastSidebarSelectionIndex, + targetRowHeight: nil, + dragAutoScrollController: dragAutoScrollController, + dropIndicator: $dropIndicator + )) + .overlay(alignment: .top) { + if shouldShowTopDropIndicator { + Rectangle() + .fill(programaAccentColor()) + .frame(height: 2) + .padding(.horizontal, 8) + .offset(y: -(rowSpacing / 2)) + } + } + } + + private var shouldShowTopDropIndicator: Bool { + guard draggedTabId != nil, let indicator = dropIndicator else { return false } + if indicator.tabId == nil { + return true + } + guard indicator.edge == .bottom, let lastTabId = tabManager.tabs.last?.id else { return false } + return indicator.tabId == lastTabId + } +} + +enum SidebarPathFormatter { + static let homeDirectoryPath: String = FileManager.default.homeDirectoryForCurrentUser.path + + static func shortenedPath( + _ path: String, + homeDirectoryPath: String = Self.homeDirectoryPath + ) -> String { + let trimmed = path.trimmingCharacters(in: .whitespacesAndNewlines) + guard !trimmed.isEmpty else { return path } + if trimmed == homeDirectoryPath { + return "~" + } + if trimmed.hasPrefix(homeDirectoryPath + "/") { + return "~" + trimmed.dropFirst(homeDirectoryPath.count) + } + return trimmed + } +} + +enum SidebarWorkspaceShortcutHintMetrics { + private static let measurementFont = NSFont.systemFont(ofSize: 10, weight: .semibold) + private static let minimumSlotWidth: CGFloat = 28 + private static let horizontalPadding: CGFloat = 12 + private static let lock = NSLock() + private static var cachedHintWidths: [String: CGFloat] = [:] + #if DEBUG + private static var measurementCount = 0 + #endif + + static func slotWidth(label: String?, debugXOffset: Double) -> CGFloat { + guard let label else { return minimumSlotWidth } + let positiveDebugInset = max(0, CGFloat(ShortcutHintDebugSettings.clamped(debugXOffset))) + 2 + return max(minimumSlotWidth, hintWidth(for: label) + positiveDebugInset) + } + + static func hintWidth(for label: String) -> CGFloat { + lock.lock() + if let cached = cachedHintWidths[label] { + lock.unlock() + return cached + } + lock.unlock() + + let textWidth = (label as NSString).size(withAttributes: [.font: measurementFont]).width + let measuredWidth = ceil(textWidth) + horizontalPadding + + lock.lock() + cachedHintWidths[label] = measuredWidth + #if DEBUG + measurementCount += 1 + #endif + lock.unlock() + return measuredWidth + } + + #if DEBUG + static func resetCacheForTesting() { + lock.lock() + cachedHintWidths.removeAll() + measurementCount = 0 + lock.unlock() + } + + static func measurementCountForTesting() -> Int { + lock.lock() + let count = measurementCount + lock.unlock() + return count + } + #endif +} + +enum SidebarTrailingAccessoryWidthPolicy { + static let closeButtonWidth: CGFloat = 16 + + static func width( + canCloseWorkspace: Bool, + showsWorkspaceShortcutHint: Bool, + workspaceShortcutLabel: String?, + debugXOffset: Double + ) -> CGFloat { + if showsWorkspaceShortcutHint, let workspaceShortcutLabel { + return SidebarWorkspaceShortcutHintMetrics.slotWidth( + label: workspaceShortcutLabel, + debugXOffset: debugXOffset + ) + } + + return canCloseWorkspace ? closeButtonWidth : 0 + } +} + + +struct MiddleClickCapture: NSViewRepresentable { + let onMiddleClick: () -> Void + + func makeNSView(context: Context) -> MiddleClickCaptureView { + let view = MiddleClickCaptureView() + view.onMiddleClick = onMiddleClick + return view + } + + func updateNSView(_ nsView: MiddleClickCaptureView, context: Context) { + nsView.onMiddleClick = onMiddleClick + } +} + +final class MiddleClickCaptureView: NSView { + var onMiddleClick: (() -> Void)? + + override func hitTest(_ point: NSPoint) -> NSView? { + // Only intercept middle-click so left-click selection and right-click context menus + // continue to hit-test through to SwiftUI/AppKit normally. + guard let event = NSApp.currentEvent, + event.type == .otherMouseDown, + event.buttonNumber == 2 else { + return nil + } + return self + } + + override func otherMouseDown(with event: NSEvent) { + guard event.buttonNumber == 2 else { + super.otherMouseDown(with: event) + return + } + onMiddleClick?() + } +} + +enum SidebarSelection { + case tabs + case notifications +} + +struct ClearScrollBackground: ViewModifier { + func body(content: Content) -> some View { + content + .scrollContentBackground(.hidden) + .background(ScrollBackgroundClearer()) + } +} + +private struct ScrollBackgroundClearer: NSViewRepresentable { + func makeNSView(context: Context) -> NSView { + NSView() + } + + func updateNSView(_ nsView: NSView, context: Context) { + DispatchQueue.main.async { + guard let scrollView = findScrollView(startingAt: nsView) else { return } + // Clear all backgrounds and mark as non-opaque for transparency + scrollView.drawsBackground = false + scrollView.backgroundColor = .clear + scrollView.wantsLayer = true + scrollView.layer?.backgroundColor = NSColor.clear.cgColor + scrollView.layer?.isOpaque = false + + scrollView.contentView.drawsBackground = false + scrollView.contentView.backgroundColor = .clear + scrollView.contentView.wantsLayer = true + scrollView.contentView.layer?.backgroundColor = NSColor.clear.cgColor + scrollView.contentView.layer?.isOpaque = false + + if let docView = scrollView.documentView { + docView.wantsLayer = true + docView.layer?.backgroundColor = NSColor.clear.cgColor + docView.layer?.isOpaque = false + } + } + } + + private func findScrollView(startingAt view: NSView) -> NSScrollView? { + var current: NSView? = view + while let candidate = current { + if let scrollView = candidate as? NSScrollView { + return scrollView + } + current = candidate.superview + } + return nil + } +} + +struct DraggableFolderIcon: View { + let directory: String + + var body: some View { + DraggableFolderIconRepresentable(directory: directory) + .frame(width: 16, height: 16) + .safeHelp(String(localized: "sidebar.folderIcon.dragHint", defaultValue: "Drag to open in Finder or another app")) + .onTapGesture(count: 2) { + NSWorkspace.shared.selectFile(nil, inFileViewerRootedAtPath: directory) + } + } +} + +private struct DraggableFolderIconRepresentable: NSViewRepresentable { + let directory: String + + func makeNSView(context: Context) -> DraggableFolderNSView { + DraggableFolderNSView(directory: directory) + } + + func updateNSView(_ nsView: DraggableFolderNSView, context: Context) { + nsView.directory = directory + nsView.updateIcon() + } +} + +final class DraggableFolderNSView: NSView, NSDraggingSource { + private final class FolderIconImageView: NSImageView { + override var mouseDownCanMoveWindow: Bool { false } + } + + var directory: String + private var imageView: FolderIconImageView! + private var previousWindowMovableState: Bool? + private weak var suppressedWindow: NSWindow? + private var hasActiveDragSession = false + private var didArmWindowDragSuppression = false + + private func formatPoint(_ point: NSPoint) -> String { + String(format: "(%.1f,%.1f)", point.x, point.y) + } + + init(directory: String) { + self.directory = directory + super.init(frame: .zero) + setupImageView() + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + override var intrinsicContentSize: NSSize { + NSSize(width: 16, height: 16) + } + + override var mouseDownCanMoveWindow: Bool { false } + + private func setupImageView() { + imageView = FolderIconImageView() + imageView.imageScaling = .scaleProportionallyDown + imageView.translatesAutoresizingMaskIntoConstraints = false + addSubview(imageView) + NSLayoutConstraint.activate([ + imageView.leadingAnchor.constraint(equalTo: leadingAnchor), + imageView.trailingAnchor.constraint(equalTo: trailingAnchor), + imageView.topAnchor.constraint(equalTo: topAnchor), + imageView.bottomAnchor.constraint(equalTo: bottomAnchor), + imageView.widthAnchor.constraint(equalToConstant: 16), + imageView.heightAnchor.constraint(equalToConstant: 16), + ]) + updateIcon() + } + + func updateIcon() { + let icon = NSWorkspace.shared.icon(forFile: directory) + icon.size = NSSize(width: 16, height: 16) + imageView.image = icon + } + + func draggingSession(_ session: NSDraggingSession, sourceOperationMaskFor context: NSDraggingContext) -> NSDragOperation { + return context == .outsideApplication ? [.copy, .link] : .copy + } + + func draggingSession(_ session: NSDraggingSession, endedAt screenPoint: NSPoint, operation: NSDragOperation) { + hasActiveDragSession = false + restoreWindowMovableStateIfNeeded() + #if DEBUG + let nowMovable = window.map { String($0.isMovable) } ?? "nil" + let windowOrigin = window.map { formatPoint($0.frame.origin) } ?? "nil" + dlog("folder.dragEnd dir=\(directory) operation=\(operation.rawValue) screen=\(formatPoint(screenPoint)) nowMovable=\(nowMovable) windowOrigin=\(windowOrigin)") + #endif + } + + override func hitTest(_ point: NSPoint) -> NSView? { + guard bounds.contains(point) else { return nil } + let hit = super.hitTest(point) + #if DEBUG + let hitDesc = hit.map { String(describing: type(of: $0)) } ?? "nil" + let imageHit = (hit === imageView) + let wasMovable = previousWindowMovableState.map(String.init) ?? "nil" + let nowMovable = window.map { String($0.isMovable) } ?? "nil" + dlog("folder.hitTest point=\(formatPoint(point)) hit=\(hitDesc) imageViewHit=\(imageHit) returning=DraggableFolderNSView wasMovable=\(wasMovable) nowMovable=\(nowMovable)") + #endif + return self + } + + override func mouseDown(with event: NSEvent) { + maybeDisableWindowDraggingEarly(trigger: "mouseDown") + hasActiveDragSession = false + #if DEBUG + let localPoint = convert(event.locationInWindow, from: nil) + let responderDesc = window?.firstResponder.map { String(describing: type(of: $0)) } ?? "nil" + let wasMovable = previousWindowMovableState.map(String.init) ?? "nil" + let nowMovable = window.map { String($0.isMovable) } ?? "nil" + let windowOrigin = window.map { formatPoint($0.frame.origin) } ?? "nil" + dlog("folder.mouseDown dir=\(directory) point=\(formatPoint(localPoint)) firstResponder=\(responderDesc) wasMovable=\(wasMovable) nowMovable=\(nowMovable) windowOrigin=\(windowOrigin)") + #endif + let fileURL = URL(fileURLWithPath: directory) + let draggingItem = NSDraggingItem(pasteboardWriter: fileURL as NSURL) + + let iconImage = NSWorkspace.shared.icon(forFile: directory) + iconImage.size = NSSize(width: 32, height: 32) + draggingItem.setDraggingFrame(bounds, contents: iconImage) + + let session = beginDraggingSession(with: [draggingItem], event: event, source: self) + hasActiveDragSession = true + #if DEBUG + let itemCount = session.draggingPasteboard.pasteboardItems?.count ?? 0 + dlog("folder.dragStart dir=\(directory) pasteboardItems=\(itemCount)") + #endif + } + + override func mouseUp(with event: NSEvent) { + super.mouseUp(with: event) + // Always restore suppression on mouse-up; drag-session callbacks can be + // skipped for non-started drags, which would otherwise leave suppression stuck. + restoreWindowMovableStateIfNeeded() + } + + override func rightMouseDown(with event: NSEvent) { + let menu = buildPathMenu() + // Pop up menu at bottom-left of icon (like native proxy icon) + let menuLocation = NSPoint(x: 0, y: bounds.height) + menu.popUp(positioning: nil, at: menuLocation, in: self) + } + + private func buildPathMenu() -> NSMenu { + let menu = NSMenu() + let url = URL(fileURLWithPath: directory).standardized + var pathComponents: [URL] = [] + + // Build path from current directory up to root + var current = url + while current.path != "/" { + pathComponents.append(current) + current = current.deletingLastPathComponent() + } + pathComponents.append(URL(fileURLWithPath: "/")) + + // Add path components (current dir at top, root at bottom - matches native macOS) + for pathURL in pathComponents { + let icon = NSWorkspace.shared.icon(forFile: pathURL.path) + icon.size = NSSize(width: 16, height: 16) + + let displayName: String + if pathURL.path == "/" { + // Use the volume name for root + if let volumeName = try? URL(fileURLWithPath: "/").resourceValues(forKeys: [.volumeNameKey]).volumeName { + displayName = volumeName + } else { + displayName = String(localized: "sidebar.pathMenu.macintoshHD", defaultValue: "Macintosh HD") + } + } else { + displayName = FileManager.default.displayName(atPath: pathURL.path) + } + + let item = NSMenuItem(title: displayName, action: #selector(openPathComponent(_:)), keyEquivalent: "") + item.target = self + item.image = icon + item.representedObject = pathURL + menu.addItem(item) + } + + // Add computer name at the bottom (like native proxy icon) + let computerName = Host.current().localizedName ?? ProcessInfo.processInfo.hostName + let computerIcon = NSImage(named: NSImage.computerName) ?? NSImage() + computerIcon.size = NSSize(width: 16, height: 16) + + let computerItem = NSMenuItem(title: computerName, action: #selector(openComputer(_:)), keyEquivalent: "") + computerItem.target = self + computerItem.image = computerIcon + menu.addItem(computerItem) + + return menu + } + + @objc private func openPathComponent(_ sender: NSMenuItem) { + guard let url = sender.representedObject as? URL else { return } + NSWorkspace.shared.selectFile(nil, inFileViewerRootedAtPath: url.path) + } + + @objc private func openComputer(_ sender: NSMenuItem) { + // Open "Computer" view in Finder (shows all volumes) + NSWorkspace.shared.open(URL(fileURLWithPath: "/", isDirectory: true)) + } + + private func restoreWindowMovableStateIfNeeded() { + guard didArmWindowDragSuppression || previousWindowMovableState != nil else { return } + let targetWindow = suppressedWindow ?? window + let depthAfter = endWindowDragSuppression(window: targetWindow) + restoreWindowDragging(window: targetWindow, previousMovableState: previousWindowMovableState) + self.previousWindowMovableState = nil + self.suppressedWindow = nil + self.didArmWindowDragSuppression = false + #if DEBUG + let nowMovable = targetWindow.map { String($0.isMovable) } ?? "nil" + dlog("folder.dragSuppression restore depth=\(depthAfter) nowMovable=\(nowMovable)") + #endif + } + + private func maybeDisableWindowDraggingEarly(trigger: String) { + guard !didArmWindowDragSuppression else { return } + guard let eventType = NSApp.currentEvent?.type, + eventType == .leftMouseDown || eventType == .leftMouseDragged else { + return + } + guard let currentWindow = window else { return } + + didArmWindowDragSuppression = true + suppressedWindow = currentWindow + let suppressionDepth = beginWindowDragSuppression(window: currentWindow) ?? 0 + if currentWindow.isMovable { + previousWindowMovableState = temporarilyDisableWindowDragging(window: currentWindow) + } else { + previousWindowMovableState = nil + } + #if DEBUG + let wasMovable = previousWindowMovableState.map(String.init) ?? "nil" + let nowMovable = String(currentWindow.isMovable) + dlog( + "folder.dragSuppression trigger=\(trigger) event=\(eventType) depth=\(suppressionDepth) wasMovable=\(wasMovable) nowMovable=\(nowMovable)" + ) + #endif + } +} + +func temporarilyDisableWindowDragging(window: NSWindow?) -> Bool? { + guard let window else { return nil } + let wasMovable = window.isMovable + if wasMovable { + window.isMovable = false + } + return wasMovable +} + +func restoreWindowDragging(window: NSWindow?, previousMovableState: Bool?) { + guard let window, let previousMovableState else { return } + window.isMovable = previousMovableState +} + +/// Wrapper view that tries NSGlassEffectView (macOS 26+) when available or requested +private struct SidebarVisualEffectBackground: NSViewRepresentable { + let material: NSVisualEffectView.Material + let blendingMode: NSVisualEffectView.BlendingMode + let state: NSVisualEffectView.State + let opacity: Double + let tintColor: NSColor? + let cornerRadius: CGFloat + let preferLiquidGlass: Bool + + init( + material: NSVisualEffectView.Material = .hudWindow, + blendingMode: NSVisualEffectView.BlendingMode = .behindWindow, + state: NSVisualEffectView.State = .active, + opacity: Double = 1.0, + tintColor: NSColor? = nil, + cornerRadius: CGFloat = 0, + preferLiquidGlass: Bool = false + ) { + self.material = material + self.blendingMode = blendingMode + self.state = state + self.opacity = opacity + self.tintColor = tintColor + self.cornerRadius = cornerRadius + self.preferLiquidGlass = preferLiquidGlass + } + + static var liquidGlassAvailable: Bool { + NSClassFromString("NSGlassEffectView") != nil + } + + func makeNSView(context: Context) -> NSView { + // Try NSGlassEffectView if preferred or if we want to test availability + if preferLiquidGlass, let glassClass = NSClassFromString("NSGlassEffectView") as? NSView.Type { + let glass = glassClass.init(frame: .zero) + glass.autoresizingMask = [.width, .height] + glass.wantsLayer = true + return glass + } + + // Use NSVisualEffectView + let view = NSVisualEffectView() + view.autoresizingMask = [.width, .height] + view.wantsLayer = true + view.layerContentsRedrawPolicy = .onSetNeedsDisplay + return view + } + + func updateNSView(_ nsView: NSView, context: Context) { + // Configure based on view type + if nsView.className == "NSGlassEffectView" { + // NSGlassEffectView configuration via private API + nsView.alphaValue = max(0.0, min(1.0, opacity)) + nsView.layer?.cornerRadius = cornerRadius + nsView.layer?.masksToBounds = cornerRadius > 0 + + // Try to set tint color via private selector + if let color = tintColor { + let selector = NSSelectorFromString("setTintColor:") + if nsView.responds(to: selector) { + nsView.perform(selector, with: color) + } + } + } else if let visualEffect = nsView as? NSVisualEffectView { + // NSVisualEffectView configuration + visualEffect.material = material + visualEffect.blendingMode = blendingMode + visualEffect.state = state + visualEffect.alphaValue = max(0.0, min(1.0, opacity)) + visualEffect.layer?.cornerRadius = cornerRadius + visualEffect.layer?.masksToBounds = cornerRadius > 0 + visualEffect.needsDisplay = true + } + } +} + +/// Reads the leading inset required to clear traffic lights + left titlebar accessories. +final class TitlebarLeadingInsetPassthroughView: NSView { + override var mouseDownCanMoveWindow: Bool { false } + override func hitTest(_ point: NSPoint) -> NSView? { nil } +} + +struct TitlebarLeadingInsetReader: NSViewRepresentable { + @Binding var inset: CGFloat + + func makeNSView(context: Context) -> NSView { + let view = TitlebarLeadingInsetPassthroughView() + view.setFrameSize(.zero) + return view + } + + func updateNSView(_ nsView: NSView, context: Context) { + DispatchQueue.main.async { + guard let window = nsView.window else { return } + // Start past the traffic lights + var leading: CGFloat = 78 + // Add width of all left-aligned titlebar accessories + for accessory in window.titlebarAccessoryViewControllers + where accessory.layoutAttribute == .leading || accessory.layoutAttribute == .left { + leading += accessory.view.frame.width + } + leading += 0 + if leading != inset { + inset = leading + } + } + } +} + +/// 1px trailing border on the sidebar, derived from the terminal chrome background +/// using the same logic as bonsplit's TabBarColors.nsColorSeparator: +/// dark bg → lighten RGB by 0.16 at 0.36 alpha; light bg → darken by 0.12 at 0.26 alpha. +struct SidebarTrailingBorder: View { + @AppStorage("sidebarMatchTerminalBackground") private var matchTerminalBackground = false + @State private var separatorColor: NSColor = chromeSeparatorColor() + + var body: some View { + if matchTerminalBackground { + Rectangle() + .fill(Color(nsColor: separatorColor)) + .frame(width: 1) + .ignoresSafeArea() + .onAppear { + separatorColor = Self.chromeSeparatorColor() + } + .onReceive(NotificationCenter.default.publisher(for: .ghosttyDefaultBackgroundDidChange)) { _ in + separatorColor = Self.chromeSeparatorColor() + } + } + } + + /// Replicates bonsplit TabBarColors.nsColorSeparator derivation from chrome background. + private static func chromeSeparatorColor() -> NSColor { + let chrome = GhosttyBackgroundTheme.currentColor() + let srgb = chrome.usingColorSpace(.sRGB) ?? chrome + var r: CGFloat = 0, g: CGFloat = 0, b: CGFloat = 0, a: CGFloat = 0 + srgb.getRed(&r, green: &g, blue: &b, alpha: &a) + let luminance = 0.299 * r + 0.587 * g + 0.114 * b + let isLight = luminance > 0.5 + let amount: CGFloat = isLight ? -0.12 : 0.16 + let alpha: CGFloat = isLight ? 0.26 : 0.36 + return NSColor( + red: min(1.0, max(0.0, r + amount)), + green: min(1.0, max(0.0, g + amount)), + blue: min(1.0, max(0.0, b + amount)), + alpha: alpha + ) + } +} + +/// Sidebar background that uses the same technique as TitlebarLayerBackground: +/// fully opaque layer color + layer-level opacity. This matches how the terminal's +/// Metal surface composites its background. +private struct SidebarTerminalBackgroundView: NSViewRepresentable { + let backgroundColor: NSColor + let opacity: CGFloat + + func makeNSView(context: Context) -> NSView { + let view = NSView() + view.wantsLayer = true + view.layer?.backgroundColor = backgroundColor.withAlphaComponent(1.0).cgColor + view.layer?.opacity = Float(opacity) + return view + } + + func updateNSView(_ nsView: NSView, context: Context) { + nsView.layer?.backgroundColor = backgroundColor.withAlphaComponent(1.0).cgColor + nsView.layer?.opacity = Float(opacity) + } +} + +struct SidebarBackdrop: View { + @AppStorage("sidebarMatchTerminalBackground") private var matchTerminalBackground = false + @AppStorage("sidebarTintOpacity") private var sidebarTintOpacity = SidebarTintDefaults.opacity + @AppStorage("sidebarTintHex") private var sidebarTintHex = SidebarTintDefaults.hex + @AppStorage("sidebarTintHexLight") private var sidebarTintHexLight: String? + @AppStorage("sidebarTintHexDark") private var sidebarTintHexDark: String? + @AppStorage("sidebarMaterial") private var sidebarMaterial = SidebarMaterialOption.sidebar.rawValue + @AppStorage("sidebarBlendMode") private var sidebarBlendMode = SidebarBlendModeOption.withinWindow.rawValue + @AppStorage("sidebarState") private var sidebarState = SidebarStateOption.followWindow.rawValue + @AppStorage("sidebarCornerRadius") private var sidebarCornerRadius = 0.0 + @AppStorage("sidebarBlurOpacity") private var sidebarBlurOpacity = 1.0 + @Environment(\.colorScheme) private var colorScheme + @State private var terminalBackgroundColor: NSColor = GhosttyBackgroundTheme.currentColor() + + var body: some View { + let cornerRadius = CGFloat(max(0, sidebarCornerRadius)) + + if matchTerminalBackground { + // The terminal background is provided by a single CALayer, so + // the sidebar uses the configured opacity directly. + let alpha = CGFloat(GhosttyApp.shared.defaultBackgroundOpacity) + return AnyView( + SidebarTerminalBackgroundView( + backgroundColor: GhosttyApp.shared.defaultBackgroundColor, + opacity: alpha + ) + .clipShape(RoundedRectangle(cornerRadius: cornerRadius, style: .continuous)) + .onReceive(NotificationCenter.default.publisher(for: .ghosttyDefaultBackgroundDidChange)) { _ in + terminalBackgroundColor = GhosttyBackgroundTheme.currentColor() + } + ) + } + + let materialOption = SidebarMaterialOption(rawValue: sidebarMaterial) + let blendingMode = SidebarBlendModeOption(rawValue: sidebarBlendMode)?.mode ?? .behindWindow + let state = SidebarStateOption(rawValue: sidebarState)?.state ?? .active + let resolvedHex: String = { + if colorScheme == .dark, let dark = sidebarTintHexDark { + return dark + } else if colorScheme == .light, let light = sidebarTintHexLight { + return light + } + return sidebarTintHex + }() + let tintColor = (NSColor(hex: resolvedHex) ?? NSColor(hex: sidebarTintHex) ?? .black).withAlphaComponent(sidebarTintOpacity) + let useLiquidGlass = materialOption?.usesLiquidGlass ?? false + let useWindowLevelGlass = useLiquidGlass && blendingMode == .behindWindow + + return AnyView( + ZStack { + if let material = materialOption?.material { + // When using liquidGlass + behindWindow, window handles glass + tint + // Sidebar is fully transparent + if !useWindowLevelGlass { + SidebarVisualEffectBackground( + material: material, + blendingMode: blendingMode, + state: state, + opacity: sidebarBlurOpacity, + tintColor: tintColor, + cornerRadius: cornerRadius, + preferLiquidGlass: useLiquidGlass + ) + // Tint overlay for NSVisualEffectView fallback + if !useLiquidGlass { + Color(nsColor: tintColor) + } + } + } + // When material is none or useWindowLevelGlass, render nothing + } + .clipShape(RoundedRectangle(cornerRadius: cornerRadius, style: .continuous)) + ) + } +} + +enum SidebarMaterialOption: String, CaseIterable, Identifiable { + case none + case liquidGlass // macOS 26+ NSGlassEffectView + case sidebar + case hudWindow + case menu + case popover + case underWindowBackground + case windowBackground + case contentBackground + case fullScreenUI + case sheet + case headerView + case toolTip + + var id: String { rawValue } + + var title: String { + switch self { + case .none: return String(localized: "settings.material.none", defaultValue: "None") + case .liquidGlass: return String(localized: "settings.material.liquidGlass", defaultValue: "Liquid Glass (macOS 26+)") + case .sidebar: return String(localized: "settings.material.sidebar", defaultValue: "Sidebar") + case .hudWindow: return String(localized: "settings.material.hudWindow", defaultValue: "HUD Window") + case .menu: return String(localized: "settings.material.menu", defaultValue: "Menu") + case .popover: return String(localized: "settings.material.popover", defaultValue: "Popover") + case .underWindowBackground: return String(localized: "settings.material.underWindow", defaultValue: "Under Window") + case .windowBackground: return String(localized: "settings.material.windowBackground", defaultValue: "Window Background") + case .contentBackground: return String(localized: "settings.material.contentBackground", defaultValue: "Content Background") + case .fullScreenUI: return String(localized: "settings.material.fullScreenUI", defaultValue: "Full Screen UI") + case .sheet: return String(localized: "settings.material.sheet", defaultValue: "Sheet") + case .headerView: return String(localized: "settings.material.headerView", defaultValue: "Header View") + case .toolTip: return String(localized: "settings.material.toolTip", defaultValue: "Tool Tip") + } + } + + /// Returns true if this option should use NSGlassEffectView (macOS 26+) + var usesLiquidGlass: Bool { + self == .liquidGlass + } + + var material: NSVisualEffectView.Material? { + switch self { + case .none: return nil + case .liquidGlass: return .underWindowBackground // Fallback material + case .sidebar: return .sidebar + case .hudWindow: return .hudWindow + case .menu: return .menu + case .popover: return .popover + case .underWindowBackground: return .underWindowBackground + case .windowBackground: return .windowBackground + case .contentBackground: return .contentBackground + case .fullScreenUI: return .fullScreenUI + case .sheet: return .sheet + case .headerView: return .headerView + case .toolTip: return .toolTip + } + } +} + +enum SidebarBlendModeOption: String, CaseIterable, Identifiable { + case behindWindow + case withinWindow + + var id: String { rawValue } + + var title: String { + switch self { + case .behindWindow: return String(localized: "settings.blendMode.behindWindow", defaultValue: "Behind Window") + case .withinWindow: return String(localized: "settings.blendMode.withinWindow", defaultValue: "Within Window") + } + } + + var mode: NSVisualEffectView.BlendingMode { + switch self { + case .behindWindow: return .behindWindow + case .withinWindow: return .withinWindow + } + } +} + +enum SidebarStateOption: String, CaseIterable, Identifiable { + case active + case inactive + case followWindow + + var id: String { rawValue } + + var title: String { + switch self { + case .active: return String(localized: "settings.state.active", defaultValue: "Active") + case .inactive: return String(localized: "settings.state.inactive", defaultValue: "Inactive") + case .followWindow: return String(localized: "settings.state.followWindow", defaultValue: "Follow Window") + } + } + + var state: NSVisualEffectView.State { + switch self { + case .active: return .active + case .inactive: return .inactive + case .followWindow: return .followsWindowActiveState + } + } +} + +enum SidebarTintDefaults { + static let hex = "#000000" + static let opacity = 0.18 +} + +enum SidebarPresetOption: String, CaseIterable, Identifiable { + case nativeSidebar + case glassBehind + case softBlur + case popoverGlass + case hudGlass + case underWindow + + var id: String { rawValue } + + var title: String { + switch self { + case .nativeSidebar: return String(localized: "settings.preset.nativeSidebar", defaultValue: "Native Sidebar") + case .glassBehind: return String(localized: "settings.preset.raycastGray", defaultValue: "Raycast Gray") + case .softBlur: return String(localized: "settings.preset.softBlur", defaultValue: "Soft Blur") + case .popoverGlass: return String(localized: "settings.preset.popoverGlass", defaultValue: "Popover Glass") + case .hudGlass: return String(localized: "settings.preset.hudGlass", defaultValue: "HUD Glass") + case .underWindow: return String(localized: "settings.preset.underWindow", defaultValue: "Under Window") + } + } + + var material: SidebarMaterialOption { + switch self { + case .nativeSidebar: return .sidebar + case .glassBehind: return .sidebar + case .softBlur: return .sidebar + case .popoverGlass: return .popover + case .hudGlass: return .hudWindow + case .underWindow: return .underWindowBackground + } + } + + var blendMode: SidebarBlendModeOption { + switch self { + case .nativeSidebar: return .withinWindow + case .glassBehind: return .behindWindow + case .softBlur: return .behindWindow + case .popoverGlass: return .behindWindow + case .hudGlass: return .withinWindow + case .underWindow: return .withinWindow + } + } + + var state: SidebarStateOption { + switch self { + case .nativeSidebar: return .followWindow + case .glassBehind: return .active + case .softBlur: return .active + case .popoverGlass: return .active + case .hudGlass: return .active + case .underWindow: return .followWindow + } + } + + var tintHex: String { + switch self { + case .nativeSidebar: return "#000000" + case .glassBehind: return "#000000" + case .softBlur: return "#000000" + case .popoverGlass: return "#000000" + case .hudGlass: return "#000000" + case .underWindow: return "#000000" + } + } + + var tintOpacity: Double { + switch self { + case .nativeSidebar: return 0.18 + case .glassBehind: return 0.36 + case .softBlur: return 0.28 + case .popoverGlass: return 0.10 + case .hudGlass: return 0.62 + case .underWindow: return 0.14 + } + } + + var cornerRadius: Double { + switch self { + case .nativeSidebar: return 0.0 + case .glassBehind: return 0.0 + case .softBlur: return 0.0 + case .popoverGlass: return 10.0 + case .hudGlass: return 10.0 + case .underWindow: return 6.0 + } + } + + var blurOpacity: Double { + switch self { + case .nativeSidebar: return 1.0 + case .glassBehind: return 0.6 + case .softBlur: return 0.45 + case .popoverGlass: return 0.9 + case .hudGlass: return 0.98 + case .underWindow: return 0.9 + } + } +} + +extension NSColor { + func hexString(includeAlpha: Bool = false) -> String { + let color = usingColorSpace(.sRGB) ?? self + var red: CGFloat = 0 + var green: CGFloat = 0 + var blue: CGFloat = 0 + var alpha: CGFloat = 0 + color.getRed(&red, green: &green, blue: &blue, alpha: &alpha) + let redByte = min(255, max(0, Int((red * 255).rounded()))) + let greenByte = min(255, max(0, Int((green * 255).rounded()))) + let blueByte = min(255, max(0, Int((blue * 255).rounded()))) + if includeAlpha { + let alphaByte = min(255, max(0, Int((alpha * 255).rounded()))) + return String(format: "#%02X%02X%02X%02X", redByte, greenByte, blueByte, alphaByte) + } + return String(format: "#%02X%02X%02X", redByte, greenByte, blueByte) + } +} From e9c9ee8591752010f97e7ce2961fcef80e9aa7fc Mon Sep 17 00:00:00 2001 From: arzafran Date: Thu, 9 Jul 2026 14:10:24 -0300 Subject: [PATCH 16/19] refactor(terminal): split RenderStats debug introspection out of GhosttySurfaceScrollView GhosttySurfaceScrollView (~2,780 lines) is one of the god-view subsystems called out by Nuclear Review #97. Extract its debug-only render/frame introspection (debugRenderStats, debugCopyIOSurfaceCGImage, debugSampleIOSurface + their DebugRenderStats/DebugFrameSample structs; entirely #if DEBUG) into GhosttyTerminalView+RenderStats.swift as a same-type extension, matching the existing +Extension convention. Pure move; three access-level widenings were required for cross-file visibility from the moved extension (each noted inline at the declaration): - GhosttySurfaceScrollView.surfaceView: private let -> internal let (immutable, so this only grants read access). - GhosttySurfaceScrollView.isActive: private var -> private(set) var (write stays confined to the class's own declaration in GhosttyTerminalView.swift; the moved extension only reads it). - GhosttySurfaceScrollView.contentsKey(for:) and .updatePresentStats(...): private static func -> internal static func (called, not just read, from the moved extension). Refs #97. --- GhosttyTabs.xcodeproj/project.pbxproj | 4 + Sources/GhosttyTerminalView+RenderStats.swift | 291 ++++++++++++++++++ Sources/GhosttyTerminalView.swift | 285 +---------------- 3 files changed, 308 insertions(+), 272 deletions(-) create mode 100644 Sources/GhosttyTerminalView+RenderStats.swift diff --git a/GhosttyTabs.xcodeproj/project.pbxproj b/GhosttyTabs.xcodeproj/project.pbxproj index afaef0e3f3b..6999be39dce 100644 --- a/GhosttyTabs.xcodeproj/project.pbxproj +++ b/GhosttyTabs.xcodeproj/project.pbxproj @@ -26,6 +26,7 @@ A5FF0054 /* GhosttyTerminalView+Accessibility.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5FF0044 /* GhosttyTerminalView+Accessibility.swift */; }; NRGV0001 /* GhosttyTerminalView+IME.swift in Sources */ = {isa = PBXBuildFile; fileRef = NRGV0002 /* GhosttyTerminalView+IME.swift */; }; NRGV0003 /* GhosttyTerminalView+SwiftUIWrapper.swift in Sources */ = {isa = PBXBuildFile; fileRef = NRGV0004 /* GhosttyTerminalView+SwiftUIWrapper.swift */; }; + NRGV0005 /* GhosttyTerminalView+RenderStats.swift in Sources */ = {isa = PBXBuildFile; fileRef = NRGV0006 /* GhosttyTerminalView+RenderStats.swift */; }; A5001532 /* TerminalWindowPortal.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5001531 /* TerminalWindowPortal.swift */; }; A5001534 /* BrowserWindowPortal.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5001533 /* BrowserWindowPortal.swift */; }; A5FF0008 /* HostedViewPortalRegistry.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5FF0018 /* HostedViewPortalRegistry.swift */; }; @@ -251,6 +252,7 @@ A5FF0044 /* GhosttyTerminalView+Accessibility.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "GhosttyTerminalView+Accessibility.swift"; sourceTree = ""; }; NRGV0002 /* GhosttyTerminalView+IME.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "GhosttyTerminalView+IME.swift"; sourceTree = ""; }; NRGV0004 /* GhosttyTerminalView+SwiftUIWrapper.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "GhosttyTerminalView+SwiftUIWrapper.swift"; sourceTree = ""; }; + NRGV0006 /* GhosttyTerminalView+RenderStats.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "GhosttyTerminalView+RenderStats.swift"; sourceTree = ""; }; A5001531 /* TerminalWindowPortal.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TerminalWindowPortal.swift; sourceTree = ""; }; A5001533 /* BrowserWindowPortal.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BrowserWindowPortal.swift; sourceTree = ""; }; A5FF0018 /* HostedViewPortalRegistry.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HostedViewPortalRegistry.swift; sourceTree = ""; }; @@ -530,6 +532,7 @@ A5FF0044 /* GhosttyTerminalView+Accessibility.swift */, NRGV0002 /* GhosttyTerminalView+IME.swift */, NRGV0004 /* GhosttyTerminalView+SwiftUIWrapper.swift */, + NRGV0006 /* GhosttyTerminalView+RenderStats.swift */, A5001531 /* TerminalWindowPortal.swift */, A5001533 /* BrowserWindowPortal.swift */, A5FF0018 /* HostedViewPortalRegistry.swift */, @@ -861,6 +864,7 @@ A5FF0054 /* GhosttyTerminalView+Accessibility.swift in Sources */, NRGV0001 /* GhosttyTerminalView+IME.swift in Sources */, NRGV0003 /* GhosttyTerminalView+SwiftUIWrapper.swift in Sources */, + NRGV0005 /* GhosttyTerminalView+RenderStats.swift in Sources */, A5001532 /* TerminalWindowPortal.swift in Sources */, A5001534 /* BrowserWindowPortal.swift in Sources */, A5FF0008 /* HostedViewPortalRegistry.swift in Sources */, diff --git a/Sources/GhosttyTerminalView+RenderStats.swift b/Sources/GhosttyTerminalView+RenderStats.swift new file mode 100644 index 00000000000..e594afe178d --- /dev/null +++ b/Sources/GhosttyTerminalView+RenderStats.swift @@ -0,0 +1,291 @@ +import Foundation +import SwiftUI +import AppKit +import Metal +import QuartzCore +import Combine +import CoreText +import Darwin +import Carbon.HIToolbox +import Bonsplit +import IOSurface +import UniformTypeIdentifiers + +// MARK: - GhosttySurfaceScrollView + RenderStats +// +// Debug-only render/frame introspection for GhosttySurfaceScrollView: draw/present +// counters exposed to the debug socket, and IOSurface-backed frame sampling used to +// detect blank-frame regressions without Screen Recording permissions. +// +// Split out of GhosttyTerminalView.swift (Nuclear Review #97). Extracted verbatim +// as a same-type extension (entirely #if DEBUG), so behavior is unchanged. + +extension GhosttySurfaceScrollView { +#if DEBUG + struct DebugRenderStats { + let drawCount: Int + let lastDrawTime: CFTimeInterval + let metalDrawableCount: Int + let metalLastDrawableTime: CFTimeInterval + let presentCount: Int + let lastPresentTime: CFTimeInterval + let layerClass: String + let layerContentsKey: String + let inWindow: Bool + let windowIsKey: Bool + let windowOcclusionVisible: Bool + let appIsActive: Bool + let isActive: Bool + let desiredFocus: Bool + let isFirstResponder: Bool + } + + func debugRenderStats() -> DebugRenderStats { + let layerClass = surfaceView.layer.map { String(describing: type(of: $0)) } ?? "nil" + let (metalCount, metalLast) = (surfaceView.layer as? GhosttyMetalLayer)?.debugStats() ?? (0, 0) + let (drawCount, lastDraw): (Int, CFTimeInterval) = surfaceView.terminalSurface.map { terminalSurface in + Self.drawStats(for: terminalSurface.id) + } ?? (0, 0) + let (presentCount, lastPresent, contentsKey): (Int, CFTimeInterval, String) = surfaceView.terminalSurface.map { terminalSurface in + let stats = Self.updatePresentStats(surfaceId: terminalSurface.id, layer: surfaceView.layer) + return (stats.count, stats.last, stats.key) + } ?? (0, 0, Self.contentsKey(for: surfaceView.layer)) + let inWindow = (window != nil) + let windowIsKey = window?.isKeyWindow ?? false + let windowOcclusionVisible = (window?.occlusionState.contains(.visible) ?? false) || (window?.isKeyWindow ?? false) + let appIsActive = NSApp.isActive + let fr = window?.firstResponder as? NSView + let isFirstResponder = fr == surfaceView || (fr?.isDescendant(of: surfaceView) ?? false) + return DebugRenderStats( + drawCount: drawCount, + lastDrawTime: lastDraw, + metalDrawableCount: metalCount, + metalLastDrawableTime: metalLast, + presentCount: presentCount, + lastPresentTime: lastPresent, + layerClass: layerClass, + layerContentsKey: contentsKey, + inWindow: inWindow, + windowIsKey: windowIsKey, + windowOcclusionVisible: windowOcclusionVisible, + appIsActive: appIsActive, + isActive: isActive, + desiredFocus: surfaceView.desiredFocus, + isFirstResponder: isFirstResponder + ) + } +#endif + +#if DEBUG + struct DebugFrameSample { + let sampleCount: Int + let uniqueQuantized: Int + let lumaStdDev: Double + let modeFraction: Double + let fingerprint: UInt64 + let iosurfaceWidthPx: Int + let iosurfaceHeightPx: Int + let expectedWidthPx: Int + let expectedHeightPx: Int + let layerClass: String + let layerContentsGravity: String + let layerContentsKey: String + + var isProbablyBlank: Bool { + (lumaStdDev < 3.5 && modeFraction > 0.985) || + (uniqueQuantized <= 6 && modeFraction > 0.95) + } + } + + /// Create a CGImage from the terminal's IOSurface-backed layer contents. + /// + /// This avoids Screen Recording permissions (unlike CGWindowListCreateImage) and is therefore + /// suitable for debug socket tests running in headless/VM contexts. + func debugCopyIOSurfaceCGImage() -> CGImage? { + guard let modelLayer = surfaceView.layer else { return nil } + let layer = modelLayer.presentation() ?? modelLayer + guard let contents = layer.contents else { return nil } + + let cf = contents as CFTypeRef + guard CFGetTypeID(cf) == IOSurfaceGetTypeID() else { return nil } + let surfaceRef = (contents as! IOSurfaceRef) + + let width = Int(IOSurfaceGetWidth(surfaceRef)) + let height = Int(IOSurfaceGetHeight(surfaceRef)) + let bytesPerRow = Int(IOSurfaceGetBytesPerRow(surfaceRef)) + guard width > 0, height > 0, bytesPerRow > 0 else { return nil } + + IOSurfaceLock(surfaceRef, [], nil) + defer { IOSurfaceUnlock(surfaceRef, [], nil) } + + let base = IOSurfaceGetBaseAddress(surfaceRef) + let size = bytesPerRow * height + let data = Data(bytes: base, count: size) + + guard let provider = CGDataProvider(data: data as CFData) else { return nil } + let colorSpace = CGColorSpaceCreateDeviceRGB() + let bitmapInfo = CGBitmapInfo.byteOrder32Little.union( + CGBitmapInfo(rawValue: CGImageAlphaInfo.premultipliedFirst.rawValue) + ) + + return CGImage( + width: width, + height: height, + bitsPerComponent: 8, + bitsPerPixel: 32, + bytesPerRow: bytesPerRow, + space: colorSpace, + bitmapInfo: bitmapInfo, + provider: provider, + decode: nil, + shouldInterpolate: false, + intent: .defaultIntent + ) + } + + /// Sample the IOSurface backing the terminal layer (if any) to detect a transient blank frame + /// without using screenshots/screen recording permissions. + func debugSampleIOSurface(normalizedCrop: CGRect) -> DebugFrameSample? { + guard let modelLayer = surfaceView.layer else { return nil } + // Prefer the presentation layer to better match what the user sees on screen. + let layer = modelLayer.presentation() ?? modelLayer + let layerClass = String(describing: type(of: layer)) + let layerContentsGravity = layer.contentsGravity.rawValue + let contentsKey = Self.contentsKey(for: layer) + let presentationScale = max(1.0, layer.contentsScale) + let expectedWidthPx = Int((layer.bounds.width * presentationScale).rounded(.toNearestOrAwayFromZero)) + let expectedHeightPx = Int((layer.bounds.height * presentationScale).rounded(.toNearestOrAwayFromZero)) + + // Ghostty uses a CoreAnimation layer whose `contents` is an IOSurface-backed object. + // The concrete layer class is often `IOSurfaceLayer` (private), so avoid referencing it directly. + guard let anySurface = layer.contents else { + // Treat "no contents" as a blank frame: this is the visual regression we're guarding. + return DebugFrameSample( + sampleCount: 0, + uniqueQuantized: 0, + lumaStdDev: 0, + modeFraction: 1, + fingerprint: 0, + iosurfaceWidthPx: 0, + iosurfaceHeightPx: 0, + expectedWidthPx: expectedWidthPx, + expectedHeightPx: expectedHeightPx, + layerClass: layerClass, + layerContentsGravity: layerContentsGravity, + layerContentsKey: contentsKey + ) + } + + // IOSurfaceLayer.contents is usually an IOSurface, but during mitigation we may + // temporarily replace contents with a CGImage snapshot to avoid blank flashes. + // Treat non-IOSurface contents as "non-blank" and avoid unsafe casts. + let cf = anySurface as CFTypeRef + guard CFGetTypeID(cf) == IOSurfaceGetTypeID() else { + var fnv: UInt64 = 1469598103934665603 + for b in contentsKey.utf8 { + fnv ^= UInt64(b) + fnv &*= 1099511628211 + } + return DebugFrameSample( + sampleCount: 1, + uniqueQuantized: 1, + lumaStdDev: 999, + modeFraction: 0, + fingerprint: fnv, + iosurfaceWidthPx: 0, + iosurfaceHeightPx: 0, + expectedWidthPx: expectedWidthPx, + expectedHeightPx: expectedHeightPx, + layerClass: layerClass, + layerContentsGravity: layerContentsGravity, + layerContentsKey: contentsKey + ) + } + + let surfaceRef = (anySurface as! IOSurfaceRef) + + let width = Int(IOSurfaceGetWidth(surfaceRef)) + let height = Int(IOSurfaceGetHeight(surfaceRef)) + if width <= 0 || height <= 0 { return nil } + + let cropPx = CGRect( + x: max(0, min(CGFloat(width - 1), normalizedCrop.origin.x * CGFloat(width))), + y: max(0, min(CGFloat(height - 1), normalizedCrop.origin.y * CGFloat(height))), + width: max(1, min(CGFloat(width), normalizedCrop.width * CGFloat(width))), + height: max(1, min(CGFloat(height), normalizedCrop.height * CGFloat(height))) + ).integral + + let x0 = Int(cropPx.minX) + let y0 = Int(cropPx.minY) + let x1 = Int(min(CGFloat(width), cropPx.maxX)) + let y1 = Int(min(CGFloat(height), cropPx.maxY)) + if x1 <= x0 || y1 <= y0 { return nil } + + IOSurfaceLock(surfaceRef, [], nil) + defer { IOSurfaceUnlock(surfaceRef, [], nil) } + + let base = IOSurfaceGetBaseAddress(surfaceRef) + let bytesPerRow = IOSurfaceGetBytesPerRow(surfaceRef) + if bytesPerRow <= 0 { return nil } + + // Assume 4 bytes/pixel BGRA (common for IOSurfaceLayer contents). + let bytesPerPixel = 4 + let step = 6 + + var hist = [UInt16: Int]() + hist.reserveCapacity(256) + + var lumas = [Double]() + lumas.reserveCapacity(((x1 - x0) / step) * ((y1 - y0) / step)) + + var count = 0 + var fnv: UInt64 = 1469598103934665603 + + for y in stride(from: y0, to: y1, by: step) { + let row = base.advanced(by: y * bytesPerRow) + for x in stride(from: x0, to: x1, by: step) { + let p = row.advanced(by: x * bytesPerPixel) + let b = Double(p.load(fromByteOffset: 0, as: UInt8.self)) + let g = Double(p.load(fromByteOffset: 1, as: UInt8.self)) + let r = Double(p.load(fromByteOffset: 2, as: UInt8.self)) + let luma = 0.2126 * r + 0.7152 * g + 0.0722 * b + lumas.append(luma) + + let rq = UInt16(UInt8(r) >> 4) + let gq = UInt16(UInt8(g) >> 4) + let bq = UInt16(UInt8(b) >> 4) + let key = (rq << 8) | (gq << 4) | bq + hist[key, default: 0] += 1 + count += 1 + + let lq = UInt8(max(0, min(63, Int(luma / 4.0)))) + fnv ^= UInt64(lq) + fnv &*= 1099511628211 + } + } + + guard count > 0 else { return nil } + let mean = lumas.reduce(0.0, +) / Double(lumas.count) + let variance = lumas.reduce(0.0) { $0 + ($1 - mean) * ($1 - mean) } / Double(lumas.count) + let stddev = sqrt(variance) + + let modeCount = hist.values.max() ?? 0 + let modeFrac = Double(modeCount) / Double(count) + + return DebugFrameSample( + sampleCount: count, + uniqueQuantized: hist.count, + lumaStdDev: stddev, + modeFraction: modeFrac, + fingerprint: fnv, + iosurfaceWidthPx: width, + iosurfaceHeightPx: height, + expectedWidthPx: expectedWidthPx, + expectedHeightPx: expectedHeightPx, + layerClass: layerClass, + layerContentsGravity: layerContentsGravity, + layerContentsKey: contentsKey + ) + } +#endif +} diff --git a/Sources/GhosttyTerminalView.swift b/Sources/GhosttyTerminalView.swift index 36d78d9d976..e9766a53109 100644 --- a/Sources/GhosttyTerminalView.swift +++ b/Sources/GhosttyTerminalView.swift @@ -5831,7 +5831,10 @@ final class GhosttySurfaceScrollView: NSView { private let backgroundView: NSView private let scrollView: GhosttyScrollView private let documentView: NSView - private let surfaceView: GhosttyNSView + // Widened from private to internal (immutable `let`, so this only grants + // read access): read from the debug-only RenderStats extension + // (GhosttyTerminalView+RenderStats.swift, Nuclear Review #97 split). + let surfaceView: GhosttyNSView private let inactiveOverlayView: GhosttyFlashOverlayView private let dropZoneOverlayView: GhosttyFlashOverlayView private let notificationRingOverlayView: GhosttyFlashOverlayView @@ -5866,7 +5869,9 @@ final class GhosttySurfaceScrollView: NSView { private var allowExplicitScrollbarSync = false /// Threshold in points from bottom to consider "at bottom" (allows for minor float drift) private static let scrollToBottomThreshold: CGFloat = 5.0 - private var isActive = true + // private(set): read from the debug-only RenderStats extension + // (GhosttyTerminalView+RenderStats.swift, Nuclear Review #97 split), written only here. + private(set) var isActive = true private var lastFocusRefreshAt: CFTimeInterval = 0 private var lastRequestedPortalOcclusionVisible: Bool? private var activeDropZone: DropZone? @@ -5924,7 +5929,9 @@ final class GhosttySurfaceScrollView: NSView { lastDrawTimes[surfaceId] = CACurrentMediaTime() } - private static func contentsKey(for layer: CALayer?) -> String { + // Widened from private to internal: called from the debug-only RenderStats + // extension (GhosttyTerminalView+RenderStats.swift, Nuclear Review #97 split). + static func contentsKey(for layer: CALayer?) -> String { guard let modelLayer = layer else { return "nil" } // Prefer the presentation layer to better reflect what the user sees on screen. let layer = modelLayer.presentation() ?? modelLayer @@ -5949,7 +5956,9 @@ final class GhosttySurfaceScrollView: NSView { return String(describing: contents) } - private static func updatePresentStats(surfaceId: UUID, layer: CALayer?) -> (count: Int, last: CFTimeInterval, key: String) { + // Widened from private to internal: called from the debug-only RenderStats + // extension (GhosttyTerminalView+RenderStats.swift, Nuclear Review #97 split). + static func updatePresentStats(surfaceId: UUID, layer: CALayer?) -> (count: Int, last: CFTimeInterval, key: String) { let key = contentsKey(for: layer) if lastContentsKeys[surfaceId] != key { presentCounts[surfaceId, default: 0] += 1 @@ -8134,274 +8143,6 @@ final class GhosttySurfaceScrollView: NSView { return textField.isDescendant(of: self) && isSearchOverlayOrDescendant(textField) } -#if DEBUG - struct DebugRenderStats { - let drawCount: Int - let lastDrawTime: CFTimeInterval - let metalDrawableCount: Int - let metalLastDrawableTime: CFTimeInterval - let presentCount: Int - let lastPresentTime: CFTimeInterval - let layerClass: String - let layerContentsKey: String - let inWindow: Bool - let windowIsKey: Bool - let windowOcclusionVisible: Bool - let appIsActive: Bool - let isActive: Bool - let desiredFocus: Bool - let isFirstResponder: Bool - } - - func debugRenderStats() -> DebugRenderStats { - let layerClass = surfaceView.layer.map { String(describing: type(of: $0)) } ?? "nil" - let (metalCount, metalLast) = (surfaceView.layer as? GhosttyMetalLayer)?.debugStats() ?? (0, 0) - let (drawCount, lastDraw): (Int, CFTimeInterval) = surfaceView.terminalSurface.map { terminalSurface in - Self.drawStats(for: terminalSurface.id) - } ?? (0, 0) - let (presentCount, lastPresent, contentsKey): (Int, CFTimeInterval, String) = surfaceView.terminalSurface.map { terminalSurface in - let stats = Self.updatePresentStats(surfaceId: terminalSurface.id, layer: surfaceView.layer) - return (stats.count, stats.last, stats.key) - } ?? (0, 0, Self.contentsKey(for: surfaceView.layer)) - let inWindow = (window != nil) - let windowIsKey = window?.isKeyWindow ?? false - let windowOcclusionVisible = (window?.occlusionState.contains(.visible) ?? false) || (window?.isKeyWindow ?? false) - let appIsActive = NSApp.isActive - let fr = window?.firstResponder as? NSView - let isFirstResponder = fr == surfaceView || (fr?.isDescendant(of: surfaceView) ?? false) - return DebugRenderStats( - drawCount: drawCount, - lastDrawTime: lastDraw, - metalDrawableCount: metalCount, - metalLastDrawableTime: metalLast, - presentCount: presentCount, - lastPresentTime: lastPresent, - layerClass: layerClass, - layerContentsKey: contentsKey, - inWindow: inWindow, - windowIsKey: windowIsKey, - windowOcclusionVisible: windowOcclusionVisible, - appIsActive: appIsActive, - isActive: isActive, - desiredFocus: surfaceView.desiredFocus, - isFirstResponder: isFirstResponder - ) - } -#endif - -#if DEBUG - struct DebugFrameSample { - let sampleCount: Int - let uniqueQuantized: Int - let lumaStdDev: Double - let modeFraction: Double - let fingerprint: UInt64 - let iosurfaceWidthPx: Int - let iosurfaceHeightPx: Int - let expectedWidthPx: Int - let expectedHeightPx: Int - let layerClass: String - let layerContentsGravity: String - let layerContentsKey: String - - var isProbablyBlank: Bool { - (lumaStdDev < 3.5 && modeFraction > 0.985) || - (uniqueQuantized <= 6 && modeFraction > 0.95) - } - } - - /// Create a CGImage from the terminal's IOSurface-backed layer contents. - /// - /// This avoids Screen Recording permissions (unlike CGWindowListCreateImage) and is therefore - /// suitable for debug socket tests running in headless/VM contexts. - func debugCopyIOSurfaceCGImage() -> CGImage? { - guard let modelLayer = surfaceView.layer else { return nil } - let layer = modelLayer.presentation() ?? modelLayer - guard let contents = layer.contents else { return nil } - - let cf = contents as CFTypeRef - guard CFGetTypeID(cf) == IOSurfaceGetTypeID() else { return nil } - let surfaceRef = (contents as! IOSurfaceRef) - - let width = Int(IOSurfaceGetWidth(surfaceRef)) - let height = Int(IOSurfaceGetHeight(surfaceRef)) - let bytesPerRow = Int(IOSurfaceGetBytesPerRow(surfaceRef)) - guard width > 0, height > 0, bytesPerRow > 0 else { return nil } - - IOSurfaceLock(surfaceRef, [], nil) - defer { IOSurfaceUnlock(surfaceRef, [], nil) } - - let base = IOSurfaceGetBaseAddress(surfaceRef) - let size = bytesPerRow * height - let data = Data(bytes: base, count: size) - - guard let provider = CGDataProvider(data: data as CFData) else { return nil } - let colorSpace = CGColorSpaceCreateDeviceRGB() - let bitmapInfo = CGBitmapInfo.byteOrder32Little.union( - CGBitmapInfo(rawValue: CGImageAlphaInfo.premultipliedFirst.rawValue) - ) - - return CGImage( - width: width, - height: height, - bitsPerComponent: 8, - bitsPerPixel: 32, - bytesPerRow: bytesPerRow, - space: colorSpace, - bitmapInfo: bitmapInfo, - provider: provider, - decode: nil, - shouldInterpolate: false, - intent: .defaultIntent - ) - } - - /// Sample the IOSurface backing the terminal layer (if any) to detect a transient blank frame - /// without using screenshots/screen recording permissions. - func debugSampleIOSurface(normalizedCrop: CGRect) -> DebugFrameSample? { - guard let modelLayer = surfaceView.layer else { return nil } - // Prefer the presentation layer to better match what the user sees on screen. - let layer = modelLayer.presentation() ?? modelLayer - let layerClass = String(describing: type(of: layer)) - let layerContentsGravity = layer.contentsGravity.rawValue - let contentsKey = Self.contentsKey(for: layer) - let presentationScale = max(1.0, layer.contentsScale) - let expectedWidthPx = Int((layer.bounds.width * presentationScale).rounded(.toNearestOrAwayFromZero)) - let expectedHeightPx = Int((layer.bounds.height * presentationScale).rounded(.toNearestOrAwayFromZero)) - - // Ghostty uses a CoreAnimation layer whose `contents` is an IOSurface-backed object. - // The concrete layer class is often `IOSurfaceLayer` (private), so avoid referencing it directly. - guard let anySurface = layer.contents else { - // Treat "no contents" as a blank frame: this is the visual regression we're guarding. - return DebugFrameSample( - sampleCount: 0, - uniqueQuantized: 0, - lumaStdDev: 0, - modeFraction: 1, - fingerprint: 0, - iosurfaceWidthPx: 0, - iosurfaceHeightPx: 0, - expectedWidthPx: expectedWidthPx, - expectedHeightPx: expectedHeightPx, - layerClass: layerClass, - layerContentsGravity: layerContentsGravity, - layerContentsKey: contentsKey - ) - } - - // IOSurfaceLayer.contents is usually an IOSurface, but during mitigation we may - // temporarily replace contents with a CGImage snapshot to avoid blank flashes. - // Treat non-IOSurface contents as "non-blank" and avoid unsafe casts. - let cf = anySurface as CFTypeRef - guard CFGetTypeID(cf) == IOSurfaceGetTypeID() else { - var fnv: UInt64 = 1469598103934665603 - for b in contentsKey.utf8 { - fnv ^= UInt64(b) - fnv &*= 1099511628211 - } - return DebugFrameSample( - sampleCount: 1, - uniqueQuantized: 1, - lumaStdDev: 999, - modeFraction: 0, - fingerprint: fnv, - iosurfaceWidthPx: 0, - iosurfaceHeightPx: 0, - expectedWidthPx: expectedWidthPx, - expectedHeightPx: expectedHeightPx, - layerClass: layerClass, - layerContentsGravity: layerContentsGravity, - layerContentsKey: contentsKey - ) - } - - let surfaceRef = (anySurface as! IOSurfaceRef) - - let width = Int(IOSurfaceGetWidth(surfaceRef)) - let height = Int(IOSurfaceGetHeight(surfaceRef)) - if width <= 0 || height <= 0 { return nil } - - let cropPx = CGRect( - x: max(0, min(CGFloat(width - 1), normalizedCrop.origin.x * CGFloat(width))), - y: max(0, min(CGFloat(height - 1), normalizedCrop.origin.y * CGFloat(height))), - width: max(1, min(CGFloat(width), normalizedCrop.width * CGFloat(width))), - height: max(1, min(CGFloat(height), normalizedCrop.height * CGFloat(height))) - ).integral - - let x0 = Int(cropPx.minX) - let y0 = Int(cropPx.minY) - let x1 = Int(min(CGFloat(width), cropPx.maxX)) - let y1 = Int(min(CGFloat(height), cropPx.maxY)) - if x1 <= x0 || y1 <= y0 { return nil } - - IOSurfaceLock(surfaceRef, [], nil) - defer { IOSurfaceUnlock(surfaceRef, [], nil) } - - let base = IOSurfaceGetBaseAddress(surfaceRef) - let bytesPerRow = IOSurfaceGetBytesPerRow(surfaceRef) - if bytesPerRow <= 0 { return nil } - - // Assume 4 bytes/pixel BGRA (common for IOSurfaceLayer contents). - let bytesPerPixel = 4 - let step = 6 - - var hist = [UInt16: Int]() - hist.reserveCapacity(256) - - var lumas = [Double]() - lumas.reserveCapacity(((x1 - x0) / step) * ((y1 - y0) / step)) - - var count = 0 - var fnv: UInt64 = 1469598103934665603 - - for y in stride(from: y0, to: y1, by: step) { - let row = base.advanced(by: y * bytesPerRow) - for x in stride(from: x0, to: x1, by: step) { - let p = row.advanced(by: x * bytesPerPixel) - let b = Double(p.load(fromByteOffset: 0, as: UInt8.self)) - let g = Double(p.load(fromByteOffset: 1, as: UInt8.self)) - let r = Double(p.load(fromByteOffset: 2, as: UInt8.self)) - let luma = 0.2126 * r + 0.7152 * g + 0.0722 * b - lumas.append(luma) - - let rq = UInt16(UInt8(r) >> 4) - let gq = UInt16(UInt8(g) >> 4) - let bq = UInt16(UInt8(b) >> 4) - let key = (rq << 8) | (gq << 4) | bq - hist[key, default: 0] += 1 - count += 1 - - let lq = UInt8(max(0, min(63, Int(luma / 4.0)))) - fnv ^= UInt64(lq) - fnv &*= 1099511628211 - } - } - - guard count > 0 else { return nil } - let mean = lumas.reduce(0.0, +) / Double(lumas.count) - let variance = lumas.reduce(0.0) { $0 + ($1 - mean) * ($1 - mean) } / Double(lumas.count) - let stddev = sqrt(variance) - - let modeCount = hist.values.max() ?? 0 - let modeFrac = Double(modeCount) / Double(count) - - return DebugFrameSample( - sampleCount: count, - uniqueQuantized: hist.count, - lumaStdDev: stddev, - modeFraction: modeFrac, - fingerprint: fnv, - iosurfaceWidthPx: width, - iosurfaceHeightPx: height, - expectedWidthPx: expectedWidthPx, - expectedHeightPx: expectedHeightPx, - layerClass: layerClass, - layerContentsGravity: layerContentsGravity, - layerContentsKey: contentsKey - ) - } -#endif - func cancelFocusRequest() { // Intentionally no-op (no retry loops). } From 593ea63c55b13d432da3bca9d73ec17454e1dda1 Mon Sep 17 00:00:00 2001 From: arzafran Date: Thu, 9 Jul 2026 14:12:05 -0300 Subject: [PATCH 17/19] refactor(workspace-remote): split WorkspaceRemoteSessionController by concern WorkspaceRemoteSession.swift (2582 lines) held a single class, WorkspaceRemoteSessionController, whose ~100 methods spanned five unrelated concerns. Split along those seams into extension files, moving every method verbatim: - WorkspaceRemoteSessionController+ConnectionOrchestration.swift: connection-attempt/reverse-relay/proxy-lease orchestration and status publishing (stopAllLocked...stopReverseRelayViaControlMasterLocked, plus the bootstrapRemoteTTYRetry* constants). - WorkspaceRemoteSessionController+ProcessExecution.swift: ssh/scp argument assembly and the underlying Process execution primitive (sshCommonArguments...runProcess). - WorkspaceRemoteSessionController+DaemonInstall.swift: remote daemon bootstrap/build/download/upload and dropped-file upload (bootstrapDaemonLocked...helloRemoteDaemonLocked, plus the remotePlatformProbe* markers and remoteDaemonManifestInfoKey). - WorkspaceRemoteSessionController+ScriptBuilders.swift: debug logging, remote shell script builders, and process/PID utilities (debugLog...shouldEscalateProxyErrorToBootstrap, plus cachedRemoteDaemonSourceFingerprint). - WorkspaceRemoteSessionController+PortScanning.swift: remote listening-port scan scheduling, polling, and script builders (updateRemotePortScanTTYs...remoteAllPortsScanScript). WorkspaceRemoteSession.swift itself now holds only the class declaration, its nested types, stored properties, init, and the start/stop/ uploadDroppedFiles entry points. Access-level widening: every member of WorkspaceRemoteSessionController (nested types, stored properties, and ~100 methods) was declared private, which in Swift restricts visibility to the declaring file. To split the class into extensions across files, all of these were widened from private to the Swift default internal access level -- still module-scoped, not exposed outside the app target. This is a blanket, mechanical widening across the whole type rather than an itemized one (the type's entire private surface needed it to support the split), verified behavior-identical by a non-blank-line multiset diff against the pre-split file before committing. project.pbxproj updated: five new extension files registered (main app target only). Refs #98. --- GhosttyTabs.xcodeproj/project.pbxproj | 20 + Sources/WorkspaceRemoteSession.swift | 2458 +---------------- ...onController+ConnectionOrchestration.swift | 613 ++++ ...emoteSessionController+DaemonInstall.swift | 599 ++++ ...RemoteSessionController+PortScanning.swift | 489 ++++ ...teSessionController+ProcessExecution.swift | 193 ++ ...moteSessionController+ScriptBuilders.swift | 545 ++++ 7 files changed, 2504 insertions(+), 2413 deletions(-) create mode 100644 Sources/WorkspaceRemoteSessionController+ConnectionOrchestration.swift create mode 100644 Sources/WorkspaceRemoteSessionController+DaemonInstall.swift create mode 100644 Sources/WorkspaceRemoteSessionController+PortScanning.swift create mode 100644 Sources/WorkspaceRemoteSessionController+ProcessExecution.swift create mode 100644 Sources/WorkspaceRemoteSessionController+ScriptBuilders.swift diff --git a/GhosttyTabs.xcodeproj/project.pbxproj b/GhosttyTabs.xcodeproj/project.pbxproj index 7e8fafe7427..de8d78f6291 100644 --- a/GhosttyTabs.xcodeproj/project.pbxproj +++ b/GhosttyTabs.xcodeproj/project.pbxproj @@ -53,6 +53,11 @@ A5001405 /* PanelContentView.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5001415 /* PanelContentView.swift */; }; A5001406 /* Workspace.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5001416 /* Workspace.swift */; }; A5FF0012 /* WorkspaceRemoteSession.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5FF0002 /* WorkspaceRemoteSession.swift */; }; + NRWS00000000000000000018 /* WorkspaceRemoteSessionController+ConnectionOrchestration.swift in Sources */ = {isa = PBXBuildFile; fileRef = NRWS00000000000000000017 /* WorkspaceRemoteSessionController+ConnectionOrchestration.swift */; }; + NRWS00000000000000000020 /* WorkspaceRemoteSessionController+ProcessExecution.swift in Sources */ = {isa = PBXBuildFile; fileRef = NRWS00000000000000000019 /* WorkspaceRemoteSessionController+ProcessExecution.swift */; }; + NRWS00000000000000000022 /* WorkspaceRemoteSessionController+DaemonInstall.swift in Sources */ = {isa = PBXBuildFile; fileRef = NRWS00000000000000000021 /* WorkspaceRemoteSessionController+DaemonInstall.swift */; }; + NRWS00000000000000000024 /* WorkspaceRemoteSessionController+ScriptBuilders.swift in Sources */ = {isa = PBXBuildFile; fileRef = NRWS00000000000000000023 /* WorkspaceRemoteSessionController+ScriptBuilders.swift */; }; + NRWS00000000000000000026 /* WorkspaceRemoteSessionController+PortScanning.swift in Sources */ = {isa = PBXBuildFile; fileRef = NRWS00000000000000000025 /* WorkspaceRemoteSessionController+PortScanning.swift */; }; NRWS00000000000000000006 /* WorkspaceRemoteDaemonPendingCallRegistry.swift in Sources */ = {isa = PBXBuildFile; fileRef = NRWS00000000000000000005 /* WorkspaceRemoteDaemonPendingCallRegistry.swift */; }; NRWS00000000000000000008 /* WorkspaceRemoteSSHBatchCommandBuilder.swift in Sources */ = {isa = PBXBuildFile; fileRef = NRWS00000000000000000007 /* WorkspaceRemoteSSHBatchCommandBuilder.swift */; }; NRWS00000000000000000010 /* WorkspaceRemoteDaemonRPCClient.swift in Sources */ = {isa = PBXBuildFile; fileRef = NRWS00000000000000000009 /* WorkspaceRemoteDaemonRPCClient.swift */; }; @@ -284,6 +289,11 @@ A5001419 /* MarkdownPanelView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Panels/MarkdownPanelView.swift; sourceTree = ""; }; A5001416 /* Workspace.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Workspace.swift; sourceTree = ""; }; A5FF0002 /* WorkspaceRemoteSession.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WorkspaceRemoteSession.swift; sourceTree = ""; }; + NRWS00000000000000000017 /* WorkspaceRemoteSessionController+ConnectionOrchestration.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "WorkspaceRemoteSessionController+ConnectionOrchestration.swift"; sourceTree = ""; }; + NRWS00000000000000000019 /* WorkspaceRemoteSessionController+ProcessExecution.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "WorkspaceRemoteSessionController+ProcessExecution.swift"; sourceTree = ""; }; + NRWS00000000000000000021 /* WorkspaceRemoteSessionController+DaemonInstall.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "WorkspaceRemoteSessionController+DaemonInstall.swift"; sourceTree = ""; }; + NRWS00000000000000000023 /* WorkspaceRemoteSessionController+ScriptBuilders.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "WorkspaceRemoteSessionController+ScriptBuilders.swift"; sourceTree = ""; }; + NRWS00000000000000000025 /* WorkspaceRemoteSessionController+PortScanning.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "WorkspaceRemoteSessionController+PortScanning.swift"; sourceTree = ""; }; NRWS00000000000000000005 /* WorkspaceRemoteDaemonPendingCallRegistry.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WorkspaceRemoteDaemonPendingCallRegistry.swift; sourceTree = ""; }; NRWS00000000000000000007 /* WorkspaceRemoteSSHBatchCommandBuilder.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WorkspaceRemoteSSHBatchCommandBuilder.swift; sourceTree = ""; }; NRWS00000000000000000009 /* WorkspaceRemoteDaemonRPCClient.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WorkspaceRemoteDaemonRPCClient.swift; sourceTree = ""; }; @@ -532,6 +542,11 @@ A5001511 /* UITestRecorder.swift */, A5001416 /* Workspace.swift */, A5FF0002 /* WorkspaceRemoteSession.swift */, + NRWS00000000000000000017 /* WorkspaceRemoteSessionController+ConnectionOrchestration.swift */, + NRWS00000000000000000019 /* WorkspaceRemoteSessionController+ProcessExecution.swift */, + NRWS00000000000000000021 /* WorkspaceRemoteSessionController+DaemonInstall.swift */, + NRWS00000000000000000023 /* WorkspaceRemoteSessionController+ScriptBuilders.swift */, + NRWS00000000000000000025 /* WorkspaceRemoteSessionController+PortScanning.swift */, NRWS00000000000000000005 /* WorkspaceRemoteDaemonPendingCallRegistry.swift */, NRWS00000000000000000007 /* WorkspaceRemoteSSHBatchCommandBuilder.swift */, NRWS00000000000000000009 /* WorkspaceRemoteDaemonRPCClient.swift */, @@ -869,6 +884,11 @@ A5001501 /* UITestRecorder.swift in Sources */, A5001406 /* Workspace.swift in Sources */, A5FF0012 /* WorkspaceRemoteSession.swift in Sources */, + NRWS00000000000000000018 /* WorkspaceRemoteSessionController+ConnectionOrchestration.swift in Sources */, + NRWS00000000000000000020 /* WorkspaceRemoteSessionController+ProcessExecution.swift in Sources */, + NRWS00000000000000000022 /* WorkspaceRemoteSessionController+DaemonInstall.swift in Sources */, + NRWS00000000000000000024 /* WorkspaceRemoteSessionController+ScriptBuilders.swift in Sources */, + NRWS00000000000000000026 /* WorkspaceRemoteSessionController+PortScanning.swift in Sources */, NRWS00000000000000000006 /* WorkspaceRemoteDaemonPendingCallRegistry.swift in Sources */, NRWS00000000000000000008 /* WorkspaceRemoteSSHBatchCommandBuilder.swift in Sources */, NRWS00000000000000000010 /* WorkspaceRemoteDaemonRPCClient.swift in Sources */, diff --git a/Sources/WorkspaceRemoteSession.swift b/Sources/WorkspaceRemoteSession.swift index bf1d1bd0c61..e497c992f61 100644 --- a/Sources/WorkspaceRemoteSession.swift +++ b/Sources/WorkspaceRemoteSession.swift @@ -36,41 +36,41 @@ final class WorkspaceRemoteSessionController { } } - private struct RetrySchedule { + struct RetrySchedule { let retry: Int let delay: TimeInterval } - private struct CommandResult { + struct CommandResult { let status: Int32 let stdout: String let stderr: String } - private struct RemotePlatform { + struct RemotePlatform { let goOS: String let goArch: String } - private struct RemoteBootstrapState { + struct RemoteBootstrapState { let platform: RemotePlatform let binaryExists: Bool } - private struct DaemonHello { + 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 let queueKey = DispatchSpecificKey() - private weak var workspace: Workspace? - private let configuration: WorkspaceRemoteConfiguration - private let controllerID: UUID + let queue = DispatchQueue(label: "com.cmux.remote-ssh.\(UUID().uuidString)", qos: .utility) + let queueKey = DispatchSpecificKey() + weak var workspace: Workspace? + let configuration: WorkspaceRemoteConfiguration + let controllerID: UUID - private enum RemotePortPollingMode { + enum RemotePortPollingMode { case hostWide case hostWideDelta case ttyScoped @@ -98,40 +98,40 @@ final class WorkspaceRemoteSessionController { } } - private var isStopping = false - private var proxyLease: WorkspaceRemoteProxyBroker.Lease? - private var proxyEndpoint: BrowserProxyEndpoint? - private var daemonReady = false - private var daemonBootstrapVersion: String? - private var daemonRemotePath: String? - private var reverseRelayProcess: Process? - private var reverseRelayControlMasterForwardSpec: String? - private var cliRelayServer: WorkspaceRemoteCLIRelayServer? - private var remotePortScanTTYNames: [UUID: String] = [:] - private var remoteScannedPortsByPanel: [UUID: [Int]] = [:] - private var remotePortScanBurstActive = false - private var remotePortScanActiveReason: PortScanKickReason? - private var remotePortScanPendingReason: PortScanKickReason? - private var remotePortScanGeneration: UInt64 = 0 - private var remotePortScanCoalesceWorkItem: DispatchWorkItem? - private var remotePortPollTimer: DispatchSourceTimer? - private var remotePortPollMode: RemotePortPollingMode? - private var polledRemotePorts: [Int] = [] - private var remotePortPollBaselinePorts: Set? - private var keepPolledRemotePortsUntilTTYScan = false - private var bootstrapRemoteTTYResolved = false - private var bootstrapRemoteTTYRetryWorkItem: DispatchWorkItem? - private var bootstrapRemoteTTYFetchInFlight = false - private var bootstrapRemoteTTYRetryCount = 0 - private var reverseRelayStderrPipe: Pipe? - private var reverseRelayRestartWorkItem: DispatchWorkItem? - private var reverseRelayStderrBuffer = "" - private var reconnectRetryCount = 0 - private var reconnectWorkItem: DispatchWorkItem? - private var heartbeatCount: Int = 0 - private var connectionAttemptStartedAt: Date? - - private static let reverseRelayStartupGracePeriod: TimeInterval = 0.5 + var isStopping = false + var proxyLease: WorkspaceRemoteProxyBroker.Lease? + var proxyEndpoint: BrowserProxyEndpoint? + var daemonReady = false + var daemonBootstrapVersion: String? + var daemonRemotePath: String? + var reverseRelayProcess: Process? + var reverseRelayControlMasterForwardSpec: String? + var cliRelayServer: WorkspaceRemoteCLIRelayServer? + var remotePortScanTTYNames: [UUID: String] = [:] + var remoteScannedPortsByPanel: [UUID: [Int]] = [:] + var remotePortScanBurstActive = false + var remotePortScanActiveReason: PortScanKickReason? + var remotePortScanPendingReason: PortScanKickReason? + var remotePortScanGeneration: UInt64 = 0 + var remotePortScanCoalesceWorkItem: DispatchWorkItem? + var remotePortPollTimer: DispatchSourceTimer? + var remotePortPollMode: RemotePortPollingMode? + var polledRemotePorts: [Int] = [] + var remotePortPollBaselinePorts: Set? + var keepPolledRemotePortsUntilTTYScan = false + var bootstrapRemoteTTYResolved = false + var bootstrapRemoteTTYRetryWorkItem: DispatchWorkItem? + var bootstrapRemoteTTYFetchInFlight = false + var bootstrapRemoteTTYRetryCount = 0 + var reverseRelayStderrPipe: Pipe? + var reverseRelayRestartWorkItem: DispatchWorkItem? + var reverseRelayStderrBuffer = "" + var reconnectRetryCount = 0 + var reconnectWorkItem: DispatchWorkItem? + var heartbeatCount: Int = 0 + var connectionAttemptStartedAt: Date? + + static let reverseRelayStartupGracePeriod: TimeInterval = 0.5 init(workspace: Workspace, configuration: WorkspaceRemoteConfiguration, controllerID: UUID) { self.workspace = workspace @@ -211,2372 +211,4 @@ final class WorkspaceRemoteSessionController { ) } - private func stopAllLocked() { - debugLog("remote.session.stop \(debugConfigSummary())") - isStopping = true - reconnectWorkItem?.cancel() - reconnectWorkItem = nil - reconnectRetryCount = 0 - reverseRelayRestartWorkItem?.cancel() - reverseRelayRestartWorkItem = nil - remotePortScanCoalesceWorkItem?.cancel() - remotePortScanCoalesceWorkItem = nil - stopReverseRelayLocked() - remotePortScanGeneration &+= 1 - remotePortScanBurstActive = false - remotePortScanActiveReason = nil - remotePortScanPendingReason = nil - remotePortScanTTYNames.removeAll() - remoteScannedPortsByPanel.removeAll() - stopRemotePortPollingLocked() - polledRemotePorts = [] - remotePortPollBaselinePorts = nil - keepPolledRemotePortsUntilTTYScan = false - bootstrapRemoteTTYResolved = false - bootstrapRemoteTTYRetryWorkItem?.cancel() - bootstrapRemoteTTYRetryWorkItem = nil - bootstrapRemoteTTYFetchInFlight = false - bootstrapRemoteTTYRetryCount = 0 - - proxyLease?.release() - proxyLease = nil - proxyEndpoint = nil - daemonReady = false - daemonBootstrapVersion = nil - daemonRemotePath = nil - publishProxyEndpoint(nil) - publishPortsSnapshotLocked() - } - - private func beginConnectionAttemptLocked() { - guard !isStopping else { return } - - Self.killOrphanedRemoteSSHProcesses( - destination: configuration.destination, - relayPort: configuration.relayPort - ) - connectionAttemptStartedAt = Date() - debugLog("remote.session.connect.begin retry=\(reconnectRetryCount) \(debugConfigSummary())") - reconnectWorkItem = nil - bootstrapRemoteTTYRetryWorkItem?.cancel() - bootstrapRemoteTTYRetryWorkItem = nil - bootstrapRemoteTTYFetchInFlight = false - if remotePortScanTTYNames.isEmpty { - bootstrapRemoteTTYResolved = false - bootstrapRemoteTTYRetryCount = 0 - } - 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() - guard hello.capabilities.contains(WorkspaceRemoteDaemonRPCClient.requiredProxyStreamCapability) else { - throw NSError(domain: "programa.remote.daemon", code: 43, userInfo: [ - NSLocalizedDescriptionKey: "remote daemon missing required capability \(WorkspaceRemoteDaemonRPCClient.requiredProxyStreamCapability)", - ]) - } - 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 - ) - recordHeartbeatActivityLocked() - startReverseRelayLocked(remotePath: hello.remotePath) - requestBootstrapRemoteTTYIfNeededLocked() - startProxyLocked() - } catch { - daemonReady = false - daemonBootstrapVersion = nil - daemonRemotePath = nil - let retrySchedule = scheduleReconnectLocked(baseDelay: 4.0) - let retrySuffix = Self.retrySuffix(retry: retrySchedule.retry, delay: retrySchedule.delay) - let detail = "Remote daemon bootstrap failed: \(error.localizedDescription)\(retrySuffix)" - publishDaemonStatus(.error, detail: detail) - publishState(.error, detail: detail) - } - } - - private func startProxyLocked() { - guard !isStopping else { return } - guard daemonReady else { return } - guard proxyLease == nil else { return } - guard let remotePath = daemonRemotePath, - !remotePath.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty else { - let retrySchedule = scheduleReconnectLocked(baseDelay: 4.0) - let retrySuffix = Self.retrySuffix(retry: retrySchedule.retry, delay: retrySchedule.delay) - let detail = "Remote daemon did not provide a valid remote path\(retrySuffix)" - publishDaemonStatus(.error, detail: detail) - publishState(.error, detail: detail) - return - } - - let lease = WorkspaceRemoteProxyBroker.shared.acquire( - configuration: configuration, - remotePath: remotePath - ) { [weak self] update in - self?.queue.async { - self?.handleProxyBrokerUpdateLocked(update) - } - } - proxyLease = lease - } - - private func startReverseRelayLocked(remotePath: String) { - guard !isStopping else { return } - guard daemonReady else { return } - guard let relayPort = configuration.relayPort, relayPort > 0, - let relayID = configuration.relayID?.trimmingCharacters(in: .whitespacesAndNewlines), - !relayID.isEmpty, - let relayToken = configuration.relayToken?.trimmingCharacters(in: .whitespacesAndNewlines), - !relayToken.isEmpty, - let localSocketPath = configuration.localSocketPath? - .trimmingCharacters(in: .whitespacesAndNewlines), - !localSocketPath.isEmpty else { - return - } - guard reverseRelayProcess == nil else { return } - guard reverseRelayControlMasterForwardSpec == nil else { return } - - reverseRelayRestartWorkItem?.cancel() - reverseRelayRestartWorkItem = nil - var relayServer: WorkspaceRemoteCLIRelayServer? - do { - let server = try ensureCLIRelayServerLocked( - localSocketPath: localSocketPath, - relayID: relayID, - relayToken: relayToken - ) - relayServer = server - let localRelayPort = try server.start() - Self.killOrphanedRemoteSSHProcesses( - destination: configuration.destination, - relayPort: relayPort - ) - let forwardSpec = "127.0.0.1:\(relayPort):127.0.0.1:\(localRelayPort)" - - if startReverseRelayViaControlMasterLocked(forwardSpec: forwardSpec) { - cliRelayServer = relayServer - reverseRelayStderrBuffer = "" - do { - try installRemoteRelayMetadataLocked( - remotePath: remotePath, - relayPort: relayPort, - relayID: relayID, - relayToken: relayToken - ) - } catch { - debugLog("remote.relay.metadata.error \(error.localizedDescription)") - stopReverseRelayLocked() - scheduleReverseRelayRestartLocked(remotePath: remotePath, delay: 2.0) - return - } - recordHeartbeatActivityLocked() - debugLog( - "remote.relay.start relayPort=\(relayPort) localRelayPort=\(localRelayPort) " + - "target=\(configuration.displayTarget) controlMaster=1" - ) - return - } - - let process = Process() - let stderrPipe = Pipe() - process.executableURL = URL(fileURLWithPath: "/usr/bin/ssh") - process.arguments = reverseRelayArguments(relayPort: relayPort, localRelayPort: localRelayPort) - process.standardInput = FileHandle.nullDevice - process.standardOutput = FileHandle.nullDevice - process.standardError = stderrPipe - - process.terminationHandler = { [weak self] terminated in - self?.queue.async { - self?.handleReverseRelayTerminationLocked(process: terminated) - } - } - - try process.run() - if let startupFailure = Self.reverseRelayStartupFailureDetail( - process: process, - stderrPipe: stderrPipe - ) { - let retryDelay = 2.0 - let retrySeconds = max(1, Int(retryDelay.rounded())) - debugLog( - "remote.relay.startFailed relayPort=\(relayPort) " + - "error=\(startupFailure)" - ) - relayServer?.stop() - publishDaemonStatus( - .error, - detail: "Remote SSH relay unavailable: \(startupFailure) (retry in \(retrySeconds)s)" - ) - scheduleReverseRelayRestartLocked(remotePath: remotePath, delay: retryDelay) - return - } - installReverseRelayStderrHandlerLocked(stderrPipe) - reverseRelayProcess = process - cliRelayServer = relayServer - reverseRelayStderrPipe = stderrPipe - reverseRelayStderrBuffer = "" - do { - try installRemoteRelayMetadataLocked( - remotePath: remotePath, - relayPort: relayPort, - relayID: relayID, - relayToken: relayToken - ) - } catch { - debugLog("remote.relay.metadata.error \(error.localizedDescription)") - stopReverseRelayLocked() - scheduleReverseRelayRestartLocked(remotePath: remotePath, delay: 2.0) - return - } - recordHeartbeatActivityLocked() - debugLog( - "remote.relay.start relayPort=\(relayPort) localRelayPort=\(localRelayPort) " + - "target=\(configuration.displayTarget) controlMaster=0" - ) - } catch { - debugLog( - "remote.relay.startFailed relayPort=\(relayPort) " + - "error=\(error.localizedDescription)" - ) - relayServer?.stop() - cliRelayServer = nil - scheduleReverseRelayRestartLocked(remotePath: remotePath, delay: 2.0) - } - } - - private func installReverseRelayStderrHandlerLocked(_ stderrPipe: Pipe) { - 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.reverseRelayStderrBuffer.append(chunk) - if self.reverseRelayStderrBuffer.count > 8192 { - self.reverseRelayStderrBuffer.removeFirst(self.reverseRelayStderrBuffer.count - 8192) - } - } - } - } - } - - private func handleReverseRelayTerminationLocked(process: Process) { - guard reverseRelayProcess === process else { return } - let stderrDetail = Self.bestErrorLine(stderr: reverseRelayStderrBuffer) - reverseRelayStderrPipe?.fileHandleForReading.readabilityHandler = nil - reverseRelayProcess = nil - reverseRelayStderrPipe = nil - - guard !isStopping else { return } - guard let remotePath = daemonRemotePath, - !remotePath.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty else { return } - - let detail = stderrDetail ?? "status=\(process.terminationStatus)" - debugLog("remote.relay.exit \(detail)") - scheduleReverseRelayRestartLocked(remotePath: remotePath, delay: 2.0) - } - - private func scheduleReverseRelayRestartLocked(remotePath: String, delay: TimeInterval) { - guard !isStopping else { return } - reverseRelayRestartWorkItem?.cancel() - - let workItem = DispatchWorkItem { [weak self] in - guard let self else { return } - self.reverseRelayRestartWorkItem = nil - guard !self.isStopping else { return } - guard self.reverseRelayProcess == nil else { return } - guard self.daemonReady else { return } - self.startReverseRelayLocked(remotePath: self.daemonRemotePath ?? remotePath) - } - reverseRelayRestartWorkItem = workItem - queue.asyncAfter(deadline: .now() + delay, execute: workItem) - } - - private func stopReverseRelayLocked() { - reverseRelayStderrPipe?.fileHandleForReading.readabilityHandler = nil - if let reverseRelayProcess, reverseRelayProcess.isRunning { - reverseRelayProcess.terminate() - } - reverseRelayProcess = nil - stopReverseRelayViaControlMasterLocked() - reverseRelayStderrPipe = nil - reverseRelayStderrBuffer = "" - cliRelayServer?.stop() - cliRelayServer = nil - removeRemoteRelayMetadataLocked() - } - - private func handleProxyBrokerUpdateLocked(_ update: WorkspaceRemoteProxyBroker.Update) { - guard !isStopping else { return } - switch update { - case .connecting: - debugLog("remote.proxy.connecting \(debugConfigSummary())") - if proxyEndpoint == nil { - publishState(.connecting, detail: "Connecting to \(configuration.displayTarget)") - } - case .ready(let endpoint): - debugLog("remote.proxy.ready host=\(endpoint.host) port=\(endpoint.port) \(debugConfigSummary())") - reconnectWorkItem?.cancel() - reconnectWorkItem = nil - reconnectRetryCount = 0 - guard proxyEndpoint != endpoint else { - recordHeartbeatActivityLocked() - return - } - proxyEndpoint = endpoint - publishProxyEndpoint(endpoint) - updateRemotePortPollingStateLocked() - publishPortsSnapshotLocked() - publishState( - .connected, - detail: "Connected to \(configuration.displayTarget) via shared local proxy \(endpoint.host):\(endpoint.port)" - ) - requestBootstrapRemoteTTYIfNeededLocked() - recordHeartbeatActivityLocked() - case .error(let detail): - debugLog("remote.proxy.error detail=\(detail) \(debugConfigSummary())") - remotePortScanGeneration &+= 1 - remotePortScanBurstActive = false - remotePortScanActiveReason = nil - remotePortScanPendingReason = nil - remotePortScanCoalesceWorkItem?.cancel() - remotePortScanCoalesceWorkItem = nil - remoteScannedPortsByPanel.removeAll() - stopRemotePortPollingLocked() - polledRemotePorts = [] - keepPolledRemotePortsUntilTTYScan = false - proxyEndpoint = nil - publishProxyEndpoint(nil) - publishPortsSnapshotLocked() - publishState(.error, detail: "Remote proxy to \(configuration.displayTarget) unavailable: \(detail)") - guard Self.shouldEscalateProxyErrorToBootstrap(detail) else { return } - - proxyLease?.release() - proxyLease = nil - daemonReady = false - daemonBootstrapVersion = nil - daemonRemotePath = nil - - let retrySchedule = scheduleReconnectLocked(baseDelay: 2.0) - let retrySuffix = Self.retrySuffix(retry: retrySchedule.retry, delay: retrySchedule.delay) - publishDaemonStatus( - .error, - detail: "Remote daemon transport needs re-bootstrap after proxy failure\(retrySuffix)" - ) - } - } - - @discardableResult - private func scheduleReconnectLocked(baseDelay: TimeInterval) -> RetrySchedule { - let retryNumber = reconnectRetryCount + 1 - let retryDelay = Self.retryDelay(baseDelay: baseDelay, retry: retryNumber) - guard !isStopping else { return RetrySchedule(retry: retryNumber, delay: retryDelay) } - reconnectWorkItem?.cancel() - reconnectRetryCount = retryNumber - let workItem = DispatchWorkItem { [weak self] in - guard let self else { return } - self.reconnectWorkItem = nil - guard !self.isStopping else { return } - guard self.proxyLease == nil else { return } - self.beginConnectionAttemptLocked() - } - reconnectWorkItem = workItem - queue.asyncAfter(deadline: .now() + retryDelay, execute: workItem) - return RetrySchedule(retry: retryNumber, delay: retryDelay) - } - - private func publishState(_ state: WorkspaceRemoteConnectionState, detail: String?) { - let controllerID = self.controllerID - DispatchQueue.main.async { [weak workspace] in - guard let workspace else { return } - guard workspace.activeRemoteSessionControllerID == controllerID 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 controllerID = self.controllerID - 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 } - guard workspace.activeRemoteSessionControllerID == controllerID else { return } - workspace.applyRemoteDaemonStatusUpdate( - status, - target: workspace.remoteDisplayTarget ?? "remote host" - ) - } - } - - private func publishProxyEndpoint(_ endpoint: BrowserProxyEndpoint?) { - let controllerID = self.controllerID - DispatchQueue.main.async { [weak workspace] in - guard let workspace else { return } - guard workspace.activeRemoteSessionControllerID == controllerID else { return } - workspace.applyRemoteProxyEndpointUpdate(endpoint) - } - } - - private func publishPortsSnapshotLocked() { - let controllerID = self.controllerID - let detectedByPanel = remotePortScanTTYNames.keys.reduce(into: [UUID: [Int]]()) { result, panelId in - result[panelId] = remoteScannedPortsByPanel[panelId] ?? [] - } - let detected = Array( - Set(polledRemotePorts) - .union(detectedByPanel.values.flatMap { $0 }) - ).sorted() - DispatchQueue.main.async { [weak workspace] in - guard let workspace else { return } - guard workspace.activeRemoteSessionControllerID == controllerID else { return } - workspace.applyRemoteDetectedSurfacePortsSnapshot( - detectedByPanel: detectedByPanel, - detected: detected, - forwarded: [], - conflicts: [], - target: workspace.remoteDisplayTarget ?? "remote host" - ) - } - } - - private func recordHeartbeatActivityLocked() { - heartbeatCount += 1 - publishHeartbeat(count: heartbeatCount, at: Date()) - } - - private func publishHeartbeat(count: Int, at date: Date?) { - let controllerID = self.controllerID - DispatchQueue.main.async { [weak workspace] in - guard let workspace else { return } - guard workspace.activeRemoteSessionControllerID == controllerID else { return } - workspace.applyRemoteHeartbeatUpdate(count: count, lastSeenAt: date) - } - } - - private func requestBootstrapRemoteTTYIfNeededLocked() { - guard !bootstrapRemoteTTYResolved else { return } - guard let relayPort = configuration.relayPort, relayPort > 0 else { return } - if !remotePortScanTTYNames.isEmpty { - bootstrapRemoteTTYResolved = true - bootstrapRemoteTTYRetryWorkItem?.cancel() - bootstrapRemoteTTYRetryWorkItem = nil - bootstrapRemoteTTYRetryCount = 0 - return - } - guard !bootstrapRemoteTTYFetchInFlight else { return } - bootstrapRemoteTTYFetchInFlight = true - defer { bootstrapRemoteTTYFetchInFlight = false } - - let command = "sh -c \(RemoteSSHConnectionPolicy.shellSingleQuoted("tty_path=\"$HOME/.programa/relay/\(relayPort).tty\"; if [ -r \"$tty_path\" ]; then cat \"$tty_path\"; fi"))" - do { - let result = try sshExec( - arguments: sshCommonArguments(batchMode: true) + [configuration.destination, command], - timeout: 2 - ) - guard result.status == 0 else { - scheduleBootstrapRemoteTTYRetryLocked() - return - } - guard let ttyName = Self.normalizedRemotePortScanTTYName(result.stdout) else { - scheduleBootstrapRemoteTTYRetryLocked() - return - } - bootstrapRemoteTTYResolved = true - bootstrapRemoteTTYRetryWorkItem?.cancel() - bootstrapRemoteTTYRetryWorkItem = nil - bootstrapRemoteTTYRetryCount = 0 - debugLog("remote.tty.bootstrap.ready tty=\(ttyName) \(debugConfigSummary())") - publishBootstrapRemoteTTY(ttyName) - } catch { - debugLog("remote.tty.bootstrap.failed error=\(error.localizedDescription) \(debugConfigSummary())") - scheduleBootstrapRemoteTTYRetryLocked() - } - } - - private func scheduleBootstrapRemoteTTYRetryLocked() { - guard !isStopping else { return } - guard daemonReady else { return } - guard !bootstrapRemoteTTYResolved else { return } - guard remotePortScanTTYNames.isEmpty else { return } - guard bootstrapRemoteTTYRetryCount < Self.bootstrapRemoteTTYRetryLimit else { return } - guard bootstrapRemoteTTYRetryWorkItem == nil else { return } - - bootstrapRemoteTTYRetryCount += 1 - let workItem = DispatchWorkItem { [weak self] in - guard let self else { return } - self.bootstrapRemoteTTYRetryWorkItem = nil - self.requestBootstrapRemoteTTYIfNeededLocked() - } - bootstrapRemoteTTYRetryWorkItem = workItem - queue.asyncAfter(deadline: .now() + Self.bootstrapRemoteTTYRetryDelay, execute: workItem) - } - - private func publishBootstrapRemoteTTY(_ ttyName: String) { - let controllerID = self.controllerID - DispatchQueue.main.async { [weak workspace] in - guard let workspace else { return } - guard workspace.activeRemoteSessionControllerID == controllerID else { return } - workspace.applyBootstrapRemoteTTY(ttyName) - } - } - - private func reverseRelayArguments(relayPort: Int, localRelayPort: Int) -> [String] { - // Fallback standalone transport when dynamic forwarding through an existing - // control master is unavailable. - var args: [String] = ["-N", "-T", "-S", "none"] - args += sshCommonArguments(batchMode: true) - args += [ - "-o", "ExitOnForwardFailure=yes", - "-o", "RequestTTY=no", - "-R", "127.0.0.1:\(relayPort):127.0.0.1:\(localRelayPort)", - configuration.destination, - ] - return args - } - - private func startReverseRelayViaControlMasterLocked(forwardSpec: String) -> Bool { - guard let arguments = WorkspaceRemoteSSHBatchCommandBuilder.reverseRelayControlMasterArguments( - configuration: configuration, - controlCommand: "forward", - forwardSpec: forwardSpec - ) else { - return false - } - - do { - let result = try sshExec(arguments: arguments, timeout: 6) - guard result.status == 0 else { - let detail = Self.bestErrorLine(stderr: result.stderr, stdout: result.stdout) - ?? "ssh exited \(result.status)" - debugLog("remote.relay.controlmaster.forwardFailed \(detail) \(debugConfigSummary())") - return false - } - reverseRelayControlMasterForwardSpec = forwardSpec - return true - } catch { - debugLog("remote.relay.controlmaster.forwardFailed \(error.localizedDescription) \(debugConfigSummary())") - return false - } - } - - private func stopReverseRelayViaControlMasterLocked() { - guard let forwardSpec = reverseRelayControlMasterForwardSpec else { return } - reverseRelayControlMasterForwardSpec = nil - guard let arguments = WorkspaceRemoteSSHBatchCommandBuilder.reverseRelayControlMasterArguments( - configuration: configuration, - controlCommand: "cancel", - forwardSpec: forwardSpec - ) else { - return - } - _ = try? sshExec(arguments: arguments, timeout: 4) - } - - private static let remotePlatformProbeOSMarker = "__PROGRAMA_REMOTE_OS__=" - private static let remotePlatformProbeArchMarker = "__PROGRAMA_REMOTE_ARCH__=" - private static let remotePlatformProbeExistsMarker = "__PROGRAMA_REMOTE_EXISTS__=" - private static let bootstrapRemoteTTYRetryDelay: TimeInterval = 0.5 - private static let bootstrapRemoteTTYRetryLimit = 8 - - private func sshCommonArguments(batchMode: Bool) -> [String] { - let effectiveSSHOptions: [String] = { - if batchMode { - return RemoteSSHConnectionPolicy.backgroundOptions(configuration.sshOptions) - } - return RemoteSSHConnectionPolicy.normalizedOptions(configuration.sshOptions) - }() - var args = RemoteSSHConnectionPolicy.keepaliveArguments - args += RemoteSSHConnectionPolicy.strictHostKeyCheckingArguments(unlessSetIn: effectiveSSHOptions) - if batchMode { - args += RemoteSSHConnectionPolicy.batchModeArguments - } - 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 effectiveSSHOptions { - args += ["-o", option] - } - 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, - operation: TerminalImageTransferOperation? = nil - ) throws -> CommandResult { - try runProcess( - executable: "/usr/bin/scp", - arguments: arguments, - stdin: nil, - timeout: timeout, - operation: operation - ) - } - - private func runProcess( - executable: String, - arguments: [String], - environment: [String: String]? = nil, - currentDirectory: URL? = nil, - stdin: Data?, - timeout: TimeInterval, - operation: TerminalImageTransferOperation? = nil - ) throws -> CommandResult { - debugLog( - "remote.proc.start exec=\(URL(fileURLWithPath: executable).lastPathComponent) " + - "timeout=\(Int(timeout)) args=\(debugShellCommand(executable: executable, arguments: arguments))" - ) - 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 - } - - let stdoutHandle = stdoutPipe.fileHandleForReading - let stderrHandle = stderrPipe.fileHandleForReading - let captureQueue = DispatchQueue(label: "programa.remote.process.capture") - let exitSemaphore = DispatchSemaphore(value: 0) - var stdoutData = Data() - var stderrData = Data() - let captureGroup = DispatchGroup() - process.terminationHandler = { _ in - exitSemaphore.signal() - } - captureGroup.enter() - DispatchQueue.global(qos: .utility).async { - let data = stdoutHandle.readDataToEndOfFile() - captureQueue.sync { - stdoutData = data - } - captureGroup.leave() - } - captureGroup.enter() - DispatchQueue.global(qos: .utility).async { - let data = stderrHandle.readDataToEndOfFile() - captureQueue.sync { - stderrData = data - } - captureGroup.leave() - } - - do { - try operation?.throwIfCancelled() - try process.run() - } catch { - try? stdoutPipe.fileHandleForWriting.close() - try? stderrPipe.fileHandleForWriting.close() - debugLog( - "remote.proc.launchFailed exec=\(URL(fileURLWithPath: executable).lastPathComponent) " + - "error=\(error.localizedDescription)" - ) - throw NSError(domain: "programa.remote.process", code: 1, userInfo: [ - NSLocalizedDescriptionKey: "Failed to launch \(URL(fileURLWithPath: executable).lastPathComponent): \(error.localizedDescription)", - ]) - } - try? stdoutPipe.fileHandleForWriting.close() - try? stderrPipe.fileHandleForWriting.close() - operation?.installCancellationHandler { - if process.isRunning { - process.terminate() - } - } - defer { operation?.clearCancellationHandler() } - - if let stdin, let pipe = process.standardInput as? Pipe { - pipe.fileHandleForWriting.write(stdin) - try? pipe.fileHandleForWriting.close() - } - - func terminateProcessAndWait() { - process.terminate() - let terminatedGracefully = exitSemaphore.wait(timeout: .now() + 2.0) == .success - if !terminatedGracefully, process.isRunning { - _ = Darwin.kill(process.processIdentifier, SIGKILL) - process.waitUntilExit() - } - } - - let didExitBeforeTimeout = exitSemaphore.wait(timeout: .now() + max(0, timeout)) == .success - if !didExitBeforeTimeout, process.isRunning { - if operation?.isCancelled == true { - terminateProcessAndWait() - throw TerminalImageTransferExecutionError.cancelled - } - terminateProcessAndWait() - debugLog( - "remote.proc.timeout exec=\(URL(fileURLWithPath: executable).lastPathComponent) " + - "timeout=\(Int(timeout)) args=\(debugShellCommand(executable: executable, arguments: arguments))" - ) - throw NSError(domain: "programa.remote.process", code: 2, userInfo: [ - NSLocalizedDescriptionKey: "\(URL(fileURLWithPath: executable).lastPathComponent) timed out after \(Int(timeout))s", - ]) - } - - _ = captureGroup.wait(timeout: .now() + 2.0) - try? stdoutHandle.close() - try? stderrHandle.close() - let stdout = String(data: stdoutData, encoding: .utf8) ?? "" - let stderr = String(data: stderrData, encoding: .utf8) ?? "" - if operation?.isCancelled == true { - throw TerminalImageTransferExecutionError.cancelled - } - debugLog( - "remote.proc.end exec=\(URL(fileURLWithPath: executable).lastPathComponent) " + - "status=\(process.terminationStatus) stdout=\(Self.debugLogSnippet(stdout)) " + - "stderr=\(Self.debugLogSnippet(stderr))" - ) - return CommandResult(status: process.terminationStatus, stdout: stdout, stderr: stderr) - } - - private func bootstrapDaemonLocked() throws -> DaemonHello { - debugLog("remote.bootstrap.begin \(debugConfigSummary())") - let version = Self.remoteDaemonVersion() - let bootstrapState = try probeRemoteBootstrapStateLocked(version: version) - let platform = bootstrapState.platform - let remotePath = Self.remoteDaemonPath(version: version, goOS: platform.goOS, goArch: platform.goArch) - let explicitOverrideBinary = Self.explicitRemoteDaemonBinaryURL() - let forceExplicitOverrideInstall = explicitOverrideBinary != nil - debugLog( - "remote.bootstrap.platform os=\(platform.goOS) arch=\(platform.goArch) " + - "version=\(version) remotePath=\(remotePath) " + - "allowLocalBuildFallback=\(Self.allowLocalDaemonBuildFallback() ? 1 : 0) " + - "explicitOverride=\(forceExplicitOverrideInstall ? 1 : 0)" - ) - - let hadExistingBinary = bootstrapState.binaryExists - debugLog("remote.bootstrap.binaryExists remotePath=\(remotePath) exists=\(hadExistingBinary ? 1 : 0)") - if forceExplicitOverrideInstall || !hadExistingBinary { - let localBinary = try buildLocalDaemonBinary(goOS: platform.goOS, goArch: platform.goArch, version: version) - try uploadRemoteDaemonBinaryLocked(localBinary: localBinary, remotePath: remotePath) - } - - var hello: DaemonHello - do { - hello = try helloRemoteDaemonLocked(remotePath: remotePath) - } catch { - guard hadExistingBinary else { - throw error - } - debugLog( - "remote.bootstrap.helloRetry remotePath=\(remotePath) " + - "detail=\(error.localizedDescription)" - ) - let localBinary = try buildLocalDaemonBinary(goOS: platform.goOS, goArch: platform.goArch, version: version) - try uploadRemoteDaemonBinaryLocked(localBinary: localBinary, remotePath: remotePath) - hello = try helloRemoteDaemonLocked(remotePath: remotePath) - } - if hadExistingBinary, !hello.capabilities.contains(WorkspaceRemoteDaemonRPCClient.requiredProxyStreamCapability) { - debugLog("remote.bootstrap.capabilityMissing remotePath=\(remotePath) capabilities=\(hello.capabilities.joined(separator: ","))") - let localBinary = try buildLocalDaemonBinary(goOS: platform.goOS, goArch: platform.goArch, version: version) - try uploadRemoteDaemonBinaryLocked(localBinary: localBinary, remotePath: remotePath) - hello = try helloRemoteDaemonLocked(remotePath: remotePath) - } - - debugLog( - "remote.bootstrap.ready name=\(hello.name) version=\(hello.version) " + - "capabilities=\(hello.capabilities.joined(separator: ",")) remotePath=\(hello.remotePath)" - ) - if let connectionAttemptStartedAt { - debugLog( - "remote.timing.bootstrap.ready elapsedMs=\(Int(Date().timeIntervalSince(connectionAttemptStartedAt) * 1000)) " + - "\(debugConfigSummary())" - ) - } - return hello - } - - private func ensureCLIRelayServerLocked(localSocketPath: String, relayID: String, relayToken: String) throws -> WorkspaceRemoteCLIRelayServer { - if let cliRelayServer { - return cliRelayServer - } - let relayServer = try WorkspaceRemoteCLIRelayServer( - localSocketPath: localSocketPath, - relayID: relayID, - relayTokenHex: relayToken - ) - cliRelayServer = relayServer - return relayServer - } - - private func installRemoteRelayMetadataLocked( - remotePath: String, - relayPort: Int, - relayID: String, - relayToken: String - ) throws { - let script = Self.remoteRelayMetadataInstallScript( - daemonRemotePath: remotePath, - relayPort: relayPort, - relayID: relayID, - relayToken: relayToken - ) - let command = "sh -c \(RemoteSSHConnectionPolicy.shellSingleQuoted(script))" - let result = try sshExec(arguments: sshCommonArguments(batchMode: true) + [configuration.destination, command], timeout: 8) - guard result.status == 0 else { - let detail = Self.bestErrorLine(stderr: result.stderr, stdout: result.stdout) ?? "ssh exited \(result.status)" - throw NSError(domain: "programa.remote.relay", code: 70, userInfo: [ - NSLocalizedDescriptionKey: "failed to install remote relay metadata: \(detail)", - ]) - } - } - - private func removeRemoteRelayMetadataLocked() { - guard let relayPort = configuration.relayPort, relayPort > 0 else { return } - let script = Self.remoteRelayMetadataCleanupScript(relayPort: relayPort) - let command = "sh -c \(RemoteSSHConnectionPolicy.shellSingleQuoted(script))" - do { - _ = try sshExec(arguments: sshCommonArguments(batchMode: true) + [configuration.destination, command], timeout: 8) - } catch { - debugLog("remote.relay.cleanup.error \(error.localizedDescription)") - } - } - - static func remoteRelayMetadataCleanupScript(relayPort: Int) -> String { - """ - relay_socket='127.0.0.1:\(relayPort)' - socket_addr_file="$HOME/.programa/socket_addr" - if [ -r "$socket_addr_file" ] && [ "$(tr -d '\\r\\n' < "$socket_addr_file")" = "$relay_socket" ]; then - rm -f "$socket_addr_file" - fi - rm -f "$HOME/.programa/relay/\(relayPort).auth" "$HOME/.programa/relay/\(relayPort).daemon_path" "$HOME/.programa/relay/\(relayPort).tty" - """ - } - - private func probeRemoteBootstrapStateLocked(version: String) throws -> RemoteBootstrapState { - let script = """ - programa_uname_os="$(uname -s)" - programa_uname_arch="$(uname -m)" - printf '%s%s\\n' '\(Self.remotePlatformProbeOSMarker)' "$programa_uname_os" - printf '%s%s\\n' '\(Self.remotePlatformProbeArchMarker)' "$programa_uname_arch" - case "$(printf '%s' "$programa_uname_os" | tr '[:upper:]' '[:lower:]')" in - linux|darwin|freebsd) programa_go_os="$(printf '%s' "$programa_uname_os" | tr '[:upper:]' '[:lower:]')" ;; - *) exit 70 ;; - esac - case "$(printf '%s' "$programa_uname_arch" | tr '[:upper:]' '[:lower:]')" in - x86_64|amd64) programa_go_arch=amd64 ;; - aarch64|arm64) programa_go_arch=arm64 ;; - armv7l) programa_go_arch=arm ;; - *) exit 71 ;; - esac - programa_remote_path="$HOME/.programa/bin/programad-remote/\(version)/${programa_go_os}-${programa_go_arch}/programad-remote" - if [ -x "$programa_remote_path" ]; then - printf '%syes\\n' '\(Self.remotePlatformProbeExistsMarker)' - else - printf '%sno\\n' '\(Self.remotePlatformProbeExistsMarker)' - fi - """ - let command = "sh -c \(RemoteSSHConnectionPolicy.shellSingleQuoted(script))" - let result = try sshExec(arguments: sshCommonArguments(batchMode: true) + [configuration.destination, command], timeout: 20) - - let lines = result.stdout - .split(separator: "\n", omittingEmptySubsequences: false) - .map { $0.trimmingCharacters(in: .whitespacesAndNewlines) } - .filter { !$0.isEmpty } - let unameOS = lines.first { $0.hasPrefix(Self.remotePlatformProbeOSMarker) } - .map { String($0.dropFirst(Self.remotePlatformProbeOSMarker.count)) } - let unameArch = lines.first { $0.hasPrefix(Self.remotePlatformProbeArchMarker) } - .map { String($0.dropFirst(Self.remotePlatformProbeArchMarker.count)) } - guard let unameOS, let unameArch else { - let detail = Self.bestErrorLine(stderr: result.stderr, stdout: result.stdout) ?? "ssh exited \(result.status)" - throw NSError(domain: "programa.remote.daemon", code: 11, userInfo: [ - NSLocalizedDescriptionKey: "failed to query remote platform: \(detail)", - ]) - } - - guard let goOS = Self.mapUnameOS(unameOS), - let goArch = Self.mapUnameArch(unameArch) else { - throw NSError(domain: "programa.remote.daemon", code: 12, userInfo: [ - NSLocalizedDescriptionKey: "unsupported remote platform \(unameOS)/\(unameArch)", - ]) - } - - let binaryExists = lines.first { $0.hasPrefix(Self.remotePlatformProbeExistsMarker) } - .map { String($0.dropFirst(Self.remotePlatformProbeExistsMarker.count)) == "yes" } - if result.status != 0, binaryExists == nil { - let detail = Self.bestErrorLine(stderr: result.stderr, stdout: result.stdout) ?? "ssh exited \(result.status)" - throw NSError(domain: "programa.remote.daemon", code: 13, userInfo: [ - NSLocalizedDescriptionKey: "failed to query remote daemon state: \(detail)", - ]) - } - - return RemoteBootstrapState( - platform: RemotePlatform(goOS: goOS, goArch: goArch), - binaryExists: binaryExists ?? false - ) - } - - static let remoteDaemonManifestInfoKey = "CMUXRemoteDaemonManifestJSON" - - static func remoteDaemonManifest(from infoDictionary: [String: Any]?) -> WorkspaceRemoteDaemonManifest? { - guard let rawManifest = infoDictionary?[remoteDaemonManifestInfoKey] as? String else { return nil } - let trimmed = rawManifest.trimmingCharacters(in: .whitespacesAndNewlines) - guard !trimmed.isEmpty else { return nil } - guard let data = trimmed.data(using: .utf8) else { return nil } - return try? JSONDecoder().decode(WorkspaceRemoteDaemonManifest.self, from: data) - } - - private static func remoteDaemonManifest() -> WorkspaceRemoteDaemonManifest? { - remoteDaemonManifest(from: Bundle.main.infoDictionary) - } - - private static func remoteDaemonCacheRoot(fileManager: FileManager = .default) throws -> URL { - let appSupportRoot = try fileManager.url( - for: .applicationSupportDirectory, - in: .userDomainMask, - appropriateFor: nil, - create: true - ) - let cacheRoot = appSupportRoot - .appendingPathComponent("programa", isDirectory: true) - .appendingPathComponent("remote-daemons", isDirectory: true) - try fileManager.createDirectory(at: cacheRoot, withIntermediateDirectories: true) - return cacheRoot - } - - static func remoteDaemonCachedBinaryURL( - version: String, - goOS: String, - goArch: String, - fileManager: FileManager = .default - ) throws -> URL { - try remoteDaemonCacheRoot(fileManager: fileManager) - .appendingPathComponent(version, isDirectory: true) - .appendingPathComponent("\(goOS)-\(goArch)", isDirectory: true) - .appendingPathComponent("programad-remote", isDirectory: false) - } - - private static func sha256Hex(forFile url: URL) throws -> String { - let data = try Data(contentsOf: url) - let digest = SHA256.hash(data: data) - return digest.map { String(format: "%02x", $0) }.joined() - } - - private static func allowLocalDaemonBuildFallback(environment: [String: String] = ProcessInfo.processInfo.environment) -> Bool { - environment["PROGRAMA_REMOTE_DAEMON_ALLOW_LOCAL_BUILD"] == "1" - } - - private static func explicitRemoteDaemonBinaryURL(environment: [String: String] = ProcessInfo.processInfo.environment) -> URL? { - guard allowLocalDaemonBuildFallback(environment: environment) else { return nil } - guard let path = environment["PROGRAMA_REMOTE_DAEMON_BINARY"]?.trimmingCharacters(in: .whitespacesAndNewlines), - !path.isEmpty else { - return nil - } - return URL(fileURLWithPath: path, isDirectory: false).standardizedFileURL - } - - private static func versionedRemoteDaemonBuildURL(goOS: String, goArch: String, version: String) -> URL { - URL(fileURLWithPath: NSTemporaryDirectory(), isDirectory: true) - .appendingPathComponent("programa-remote-daemon-build", isDirectory: true) - .appendingPathComponent(version, isDirectory: true) - .appendingPathComponent("\(goOS)-\(goArch)", isDirectory: true) - .appendingPathComponent("programad-remote", isDirectory: false) - } - - /// Fetch the live manifest JSON from the release, returning nil on any failure. - private static func fetchRemoteManifestLocked(releaseURL: String, version: String) -> WorkspaceRemoteDaemonManifest? { - guard let manifestURL = URL(string: "\(releaseURL)/programad-remote-manifest.json") else { return nil } - let request = NSMutableURLRequest(url: manifestURL) - request.timeoutInterval = 15 - request.setValue("cmux/\(version)", forHTTPHeaderField: "User-Agent") - let session = URLSession(configuration: .ephemeral) - let semaphore = DispatchSemaphore(value: 0) - var resultData: Data? - session.dataTask(with: request as URLRequest) { data, response, error in - defer { semaphore.signal() } - guard error == nil, - let httpResponse = response as? HTTPURLResponse, - (200...299).contains(httpResponse.statusCode) else { return } - resultData = data - }.resume() - _ = semaphore.wait(timeout: .now() + 20.0) - session.finishTasksAndInvalidate() - guard let data = resultData else { return nil } - return try? JSONDecoder().decode(WorkspaceRemoteDaemonManifest.self, from: data) - } - - private func downloadRemoteDaemonBinaryLocked(entry: WorkspaceRemoteDaemonManifest.Entry, version: String, releaseURL: String? = nil) throws -> URL { - guard let url = URL(string: entry.downloadURL) else { - throw NSError(domain: "programa.remote.daemon", code: 25, userInfo: [ - NSLocalizedDescriptionKey: "remote daemon manifest has an invalid download URL", - ]) - } - - let cacheURL = try Self.remoteDaemonCachedBinaryURL(version: version, goOS: entry.goOS, goArch: entry.goArch) - let fileManager = FileManager.default - try fileManager.createDirectory(at: cacheURL.deletingLastPathComponent(), withIntermediateDirectories: true) - - let request = NSMutableURLRequest(url: url) - request.timeoutInterval = 60 - request.setValue("cmux/\(version)", forHTTPHeaderField: "User-Agent") - let session = URLSession(configuration: .ephemeral) - - let semaphore = DispatchSemaphore(value: 0) - var downloadedURL: URL? - var downloadError: Error? - session.downloadTask(with: request as URLRequest) { localURL, response, error in - defer { semaphore.signal() } - if let error { - downloadError = error - return - } - if let httpResponse = response as? HTTPURLResponse, - !(200...299).contains(httpResponse.statusCode) { - downloadError = NSError(domain: "programa.remote.daemon", code: 26, userInfo: [ - NSLocalizedDescriptionKey: "remote daemon download failed with HTTP \(httpResponse.statusCode)", - ]) - return - } - downloadedURL = localURL - }.resume() - _ = semaphore.wait(timeout: .now() + 75.0) - session.finishTasksAndInvalidate() - - if let downloadError { - throw downloadError - } - guard let downloadedURL else { - throw NSError(domain: "programa.remote.daemon", code: 27, userInfo: [ - NSLocalizedDescriptionKey: "remote daemon download did not produce a file", - ]) - } - - let downloadedSHA = try Self.sha256Hex(forFile: downloadedURL) - if downloadedSHA != entry.sha256.lowercased() { - // The embedded manifest's checksum doesn't match the downloaded binary. - // This can happen when a newer build overwrites the shared release - // asset after this build's manifest was embedded. As a fallback, fetch - // the live manifest from the release and verify against that. - if let releaseURL, - let liveManifest = Self.fetchRemoteManifestLocked(releaseURL: releaseURL, version: version), - let liveEntry = liveManifest.entry(goOS: entry.goOS, goArch: entry.goArch), - downloadedSHA == liveEntry.sha256.lowercased() { - debugLog("remote.download.checksum-fallback: embedded manifest checksum stale, live manifest matched for \(entry.assetName)") - } else { - throw NSError(domain: "programa.remote.daemon", code: 28, userInfo: [ - NSLocalizedDescriptionKey: "remote daemon checksum mismatch for \(entry.assetName)", - ]) - } - } - - let tempURL = cacheURL.deletingLastPathComponent() - .appendingPathComponent(".\(cacheURL.lastPathComponent).tmp-\(UUID().uuidString)") - try? fileManager.removeItem(at: tempURL) - try fileManager.moveItem(at: downloadedURL, to: tempURL) - try fileManager.setAttributes([.posixPermissions: 0o755], ofItemAtPath: tempURL.path) - try? fileManager.removeItem(at: cacheURL) - try fileManager.moveItem(at: tempURL, to: cacheURL) - return cacheURL - } - - private func buildLocalDaemonBinary(goOS: String, goArch: String, version: String) throws -> URL { - if let explicitBinary = Self.explicitRemoteDaemonBinaryURL(), - FileManager.default.isExecutableFile(atPath: explicitBinary.path) { - debugLog("remote.build.explicit path=\(explicitBinary.path)") - return explicitBinary - } - - if let manifest = Self.remoteDaemonManifest(), - manifest.appVersion == version, - let entry = manifest.entry(goOS: goOS, goArch: goArch) { - let cacheURL = try Self.remoteDaemonCachedBinaryURL(version: manifest.appVersion, goOS: goOS, goArch: goArch) - if FileManager.default.fileExists(atPath: cacheURL.path) { - let cachedSHA = try Self.sha256Hex(forFile: cacheURL) - if cachedSHA == entry.sha256.lowercased(), - FileManager.default.isExecutableFile(atPath: cacheURL.path) { - debugLog("remote.build.cached path=\(cacheURL.path)") - return cacheURL - } - try? FileManager.default.removeItem(at: cacheURL) - } - let downloadedURL = try downloadRemoteDaemonBinaryLocked(entry: entry, version: manifest.appVersion, releaseURL: manifest.releaseURL) - debugLog("remote.build.downloaded path=\(downloadedURL.path)") - return downloadedURL - } - - guard Self.allowLocalDaemonBuildFallback() else { - throw NSError(domain: "programa.remote.daemon", code: 20, userInfo: [ - NSLocalizedDescriptionKey: "this build does not include a verified programad-remote manifest for \(goOS)-\(goArch). Use a release build, or set PROGRAMA_REMOTE_DAEMON_ALLOW_LOCAL_BUILD=1 for a dev-only fallback.", - ]) - } - - guard let repoRoot = Self.findRepoRoot() else { - throw NSError(domain: "programa.remote.daemon", code: 20, userInfo: [ - NSLocalizedDescriptionKey: "cannot locate cmux repo root for dev-only programad-remote build fallback", - ]) - } - 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: "programa.remote.daemon", code: 21, userInfo: [ - NSLocalizedDescriptionKey: "missing daemon module at \(goModPath)", - ]) - } - guard let goBinary = Self.which("go") else { - throw NSError(domain: "programa.remote.daemon", code: 22, userInfo: [ - NSLocalizedDescriptionKey: "go is required for the dev-only programad-remote build fallback", - ]) - } - - let output = Self.versionedRemoteDaemonBuildURL(goOS: goOS, goArch: goArch, version: version) - try FileManager.default.createDirectory(at: output.deletingLastPathComponent(), withIntermediateDirectories: true) - - 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", "-buildvcs=false", "-ldflags", ldflags, "-o", output.path, "./cmd/programad-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: "programa.remote.daemon", code: 23, userInfo: [ - NSLocalizedDescriptionKey: "failed to build programad-remote: \(detail)", - ]) - } - guard FileManager.default.isExecutableFile(atPath: output.path) else { - throw NSError(domain: "programa.remote.daemon", code: 24, userInfo: [ - NSLocalizedDescriptionKey: "programad-remote build output is not executable", - ]) - } - debugLog("remote.build.output path=\(output.path)") - return output - } - - private func uploadRemoteDaemonBinaryLocked(localBinary: URL, remotePath: String) throws { - let remoteDirectory = (remotePath as NSString).deletingLastPathComponent - let remoteTempPath = "\(remotePath).tmp-\(UUID().uuidString.prefix(8))" - debugLog( - "remote.upload.begin local=\(localBinary.path) remoteTemp=\(remoteTempPath) remote=\(remotePath)" - ) - - let mkdirScript = "mkdir -p \(RemoteSSHConnectionPolicy.shellSingleQuoted(remoteDirectory))" - let mkdirCommand = "sh -c \(RemoteSSHConnectionPolicy.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: "programa.remote.daemon", code: 30, userInfo: [ - NSLocalizedDescriptionKey: "failed to create remote daemon directory: \(detail)", - ]) - } - - let scpSSHOptions = RemoteSSHConnectionPolicy.backgroundOptions(configuration.sshOptions) - var scpArgs: [String] = ["-q"] - scpArgs += RemoteSSHConnectionPolicy.strictHostKeyCheckingArguments(unlessSetIn: scpSSHOptions) - scpArgs += ["-o", "ControlMaster=no"] - if let port = configuration.port { - scpArgs += ["-P", String(port)] - } - if let identityFile = configuration.identityFile, - !identityFile.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty { - scpArgs += ["-i", identityFile] - } - for option in scpSSHOptions { - scpArgs += ["-o", option] - } - scpArgs += [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: "programa.remote.daemon", code: 31, userInfo: [ - NSLocalizedDescriptionKey: "failed to upload programad-remote: \(detail)", - ]) - } - - let finalizeScript = """ - chmod 755 \(RemoteSSHConnectionPolicy.shellSingleQuoted(remoteTempPath)) && \ - mv \(RemoteSSHConnectionPolicy.shellSingleQuoted(remoteTempPath)) \(RemoteSSHConnectionPolicy.shellSingleQuoted(remotePath)) - """ - let finalizeCommand = "sh -c \(RemoteSSHConnectionPolicy.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: "programa.remote.daemon", code: 32, userInfo: [ - NSLocalizedDescriptionKey: "failed to install remote daemon binary: \(detail)", - ]) - } - } - - private func uploadDroppedFilesLocked( - _ fileURLs: [URL], - operation: TerminalImageTransferOperation - ) throws -> [String] { - let scpSSHOptions = RemoteSSHConnectionPolicy.backgroundOptions(configuration.sshOptions) - return try performSCPUploadWithCancelCleanup( - items: fileURLs, - checkCancelled: { try operation.throwIfCancelled() }, - performUpload: { localURL, record in - let normalizedLocalURL = localURL.standardizedFileURL - guard normalizedLocalURL.isFileURL else { - throw RemoteDropUploadError.invalidFileURL - } - - let remotePath = Self.remoteDropPath(for: normalizedLocalURL) - record(remotePath) - var scpArgs: [String] = ["-q", "-o", "ControlMaster=no"] - scpArgs += RemoteSSHConnectionPolicy.strictHostKeyCheckingArguments(unlessSetIn: scpSSHOptions) - if let port = configuration.port { - scpArgs += ["-P", String(port)] - } - if let identityFile = configuration.identityFile, - !identityFile.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty { - scpArgs += ["-i", identityFile] - } - for option in scpSSHOptions { - scpArgs += ["-o", option] - } - scpArgs += [normalizedLocalURL.path, "\(configuration.destination):\(remotePath)"] - - let scpResult = try scpExec(arguments: scpArgs, timeout: 45, operation: operation) - guard scpResult.status == 0 else { - let detail = Self.bestErrorLine(stderr: scpResult.stderr, stdout: scpResult.stdout) ?? - "scp exited \(scpResult.status)" - throw RemoteDropUploadError.uploadFailed(detail) - } - }, - cleanup: { cleanupUploadedRemotePaths($0) } - ) - } - - static func remoteDropPath(for fileURL: URL, uuid: UUID = UUID()) -> String { - let extensionSuffix = fileURL.pathExtension.trimmingCharacters(in: .whitespacesAndNewlines) - let lowercasedSuffix = extensionSuffix.isEmpty ? "" : ".\(extensionSuffix.lowercased())" - return "/tmp/programa-drop-\(uuid.uuidString.lowercased())\(lowercasedSuffix)" - } - - private func cleanupUploadedRemotePaths(_ remotePaths: [String]) { - guard !remotePaths.isEmpty else { return } - let cleanupScript = "rm -f -- " + remotePaths.map(RemoteSSHConnectionPolicy.shellSingleQuoted).joined(separator: " ") - let cleanupCommand = "sh -c \(RemoteSSHConnectionPolicy.shellSingleQuoted(cleanupScript))" - _ = try? sshExec( - arguments: sshCommonArguments(batchMode: true) + [configuration.destination, cleanupCommand], - timeout: 8 - ) - } - - private func helloRemoteDaemonLocked(remotePath: String) throws -> DaemonHello { - let request = #"{"id":1,"method":"hello","params":{}}"# - let script = "printf '%s\\n' \(RemoteSSHConnectionPolicy.shellSingleQuoted(request)) | \(RemoteSSHConnectionPolicy.shellSingleQuoted(remotePath)) serve --stdio" - let command = "sh -c \(RemoteSSHConnectionPolicy.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: "programa.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: "programa.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: "programa.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! : "programad-remote"), - version: (version?.isEmpty == false ? version! : "dev"), - capabilities: capabilities, - remotePath: remotePath - ) - } - - private func debugLog(_ message: @autoclosure () -> String) { -#if DEBUG - dlog(message()) -#endif - } - - private func debugConfigSummary() -> String { - let controlPath = Self.debugSSHOptionValue(named: "ControlPath", in: configuration.sshOptions) ?? "nil" - return - "target=\(configuration.displayTarget) port=\(configuration.port.map(String.init) ?? "nil") " + - "relayPort=\(configuration.relayPort.map(String.init) ?? "nil") " + - "localSocket=\(configuration.localSocketPath ?? "nil") " + - "controlPath=\(controlPath)" - } - - private func debugShellCommand(executable: String, arguments: [String]) -> String { - ([URL(fileURLWithPath: executable).lastPathComponent] + arguments) - .map(RemoteSSHConnectionPolicy.shellSingleQuoted) - .joined(separator: " ") - } - - private static func debugSSHOptionValue(named key: String, in options: [String]) -> String? { - let loweredKey = key.lowercased() - for option in options { - let trimmed = option.trimmingCharacters(in: .whitespacesAndNewlines) - guard !trimmed.isEmpty else { continue } - let parts = trimmed.split(separator: "=", maxSplits: 1, omittingEmptySubsequences: false) - if parts.count == 2, - parts[0].trimmingCharacters(in: .whitespacesAndNewlines).lowercased() == loweredKey { - return parts[1].trimmingCharacters(in: .whitespacesAndNewlines) - } - } - return nil - } - - private static func debugLogSnippet(_ text: String, limit: Int = 160) -> String { - let normalized = text - .replacingOccurrences(of: "\n", with: "\\n") - .replacingOccurrences(of: "\r", with: "\\r") - .trimmingCharacters(in: .whitespacesAndNewlines) - guard !normalized.isEmpty else { return "\"\"" } - if normalized.count <= limit { - return normalized - } - return String(normalized.prefix(limit)) + "..." - } - - static func remoteCLIWrapperScript() -> String { - """ - #!/bin/sh - set -eu - - daemon="$HOME/.programa/bin/programad-remote-current" - socket_path="${PROGRAMA_SOCKET_PATH:-}" - if [ -z "$socket_path" ] && [ -r "$HOME/.programa/socket_addr" ]; then - socket_path="$(tr -d '\\r\\n' < "$HOME/.programa/socket_addr")" - fi - - if [ -n "$socket_path" ] && [ "${socket_path#/}" = "$socket_path" ] && [ "${socket_path#*:}" != "$socket_path" ]; then - relay_port="${socket_path##*:}" - relay_map="$HOME/.programa/relay/${relay_port}.daemon_path" - if [ -r "$relay_map" ]; then - mapped_daemon="$(tr -d '\\r\\n' < "$relay_map")" - if [ -n "$mapped_daemon" ] && [ -x "$mapped_daemon" ]; then - daemon="$mapped_daemon" - fi - fi - fi - - exec "$daemon" "$@" - """ - } - - static func remoteCLIWrapperInstallScript(daemonRemotePath: String) -> String { - let trimmedRemotePath = daemonRemotePath.trimmingCharacters(in: .whitespacesAndNewlines) - return """ - mkdir -p "$HOME/.programa/bin" "$HOME/.programa/relay" - ln -sf "$HOME/\(trimmedRemotePath)" "$HOME/.programa/bin/programad-remote-current" - wrapper_tmp="$HOME/.programa/bin/.programa-wrapper.tmp.$$" - cat > "$wrapper_tmp" <<'CMUXWRAPPER' - \(remoteCLIWrapperScript()) - CMUXWRAPPER - chmod 755 "$wrapper_tmp" - mv -f "$wrapper_tmp" "$HOME/.programa/bin/programa" - """ - } - - static func remoteRelayMetadataInstallScript( - daemonRemotePath: String, - relayPort: Int, - relayID: String, - relayToken: String - ) -> String { - let trimmedRemotePath = daemonRemotePath.trimmingCharacters(in: .whitespacesAndNewlines) - let authPayload = """ - {"relay_id":"\(relayID)","relay_token":"\(relayToken)"} - """ - return """ - umask 077 - mkdir -p "$HOME/.programa" "$HOME/.programa/relay" - chmod 700 "$HOME/.programa/relay" - \(remoteCLIWrapperInstallScript(daemonRemotePath: trimmedRemotePath)) - printf '%s' "$HOME/\(trimmedRemotePath)" > "$HOME/.programa/relay/\(relayPort).daemon_path" - cat > "$HOME/.programa/relay/\(relayPort).auth" <<'PROGRAMARELAYAUTH' - \(authPayload) - PROGRAMARELAYAUTH - chmod 600 "$HOME/.programa/relay/\(relayPort).auth" - printf '%s' '127.0.0.1:\(relayPort)' > "$HOME/.programa/socket_addr" - """ - } - - 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) - let baseVersion = (bundleVersion?.isEmpty == false) ? bundleVersion! : "dev" - guard allowLocalDaemonBuildFallback(), - let sourceFingerprint = remoteDaemonSourceFingerprint(), - !sourceFingerprint.isEmpty else { - return baseVersion - } - return "\(baseVersion)-dev-\(sourceFingerprint)" - } - - private static let cachedRemoteDaemonSourceFingerprint: String? = computeRemoteDaemonSourceFingerprint() - - private static func remoteDaemonSourceFingerprint() -> String? { - cachedRemoteDaemonSourceFingerprint - } - - private static func computeRemoteDaemonSourceFingerprint(fileManager: FileManager = .default) -> String? { - guard let repoRoot = findRepoRoot() else { return nil } - let daemonRoot = repoRoot.appendingPathComponent("daemon/remote", isDirectory: true) - guard let enumerator = fileManager.enumerator( - at: daemonRoot, - includingPropertiesForKeys: [.isRegularFileKey], - options: [.skipsHiddenFiles] - ) else { - return nil - } - - var relativePaths: [String] = [] - for case let fileURL as URL in enumerator { - guard let resourceValues = try? fileURL.resourceValues(forKeys: [.isRegularFileKey]), - resourceValues.isRegularFile == true else { - continue - } - - let relativePath = fileURL.path.replacingOccurrences(of: daemonRoot.path + "/", with: "") - if relativePath == "go.mod" || relativePath == "go.sum" || relativePath.hasSuffix(".go") { - relativePaths.append(relativePath) - } - } - - guard !relativePaths.isEmpty else { return nil } - - let digest = SHA256.hash(data: relativePaths.sorted().reduce(into: Data()) { partialResult, relativePath in - let fileURL = daemonRoot.appendingPathComponent(relativePath, isDirectory: false) - guard let fileData = try? Data(contentsOf: fileURL) else { return } - partialResult.append(Data(relativePath.utf8)) - partialResult.append(0) - partialResult.append(fileData) - partialResult.append(0) - }) - let hex = digest.map { String(format: "%02x", $0) }.joined() - return String(hex.prefix(12)) - } - - private static func remoteDaemonPath(version: String, goOS: String, goArch: String) -> String { - ".programa/bin/programad-remote/\(version)/\(goOS)-\(goArch)/programad-remote" - } - - static func orphanedCMUXRemoteSSHPIDs( - psOutput: String, - destination: String, - relayPort: Int? = nil - ) -> [Int] { - let trimmedDestination = destination.trimmingCharacters(in: .whitespacesAndNewlines) - guard !trimmedDestination.isEmpty else { return [] } - - return psOutput - .split(separator: "\n", omittingEmptySubsequences: false) - .compactMap { line -> Int? in - guard let parsed = parsePSLine(line) else { return nil } - guard parsed.ppid == 1 else { return nil } - guard isOrphanedCMUXRemoteSSHCommand( - parsed.command, - destination: trimmedDestination, - relayPort: relayPort - ) else { - return nil - } - return parsed.pid - } - .sorted() - } - - private static func killOrphanedRemoteSSHProcesses(destination: String, relayPort: Int? = nil) { - guard let output = captureCommandStandardOutput( - executablePath: "/bin/ps", - arguments: ["-axo", "pid=,ppid=,command="] - ) else { - return - } - - for pid in orphanedCMUXRemoteSSHPIDs( - psOutput: output, - destination: destination, - relayPort: relayPort - ) { - _ = Darwin.kill(pid_t(pid), SIGTERM) - } - } - - private static func captureCommandStandardOutput( - executablePath: String, - arguments: [String] - ) -> String? { - let process = Process() - let stdoutPipe = Pipe() - process.executableURL = URL(fileURLWithPath: executablePath) - process.arguments = arguments - process.standardOutput = stdoutPipe - process.standardError = FileHandle.nullDevice - - do { - try process.run() - let outputData = stdoutPipe.fileHandleForReading.readDataToEndOfFile() - process.waitUntilExit() - guard process.terminationStatus == 0, - let output = String(data: outputData, encoding: .utf8), - !output.isEmpty else { - return nil - } - return output - } catch { - // Best effort cleanup only. - return nil - } - } - - private static func parsePSLine(_ line: Substring) -> (pid: Int, ppid: Int, command: String)? { - let trimmed = line.trimmingCharacters(in: .whitespacesAndNewlines) - guard !trimmed.isEmpty else { return nil } - - let scanner = Scanner(string: trimmed) - var pidValue: Int = 0 - var ppidValue: Int = 0 - guard scanner.scanInt(&pidValue), scanner.scanInt(&ppidValue) else { - return nil - } - - let commandStart = scanner.currentIndex - let command = String(trimmed[commandStart...]).trimmingCharacters(in: .whitespacesAndNewlines) - guard !command.isEmpty else { return nil } - return (pidValue, ppidValue, command) - } - - private static func isOrphanedCMUXRemoteSSHCommand( - _ command: String, - destination: String, - relayPort: Int? - ) -> Bool { - let trimmed = command.trimmingCharacters(in: .whitespacesAndNewlines) - guard !trimmed.isEmpty else { return false } - guard trimmed.hasPrefix("/usr/bin/ssh ") || trimmed.hasPrefix("ssh ") else { return false } - guard commandContainsDestination(trimmed, destination: destination) else { return false } - - if let relayPort { - return trimmed.contains(" -N ") - && trimmed.contains(" -R 127.0.0.1:\(relayPort):127.0.0.1:") - } - - if trimmed.contains(" -N ") && trimmed.contains(" -R 127.0.0.1:") { - return true - } - if trimmed.contains("programad-remote") && trimmed.contains(" serve --stdio") { - return true - } - return false - } - - private static func commandContainsDestination(_ command: String, destination: String) -> Bool { - guard !destination.isEmpty else { return false } - let escaped = NSRegularExpression.escapedPattern(for: destination) - guard let regex = try? NSRegularExpression( - pattern: "(^|[\\s'\\\"])\(escaped)($|[\\s'\\\"])", - options: [] - ) else { - return command.contains(destination) - } - let range = NSRange(command.startIndex.. [String] { - var ordered: [String] = [] - var seen: Set = [] - - func appendSearchPath(_ rawPath: String?) { - guard let rawPath else { return } - let trimmed = rawPath.trimmingCharacters(in: .whitespacesAndNewlines) - guard !trimmed.isEmpty else { return } - guard seen.insert(trimmed).inserted else { return } - ordered.append(trimmed) - } - - if let path = environment["PATH"] { - for component in path.split(separator: ":") { - appendSearchPath(String(component)) - } - } - - if let home = environment["HOME"], !home.isEmpty { - appendSearchPath((home as NSString).appendingPathComponent(".local/bin")) - appendSearchPath((home as NSString).appendingPathComponent("go/bin")) - appendSearchPath((home as NSString).appendingPathComponent("bin")) - } - - let helperOutput = pathHelperOutput ?? pathHelperShellOutput() - for component in parsePathHelperPaths(helperOutput) { - appendSearchPath(component) - } - - for component in [ - "/opt/homebrew/bin", - "/opt/homebrew/sbin", - "/usr/local/bin", - "/usr/local/sbin", - "/usr/bin", - "/bin", - "/usr/sbin", - "/sbin", - ] { - appendSearchPath(component) - } - - return ordered - } - - static func parsePathHelperPaths(_ output: String) -> [String] { - for fragment in output.split(whereSeparator: { $0 == "\n" || $0 == ";" }) { - let trimmed = fragment.trimmingCharacters(in: .whitespacesAndNewlines) - guard trimmed.hasPrefix("PATH=\"") else { continue } - let suffix = trimmed.dropFirst("PATH=\"".count) - guard let closingQuote = suffix.firstIndex(of: "\"") else { return [] } - return suffix[.. String { - let executable = "/usr/libexec/path_helper" - guard FileManager.default.isExecutableFile(atPath: executable) else { return "" } - - let process = Process() - process.executableURL = URL(fileURLWithPath: executable) - process.arguments = ["-s"] - - let stdout = Pipe() - let stderr = Pipe() - process.standardOutput = stdout - process.standardError = stderr - - do { - try process.run() - } catch { - return "" - } - - process.waitUntilExit() - guard process.terminationStatus == 0 else { return "" } - let data = stdout.fileHandleForReading.readDataToEndOfFile() - return String(data: data, encoding: .utf8) ?? "" - } - - private static func which(_ executable: String) -> String? { - for component in executableSearchPaths() { - let candidate = (component as NSString).appendingPathComponent(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["PROGRAMA_REMOTE_DAEMON_SOURCE_ROOT"], - !envRoot.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty { - candidates.append(URL(fileURLWithPath: envRoot, isDirectory: true)) - } - if let envRoot = environment["PROGRAMATERM_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 - } - - static func reverseRelayStartupFailureDetail( - process: Process, - stderrPipe: Pipe, - gracePeriod: TimeInterval = reverseRelayStartupGracePeriod - ) -> String? { - if process.isRunning { - let originalTerminationHandler = process.terminationHandler - let exitSemaphore = DispatchSemaphore(value: 0) - process.terminationHandler = { terminated in - originalTerminationHandler?(terminated) - exitSemaphore.signal() - } - if !process.isRunning { - exitSemaphore.signal() - } - guard exitSemaphore.wait(timeout: .now() + max(0, gracePeriod)) == .success else { - return nil - } - } - let stderrData = stderrPipe.fileHandleForReading.readDataToEndOfFile() - let stderr = String(data: stderrData, encoding: .utf8) ?? "" - return bestErrorLine(stderr: stderr) ?? "status=\(process.terminationStatus)" - } - - 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)" - } - - private static func retryDelay(baseDelay: TimeInterval, retry: Int) -> TimeInterval { - let exponent = Double(max(0, retry - 1)) - return min(baseDelay * pow(2.0, exponent), 60.0) - } - - private static func shouldEscalateProxyErrorToBootstrap(_ detail: String) -> Bool { - let lowered = detail.lowercased() - return lowered.contains("remote daemon transport failed") - || lowered.contains("daemon transport closed stdout") - || lowered.contains("daemon transport exited") - || lowered.contains("daemon transport is not connected") - || lowered.contains("daemon transport stopped") - } - - func updateRemotePortScanTTYs(_ ttyNames: [UUID: String]) { - queue.async { [weak self] in - self?.updateRemotePortScanTTYsLocked(ttyNames) - } - } - - func kickRemotePortScan(panelId: UUID, reason: PortScanKickReason = .command) { - queue.async { [weak self] in - self?.kickRemotePortScanLocked(panelId: panelId, reason: reason) - } - } - - private func updateRemotePortScanTTYsLocked(_ ttyNames: [UUID: String]) { - let previousTTYNames = remotePortScanTTYNames - let nextTTYNames = ttyNames.reduce(into: [UUID: String]()) { result, entry in - guard let ttyName = Self.normalizedRemotePortScanTTYName(entry.value) else { return } - result[entry.key] = ttyName - } - guard previousTTYNames != nextTTYNames else { return } - if !nextTTYNames.isEmpty { - bootstrapRemoteTTYResolved = true - bootstrapRemoteTTYRetryWorkItem?.cancel() - bootstrapRemoteTTYRetryWorkItem = nil - bootstrapRemoteTTYRetryCount = 0 - } - keepPolledRemotePortsUntilTTYScan = - !previousTTYNames.isEmpty - ? keepPolledRemotePortsUntilTTYScan - : shouldUseFallbackRemotePortPollingLocked() && !polledRemotePorts.isEmpty && !nextTTYNames.isEmpty - remoteScannedPortsByPanel = remoteScannedPortsByPanel.filter { panelId, _ in - guard let oldTTY = previousTTYNames[panelId], - let newTTY = nextTTYNames[panelId] else { - return false - } - return oldTTY == newTTY - } - remotePortScanTTYNames = nextTTYNames - if nextTTYNames.isEmpty { - keepPolledRemotePortsUntilTTYScan = false - } - updateRemotePortPollingStateLocked() - publishPortsSnapshotLocked() - } - - private func kickRemotePortScanLocked(panelId: UUID, reason: PortScanKickReason) { - guard !isStopping else { return } - guard daemonReady else { return } - guard remotePortScanTTYNames[panelId] != nil else { return } - if remotePortScanBurstActive, remotePortScanActiveReason == .command, reason == .refresh { - return - } - remotePortScanPendingReason = remotePortScanPendingReason?.merged(with: reason) ?? reason - scheduleRemotePortScanCoalesceLocked() - } - - private func scheduleRemotePortScanCoalesceLocked() { - guard !remotePortScanBurstActive else { return } - guard remotePortScanCoalesceWorkItem == nil else { return } - - let generation = remotePortScanGeneration - let workItem = DispatchWorkItem { [weak self] in - guard let self else { return } - guard self.remotePortScanGeneration == generation else { return } - self.remotePortScanCoalesceWorkItem = nil - guard let reason = self.remotePortScanPendingReason else { return } - self.remotePortScanPendingReason = nil - self.remotePortScanBurstActive = true - self.remotePortScanActiveReason = reason - self.runRemotePortScanBurstLocked(index: 0, generation: generation, reason: reason) - } - remotePortScanCoalesceWorkItem = workItem - queue.asyncAfter(deadline: .now() + 0.2, execute: workItem) - } - - private func runRemotePortScanBurstLocked( - index: Int, - generation: UInt64, - reason: PortScanKickReason, - burstStart: DispatchTime? = nil - ) { - guard remotePortScanGeneration == generation else { return } - - let burstOffsets = reason.burstOffsets - guard index < burstOffsets.count else { - remotePortScanBurstActive = false - remotePortScanActiveReason = nil - if remotePortScanPendingReason != nil && remotePortScanCoalesceWorkItem == nil { - scheduleRemotePortScanCoalesceLocked() - } - return - } - - let start = burstStart ?? .now() - let deadline = start + burstOffsets[index] - queue.asyncAfter(deadline: deadline) { [weak self] in - guard let self else { return } - guard self.remotePortScanGeneration == generation else { return } - self.performRemotePortScanLocked() - self.runRemotePortScanBurstLocked( - index: index + 1, - generation: generation, - reason: reason, - burstStart: start - ) - } - } - - private func performRemotePortScanLocked() { - let ttyNamesByPanel = remotePortScanTTYNames - guard !ttyNamesByPanel.isEmpty else { - remoteScannedPortsByPanel.removeAll() - keepPolledRemotePortsUntilTTYScan = false - publishPortsSnapshotLocked() - return - } - - do { - remoteScannedPortsByPanel = try scanRemotePortsByPanelLocked(ttyNamesByPanel: ttyNamesByPanel) - keepPolledRemotePortsUntilTTYScan = false - polledRemotePorts = [] - publishPortsSnapshotLocked() - } catch { - debugLog("remote.ports.scan.failed error=\(error.localizedDescription) \(debugConfigSummary())") - } - } - - private func scanRemotePortsByPanelLocked(ttyNamesByPanel: [UUID: String]) throws -> [UUID: [Int]] { - let ttyNames = Array(Set(ttyNamesByPanel.values)).sorted() - guard !ttyNames.isEmpty else { return [:] } - - let command = "sh -c \(RemoteSSHConnectionPolicy.shellSingleQuoted(Self.remotePortScanScript(ttyNames: ttyNames, excluding: excludedRemoteScanPorts())))" - let result = try sshExec( - arguments: sshCommonArguments(batchMode: true) + [configuration.destination, command], - timeout: 8 - ) - guard result.status == 0 else { - let detail = Self.bestErrorLine(stderr: result.stderr, stdout: result.stdout) ?? "ssh exited \(result.status)" - throw NSError(domain: "programa.remote.ports", code: 90, userInfo: [ - NSLocalizedDescriptionKey: "remote port scan failed: \(detail)", - ]) - } - - let portsByTTY = Self.parseRemoteTTYPortPairs( - output: result.stdout, - trackedTTYNames: Set(ttyNames) - ) - - return ttyNamesByPanel.reduce(into: [UUID: [Int]]()) { result, entry in - result[entry.key] = portsByTTY[entry.value] ?? [] - } - } - - private func startRemotePortPollingLocked(mode: RemotePortPollingMode) { - if remotePortPollTimer != nil, remotePortPollMode == mode { - return - } - stopRemotePortPollingLocked() - - let timer = DispatchSource.makeTimerSource(queue: queue) - timer.schedule(deadline: .now() + mode.initialDelay, repeating: mode.repeatInterval) - timer.setEventHandler { [weak self] in - self?.pollRemotePortsLocked() - } - remotePortPollTimer = timer - remotePortPollMode = mode - timer.resume() - pollRemotePortsLocked() - } - - private func stopRemotePortPollingLocked() { - remotePortPollTimer?.setEventHandler {} - remotePortPollTimer?.cancel() - remotePortPollTimer = nil - remotePortPollMode = nil - } - - private func updateRemotePortPollingStateLocked() { - guard daemonReady, !isStopping, let pollingMode = remotePortPollingModeLocked() else { - stopRemotePortPollingLocked() - if !keepPolledRemotePortsUntilTTYScan { - polledRemotePorts = [] - } - remotePortPollBaselinePorts = nil - return - } - startRemotePortPollingLocked(mode: pollingMode) - } - - private func pollRemotePortsLocked() { - guard !isStopping else { return } - guard daemonReady else { return } - if !remotePortScanTTYNames.isEmpty { - guard shouldUseTTYFallbackRemotePortPollingLocked() else { - stopRemotePortPollingLocked() - if !keepPolledRemotePortsUntilTTYScan { - polledRemotePorts = [] - } - publishPortsSnapshotLocked() - return - } - if remotePortScanBurstActive || remotePortScanCoalesceWorkItem != nil || remotePortScanPendingReason != nil { - return - } - performRemotePortScanLocked() - return - } - guard let pollingMode = remotePortPollingModeLocked() else { - stopRemotePortPollingLocked() - polledRemotePorts = [] - remotePortPollBaselinePorts = nil - keepPolledRemotePortsUntilTTYScan = false - publishPortsSnapshotLocked() - return - } - guard remotePortScanTTYNames.isEmpty else { - stopRemotePortPollingLocked() - if !keepPolledRemotePortsUntilTTYScan { - polledRemotePorts = [] - } - remotePortPollBaselinePorts = nil - publishPortsSnapshotLocked() - return - } - - let command = "sh -c \(RemoteSSHConnectionPolicy.shellSingleQuoted(Self.remoteAllPortsScanScript(excluding: excludedRemoteScanPorts())))" - do { - let result = try sshExec( - arguments: sshCommonArguments(batchMode: true) + [configuration.destination, command], - timeout: 8 - ) - guard result.status == 0 else { - let detail = Self.bestErrorLine(stderr: result.stderr, stdout: result.stdout) ?? "ssh exited \(result.status)" - throw NSError(domain: "programa.remote.ports", code: 90, userInfo: [ - NSLocalizedDescriptionKey: "remote port scan failed: \(detail)", - ]) - } - let currentPorts = Set(Self.parseRemotePorts(output: result.stdout)) - switch pollingMode { - case .hostWide: - polledRemotePorts = currentPorts.sorted() - remotePortPollBaselinePorts = nil - case .hostWideDelta: - if let baselinePorts = remotePortPollBaselinePorts { - polledRemotePorts = currentPorts.subtracting(baselinePorts).sorted() - } else { - remotePortPollBaselinePorts = currentPorts - polledRemotePorts = [] - } - case .ttyScoped: - polledRemotePorts = [] - remotePortPollBaselinePorts = nil - } - keepPolledRemotePortsUntilTTYScan = false - publishPortsSnapshotLocked() - } catch { - debugLog("remote.ports.poll.failed error=\(error.localizedDescription) \(debugConfigSummary())") - } - } - - private func excludedRemoteScanPorts() -> Set { - var excluded: Set = [] - if let relayPort = configuration.relayPort, relayPort > 0 { - excluded.insert(relayPort) - } - if let configuredPort = configuration.port, configuredPort > 0 { - excluded.insert(configuredPort) - } - return excluded - } - - private func shouldUseFallbackRemotePortPollingLocked() -> Bool { - // `cmux ssh` owns the remote shell bootstrap and can report the remote - // TTY precisely. Falling back to host-wide port scans in that path leaks - // unrelated listeners from the remote machine into the workspace card. - let startupCommand = configuration.terminalStartupCommand? - .trimmingCharacters(in: .whitespacesAndNewlines) - return startupCommand?.isEmpty != false - } - - private func shouldUseTTYFallbackRemotePortPollingLocked() -> Bool { - // `cmux ssh` can still land in shells without our command hooks, such as - // `/bin/sh` in the Docker fixture. Once the workspace knows the TTY, - // keep a low-frequency TTY-scoped poll so unsupported shells still - // surface ports without bringing back noisy host-wide scans. - let startupCommand = configuration.terminalStartupCommand? - .trimmingCharacters(in: .whitespacesAndNewlines) - return startupCommand?.isEmpty == false - } - - private func remotePortPollingModeLocked() -> RemotePortPollingMode? { - if !remotePortScanTTYNames.isEmpty { - return shouldUseTTYFallbackRemotePortPollingLocked() ? .ttyScoped : nil - } - let startupCommand = configuration.terminalStartupCommand? - .trimmingCharacters(in: .whitespacesAndNewlines) - if startupCommand?.isEmpty == false { - return .hostWideDelta - } - return shouldUseFallbackRemotePortPollingLocked() ? .hostWide : nil - } - - private static func parseRemoteTTYPortPairs(output: String, trackedTTYNames: Set) -> [String: [Int]] { - var portsByTTY = Dictionary(uniqueKeysWithValues: trackedTTYNames.map { ($0, Set()) }) - - for line in output.split(separator: "\n") { - let parts = line.split(separator: "\t", omittingEmptySubsequences: false) - guard parts.count == 2 else { continue } - let ttyName = String(parts[0]).trimmingCharacters(in: .whitespacesAndNewlines) - guard trackedTTYNames.contains(ttyName), - let port = Int(parts[1]), - port >= 1024, - port <= 65535 else { - continue - } - portsByTTY[ttyName, default: []].insert(port) - } - - return portsByTTY.reduce(into: [String: [Int]]()) { result, entry in - result[entry.key] = entry.value.sorted() - } - } - - private static func parseRemotePorts(output: String) -> [Int] { - let values = output - .split(whereSeparator: \.isWhitespace) - .compactMap { Int($0) } - .filter { $0 >= 1024 && $0 <= 65535 } - return Array(Set(values)).sorted() - } - - private static func normalizedRemotePortScanTTYName(_ raw: String) -> String? { - let trimmed = raw.trimmingCharacters(in: .whitespacesAndNewlines) - guard !trimmed.isEmpty else { return nil } - let candidate = trimmed.split(separator: "/").last.map(String.init) ?? trimmed - guard !candidate.isEmpty else { return nil } - let allowed = CharacterSet.alphanumerics.union(CharacterSet(charactersIn: "._-")) - guard candidate.unicodeScalars.allSatisfy({ allowed.contains($0) }) else { return nil } - return candidate - } - - private static func remotePortScanScript(ttyNames: [String], excluding ports: Set) -> String { - let ttySet = ttyNames.joined(separator: " ") - let ttyCSV = ttyNames.joined(separator: ",") - let excludedPorts = ports.sorted().map(String.init).joined(separator: " ") - - return """ - set -eu - programa_tracked_ttys=" \(ttySet) " - programa_tty_csv='\(ttyCSV)' - programa_excluded_ports=" \(excludedPorts) " - - programa_emit_port() { - programa_tty="$1" - programa_port="$2" - case "$programa_tracked_ttys" in - *" $programa_tty "*) ;; - *) return 0 ;; - esac - case "$programa_excluded_ports" in - *" $programa_port "*) return 0 ;; - esac - [ "$programa_port" -ge 1024 ] && [ "$programa_port" -le 65535 ] || return 0 - printf '%s\\t%s\\n' "$programa_tty" "$programa_port" - } - - programa_used_ss=0 - if [ -d /proc ] && command -v ss >/dev/null 2>&1; then - programa_ss_output="$(ss -ltnpH 2>/dev/null || true)" - case "$programa_ss_output" in - *pid=*) - programa_used_ss=1 - printf '%s\\n' "$programa_ss_output" | while IFS= read -r programa_line; do - [ -n "$programa_line" ] || continue - programa_port="$(printf '%s\\n' "$programa_line" | awk '{print $4}' | sed -E 's/.*:([0-9]+)$/\\1/' | awk '/^[0-9]+$/ { print $1; exit }')" - [ -n "$programa_port" ] || continue - printf '%s\\n' "$programa_line" | awk ' - { - line = $0 - while (match(line, /pid=[0-9]+/)) { - print substr(line, RSTART + 4, RLENGTH - 4) - line = substr(line, RSTART + RLENGTH) - } - } - ' | while IFS= read -r programa_pid; do - [ -n "$programa_pid" ] || continue - programa_tty_path="$(readlink "/proc/$programa_pid/fd/0" 2>/dev/null || true)" - [ -n "$programa_tty_path" ] || continue - programa_tty="${programa_tty_path##*/}" - [ -n "$programa_tty" ] || continue - programa_emit_port "$programa_tty" "$programa_port" - done - done - ;; - esac - fi - - if [ "$programa_used_ss" -eq 0 ] && command -v lsof >/dev/null 2>&1 && [ -n "$programa_tty_csv" ]; then - programa_tmpdir="$(mktemp -d 2>/dev/null || mktemp -d -t programa-ports)" - trap 'rm -rf "$programa_tmpdir"' EXIT INT TERM - programa_pid_tty_map="$programa_tmpdir/pid_tty" - ps -t "$programa_tty_csv" -o pid=,tty= 2>/dev/null | awk ' - NF >= 2 { - tty = $2 - sub(/^.*\\//, "", tty) - print $1 "\\t" tty - } - ' > "$programa_pid_tty_map" - [ -s "$programa_pid_tty_map" ] || exit 0 - programa_pid_csv="$(awk '{print $1}' "$programa_pid_tty_map" | paste -sd, -)" - [ -n "$programa_pid_csv" ] || exit 0 - lsof -nP -a -p "$programa_pid_csv" -iTCP -sTCP:LISTEN -Fpn 2>/dev/null | awk -v map="$programa_pid_tty_map" ' - BEGIN { - while ((getline < map) > 0) { - pid_to_tty[$1] = $2 - } - close(map) - } - $0 ~ /^p/ { - pid = substr($0, 2) - tty = pid_to_tty[pid] - next - } - $0 ~ /^n/ && tty != "" { - name = substr($0, 2) - sub(/->.*/, "", name) - sub(/^.*:/, "", name) - sub(/[^0-9].*/, "", name) - if (name != "") { - print tty "\\t" name - } - } - ' | while IFS=$'\\t' read -r programa_tty programa_port; do - [ -n "$programa_tty" ] || continue - [ -n "$programa_port" ] || continue - programa_emit_port "$programa_tty" "$programa_port" - done - fi - """ - } - - private static func remoteAllPortsScanScript(excluding ports: Set) -> String { - let excludedPorts = ports.sorted().map(String.init).joined(separator: " ") - - return """ - set -eu - programa_excluded_ports=" \(excludedPorts) " - - programa_emit_port() { - programa_port="$1" - case "$programa_excluded_ports" in - *" $programa_port "*) return 0 ;; - esac - [ "$programa_port" -ge 1024 ] && [ "$programa_port" -le 65535 ] || return 0 - printf '%s\\n' "$programa_port" - } - - if command -v ss >/dev/null 2>&1; then - ss -ltnH 2>/dev/null | awk '{print $4}' | sed -E 's/.*:([0-9]+)$/\\1/' | awk '/^[0-9]+$/ {print $1}' | while IFS= read -r programa_port; do - [ -n "$programa_port" ] || continue - programa_emit_port "$programa_port" - done - elif command -v netstat >/dev/null 2>&1; then - netstat -lnt 2>/dev/null | awk 'NR > 2 {print $4}' | sed -E 's/.*:([0-9]+)$/\\1/' | awk '/^[0-9]+$/ {print $1}' | while IFS= read -r programa_port; do - [ -n "$programa_port" ] || continue - programa_emit_port "$programa_port" - done - elif command -v lsof >/dev/null 2>&1; then - lsof -nP -iTCP -sTCP:LISTEN 2>/dev/null | awk 'NR > 1 {print $9}' | sed -E 's/.*:([0-9]+)$/\\1/' | awk '/^[0-9]+$/ {print $1}' | while IFS= read -r programa_port; do - [ -n "$programa_port" ] || continue - programa_emit_port "$programa_port" - done - fi - """ - } - } - diff --git a/Sources/WorkspaceRemoteSessionController+ConnectionOrchestration.swift b/Sources/WorkspaceRemoteSessionController+ConnectionOrchestration.swift new file mode 100644 index 00000000000..dc0297483aa --- /dev/null +++ b/Sources/WorkspaceRemoteSessionController+ConnectionOrchestration.swift @@ -0,0 +1,613 @@ +// Extracted from WorkspaceRemoteSession.swift (nuclear-review #98): connection-attempt/reverse-relay/proxy orchestration and status publishing. + +import Foundation +import SwiftUI +import AppKit +import Bonsplit +import Combine +import CryptoKit +import Darwin +import Network +import CoreText + +extension WorkspaceRemoteSessionController { + func stopAllLocked() { + debugLog("remote.session.stop \(debugConfigSummary())") + isStopping = true + reconnectWorkItem?.cancel() + reconnectWorkItem = nil + reconnectRetryCount = 0 + reverseRelayRestartWorkItem?.cancel() + reverseRelayRestartWorkItem = nil + remotePortScanCoalesceWorkItem?.cancel() + remotePortScanCoalesceWorkItem = nil + stopReverseRelayLocked() + remotePortScanGeneration &+= 1 + remotePortScanBurstActive = false + remotePortScanActiveReason = nil + remotePortScanPendingReason = nil + remotePortScanTTYNames.removeAll() + remoteScannedPortsByPanel.removeAll() + stopRemotePortPollingLocked() + polledRemotePorts = [] + remotePortPollBaselinePorts = nil + keepPolledRemotePortsUntilTTYScan = false + bootstrapRemoteTTYResolved = false + bootstrapRemoteTTYRetryWorkItem?.cancel() + bootstrapRemoteTTYRetryWorkItem = nil + bootstrapRemoteTTYFetchInFlight = false + bootstrapRemoteTTYRetryCount = 0 + + proxyLease?.release() + proxyLease = nil + proxyEndpoint = nil + daemonReady = false + daemonBootstrapVersion = nil + daemonRemotePath = nil + publishProxyEndpoint(nil) + publishPortsSnapshotLocked() + } + + func beginConnectionAttemptLocked() { + guard !isStopping else { return } + + Self.killOrphanedRemoteSSHProcesses( + destination: configuration.destination, + relayPort: configuration.relayPort + ) + connectionAttemptStartedAt = Date() + debugLog("remote.session.connect.begin retry=\(reconnectRetryCount) \(debugConfigSummary())") + reconnectWorkItem = nil + bootstrapRemoteTTYRetryWorkItem?.cancel() + bootstrapRemoteTTYRetryWorkItem = nil + bootstrapRemoteTTYFetchInFlight = false + if remotePortScanTTYNames.isEmpty { + bootstrapRemoteTTYResolved = false + bootstrapRemoteTTYRetryCount = 0 + } + 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() + guard hello.capabilities.contains(WorkspaceRemoteDaemonRPCClient.requiredProxyStreamCapability) else { + throw NSError(domain: "programa.remote.daemon", code: 43, userInfo: [ + NSLocalizedDescriptionKey: "remote daemon missing required capability \(WorkspaceRemoteDaemonRPCClient.requiredProxyStreamCapability)", + ]) + } + 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 + ) + recordHeartbeatActivityLocked() + startReverseRelayLocked(remotePath: hello.remotePath) + requestBootstrapRemoteTTYIfNeededLocked() + startProxyLocked() + } catch { + daemonReady = false + daemonBootstrapVersion = nil + daemonRemotePath = nil + let retrySchedule = scheduleReconnectLocked(baseDelay: 4.0) + let retrySuffix = Self.retrySuffix(retry: retrySchedule.retry, delay: retrySchedule.delay) + let detail = "Remote daemon bootstrap failed: \(error.localizedDescription)\(retrySuffix)" + publishDaemonStatus(.error, detail: detail) + publishState(.error, detail: detail) + } + } + + func startProxyLocked() { + guard !isStopping else { return } + guard daemonReady else { return } + guard proxyLease == nil else { return } + guard let remotePath = daemonRemotePath, + !remotePath.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty else { + let retrySchedule = scheduleReconnectLocked(baseDelay: 4.0) + let retrySuffix = Self.retrySuffix(retry: retrySchedule.retry, delay: retrySchedule.delay) + let detail = "Remote daemon did not provide a valid remote path\(retrySuffix)" + publishDaemonStatus(.error, detail: detail) + publishState(.error, detail: detail) + return + } + + let lease = WorkspaceRemoteProxyBroker.shared.acquire( + configuration: configuration, + remotePath: remotePath + ) { [weak self] update in + self?.queue.async { + self?.handleProxyBrokerUpdateLocked(update) + } + } + proxyLease = lease + } + + func startReverseRelayLocked(remotePath: String) { + guard !isStopping else { return } + guard daemonReady else { return } + guard let relayPort = configuration.relayPort, relayPort > 0, + let relayID = configuration.relayID?.trimmingCharacters(in: .whitespacesAndNewlines), + !relayID.isEmpty, + let relayToken = configuration.relayToken?.trimmingCharacters(in: .whitespacesAndNewlines), + !relayToken.isEmpty, + let localSocketPath = configuration.localSocketPath? + .trimmingCharacters(in: .whitespacesAndNewlines), + !localSocketPath.isEmpty else { + return + } + guard reverseRelayProcess == nil else { return } + guard reverseRelayControlMasterForwardSpec == nil else { return } + + reverseRelayRestartWorkItem?.cancel() + reverseRelayRestartWorkItem = nil + var relayServer: WorkspaceRemoteCLIRelayServer? + do { + let server = try ensureCLIRelayServerLocked( + localSocketPath: localSocketPath, + relayID: relayID, + relayToken: relayToken + ) + relayServer = server + let localRelayPort = try server.start() + Self.killOrphanedRemoteSSHProcesses( + destination: configuration.destination, + relayPort: relayPort + ) + let forwardSpec = "127.0.0.1:\(relayPort):127.0.0.1:\(localRelayPort)" + + if startReverseRelayViaControlMasterLocked(forwardSpec: forwardSpec) { + cliRelayServer = relayServer + reverseRelayStderrBuffer = "" + do { + try installRemoteRelayMetadataLocked( + remotePath: remotePath, + relayPort: relayPort, + relayID: relayID, + relayToken: relayToken + ) + } catch { + debugLog("remote.relay.metadata.error \(error.localizedDescription)") + stopReverseRelayLocked() + scheduleReverseRelayRestartLocked(remotePath: remotePath, delay: 2.0) + return + } + recordHeartbeatActivityLocked() + debugLog( + "remote.relay.start relayPort=\(relayPort) localRelayPort=\(localRelayPort) " + + "target=\(configuration.displayTarget) controlMaster=1" + ) + return + } + + let process = Process() + let stderrPipe = Pipe() + process.executableURL = URL(fileURLWithPath: "/usr/bin/ssh") + process.arguments = reverseRelayArguments(relayPort: relayPort, localRelayPort: localRelayPort) + process.standardInput = FileHandle.nullDevice + process.standardOutput = FileHandle.nullDevice + process.standardError = stderrPipe + + process.terminationHandler = { [weak self] terminated in + self?.queue.async { + self?.handleReverseRelayTerminationLocked(process: terminated) + } + } + + try process.run() + if let startupFailure = Self.reverseRelayStartupFailureDetail( + process: process, + stderrPipe: stderrPipe + ) { + let retryDelay = 2.0 + let retrySeconds = max(1, Int(retryDelay.rounded())) + debugLog( + "remote.relay.startFailed relayPort=\(relayPort) " + + "error=\(startupFailure)" + ) + relayServer?.stop() + publishDaemonStatus( + .error, + detail: "Remote SSH relay unavailable: \(startupFailure) (retry in \(retrySeconds)s)" + ) + scheduleReverseRelayRestartLocked(remotePath: remotePath, delay: retryDelay) + return + } + installReverseRelayStderrHandlerLocked(stderrPipe) + reverseRelayProcess = process + cliRelayServer = relayServer + reverseRelayStderrPipe = stderrPipe + reverseRelayStderrBuffer = "" + do { + try installRemoteRelayMetadataLocked( + remotePath: remotePath, + relayPort: relayPort, + relayID: relayID, + relayToken: relayToken + ) + } catch { + debugLog("remote.relay.metadata.error \(error.localizedDescription)") + stopReverseRelayLocked() + scheduleReverseRelayRestartLocked(remotePath: remotePath, delay: 2.0) + return + } + recordHeartbeatActivityLocked() + debugLog( + "remote.relay.start relayPort=\(relayPort) localRelayPort=\(localRelayPort) " + + "target=\(configuration.displayTarget) controlMaster=0" + ) + } catch { + debugLog( + "remote.relay.startFailed relayPort=\(relayPort) " + + "error=\(error.localizedDescription)" + ) + relayServer?.stop() + cliRelayServer = nil + scheduleReverseRelayRestartLocked(remotePath: remotePath, delay: 2.0) + } + } + + func installReverseRelayStderrHandlerLocked(_ stderrPipe: Pipe) { + 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.reverseRelayStderrBuffer.append(chunk) + if self.reverseRelayStderrBuffer.count > 8192 { + self.reverseRelayStderrBuffer.removeFirst(self.reverseRelayStderrBuffer.count - 8192) + } + } + } + } + } + + func handleReverseRelayTerminationLocked(process: Process) { + guard reverseRelayProcess === process else { return } + let stderrDetail = Self.bestErrorLine(stderr: reverseRelayStderrBuffer) + reverseRelayStderrPipe?.fileHandleForReading.readabilityHandler = nil + reverseRelayProcess = nil + reverseRelayStderrPipe = nil + + guard !isStopping else { return } + guard let remotePath = daemonRemotePath, + !remotePath.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty else { return } + + let detail = stderrDetail ?? "status=\(process.terminationStatus)" + debugLog("remote.relay.exit \(detail)") + scheduleReverseRelayRestartLocked(remotePath: remotePath, delay: 2.0) + } + + func scheduleReverseRelayRestartLocked(remotePath: String, delay: TimeInterval) { + guard !isStopping else { return } + reverseRelayRestartWorkItem?.cancel() + + let workItem = DispatchWorkItem { [weak self] in + guard let self else { return } + self.reverseRelayRestartWorkItem = nil + guard !self.isStopping else { return } + guard self.reverseRelayProcess == nil else { return } + guard self.daemonReady else { return } + self.startReverseRelayLocked(remotePath: self.daemonRemotePath ?? remotePath) + } + reverseRelayRestartWorkItem = workItem + queue.asyncAfter(deadline: .now() + delay, execute: workItem) + } + + func stopReverseRelayLocked() { + reverseRelayStderrPipe?.fileHandleForReading.readabilityHandler = nil + if let reverseRelayProcess, reverseRelayProcess.isRunning { + reverseRelayProcess.terminate() + } + reverseRelayProcess = nil + stopReverseRelayViaControlMasterLocked() + reverseRelayStderrPipe = nil + reverseRelayStderrBuffer = "" + cliRelayServer?.stop() + cliRelayServer = nil + removeRemoteRelayMetadataLocked() + } + + func handleProxyBrokerUpdateLocked(_ update: WorkspaceRemoteProxyBroker.Update) { + guard !isStopping else { return } + switch update { + case .connecting: + debugLog("remote.proxy.connecting \(debugConfigSummary())") + if proxyEndpoint == nil { + publishState(.connecting, detail: "Connecting to \(configuration.displayTarget)") + } + case .ready(let endpoint): + debugLog("remote.proxy.ready host=\(endpoint.host) port=\(endpoint.port) \(debugConfigSummary())") + reconnectWorkItem?.cancel() + reconnectWorkItem = nil + reconnectRetryCount = 0 + guard proxyEndpoint != endpoint else { + recordHeartbeatActivityLocked() + return + } + proxyEndpoint = endpoint + publishProxyEndpoint(endpoint) + updateRemotePortPollingStateLocked() + publishPortsSnapshotLocked() + publishState( + .connected, + detail: "Connected to \(configuration.displayTarget) via shared local proxy \(endpoint.host):\(endpoint.port)" + ) + requestBootstrapRemoteTTYIfNeededLocked() + recordHeartbeatActivityLocked() + case .error(let detail): + debugLog("remote.proxy.error detail=\(detail) \(debugConfigSummary())") + remotePortScanGeneration &+= 1 + remotePortScanBurstActive = false + remotePortScanActiveReason = nil + remotePortScanPendingReason = nil + remotePortScanCoalesceWorkItem?.cancel() + remotePortScanCoalesceWorkItem = nil + remoteScannedPortsByPanel.removeAll() + stopRemotePortPollingLocked() + polledRemotePorts = [] + keepPolledRemotePortsUntilTTYScan = false + proxyEndpoint = nil + publishProxyEndpoint(nil) + publishPortsSnapshotLocked() + publishState(.error, detail: "Remote proxy to \(configuration.displayTarget) unavailable: \(detail)") + guard Self.shouldEscalateProxyErrorToBootstrap(detail) else { return } + + proxyLease?.release() + proxyLease = nil + daemonReady = false + daemonBootstrapVersion = nil + daemonRemotePath = nil + + let retrySchedule = scheduleReconnectLocked(baseDelay: 2.0) + let retrySuffix = Self.retrySuffix(retry: retrySchedule.retry, delay: retrySchedule.delay) + publishDaemonStatus( + .error, + detail: "Remote daemon transport needs re-bootstrap after proxy failure\(retrySuffix)" + ) + } + } + + @discardableResult + func scheduleReconnectLocked(baseDelay: TimeInterval) -> RetrySchedule { + let retryNumber = reconnectRetryCount + 1 + let retryDelay = Self.retryDelay(baseDelay: baseDelay, retry: retryNumber) + guard !isStopping else { return RetrySchedule(retry: retryNumber, delay: retryDelay) } + reconnectWorkItem?.cancel() + reconnectRetryCount = retryNumber + let workItem = DispatchWorkItem { [weak self] in + guard let self else { return } + self.reconnectWorkItem = nil + guard !self.isStopping else { return } + guard self.proxyLease == nil else { return } + self.beginConnectionAttemptLocked() + } + reconnectWorkItem = workItem + queue.asyncAfter(deadline: .now() + retryDelay, execute: workItem) + return RetrySchedule(retry: retryNumber, delay: retryDelay) + } + + func publishState(_ state: WorkspaceRemoteConnectionState, detail: String?) { + let controllerID = self.controllerID + DispatchQueue.main.async { [weak workspace] in + guard let workspace else { return } + guard workspace.activeRemoteSessionControllerID == controllerID else { return } + workspace.applyRemoteConnectionStateUpdate( + state, + detail: detail, + target: workspace.remoteDisplayTarget ?? "remote host" + ) + } + } + + func publishDaemonStatus( + _ state: WorkspaceRemoteDaemonState, + detail: String?, + version: String? = nil, + name: String? = nil, + capabilities: [String] = [], + remotePath: String? = nil + ) { + let controllerID = self.controllerID + 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 } + guard workspace.activeRemoteSessionControllerID == controllerID else { return } + workspace.applyRemoteDaemonStatusUpdate( + status, + target: workspace.remoteDisplayTarget ?? "remote host" + ) + } + } + + func publishProxyEndpoint(_ endpoint: BrowserProxyEndpoint?) { + let controllerID = self.controllerID + DispatchQueue.main.async { [weak workspace] in + guard let workspace else { return } + guard workspace.activeRemoteSessionControllerID == controllerID else { return } + workspace.applyRemoteProxyEndpointUpdate(endpoint) + } + } + + func publishPortsSnapshotLocked() { + let controllerID = self.controllerID + let detectedByPanel = remotePortScanTTYNames.keys.reduce(into: [UUID: [Int]]()) { result, panelId in + result[panelId] = remoteScannedPortsByPanel[panelId] ?? [] + } + let detected = Array( + Set(polledRemotePorts) + .union(detectedByPanel.values.flatMap { $0 }) + ).sorted() + DispatchQueue.main.async { [weak workspace] in + guard let workspace else { return } + guard workspace.activeRemoteSessionControllerID == controllerID else { return } + workspace.applyRemoteDetectedSurfacePortsSnapshot( + detectedByPanel: detectedByPanel, + detected: detected, + forwarded: [], + conflicts: [], + target: workspace.remoteDisplayTarget ?? "remote host" + ) + } + } + + func recordHeartbeatActivityLocked() { + heartbeatCount += 1 + publishHeartbeat(count: heartbeatCount, at: Date()) + } + + func publishHeartbeat(count: Int, at date: Date?) { + let controllerID = self.controllerID + DispatchQueue.main.async { [weak workspace] in + guard let workspace else { return } + guard workspace.activeRemoteSessionControllerID == controllerID else { return } + workspace.applyRemoteHeartbeatUpdate(count: count, lastSeenAt: date) + } + } + + func requestBootstrapRemoteTTYIfNeededLocked() { + guard !bootstrapRemoteTTYResolved else { return } + guard let relayPort = configuration.relayPort, relayPort > 0 else { return } + if !remotePortScanTTYNames.isEmpty { + bootstrapRemoteTTYResolved = true + bootstrapRemoteTTYRetryWorkItem?.cancel() + bootstrapRemoteTTYRetryWorkItem = nil + bootstrapRemoteTTYRetryCount = 0 + return + } + guard !bootstrapRemoteTTYFetchInFlight else { return } + bootstrapRemoteTTYFetchInFlight = true + defer { bootstrapRemoteTTYFetchInFlight = false } + + let command = "sh -c \(RemoteSSHConnectionPolicy.shellSingleQuoted("tty_path=\"$HOME/.programa/relay/\(relayPort).tty\"; if [ -r \"$tty_path\" ]; then cat \"$tty_path\"; fi"))" + do { + let result = try sshExec( + arguments: sshCommonArguments(batchMode: true) + [configuration.destination, command], + timeout: 2 + ) + guard result.status == 0 else { + scheduleBootstrapRemoteTTYRetryLocked() + return + } + guard let ttyName = Self.normalizedRemotePortScanTTYName(result.stdout) else { + scheduleBootstrapRemoteTTYRetryLocked() + return + } + bootstrapRemoteTTYResolved = true + bootstrapRemoteTTYRetryWorkItem?.cancel() + bootstrapRemoteTTYRetryWorkItem = nil + bootstrapRemoteTTYRetryCount = 0 + debugLog("remote.tty.bootstrap.ready tty=\(ttyName) \(debugConfigSummary())") + publishBootstrapRemoteTTY(ttyName) + } catch { + debugLog("remote.tty.bootstrap.failed error=\(error.localizedDescription) \(debugConfigSummary())") + scheduleBootstrapRemoteTTYRetryLocked() + } + } + + func scheduleBootstrapRemoteTTYRetryLocked() { + guard !isStopping else { return } + guard daemonReady else { return } + guard !bootstrapRemoteTTYResolved else { return } + guard remotePortScanTTYNames.isEmpty else { return } + guard bootstrapRemoteTTYRetryCount < Self.bootstrapRemoteTTYRetryLimit else { return } + guard bootstrapRemoteTTYRetryWorkItem == nil else { return } + + bootstrapRemoteTTYRetryCount += 1 + let workItem = DispatchWorkItem { [weak self] in + guard let self else { return } + self.bootstrapRemoteTTYRetryWorkItem = nil + self.requestBootstrapRemoteTTYIfNeededLocked() + } + bootstrapRemoteTTYRetryWorkItem = workItem + queue.asyncAfter(deadline: .now() + Self.bootstrapRemoteTTYRetryDelay, execute: workItem) + } + + func publishBootstrapRemoteTTY(_ ttyName: String) { + let controllerID = self.controllerID + DispatchQueue.main.async { [weak workspace] in + guard let workspace else { return } + guard workspace.activeRemoteSessionControllerID == controllerID else { return } + workspace.applyBootstrapRemoteTTY(ttyName) + } + } + + func reverseRelayArguments(relayPort: Int, localRelayPort: Int) -> [String] { + // Fallback standalone transport when dynamic forwarding through an existing + // control master is unavailable. + var args: [String] = ["-N", "-T", "-S", "none"] + args += sshCommonArguments(batchMode: true) + args += [ + "-o", "ExitOnForwardFailure=yes", + "-o", "RequestTTY=no", + "-R", "127.0.0.1:\(relayPort):127.0.0.1:\(localRelayPort)", + configuration.destination, + ] + return args + } + + func startReverseRelayViaControlMasterLocked(forwardSpec: String) -> Bool { + guard let arguments = WorkspaceRemoteSSHBatchCommandBuilder.reverseRelayControlMasterArguments( + configuration: configuration, + controlCommand: "forward", + forwardSpec: forwardSpec + ) else { + return false + } + + do { + let result = try sshExec(arguments: arguments, timeout: 6) + guard result.status == 0 else { + let detail = Self.bestErrorLine(stderr: result.stderr, stdout: result.stdout) + ?? "ssh exited \(result.status)" + debugLog("remote.relay.controlmaster.forwardFailed \(detail) \(debugConfigSummary())") + return false + } + reverseRelayControlMasterForwardSpec = forwardSpec + return true + } catch { + debugLog("remote.relay.controlmaster.forwardFailed \(error.localizedDescription) \(debugConfigSummary())") + return false + } + } + + func stopReverseRelayViaControlMasterLocked() { + guard let forwardSpec = reverseRelayControlMasterForwardSpec else { return } + reverseRelayControlMasterForwardSpec = nil + guard let arguments = WorkspaceRemoteSSHBatchCommandBuilder.reverseRelayControlMasterArguments( + configuration: configuration, + controlCommand: "cancel", + forwardSpec: forwardSpec + ) else { + return + } + _ = try? sshExec(arguments: arguments, timeout: 4) + } + + static let bootstrapRemoteTTYRetryDelay: TimeInterval = 0.5 + static let bootstrapRemoteTTYRetryLimit = 8 + +} diff --git a/Sources/WorkspaceRemoteSessionController+DaemonInstall.swift b/Sources/WorkspaceRemoteSessionController+DaemonInstall.swift new file mode 100644 index 00000000000..c78e0567d03 --- /dev/null +++ b/Sources/WorkspaceRemoteSessionController+DaemonInstall.swift @@ -0,0 +1,599 @@ +// Extracted from WorkspaceRemoteSession.swift (nuclear-review #98): remote daemon bootstrap/build/download/upload and file-drop upload. + +import Foundation +import SwiftUI +import AppKit +import Bonsplit +import Combine +import CryptoKit +import Darwin +import Network +import CoreText + +extension WorkspaceRemoteSessionController { + static let remotePlatformProbeOSMarker = "__PROGRAMA_REMOTE_OS__=" + static let remotePlatformProbeArchMarker = "__PROGRAMA_REMOTE_ARCH__=" + static let remotePlatformProbeExistsMarker = "__PROGRAMA_REMOTE_EXISTS__=" + + func bootstrapDaemonLocked() throws -> DaemonHello { + debugLog("remote.bootstrap.begin \(debugConfigSummary())") + let version = Self.remoteDaemonVersion() + let bootstrapState = try probeRemoteBootstrapStateLocked(version: version) + let platform = bootstrapState.platform + let remotePath = Self.remoteDaemonPath(version: version, goOS: platform.goOS, goArch: platform.goArch) + let explicitOverrideBinary = Self.explicitRemoteDaemonBinaryURL() + let forceExplicitOverrideInstall = explicitOverrideBinary != nil + debugLog( + "remote.bootstrap.platform os=\(platform.goOS) arch=\(platform.goArch) " + + "version=\(version) remotePath=\(remotePath) " + + "allowLocalBuildFallback=\(Self.allowLocalDaemonBuildFallback() ? 1 : 0) " + + "explicitOverride=\(forceExplicitOverrideInstall ? 1 : 0)" + ) + + let hadExistingBinary = bootstrapState.binaryExists + debugLog("remote.bootstrap.binaryExists remotePath=\(remotePath) exists=\(hadExistingBinary ? 1 : 0)") + if forceExplicitOverrideInstall || !hadExistingBinary { + let localBinary = try buildLocalDaemonBinary(goOS: platform.goOS, goArch: platform.goArch, version: version) + try uploadRemoteDaemonBinaryLocked(localBinary: localBinary, remotePath: remotePath) + } + + var hello: DaemonHello + do { + hello = try helloRemoteDaemonLocked(remotePath: remotePath) + } catch { + guard hadExistingBinary else { + throw error + } + debugLog( + "remote.bootstrap.helloRetry remotePath=\(remotePath) " + + "detail=\(error.localizedDescription)" + ) + let localBinary = try buildLocalDaemonBinary(goOS: platform.goOS, goArch: platform.goArch, version: version) + try uploadRemoteDaemonBinaryLocked(localBinary: localBinary, remotePath: remotePath) + hello = try helloRemoteDaemonLocked(remotePath: remotePath) + } + if hadExistingBinary, !hello.capabilities.contains(WorkspaceRemoteDaemonRPCClient.requiredProxyStreamCapability) { + debugLog("remote.bootstrap.capabilityMissing remotePath=\(remotePath) capabilities=\(hello.capabilities.joined(separator: ","))") + let localBinary = try buildLocalDaemonBinary(goOS: platform.goOS, goArch: platform.goArch, version: version) + try uploadRemoteDaemonBinaryLocked(localBinary: localBinary, remotePath: remotePath) + hello = try helloRemoteDaemonLocked(remotePath: remotePath) + } + + debugLog( + "remote.bootstrap.ready name=\(hello.name) version=\(hello.version) " + + "capabilities=\(hello.capabilities.joined(separator: ",")) remotePath=\(hello.remotePath)" + ) + if let connectionAttemptStartedAt { + debugLog( + "remote.timing.bootstrap.ready elapsedMs=\(Int(Date().timeIntervalSince(connectionAttemptStartedAt) * 1000)) " + + "\(debugConfigSummary())" + ) + } + return hello + } + + func ensureCLIRelayServerLocked(localSocketPath: String, relayID: String, relayToken: String) throws -> WorkspaceRemoteCLIRelayServer { + if let cliRelayServer { + return cliRelayServer + } + let relayServer = try WorkspaceRemoteCLIRelayServer( + localSocketPath: localSocketPath, + relayID: relayID, + relayTokenHex: relayToken + ) + cliRelayServer = relayServer + return relayServer + } + + func installRemoteRelayMetadataLocked( + remotePath: String, + relayPort: Int, + relayID: String, + relayToken: String + ) throws { + let script = Self.remoteRelayMetadataInstallScript( + daemonRemotePath: remotePath, + relayPort: relayPort, + relayID: relayID, + relayToken: relayToken + ) + let command = "sh -c \(RemoteSSHConnectionPolicy.shellSingleQuoted(script))" + let result = try sshExec(arguments: sshCommonArguments(batchMode: true) + [configuration.destination, command], timeout: 8) + guard result.status == 0 else { + let detail = Self.bestErrorLine(stderr: result.stderr, stdout: result.stdout) ?? "ssh exited \(result.status)" + throw NSError(domain: "programa.remote.relay", code: 70, userInfo: [ + NSLocalizedDescriptionKey: "failed to install remote relay metadata: \(detail)", + ]) + } + } + + func removeRemoteRelayMetadataLocked() { + guard let relayPort = configuration.relayPort, relayPort > 0 else { return } + let script = Self.remoteRelayMetadataCleanupScript(relayPort: relayPort) + let command = "sh -c \(RemoteSSHConnectionPolicy.shellSingleQuoted(script))" + do { + _ = try sshExec(arguments: sshCommonArguments(batchMode: true) + [configuration.destination, command], timeout: 8) + } catch { + debugLog("remote.relay.cleanup.error \(error.localizedDescription)") + } + } + + static func remoteRelayMetadataCleanupScript(relayPort: Int) -> String { + """ + relay_socket='127.0.0.1:\(relayPort)' + socket_addr_file="$HOME/.programa/socket_addr" + if [ -r "$socket_addr_file" ] && [ "$(tr -d '\\r\\n' < "$socket_addr_file")" = "$relay_socket" ]; then + rm -f "$socket_addr_file" + fi + rm -f "$HOME/.programa/relay/\(relayPort).auth" "$HOME/.programa/relay/\(relayPort).daemon_path" "$HOME/.programa/relay/\(relayPort).tty" + """ + } + + func probeRemoteBootstrapStateLocked(version: String) throws -> RemoteBootstrapState { + let script = """ + programa_uname_os="$(uname -s)" + programa_uname_arch="$(uname -m)" + printf '%s%s\\n' '\(Self.remotePlatformProbeOSMarker)' "$programa_uname_os" + printf '%s%s\\n' '\(Self.remotePlatformProbeArchMarker)' "$programa_uname_arch" + case "$(printf '%s' "$programa_uname_os" | tr '[:upper:]' '[:lower:]')" in + linux|darwin|freebsd) programa_go_os="$(printf '%s' "$programa_uname_os" | tr '[:upper:]' '[:lower:]')" ;; + *) exit 70 ;; + esac + case "$(printf '%s' "$programa_uname_arch" | tr '[:upper:]' '[:lower:]')" in + x86_64|amd64) programa_go_arch=amd64 ;; + aarch64|arm64) programa_go_arch=arm64 ;; + armv7l) programa_go_arch=arm ;; + *) exit 71 ;; + esac + programa_remote_path="$HOME/.programa/bin/programad-remote/\(version)/${programa_go_os}-${programa_go_arch}/programad-remote" + if [ -x "$programa_remote_path" ]; then + printf '%syes\\n' '\(Self.remotePlatformProbeExistsMarker)' + else + printf '%sno\\n' '\(Self.remotePlatformProbeExistsMarker)' + fi + """ + let command = "sh -c \(RemoteSSHConnectionPolicy.shellSingleQuoted(script))" + let result = try sshExec(arguments: sshCommonArguments(batchMode: true) + [configuration.destination, command], timeout: 20) + + let lines = result.stdout + .split(separator: "\n", omittingEmptySubsequences: false) + .map { $0.trimmingCharacters(in: .whitespacesAndNewlines) } + .filter { !$0.isEmpty } + let unameOS = lines.first { $0.hasPrefix(Self.remotePlatformProbeOSMarker) } + .map { String($0.dropFirst(Self.remotePlatformProbeOSMarker.count)) } + let unameArch = lines.first { $0.hasPrefix(Self.remotePlatformProbeArchMarker) } + .map { String($0.dropFirst(Self.remotePlatformProbeArchMarker.count)) } + guard let unameOS, let unameArch else { + let detail = Self.bestErrorLine(stderr: result.stderr, stdout: result.stdout) ?? "ssh exited \(result.status)" + throw NSError(domain: "programa.remote.daemon", code: 11, userInfo: [ + NSLocalizedDescriptionKey: "failed to query remote platform: \(detail)", + ]) + } + + guard let goOS = Self.mapUnameOS(unameOS), + let goArch = Self.mapUnameArch(unameArch) else { + throw NSError(domain: "programa.remote.daemon", code: 12, userInfo: [ + NSLocalizedDescriptionKey: "unsupported remote platform \(unameOS)/\(unameArch)", + ]) + } + + let binaryExists = lines.first { $0.hasPrefix(Self.remotePlatformProbeExistsMarker) } + .map { String($0.dropFirst(Self.remotePlatformProbeExistsMarker.count)) == "yes" } + if result.status != 0, binaryExists == nil { + let detail = Self.bestErrorLine(stderr: result.stderr, stdout: result.stdout) ?? "ssh exited \(result.status)" + throw NSError(domain: "programa.remote.daemon", code: 13, userInfo: [ + NSLocalizedDescriptionKey: "failed to query remote daemon state: \(detail)", + ]) + } + + return RemoteBootstrapState( + platform: RemotePlatform(goOS: goOS, goArch: goArch), + binaryExists: binaryExists ?? false + ) + } + + static let remoteDaemonManifestInfoKey = "CMUXRemoteDaemonManifestJSON" + + static func remoteDaemonManifest(from infoDictionary: [String: Any]?) -> WorkspaceRemoteDaemonManifest? { + guard let rawManifest = infoDictionary?[remoteDaemonManifestInfoKey] as? String else { return nil } + let trimmed = rawManifest.trimmingCharacters(in: .whitespacesAndNewlines) + guard !trimmed.isEmpty else { return nil } + guard let data = trimmed.data(using: .utf8) else { return nil } + return try? JSONDecoder().decode(WorkspaceRemoteDaemonManifest.self, from: data) + } + + static func remoteDaemonManifest() -> WorkspaceRemoteDaemonManifest? { + remoteDaemonManifest(from: Bundle.main.infoDictionary) + } + + static func remoteDaemonCacheRoot(fileManager: FileManager = .default) throws -> URL { + let appSupportRoot = try fileManager.url( + for: .applicationSupportDirectory, + in: .userDomainMask, + appropriateFor: nil, + create: true + ) + let cacheRoot = appSupportRoot + .appendingPathComponent("programa", isDirectory: true) + .appendingPathComponent("remote-daemons", isDirectory: true) + try fileManager.createDirectory(at: cacheRoot, withIntermediateDirectories: true) + return cacheRoot + } + + static func remoteDaemonCachedBinaryURL( + version: String, + goOS: String, + goArch: String, + fileManager: FileManager = .default + ) throws -> URL { + try remoteDaemonCacheRoot(fileManager: fileManager) + .appendingPathComponent(version, isDirectory: true) + .appendingPathComponent("\(goOS)-\(goArch)", isDirectory: true) + .appendingPathComponent("programad-remote", isDirectory: false) + } + + static func sha256Hex(forFile url: URL) throws -> String { + let data = try Data(contentsOf: url) + let digest = SHA256.hash(data: data) + return digest.map { String(format: "%02x", $0) }.joined() + } + + static func allowLocalDaemonBuildFallback(environment: [String: String] = ProcessInfo.processInfo.environment) -> Bool { + environment["PROGRAMA_REMOTE_DAEMON_ALLOW_LOCAL_BUILD"] == "1" + } + + static func explicitRemoteDaemonBinaryURL(environment: [String: String] = ProcessInfo.processInfo.environment) -> URL? { + guard allowLocalDaemonBuildFallback(environment: environment) else { return nil } + guard let path = environment["PROGRAMA_REMOTE_DAEMON_BINARY"]?.trimmingCharacters(in: .whitespacesAndNewlines), + !path.isEmpty else { + return nil + } + return URL(fileURLWithPath: path, isDirectory: false).standardizedFileURL + } + + static func versionedRemoteDaemonBuildURL(goOS: String, goArch: String, version: String) -> URL { + URL(fileURLWithPath: NSTemporaryDirectory(), isDirectory: true) + .appendingPathComponent("programa-remote-daemon-build", isDirectory: true) + .appendingPathComponent(version, isDirectory: true) + .appendingPathComponent("\(goOS)-\(goArch)", isDirectory: true) + .appendingPathComponent("programad-remote", isDirectory: false) + } + + /// Fetch the live manifest JSON from the release, returning nil on any failure. + static func fetchRemoteManifestLocked(releaseURL: String, version: String) -> WorkspaceRemoteDaemonManifest? { + guard let manifestURL = URL(string: "\(releaseURL)/programad-remote-manifest.json") else { return nil } + let request = NSMutableURLRequest(url: manifestURL) + request.timeoutInterval = 15 + request.setValue("cmux/\(version)", forHTTPHeaderField: "User-Agent") + let session = URLSession(configuration: .ephemeral) + let semaphore = DispatchSemaphore(value: 0) + var resultData: Data? + session.dataTask(with: request as URLRequest) { data, response, error in + defer { semaphore.signal() } + guard error == nil, + let httpResponse = response as? HTTPURLResponse, + (200...299).contains(httpResponse.statusCode) else { return } + resultData = data + }.resume() + _ = semaphore.wait(timeout: .now() + 20.0) + session.finishTasksAndInvalidate() + guard let data = resultData else { return nil } + return try? JSONDecoder().decode(WorkspaceRemoteDaemonManifest.self, from: data) + } + + func downloadRemoteDaemonBinaryLocked(entry: WorkspaceRemoteDaemonManifest.Entry, version: String, releaseURL: String? = nil) throws -> URL { + guard let url = URL(string: entry.downloadURL) else { + throw NSError(domain: "programa.remote.daemon", code: 25, userInfo: [ + NSLocalizedDescriptionKey: "remote daemon manifest has an invalid download URL", + ]) + } + + let cacheURL = try Self.remoteDaemonCachedBinaryURL(version: version, goOS: entry.goOS, goArch: entry.goArch) + let fileManager = FileManager.default + try fileManager.createDirectory(at: cacheURL.deletingLastPathComponent(), withIntermediateDirectories: true) + + let request = NSMutableURLRequest(url: url) + request.timeoutInterval = 60 + request.setValue("cmux/\(version)", forHTTPHeaderField: "User-Agent") + let session = URLSession(configuration: .ephemeral) + + let semaphore = DispatchSemaphore(value: 0) + var downloadedURL: URL? + var downloadError: Error? + session.downloadTask(with: request as URLRequest) { localURL, response, error in + defer { semaphore.signal() } + if let error { + downloadError = error + return + } + if let httpResponse = response as? HTTPURLResponse, + !(200...299).contains(httpResponse.statusCode) { + downloadError = NSError(domain: "programa.remote.daemon", code: 26, userInfo: [ + NSLocalizedDescriptionKey: "remote daemon download failed with HTTP \(httpResponse.statusCode)", + ]) + return + } + downloadedURL = localURL + }.resume() + _ = semaphore.wait(timeout: .now() + 75.0) + session.finishTasksAndInvalidate() + + if let downloadError { + throw downloadError + } + guard let downloadedURL else { + throw NSError(domain: "programa.remote.daemon", code: 27, userInfo: [ + NSLocalizedDescriptionKey: "remote daemon download did not produce a file", + ]) + } + + let downloadedSHA = try Self.sha256Hex(forFile: downloadedURL) + if downloadedSHA != entry.sha256.lowercased() { + // The embedded manifest's checksum doesn't match the downloaded binary. + // This can happen when a newer build overwrites the shared release + // asset after this build's manifest was embedded. As a fallback, fetch + // the live manifest from the release and verify against that. + if let releaseURL, + let liveManifest = Self.fetchRemoteManifestLocked(releaseURL: releaseURL, version: version), + let liveEntry = liveManifest.entry(goOS: entry.goOS, goArch: entry.goArch), + downloadedSHA == liveEntry.sha256.lowercased() { + debugLog("remote.download.checksum-fallback: embedded manifest checksum stale, live manifest matched for \(entry.assetName)") + } else { + throw NSError(domain: "programa.remote.daemon", code: 28, userInfo: [ + NSLocalizedDescriptionKey: "remote daemon checksum mismatch for \(entry.assetName)", + ]) + } + } + + let tempURL = cacheURL.deletingLastPathComponent() + .appendingPathComponent(".\(cacheURL.lastPathComponent).tmp-\(UUID().uuidString)") + try? fileManager.removeItem(at: tempURL) + try fileManager.moveItem(at: downloadedURL, to: tempURL) + try fileManager.setAttributes([.posixPermissions: 0o755], ofItemAtPath: tempURL.path) + try? fileManager.removeItem(at: cacheURL) + try fileManager.moveItem(at: tempURL, to: cacheURL) + return cacheURL + } + + func buildLocalDaemonBinary(goOS: String, goArch: String, version: String) throws -> URL { + if let explicitBinary = Self.explicitRemoteDaemonBinaryURL(), + FileManager.default.isExecutableFile(atPath: explicitBinary.path) { + debugLog("remote.build.explicit path=\(explicitBinary.path)") + return explicitBinary + } + + if let manifest = Self.remoteDaemonManifest(), + manifest.appVersion == version, + let entry = manifest.entry(goOS: goOS, goArch: goArch) { + let cacheURL = try Self.remoteDaemonCachedBinaryURL(version: manifest.appVersion, goOS: goOS, goArch: goArch) + if FileManager.default.fileExists(atPath: cacheURL.path) { + let cachedSHA = try Self.sha256Hex(forFile: cacheURL) + if cachedSHA == entry.sha256.lowercased(), + FileManager.default.isExecutableFile(atPath: cacheURL.path) { + debugLog("remote.build.cached path=\(cacheURL.path)") + return cacheURL + } + try? FileManager.default.removeItem(at: cacheURL) + } + let downloadedURL = try downloadRemoteDaemonBinaryLocked(entry: entry, version: manifest.appVersion, releaseURL: manifest.releaseURL) + debugLog("remote.build.downloaded path=\(downloadedURL.path)") + return downloadedURL + } + + guard Self.allowLocalDaemonBuildFallback() else { + throw NSError(domain: "programa.remote.daemon", code: 20, userInfo: [ + NSLocalizedDescriptionKey: "this build does not include a verified programad-remote manifest for \(goOS)-\(goArch). Use a release build, or set PROGRAMA_REMOTE_DAEMON_ALLOW_LOCAL_BUILD=1 for a dev-only fallback.", + ]) + } + + guard let repoRoot = Self.findRepoRoot() else { + throw NSError(domain: "programa.remote.daemon", code: 20, userInfo: [ + NSLocalizedDescriptionKey: "cannot locate cmux repo root for dev-only programad-remote build fallback", + ]) + } + 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: "programa.remote.daemon", code: 21, userInfo: [ + NSLocalizedDescriptionKey: "missing daemon module at \(goModPath)", + ]) + } + guard let goBinary = Self.which("go") else { + throw NSError(domain: "programa.remote.daemon", code: 22, userInfo: [ + NSLocalizedDescriptionKey: "go is required for the dev-only programad-remote build fallback", + ]) + } + + let output = Self.versionedRemoteDaemonBuildURL(goOS: goOS, goArch: goArch, version: version) + try FileManager.default.createDirectory(at: output.deletingLastPathComponent(), withIntermediateDirectories: true) + + 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", "-buildvcs=false", "-ldflags", ldflags, "-o", output.path, "./cmd/programad-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: "programa.remote.daemon", code: 23, userInfo: [ + NSLocalizedDescriptionKey: "failed to build programad-remote: \(detail)", + ]) + } + guard FileManager.default.isExecutableFile(atPath: output.path) else { + throw NSError(domain: "programa.remote.daemon", code: 24, userInfo: [ + NSLocalizedDescriptionKey: "programad-remote build output is not executable", + ]) + } + debugLog("remote.build.output path=\(output.path)") + return output + } + + func uploadRemoteDaemonBinaryLocked(localBinary: URL, remotePath: String) throws { + let remoteDirectory = (remotePath as NSString).deletingLastPathComponent + let remoteTempPath = "\(remotePath).tmp-\(UUID().uuidString.prefix(8))" + debugLog( + "remote.upload.begin local=\(localBinary.path) remoteTemp=\(remoteTempPath) remote=\(remotePath)" + ) + + let mkdirScript = "mkdir -p \(RemoteSSHConnectionPolicy.shellSingleQuoted(remoteDirectory))" + let mkdirCommand = "sh -c \(RemoteSSHConnectionPolicy.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: "programa.remote.daemon", code: 30, userInfo: [ + NSLocalizedDescriptionKey: "failed to create remote daemon directory: \(detail)", + ]) + } + + let scpSSHOptions = RemoteSSHConnectionPolicy.backgroundOptions(configuration.sshOptions) + var scpArgs: [String] = ["-q"] + scpArgs += RemoteSSHConnectionPolicy.strictHostKeyCheckingArguments(unlessSetIn: scpSSHOptions) + scpArgs += ["-o", "ControlMaster=no"] + if let port = configuration.port { + scpArgs += ["-P", String(port)] + } + if let identityFile = configuration.identityFile, + !identityFile.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty { + scpArgs += ["-i", identityFile] + } + for option in scpSSHOptions { + scpArgs += ["-o", option] + } + scpArgs += [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: "programa.remote.daemon", code: 31, userInfo: [ + NSLocalizedDescriptionKey: "failed to upload programad-remote: \(detail)", + ]) + } + + let finalizeScript = """ + chmod 755 \(RemoteSSHConnectionPolicy.shellSingleQuoted(remoteTempPath)) && \ + mv \(RemoteSSHConnectionPolicy.shellSingleQuoted(remoteTempPath)) \(RemoteSSHConnectionPolicy.shellSingleQuoted(remotePath)) + """ + let finalizeCommand = "sh -c \(RemoteSSHConnectionPolicy.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: "programa.remote.daemon", code: 32, userInfo: [ + NSLocalizedDescriptionKey: "failed to install remote daemon binary: \(detail)", + ]) + } + } + + func uploadDroppedFilesLocked( + _ fileURLs: [URL], + operation: TerminalImageTransferOperation + ) throws -> [String] { + let scpSSHOptions = RemoteSSHConnectionPolicy.backgroundOptions(configuration.sshOptions) + return try performSCPUploadWithCancelCleanup( + items: fileURLs, + checkCancelled: { try operation.throwIfCancelled() }, + performUpload: { localURL, record in + let normalizedLocalURL = localURL.standardizedFileURL + guard normalizedLocalURL.isFileURL else { + throw RemoteDropUploadError.invalidFileURL + } + + let remotePath = Self.remoteDropPath(for: normalizedLocalURL) + record(remotePath) + var scpArgs: [String] = ["-q", "-o", "ControlMaster=no"] + scpArgs += RemoteSSHConnectionPolicy.strictHostKeyCheckingArguments(unlessSetIn: scpSSHOptions) + if let port = configuration.port { + scpArgs += ["-P", String(port)] + } + if let identityFile = configuration.identityFile, + !identityFile.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty { + scpArgs += ["-i", identityFile] + } + for option in scpSSHOptions { + scpArgs += ["-o", option] + } + scpArgs += [normalizedLocalURL.path, "\(configuration.destination):\(remotePath)"] + + let scpResult = try scpExec(arguments: scpArgs, timeout: 45, operation: operation) + guard scpResult.status == 0 else { + let detail = Self.bestErrorLine(stderr: scpResult.stderr, stdout: scpResult.stdout) ?? + "scp exited \(scpResult.status)" + throw RemoteDropUploadError.uploadFailed(detail) + } + }, + cleanup: { cleanupUploadedRemotePaths($0) } + ) + } + + static func remoteDropPath(for fileURL: URL, uuid: UUID = UUID()) -> String { + let extensionSuffix = fileURL.pathExtension.trimmingCharacters(in: .whitespacesAndNewlines) + let lowercasedSuffix = extensionSuffix.isEmpty ? "" : ".\(extensionSuffix.lowercased())" + return "/tmp/programa-drop-\(uuid.uuidString.lowercased())\(lowercasedSuffix)" + } + + func cleanupUploadedRemotePaths(_ remotePaths: [String]) { + guard !remotePaths.isEmpty else { return } + let cleanupScript = "rm -f -- " + remotePaths.map(RemoteSSHConnectionPolicy.shellSingleQuoted).joined(separator: " ") + let cleanupCommand = "sh -c \(RemoteSSHConnectionPolicy.shellSingleQuoted(cleanupScript))" + _ = try? sshExec( + arguments: sshCommonArguments(batchMode: true) + [configuration.destination, cleanupCommand], + timeout: 8 + ) + } + + func helloRemoteDaemonLocked(remotePath: String) throws -> DaemonHello { + let request = #"{"id":1,"method":"hello","params":{}}"# + let script = "printf '%s\\n' \(RemoteSSHConnectionPolicy.shellSingleQuoted(request)) | \(RemoteSSHConnectionPolicy.shellSingleQuoted(remotePath)) serve --stdio" + let command = "sh -c \(RemoteSSHConnectionPolicy.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: "programa.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: "programa.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: "programa.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! : "programad-remote"), + version: (version?.isEmpty == false ? version! : "dev"), + capabilities: capabilities, + remotePath: remotePath + ) + } + +} diff --git a/Sources/WorkspaceRemoteSessionController+PortScanning.swift b/Sources/WorkspaceRemoteSessionController+PortScanning.swift new file mode 100644 index 00000000000..b58289f2ee2 --- /dev/null +++ b/Sources/WorkspaceRemoteSessionController+PortScanning.swift @@ -0,0 +1,489 @@ +// Extracted from WorkspaceRemoteSession.swift (nuclear-review #98): remote listening-port scan scheduling, polling, and script builders. + +import Foundation +import SwiftUI +import AppKit +import Bonsplit +import Combine +import CryptoKit +import Darwin +import Network +import CoreText + +extension WorkspaceRemoteSessionController { + func updateRemotePortScanTTYs(_ ttyNames: [UUID: String]) { + queue.async { [weak self] in + self?.updateRemotePortScanTTYsLocked(ttyNames) + } + } + + func kickRemotePortScan(panelId: UUID, reason: PortScanKickReason = .command) { + queue.async { [weak self] in + self?.kickRemotePortScanLocked(panelId: panelId, reason: reason) + } + } + + func updateRemotePortScanTTYsLocked(_ ttyNames: [UUID: String]) { + let previousTTYNames = remotePortScanTTYNames + let nextTTYNames = ttyNames.reduce(into: [UUID: String]()) { result, entry in + guard let ttyName = Self.normalizedRemotePortScanTTYName(entry.value) else { return } + result[entry.key] = ttyName + } + guard previousTTYNames != nextTTYNames else { return } + if !nextTTYNames.isEmpty { + bootstrapRemoteTTYResolved = true + bootstrapRemoteTTYRetryWorkItem?.cancel() + bootstrapRemoteTTYRetryWorkItem = nil + bootstrapRemoteTTYRetryCount = 0 + } + keepPolledRemotePortsUntilTTYScan = + !previousTTYNames.isEmpty + ? keepPolledRemotePortsUntilTTYScan + : shouldUseFallbackRemotePortPollingLocked() && !polledRemotePorts.isEmpty && !nextTTYNames.isEmpty + remoteScannedPortsByPanel = remoteScannedPortsByPanel.filter { panelId, _ in + guard let oldTTY = previousTTYNames[panelId], + let newTTY = nextTTYNames[panelId] else { + return false + } + return oldTTY == newTTY + } + remotePortScanTTYNames = nextTTYNames + if nextTTYNames.isEmpty { + keepPolledRemotePortsUntilTTYScan = false + } + updateRemotePortPollingStateLocked() + publishPortsSnapshotLocked() + } + + func kickRemotePortScanLocked(panelId: UUID, reason: PortScanKickReason) { + guard !isStopping else { return } + guard daemonReady else { return } + guard remotePortScanTTYNames[panelId] != nil else { return } + if remotePortScanBurstActive, remotePortScanActiveReason == .command, reason == .refresh { + return + } + remotePortScanPendingReason = remotePortScanPendingReason?.merged(with: reason) ?? reason + scheduleRemotePortScanCoalesceLocked() + } + + func scheduleRemotePortScanCoalesceLocked() { + guard !remotePortScanBurstActive else { return } + guard remotePortScanCoalesceWorkItem == nil else { return } + + let generation = remotePortScanGeneration + let workItem = DispatchWorkItem { [weak self] in + guard let self else { return } + guard self.remotePortScanGeneration == generation else { return } + self.remotePortScanCoalesceWorkItem = nil + guard let reason = self.remotePortScanPendingReason else { return } + self.remotePortScanPendingReason = nil + self.remotePortScanBurstActive = true + self.remotePortScanActiveReason = reason + self.runRemotePortScanBurstLocked(index: 0, generation: generation, reason: reason) + } + remotePortScanCoalesceWorkItem = workItem + queue.asyncAfter(deadline: .now() + 0.2, execute: workItem) + } + + func runRemotePortScanBurstLocked( + index: Int, + generation: UInt64, + reason: PortScanKickReason, + burstStart: DispatchTime? = nil + ) { + guard remotePortScanGeneration == generation else { return } + + let burstOffsets = reason.burstOffsets + guard index < burstOffsets.count else { + remotePortScanBurstActive = false + remotePortScanActiveReason = nil + if remotePortScanPendingReason != nil && remotePortScanCoalesceWorkItem == nil { + scheduleRemotePortScanCoalesceLocked() + } + return + } + + let start = burstStart ?? .now() + let deadline = start + burstOffsets[index] + queue.asyncAfter(deadline: deadline) { [weak self] in + guard let self else { return } + guard self.remotePortScanGeneration == generation else { return } + self.performRemotePortScanLocked() + self.runRemotePortScanBurstLocked( + index: index + 1, + generation: generation, + reason: reason, + burstStart: start + ) + } + } + + func performRemotePortScanLocked() { + let ttyNamesByPanel = remotePortScanTTYNames + guard !ttyNamesByPanel.isEmpty else { + remoteScannedPortsByPanel.removeAll() + keepPolledRemotePortsUntilTTYScan = false + publishPortsSnapshotLocked() + return + } + + do { + remoteScannedPortsByPanel = try scanRemotePortsByPanelLocked(ttyNamesByPanel: ttyNamesByPanel) + keepPolledRemotePortsUntilTTYScan = false + polledRemotePorts = [] + publishPortsSnapshotLocked() + } catch { + debugLog("remote.ports.scan.failed error=\(error.localizedDescription) \(debugConfigSummary())") + } + } + + func scanRemotePortsByPanelLocked(ttyNamesByPanel: [UUID: String]) throws -> [UUID: [Int]] { + let ttyNames = Array(Set(ttyNamesByPanel.values)).sorted() + guard !ttyNames.isEmpty else { return [:] } + + let command = "sh -c \(RemoteSSHConnectionPolicy.shellSingleQuoted(Self.remotePortScanScript(ttyNames: ttyNames, excluding: excludedRemoteScanPorts())))" + let result = try sshExec( + arguments: sshCommonArguments(batchMode: true) + [configuration.destination, command], + timeout: 8 + ) + guard result.status == 0 else { + let detail = Self.bestErrorLine(stderr: result.stderr, stdout: result.stdout) ?? "ssh exited \(result.status)" + throw NSError(domain: "programa.remote.ports", code: 90, userInfo: [ + NSLocalizedDescriptionKey: "remote port scan failed: \(detail)", + ]) + } + + let portsByTTY = Self.parseRemoteTTYPortPairs( + output: result.stdout, + trackedTTYNames: Set(ttyNames) + ) + + return ttyNamesByPanel.reduce(into: [UUID: [Int]]()) { result, entry in + result[entry.key] = portsByTTY[entry.value] ?? [] + } + } + + func startRemotePortPollingLocked(mode: RemotePortPollingMode) { + if remotePortPollTimer != nil, remotePortPollMode == mode { + return + } + stopRemotePortPollingLocked() + + let timer = DispatchSource.makeTimerSource(queue: queue) + timer.schedule(deadline: .now() + mode.initialDelay, repeating: mode.repeatInterval) + timer.setEventHandler { [weak self] in + self?.pollRemotePortsLocked() + } + remotePortPollTimer = timer + remotePortPollMode = mode + timer.resume() + pollRemotePortsLocked() + } + + func stopRemotePortPollingLocked() { + remotePortPollTimer?.setEventHandler {} + remotePortPollTimer?.cancel() + remotePortPollTimer = nil + remotePortPollMode = nil + } + + func updateRemotePortPollingStateLocked() { + guard daemonReady, !isStopping, let pollingMode = remotePortPollingModeLocked() else { + stopRemotePortPollingLocked() + if !keepPolledRemotePortsUntilTTYScan { + polledRemotePorts = [] + } + remotePortPollBaselinePorts = nil + return + } + startRemotePortPollingLocked(mode: pollingMode) + } + + func pollRemotePortsLocked() { + guard !isStopping else { return } + guard daemonReady else { return } + if !remotePortScanTTYNames.isEmpty { + guard shouldUseTTYFallbackRemotePortPollingLocked() else { + stopRemotePortPollingLocked() + if !keepPolledRemotePortsUntilTTYScan { + polledRemotePorts = [] + } + publishPortsSnapshotLocked() + return + } + if remotePortScanBurstActive || remotePortScanCoalesceWorkItem != nil || remotePortScanPendingReason != nil { + return + } + performRemotePortScanLocked() + return + } + guard let pollingMode = remotePortPollingModeLocked() else { + stopRemotePortPollingLocked() + polledRemotePorts = [] + remotePortPollBaselinePorts = nil + keepPolledRemotePortsUntilTTYScan = false + publishPortsSnapshotLocked() + return + } + guard remotePortScanTTYNames.isEmpty else { + stopRemotePortPollingLocked() + if !keepPolledRemotePortsUntilTTYScan { + polledRemotePorts = [] + } + remotePortPollBaselinePorts = nil + publishPortsSnapshotLocked() + return + } + + let command = "sh -c \(RemoteSSHConnectionPolicy.shellSingleQuoted(Self.remoteAllPortsScanScript(excluding: excludedRemoteScanPorts())))" + do { + let result = try sshExec( + arguments: sshCommonArguments(batchMode: true) + [configuration.destination, command], + timeout: 8 + ) + guard result.status == 0 else { + let detail = Self.bestErrorLine(stderr: result.stderr, stdout: result.stdout) ?? "ssh exited \(result.status)" + throw NSError(domain: "programa.remote.ports", code: 90, userInfo: [ + NSLocalizedDescriptionKey: "remote port scan failed: \(detail)", + ]) + } + let currentPorts = Set(Self.parseRemotePorts(output: result.stdout)) + switch pollingMode { + case .hostWide: + polledRemotePorts = currentPorts.sorted() + remotePortPollBaselinePorts = nil + case .hostWideDelta: + if let baselinePorts = remotePortPollBaselinePorts { + polledRemotePorts = currentPorts.subtracting(baselinePorts).sorted() + } else { + remotePortPollBaselinePorts = currentPorts + polledRemotePorts = [] + } + case .ttyScoped: + polledRemotePorts = [] + remotePortPollBaselinePorts = nil + } + keepPolledRemotePortsUntilTTYScan = false + publishPortsSnapshotLocked() + } catch { + debugLog("remote.ports.poll.failed error=\(error.localizedDescription) \(debugConfigSummary())") + } + } + + func excludedRemoteScanPorts() -> Set { + var excluded: Set = [] + if let relayPort = configuration.relayPort, relayPort > 0 { + excluded.insert(relayPort) + } + if let configuredPort = configuration.port, configuredPort > 0 { + excluded.insert(configuredPort) + } + return excluded + } + + func shouldUseFallbackRemotePortPollingLocked() -> Bool { + // `cmux ssh` owns the remote shell bootstrap and can report the remote + // TTY precisely. Falling back to host-wide port scans in that path leaks + // unrelated listeners from the remote machine into the workspace card. + let startupCommand = configuration.terminalStartupCommand? + .trimmingCharacters(in: .whitespacesAndNewlines) + return startupCommand?.isEmpty != false + } + + func shouldUseTTYFallbackRemotePortPollingLocked() -> Bool { + // `cmux ssh` can still land in shells without our command hooks, such as + // `/bin/sh` in the Docker fixture. Once the workspace knows the TTY, + // keep a low-frequency TTY-scoped poll so unsupported shells still + // surface ports without bringing back noisy host-wide scans. + let startupCommand = configuration.terminalStartupCommand? + .trimmingCharacters(in: .whitespacesAndNewlines) + return startupCommand?.isEmpty == false + } + + func remotePortPollingModeLocked() -> RemotePortPollingMode? { + if !remotePortScanTTYNames.isEmpty { + return shouldUseTTYFallbackRemotePortPollingLocked() ? .ttyScoped : nil + } + let startupCommand = configuration.terminalStartupCommand? + .trimmingCharacters(in: .whitespacesAndNewlines) + if startupCommand?.isEmpty == false { + return .hostWideDelta + } + return shouldUseFallbackRemotePortPollingLocked() ? .hostWide : nil + } + + static func parseRemoteTTYPortPairs(output: String, trackedTTYNames: Set) -> [String: [Int]] { + var portsByTTY = Dictionary(uniqueKeysWithValues: trackedTTYNames.map { ($0, Set()) }) + + for line in output.split(separator: "\n") { + let parts = line.split(separator: "\t", omittingEmptySubsequences: false) + guard parts.count == 2 else { continue } + let ttyName = String(parts[0]).trimmingCharacters(in: .whitespacesAndNewlines) + guard trackedTTYNames.contains(ttyName), + let port = Int(parts[1]), + port >= 1024, + port <= 65535 else { + continue + } + portsByTTY[ttyName, default: []].insert(port) + } + + return portsByTTY.reduce(into: [String: [Int]]()) { result, entry in + result[entry.key] = entry.value.sorted() + } + } + + static func parseRemotePorts(output: String) -> [Int] { + let values = output + .split(whereSeparator: \.isWhitespace) + .compactMap { Int($0) } + .filter { $0 >= 1024 && $0 <= 65535 } + return Array(Set(values)).sorted() + } + + static func normalizedRemotePortScanTTYName(_ raw: String) -> String? { + let trimmed = raw.trimmingCharacters(in: .whitespacesAndNewlines) + guard !trimmed.isEmpty else { return nil } + let candidate = trimmed.split(separator: "/").last.map(String.init) ?? trimmed + guard !candidate.isEmpty else { return nil } + let allowed = CharacterSet.alphanumerics.union(CharacterSet(charactersIn: "._-")) + guard candidate.unicodeScalars.allSatisfy({ allowed.contains($0) }) else { return nil } + return candidate + } + + static func remotePortScanScript(ttyNames: [String], excluding ports: Set) -> String { + let ttySet = ttyNames.joined(separator: " ") + let ttyCSV = ttyNames.joined(separator: ",") + let excludedPorts = ports.sorted().map(String.init).joined(separator: " ") + + return """ + set -eu + programa_tracked_ttys=" \(ttySet) " + programa_tty_csv='\(ttyCSV)' + programa_excluded_ports=" \(excludedPorts) " + + programa_emit_port() { + programa_tty="$1" + programa_port="$2" + case "$programa_tracked_ttys" in + *" $programa_tty "*) ;; + *) return 0 ;; + esac + case "$programa_excluded_ports" in + *" $programa_port "*) return 0 ;; + esac + [ "$programa_port" -ge 1024 ] && [ "$programa_port" -le 65535 ] || return 0 + printf '%s\\t%s\\n' "$programa_tty" "$programa_port" + } + + programa_used_ss=0 + if [ -d /proc ] && command -v ss >/dev/null 2>&1; then + programa_ss_output="$(ss -ltnpH 2>/dev/null || true)" + case "$programa_ss_output" in + *pid=*) + programa_used_ss=1 + printf '%s\\n' "$programa_ss_output" | while IFS= read -r programa_line; do + [ -n "$programa_line" ] || continue + programa_port="$(printf '%s\\n' "$programa_line" | awk '{print $4}' | sed -E 's/.*:([0-9]+)$/\\1/' | awk '/^[0-9]+$/ { print $1; exit }')" + [ -n "$programa_port" ] || continue + printf '%s\\n' "$programa_line" | awk ' + { + line = $0 + while (match(line, /pid=[0-9]+/)) { + print substr(line, RSTART + 4, RLENGTH - 4) + line = substr(line, RSTART + RLENGTH) + } + } + ' | while IFS= read -r programa_pid; do + [ -n "$programa_pid" ] || continue + programa_tty_path="$(readlink "/proc/$programa_pid/fd/0" 2>/dev/null || true)" + [ -n "$programa_tty_path" ] || continue + programa_tty="${programa_tty_path##*/}" + [ -n "$programa_tty" ] || continue + programa_emit_port "$programa_tty" "$programa_port" + done + done + ;; + esac + fi + + if [ "$programa_used_ss" -eq 0 ] && command -v lsof >/dev/null 2>&1 && [ -n "$programa_tty_csv" ]; then + programa_tmpdir="$(mktemp -d 2>/dev/null || mktemp -d -t programa-ports)" + trap 'rm -rf "$programa_tmpdir"' EXIT INT TERM + programa_pid_tty_map="$programa_tmpdir/pid_tty" + ps -t "$programa_tty_csv" -o pid=,tty= 2>/dev/null | awk ' + NF >= 2 { + tty = $2 + sub(/^.*\\//, "", tty) + print $1 "\\t" tty + } + ' > "$programa_pid_tty_map" + [ -s "$programa_pid_tty_map" ] || exit 0 + programa_pid_csv="$(awk '{print $1}' "$programa_pid_tty_map" | paste -sd, -)" + [ -n "$programa_pid_csv" ] || exit 0 + lsof -nP -a -p "$programa_pid_csv" -iTCP -sTCP:LISTEN -Fpn 2>/dev/null | awk -v map="$programa_pid_tty_map" ' + BEGIN { + while ((getline < map) > 0) { + pid_to_tty[$1] = $2 + } + close(map) + } + $0 ~ /^p/ { + pid = substr($0, 2) + tty = pid_to_tty[pid] + next + } + $0 ~ /^n/ && tty != "" { + name = substr($0, 2) + sub(/->.*/, "", name) + sub(/^.*:/, "", name) + sub(/[^0-9].*/, "", name) + if (name != "") { + print tty "\\t" name + } + } + ' | while IFS=$'\\t' read -r programa_tty programa_port; do + [ -n "$programa_tty" ] || continue + [ -n "$programa_port" ] || continue + programa_emit_port "$programa_tty" "$programa_port" + done + fi + """ + } + + static func remoteAllPortsScanScript(excluding ports: Set) -> String { + let excludedPorts = ports.sorted().map(String.init).joined(separator: " ") + + return """ + set -eu + programa_excluded_ports=" \(excludedPorts) " + + programa_emit_port() { + programa_port="$1" + case "$programa_excluded_ports" in + *" $programa_port "*) return 0 ;; + esac + [ "$programa_port" -ge 1024 ] && [ "$programa_port" -le 65535 ] || return 0 + printf '%s\\n' "$programa_port" + } + + if command -v ss >/dev/null 2>&1; then + ss -ltnH 2>/dev/null | awk '{print $4}' | sed -E 's/.*:([0-9]+)$/\\1/' | awk '/^[0-9]+$/ {print $1}' | while IFS= read -r programa_port; do + [ -n "$programa_port" ] || continue + programa_emit_port "$programa_port" + done + elif command -v netstat >/dev/null 2>&1; then + netstat -lnt 2>/dev/null | awk 'NR > 2 {print $4}' | sed -E 's/.*:([0-9]+)$/\\1/' | awk '/^[0-9]+$/ {print $1}' | while IFS= read -r programa_port; do + [ -n "$programa_port" ] || continue + programa_emit_port "$programa_port" + done + elif command -v lsof >/dev/null 2>&1; then + lsof -nP -iTCP -sTCP:LISTEN 2>/dev/null | awk 'NR > 1 {print $9}' | sed -E 's/.*:([0-9]+)$/\\1/' | awk '/^[0-9]+$/ {print $1}' | while IFS= read -r programa_port; do + [ -n "$programa_port" ] || continue + programa_emit_port "$programa_port" + done + fi + """ + } + +} diff --git a/Sources/WorkspaceRemoteSessionController+ProcessExecution.swift b/Sources/WorkspaceRemoteSessionController+ProcessExecution.swift new file mode 100644 index 00000000000..6d1bd623212 --- /dev/null +++ b/Sources/WorkspaceRemoteSessionController+ProcessExecution.swift @@ -0,0 +1,193 @@ +// Extracted from WorkspaceRemoteSession.swift (nuclear-review #98): ssh/scp argument assembly and the underlying Process execution primitive. + +import Foundation +import SwiftUI +import AppKit +import Bonsplit +import Combine +import CryptoKit +import Darwin +import Network +import CoreText + +extension WorkspaceRemoteSessionController { + func sshCommonArguments(batchMode: Bool) -> [String] { + let effectiveSSHOptions: [String] = { + if batchMode { + return RemoteSSHConnectionPolicy.backgroundOptions(configuration.sshOptions) + } + return RemoteSSHConnectionPolicy.normalizedOptions(configuration.sshOptions) + }() + var args = RemoteSSHConnectionPolicy.keepaliveArguments + args += RemoteSSHConnectionPolicy.strictHostKeyCheckingArguments(unlessSetIn: effectiveSSHOptions) + if batchMode { + args += RemoteSSHConnectionPolicy.batchModeArguments + } + 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 effectiveSSHOptions { + args += ["-o", option] + } + return args + } + + func sshExec(arguments: [String], stdin: Data? = nil, timeout: TimeInterval = 15) throws -> CommandResult { + try runProcess( + executable: "/usr/bin/ssh", + arguments: arguments, + stdin: stdin, + timeout: timeout + ) + } + + func scpExec( + arguments: [String], + timeout: TimeInterval = 30, + operation: TerminalImageTransferOperation? = nil + ) throws -> CommandResult { + try runProcess( + executable: "/usr/bin/scp", + arguments: arguments, + stdin: nil, + timeout: timeout, + operation: operation + ) + } + + func runProcess( + executable: String, + arguments: [String], + environment: [String: String]? = nil, + currentDirectory: URL? = nil, + stdin: Data?, + timeout: TimeInterval, + operation: TerminalImageTransferOperation? = nil + ) throws -> CommandResult { + debugLog( + "remote.proc.start exec=\(URL(fileURLWithPath: executable).lastPathComponent) " + + "timeout=\(Int(timeout)) args=\(debugShellCommand(executable: executable, arguments: arguments))" + ) + 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 + } + + let stdoutHandle = stdoutPipe.fileHandleForReading + let stderrHandle = stderrPipe.fileHandleForReading + let captureQueue = DispatchQueue(label: "programa.remote.process.capture") + let exitSemaphore = DispatchSemaphore(value: 0) + var stdoutData = Data() + var stderrData = Data() + let captureGroup = DispatchGroup() + process.terminationHandler = { _ in + exitSemaphore.signal() + } + captureGroup.enter() + DispatchQueue.global(qos: .utility).async { + let data = stdoutHandle.readDataToEndOfFile() + captureQueue.sync { + stdoutData = data + } + captureGroup.leave() + } + captureGroup.enter() + DispatchQueue.global(qos: .utility).async { + let data = stderrHandle.readDataToEndOfFile() + captureQueue.sync { + stderrData = data + } + captureGroup.leave() + } + + do { + try operation?.throwIfCancelled() + try process.run() + } catch { + try? stdoutPipe.fileHandleForWriting.close() + try? stderrPipe.fileHandleForWriting.close() + debugLog( + "remote.proc.launchFailed exec=\(URL(fileURLWithPath: executable).lastPathComponent) " + + "error=\(error.localizedDescription)" + ) + throw NSError(domain: "programa.remote.process", code: 1, userInfo: [ + NSLocalizedDescriptionKey: "Failed to launch \(URL(fileURLWithPath: executable).lastPathComponent): \(error.localizedDescription)", + ]) + } + try? stdoutPipe.fileHandleForWriting.close() + try? stderrPipe.fileHandleForWriting.close() + operation?.installCancellationHandler { + if process.isRunning { + process.terminate() + } + } + defer { operation?.clearCancellationHandler() } + + if let stdin, let pipe = process.standardInput as? Pipe { + pipe.fileHandleForWriting.write(stdin) + try? pipe.fileHandleForWriting.close() + } + + func terminateProcessAndWait() { + process.terminate() + let terminatedGracefully = exitSemaphore.wait(timeout: .now() + 2.0) == .success + if !terminatedGracefully, process.isRunning { + _ = Darwin.kill(process.processIdentifier, SIGKILL) + process.waitUntilExit() + } + } + + let didExitBeforeTimeout = exitSemaphore.wait(timeout: .now() + max(0, timeout)) == .success + if !didExitBeforeTimeout, process.isRunning { + if operation?.isCancelled == true { + terminateProcessAndWait() + throw TerminalImageTransferExecutionError.cancelled + } + terminateProcessAndWait() + debugLog( + "remote.proc.timeout exec=\(URL(fileURLWithPath: executable).lastPathComponent) " + + "timeout=\(Int(timeout)) args=\(debugShellCommand(executable: executable, arguments: arguments))" + ) + throw NSError(domain: "programa.remote.process", code: 2, userInfo: [ + NSLocalizedDescriptionKey: "\(URL(fileURLWithPath: executable).lastPathComponent) timed out after \(Int(timeout))s", + ]) + } + + _ = captureGroup.wait(timeout: .now() + 2.0) + try? stdoutHandle.close() + try? stderrHandle.close() + let stdout = String(data: stdoutData, encoding: .utf8) ?? "" + let stderr = String(data: stderrData, encoding: .utf8) ?? "" + if operation?.isCancelled == true { + throw TerminalImageTransferExecutionError.cancelled + } + debugLog( + "remote.proc.end exec=\(URL(fileURLWithPath: executable).lastPathComponent) " + + "status=\(process.terminationStatus) stdout=\(Self.debugLogSnippet(stdout)) " + + "stderr=\(Self.debugLogSnippet(stderr))" + ) + return CommandResult(status: process.terminationStatus, stdout: stdout, stderr: stderr) + } + + +} diff --git a/Sources/WorkspaceRemoteSessionController+ScriptBuilders.swift b/Sources/WorkspaceRemoteSessionController+ScriptBuilders.swift new file mode 100644 index 00000000000..b5910451cb8 --- /dev/null +++ b/Sources/WorkspaceRemoteSessionController+ScriptBuilders.swift @@ -0,0 +1,545 @@ +// Extracted from WorkspaceRemoteSession.swift (nuclear-review #98): debug logging, remote shell script builders, and process/PID utilities. + +import Foundation +import SwiftUI +import AppKit +import Bonsplit +import Combine +import CryptoKit +import Darwin +import Network +import CoreText + +extension WorkspaceRemoteSessionController { + func debugLog(_ message: @autoclosure () -> String) { +#if DEBUG + dlog(message()) +#endif + } + + func debugConfigSummary() -> String { + let controlPath = Self.debugSSHOptionValue(named: "ControlPath", in: configuration.sshOptions) ?? "nil" + return + "target=\(configuration.displayTarget) port=\(configuration.port.map(String.init) ?? "nil") " + + "relayPort=\(configuration.relayPort.map(String.init) ?? "nil") " + + "localSocket=\(configuration.localSocketPath ?? "nil") " + + "controlPath=\(controlPath)" + } + + func debugShellCommand(executable: String, arguments: [String]) -> String { + ([URL(fileURLWithPath: executable).lastPathComponent] + arguments) + .map(RemoteSSHConnectionPolicy.shellSingleQuoted) + .joined(separator: " ") + } + + static func debugSSHOptionValue(named key: String, in options: [String]) -> String? { + let loweredKey = key.lowercased() + for option in options { + let trimmed = option.trimmingCharacters(in: .whitespacesAndNewlines) + guard !trimmed.isEmpty else { continue } + let parts = trimmed.split(separator: "=", maxSplits: 1, omittingEmptySubsequences: false) + if parts.count == 2, + parts[0].trimmingCharacters(in: .whitespacesAndNewlines).lowercased() == loweredKey { + return parts[1].trimmingCharacters(in: .whitespacesAndNewlines) + } + } + return nil + } + + static func debugLogSnippet(_ text: String, limit: Int = 160) -> String { + let normalized = text + .replacingOccurrences(of: "\n", with: "\\n") + .replacingOccurrences(of: "\r", with: "\\r") + .trimmingCharacters(in: .whitespacesAndNewlines) + guard !normalized.isEmpty else { return "\"\"" } + if normalized.count <= limit { + return normalized + } + return String(normalized.prefix(limit)) + "..." + } + + static func remoteCLIWrapperScript() -> String { + """ + #!/bin/sh + set -eu + + daemon="$HOME/.programa/bin/programad-remote-current" + socket_path="${PROGRAMA_SOCKET_PATH:-}" + if [ -z "$socket_path" ] && [ -r "$HOME/.programa/socket_addr" ]; then + socket_path="$(tr -d '\\r\\n' < "$HOME/.programa/socket_addr")" + fi + + if [ -n "$socket_path" ] && [ "${socket_path#/}" = "$socket_path" ] && [ "${socket_path#*:}" != "$socket_path" ]; then + relay_port="${socket_path##*:}" + relay_map="$HOME/.programa/relay/${relay_port}.daemon_path" + if [ -r "$relay_map" ]; then + mapped_daemon="$(tr -d '\\r\\n' < "$relay_map")" + if [ -n "$mapped_daemon" ] && [ -x "$mapped_daemon" ]; then + daemon="$mapped_daemon" + fi + fi + fi + + exec "$daemon" "$@" + """ + } + + static func remoteCLIWrapperInstallScript(daemonRemotePath: String) -> String { + let trimmedRemotePath = daemonRemotePath.trimmingCharacters(in: .whitespacesAndNewlines) + return """ + mkdir -p "$HOME/.programa/bin" "$HOME/.programa/relay" + ln -sf "$HOME/\(trimmedRemotePath)" "$HOME/.programa/bin/programad-remote-current" + wrapper_tmp="$HOME/.programa/bin/.programa-wrapper.tmp.$$" + cat > "$wrapper_tmp" <<'CMUXWRAPPER' + \(remoteCLIWrapperScript()) + CMUXWRAPPER + chmod 755 "$wrapper_tmp" + mv -f "$wrapper_tmp" "$HOME/.programa/bin/programa" + """ + } + + static func remoteRelayMetadataInstallScript( + daemonRemotePath: String, + relayPort: Int, + relayID: String, + relayToken: String + ) -> String { + let trimmedRemotePath = daemonRemotePath.trimmingCharacters(in: .whitespacesAndNewlines) + let authPayload = """ + {"relay_id":"\(relayID)","relay_token":"\(relayToken)"} + """ + return """ + umask 077 + mkdir -p "$HOME/.programa" "$HOME/.programa/relay" + chmod 700 "$HOME/.programa/relay" + \(remoteCLIWrapperInstallScript(daemonRemotePath: trimmedRemotePath)) + printf '%s' "$HOME/\(trimmedRemotePath)" > "$HOME/.programa/relay/\(relayPort).daemon_path" + cat > "$HOME/.programa/relay/\(relayPort).auth" <<'PROGRAMARELAYAUTH' + \(authPayload) + PROGRAMARELAYAUTH + chmod 600 "$HOME/.programa/relay/\(relayPort).auth" + printf '%s' '127.0.0.1:\(relayPort)' > "$HOME/.programa/socket_addr" + """ + } + + static func mapUnameOS(_ raw: String) -> String? { + switch raw.lowercased() { + case "linux": + return "linux" + case "darwin": + return "darwin" + case "freebsd": + return "freebsd" + default: + return nil + } + } + + 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 + } + } + + static func remoteDaemonVersion() -> String { + let bundleVersion = (Bundle.main.infoDictionary?["CFBundleShortVersionString"] as? String)? + .trimmingCharacters(in: .whitespacesAndNewlines) + let baseVersion = (bundleVersion?.isEmpty == false) ? bundleVersion! : "dev" + guard allowLocalDaemonBuildFallback(), + let sourceFingerprint = remoteDaemonSourceFingerprint(), + !sourceFingerprint.isEmpty else { + return baseVersion + } + return "\(baseVersion)-dev-\(sourceFingerprint)" + } + + static let cachedRemoteDaemonSourceFingerprint: String? = computeRemoteDaemonSourceFingerprint() + + static func remoteDaemonSourceFingerprint() -> String? { + cachedRemoteDaemonSourceFingerprint + } + + static func computeRemoteDaemonSourceFingerprint(fileManager: FileManager = .default) -> String? { + guard let repoRoot = findRepoRoot() else { return nil } + let daemonRoot = repoRoot.appendingPathComponent("daemon/remote", isDirectory: true) + guard let enumerator = fileManager.enumerator( + at: daemonRoot, + includingPropertiesForKeys: [.isRegularFileKey], + options: [.skipsHiddenFiles] + ) else { + return nil + } + + var relativePaths: [String] = [] + for case let fileURL as URL in enumerator { + guard let resourceValues = try? fileURL.resourceValues(forKeys: [.isRegularFileKey]), + resourceValues.isRegularFile == true else { + continue + } + + let relativePath = fileURL.path.replacingOccurrences(of: daemonRoot.path + "/", with: "") + if relativePath == "go.mod" || relativePath == "go.sum" || relativePath.hasSuffix(".go") { + relativePaths.append(relativePath) + } + } + + guard !relativePaths.isEmpty else { return nil } + + let digest = SHA256.hash(data: relativePaths.sorted().reduce(into: Data()) { partialResult, relativePath in + let fileURL = daemonRoot.appendingPathComponent(relativePath, isDirectory: false) + guard let fileData = try? Data(contentsOf: fileURL) else { return } + partialResult.append(Data(relativePath.utf8)) + partialResult.append(0) + partialResult.append(fileData) + partialResult.append(0) + }) + let hex = digest.map { String(format: "%02x", $0) }.joined() + return String(hex.prefix(12)) + } + + static func remoteDaemonPath(version: String, goOS: String, goArch: String) -> String { + ".programa/bin/programad-remote/\(version)/\(goOS)-\(goArch)/programad-remote" + } + + static func orphanedCMUXRemoteSSHPIDs( + psOutput: String, + destination: String, + relayPort: Int? = nil + ) -> [Int] { + let trimmedDestination = destination.trimmingCharacters(in: .whitespacesAndNewlines) + guard !trimmedDestination.isEmpty else { return [] } + + return psOutput + .split(separator: "\n", omittingEmptySubsequences: false) + .compactMap { line -> Int? in + guard let parsed = parsePSLine(line) else { return nil } + guard parsed.ppid == 1 else { return nil } + guard isOrphanedCMUXRemoteSSHCommand( + parsed.command, + destination: trimmedDestination, + relayPort: relayPort + ) else { + return nil + } + return parsed.pid + } + .sorted() + } + + static func killOrphanedRemoteSSHProcesses(destination: String, relayPort: Int? = nil) { + guard let output = captureCommandStandardOutput( + executablePath: "/bin/ps", + arguments: ["-axo", "pid=,ppid=,command="] + ) else { + return + } + + for pid in orphanedCMUXRemoteSSHPIDs( + psOutput: output, + destination: destination, + relayPort: relayPort + ) { + _ = Darwin.kill(pid_t(pid), SIGTERM) + } + } + + static func captureCommandStandardOutput( + executablePath: String, + arguments: [String] + ) -> String? { + let process = Process() + let stdoutPipe = Pipe() + process.executableURL = URL(fileURLWithPath: executablePath) + process.arguments = arguments + process.standardOutput = stdoutPipe + process.standardError = FileHandle.nullDevice + + do { + try process.run() + let outputData = stdoutPipe.fileHandleForReading.readDataToEndOfFile() + process.waitUntilExit() + guard process.terminationStatus == 0, + let output = String(data: outputData, encoding: .utf8), + !output.isEmpty else { + return nil + } + return output + } catch { + // Best effort cleanup only. + return nil + } + } + + static func parsePSLine(_ line: Substring) -> (pid: Int, ppid: Int, command: String)? { + let trimmed = line.trimmingCharacters(in: .whitespacesAndNewlines) + guard !trimmed.isEmpty else { return nil } + + let scanner = Scanner(string: trimmed) + var pidValue: Int = 0 + var ppidValue: Int = 0 + guard scanner.scanInt(&pidValue), scanner.scanInt(&ppidValue) else { + return nil + } + + let commandStart = scanner.currentIndex + let command = String(trimmed[commandStart...]).trimmingCharacters(in: .whitespacesAndNewlines) + guard !command.isEmpty else { return nil } + return (pidValue, ppidValue, command) + } + + static func isOrphanedCMUXRemoteSSHCommand( + _ command: String, + destination: String, + relayPort: Int? + ) -> Bool { + let trimmed = command.trimmingCharacters(in: .whitespacesAndNewlines) + guard !trimmed.isEmpty else { return false } + guard trimmed.hasPrefix("/usr/bin/ssh ") || trimmed.hasPrefix("ssh ") else { return false } + guard commandContainsDestination(trimmed, destination: destination) else { return false } + + if let relayPort { + return trimmed.contains(" -N ") + && trimmed.contains(" -R 127.0.0.1:\(relayPort):127.0.0.1:") + } + + if trimmed.contains(" -N ") && trimmed.contains(" -R 127.0.0.1:") { + return true + } + if trimmed.contains("programad-remote") && trimmed.contains(" serve --stdio") { + return true + } + return false + } + + static func commandContainsDestination(_ command: String, destination: String) -> Bool { + guard !destination.isEmpty else { return false } + let escaped = NSRegularExpression.escapedPattern(for: destination) + guard let regex = try? NSRegularExpression( + pattern: "(^|[\\s'\\\"])\(escaped)($|[\\s'\\\"])", + options: [] + ) else { + return command.contains(destination) + } + let range = NSRange(command.startIndex.. [String] { + var ordered: [String] = [] + var seen: Set = [] + + func appendSearchPath(_ rawPath: String?) { + guard let rawPath else { return } + let trimmed = rawPath.trimmingCharacters(in: .whitespacesAndNewlines) + guard !trimmed.isEmpty else { return } + guard seen.insert(trimmed).inserted else { return } + ordered.append(trimmed) + } + + if let path = environment["PATH"] { + for component in path.split(separator: ":") { + appendSearchPath(String(component)) + } + } + + if let home = environment["HOME"], !home.isEmpty { + appendSearchPath((home as NSString).appendingPathComponent(".local/bin")) + appendSearchPath((home as NSString).appendingPathComponent("go/bin")) + appendSearchPath((home as NSString).appendingPathComponent("bin")) + } + + let helperOutput = pathHelperOutput ?? pathHelperShellOutput() + for component in parsePathHelperPaths(helperOutput) { + appendSearchPath(component) + } + + for component in [ + "/opt/homebrew/bin", + "/opt/homebrew/sbin", + "/usr/local/bin", + "/usr/local/sbin", + "/usr/bin", + "/bin", + "/usr/sbin", + "/sbin", + ] { + appendSearchPath(component) + } + + return ordered + } + + static func parsePathHelperPaths(_ output: String) -> [String] { + for fragment in output.split(whereSeparator: { $0 == "\n" || $0 == ";" }) { + let trimmed = fragment.trimmingCharacters(in: .whitespacesAndNewlines) + guard trimmed.hasPrefix("PATH=\"") else { continue } + let suffix = trimmed.dropFirst("PATH=\"".count) + guard let closingQuote = suffix.firstIndex(of: "\"") else { return [] } + return suffix[.. String { + let executable = "/usr/libexec/path_helper" + guard FileManager.default.isExecutableFile(atPath: executable) else { return "" } + + let process = Process() + process.executableURL = URL(fileURLWithPath: executable) + process.arguments = ["-s"] + + let stdout = Pipe() + let stderr = Pipe() + process.standardOutput = stdout + process.standardError = stderr + + do { + try process.run() + } catch { + return "" + } + + process.waitUntilExit() + guard process.terminationStatus == 0 else { return "" } + let data = stdout.fileHandleForReading.readDataToEndOfFile() + return String(data: data, encoding: .utf8) ?? "" + } + + static func which(_ executable: String) -> String? { + for component in executableSearchPaths() { + let candidate = (component as NSString).appendingPathComponent(executable) + if FileManager.default.isExecutableFile(atPath: candidate) { + return candidate + } + } + return nil + } + + 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["PROGRAMA_REMOTE_DAEMON_SOURCE_ROOT"], + !envRoot.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty { + candidates.append(URL(fileURLWithPath: envRoot, isDirectory: true)) + } + if let envRoot = environment["PROGRAMATERM_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 + } + + 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 + } + + static func reverseRelayStartupFailureDetail( + process: Process, + stderrPipe: Pipe, + gracePeriod: TimeInterval = reverseRelayStartupGracePeriod + ) -> String? { + if process.isRunning { + let originalTerminationHandler = process.terminationHandler + let exitSemaphore = DispatchSemaphore(value: 0) + process.terminationHandler = { terminated in + originalTerminationHandler?(terminated) + exitSemaphore.signal() + } + if !process.isRunning { + exitSemaphore.signal() + } + guard exitSemaphore.wait(timeout: .now() + max(0, gracePeriod)) == .success else { + return nil + } + } + let stderrData = stderrPipe.fileHandleForReading.readDataToEndOfFile() + let stderr = String(data: stderrData, encoding: .utf8) ?? "" + return bestErrorLine(stderr: stderr) ?? "status=\(process.terminationStatus)" + } + + 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 + } + + 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 + } + + static func retrySuffix(retry: Int, delay: TimeInterval) -> String { + let seconds = max(1, Int(delay.rounded())) + return " (retry \(retry) in \(seconds)s)" + } + + static func retryDelay(baseDelay: TimeInterval, retry: Int) -> TimeInterval { + let exponent = Double(max(0, retry - 1)) + return min(baseDelay * pow(2.0, exponent), 60.0) + } + + static func shouldEscalateProxyErrorToBootstrap(_ detail: String) -> Bool { + let lowered = detail.lowercased() + return lowered.contains("remote daemon transport failed") + || lowered.contains("daemon transport closed stdout") + || lowered.contains("daemon transport exited") + || lowered.contains("daemon transport is not connected") + || lowered.contains("daemon transport stopped") + } + +} From aac48260f5e2233570a6a2b2188a41ab84260b38 Mon Sep 17 00:00:00 2001 From: arzafran Date: Thu, 9 Jul 2026 14:20:27 -0300 Subject: [PATCH 18/19] refactor(contentview): extract CommandPaletteController for palette state ownership MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Move all 34 command-palette-only @State properties plus the 2 @AppStorage settings (commandPaletteQuery, commandPaletteMode, search corpus/results, rename/workspace-description drafts, focus-restore targets, usage history, etc.) off ContentView and onto a new CommandPaletteController: ObservableObject, as @Published/@AppStorage properties. ContentView keeps exactly one `@StateObject private var commandPaletteController`. ContentView's existing (unqualified) call sites — the ~4000 lines of palette orchestration (registerCommandPaletteHandlers, commandPaletteCommandContributions, rename/workspace-description flows, focus-restore, usage-history persistence, the view body) — are preserved unchanged: each former @State name becomes a thin computed proxy on ContentView that forwards get/set to the controller (`private var commandPaletteQuery: String { get { commandPaletteController. commandPaletteQuery } nonmutating set { ... } }`). This is a real ownership transfer (the controller is the only place these values are stored; ContentView holds no @State backing them anymore), done without touching the orchestration method bodies, which keeps this change mechanically verifiable. 5 properties that were bound via SwiftUI's $name projection at their call sites (commandPaletteQuery, commandPaletteRenameDraft, commandPaletteWorkspaceDescriptionDraft, commandPaletteWorkspaceDescriptionHeight, commandPaletteShouldFocusWorkspaceDescriptionEditor) get a matching `nameBinding: Binding` proxy instead, since computed properties can't project a $-binding; call sites were updated to use . The 2 @FocusState properties (isCommandPaletteSearchFocused, isCommandPaletteRenameFocused) stay on ContentView — @FocusState is a SwiftUI View-only property wrapper and cannot be hosted on an ObservableObject. This is the actual reason the file's previous "CV1" extraction attempt called a full split impossible; it wasn't, once the @Published-storage-plus-proxy shape is used instead of moving @State verbatim. Access-level widening (dropped `private`, no other behavior change) for nested types now referenced from CommandPaletteController.swift (a different file): CommandPaletteMode, CommandPaletteRestoreFocusTarget, CommandPaletteTextSelectionBehavior, CommandPaletteMultilineTextEditorRepresentable, CommandPaletteRenameTarget, CommandPaletteWorkspaceDescriptionTarget (enum-case associated types must be at least as visible as the enum), and their AppKit backing views CommandPaletteMultilineTextEditorView / CommandPaletteMultilineTextView (NSViewRepresentable makeNSView/updateNSView must be at least as visible as the representable struct). The ~4000-line orchestration block and the ContentView+CommandPalette.swift pure helpers were NOT physically relocated onto the controller in this pass — they continue to compile and run correctly against the controller through the proxies, but remain textually in ContentView.swift/ContentView+CommandPalette.swift. Physically moving that method block is left for a follow-up; the state ownership change (the core ask) is complete and verified. Refs #88. --- GhosttyTabs.xcodeproj/project.pbxproj | 4 + Sources/CommandPaletteController.swift | 73 ++++++++ Sources/ContentView.swift | 249 +++++++++++++++++++------ 3 files changed, 273 insertions(+), 53 deletions(-) create mode 100644 Sources/CommandPaletteController.swift diff --git a/GhosttyTabs.xcodeproj/project.pbxproj b/GhosttyTabs.xcodeproj/project.pbxproj index 0ac220775fa..dcceabc6593 100644 --- a/GhosttyTabs.xcodeproj/project.pbxproj +++ b/GhosttyTabs.xcodeproj/project.pbxproj @@ -16,6 +16,7 @@ NRCV00000000000000000001 /* CommandPaletteSearchEngine.swift in Sources */ = {isa = PBXBuildFile; fileRef = NRCV00000000000000000002 /* CommandPaletteSearchEngine.swift */; }; NRCV00000000000000000007 /* SidebarDragDrop.swift in Sources */ = {isa = PBXBuildFile; fileRef = NRCV00000000000000000008 /* SidebarDragDrop.swift */; }; NRCV00000000000000000009 /* SidebarVisuals.swift in Sources */ = {isa = PBXBuildFile; fileRef = NRCV00000000000000000010 /* SidebarVisuals.swift */; }; + NRCV00000000000000000011 /* CommandPaletteController.swift in Sources */ = {isa = PBXBuildFile; fileRef = NRCV00000000000000000012 /* CommandPaletteController.swift */; }; E62155868BB29FEB5DAAAF25 /* SidebarSelectionState.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9AD52285508B1D6A9875E7B3 /* SidebarSelectionState.swift */; }; B9000018A1B2C3D4E5F60719 /* WindowDragHandleView.swift in Sources */ = {isa = PBXBuildFile; fileRef = B9000017A1B2C3D4E5F60719 /* WindowDragHandleView.swift */; }; A5001003 /* TabManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5001013 /* TabManager.swift */; }; @@ -245,6 +246,7 @@ NRCV00000000000000000002 /* CommandPaletteSearchEngine.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CommandPaletteSearchEngine.swift; sourceTree = ""; }; NRCV00000000000000000008 /* SidebarDragDrop.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SidebarDragDrop.swift; sourceTree = ""; }; NRCV00000000000000000010 /* SidebarVisuals.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SidebarVisuals.swift; sourceTree = ""; }; + NRCV00000000000000000012 /* CommandPaletteController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CommandPaletteController.swift; sourceTree = ""; }; 9AD52285508B1D6A9875E7B3 /* SidebarSelectionState.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SidebarSelectionState.swift; sourceTree = ""; }; B9000017A1B2C3D4E5F60719 /* WindowDragHandleView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WindowDragHandleView.swift; sourceTree = ""; }; A5001013 /* TabManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TabManager.swift; sourceTree = ""; }; @@ -519,6 +521,7 @@ NRCV00000000000000000002 /* CommandPaletteSearchEngine.swift */, NRCV00000000000000000008 /* SidebarDragDrop.swift */, NRCV00000000000000000010 /* SidebarVisuals.swift */, + NRCV00000000000000000012 /* CommandPaletteController.swift */, 9AD52285508B1D6A9875E7B3 /* SidebarSelectionState.swift */, B9000017A1B2C3D4E5F60719 /* WindowDragHandleView.swift */, A50012F0 /* Backport.swift */, @@ -854,6 +857,7 @@ NRCV00000000000000000001 /* CommandPaletteSearchEngine.swift in Sources */, NRCV00000000000000000007 /* SidebarDragDrop.swift in Sources */, NRCV00000000000000000009 /* SidebarVisuals.swift in Sources */, + NRCV00000000000000000011 /* CommandPaletteController.swift in Sources */, E62155868BB29FEB5DAAAF25 /* SidebarSelectionState.swift in Sources */, B9000018A1B2C3D4E5F60719 /* WindowDragHandleView.swift in Sources */, A50012F1 /* Backport.swift in Sources */, diff --git a/Sources/CommandPaletteController.swift b/Sources/CommandPaletteController.swift new file mode 100644 index 00000000000..c12ebba8d0f --- /dev/null +++ b/Sources/CommandPaletteController.swift @@ -0,0 +1,73 @@ +// Command-palette state ownership, extracted from ContentView.swift (nuclear-review #88). +// +// CommandPaletteController owns every @State property that used to live on +// ContentView and is exclusively used by the command palette (query, mode, +// search corpus/results, rename/workspace-description drafts, focus-restore +// targets, usage history, etc.). ContentView holds a single +// `@StateObject private var commandPaletteController` and exposes each +// property back to its existing (unqualified) call sites in its body via +// thin computed proxies — this keeps the ~4000 lines of palette orchestration +// code that reads/writes these properties unchanged while genuinely moving +// storage ownership onto the controller (no more @State duplicated per-view). +// +// The two @FocusState properties (isCommandPaletteSearchFocused, +// isCommandPaletteRenameFocused) stay on ContentView: @FocusState is a +// SwiftUI View-only property wrapper and cannot be hosted on an +// ObservableObject. +// +// Access-level widening: CommandPaletteMode, CommandPaletteRestoreFocusTarget, +// CommandPaletteTextSelectionBehavior, and CommandPaletteMultilineTextEditorRepresentable +// were `private` nested types inside ContentView; widened to internal (dropped +// the `private` modifier) so this controller (a different file) can reference +// them. No other behavior change. CommandPaletteCommand, CommandPaletteSearchResult, +// CommandPaletteListScope, CommandPalettePendingActivation, and +// CommandPaletteUsageEntry were already internal nested types; referenced here +// via `ContentView.` qualification since nested-type name lookup requires it +// from outside the enclosing type's lexical scope. + +import AppKit +import Combine +import SwiftUI + +final class CommandPaletteController: ObservableObject { + @Published var isCommandPalettePresented = false + @Published var commandPaletteQuery: String = "" + @Published var commandPaletteMode: ContentView.CommandPaletteMode = .commands + @Published var commandPaletteRenameDraft: String = "" + @Published var commandPaletteWorkspaceDescriptionDraft: String = "" + @Published var commandPaletteWorkspaceDescriptionHeight: CGFloat = ContentView.CommandPaletteMultilineTextEditorRepresentable.defaultMinimumHeight + @Published var commandPaletteSelectedResultIndex: Int = 0 + @Published var commandPaletteSelectionAnchorCommandID: String? + @Published var commandPaletteHoveredResultIndex: Int? + @Published var commandPaletteScrollTargetIndex: Int? + @Published var commandPaletteScrollTargetAnchor: UnitPoint? + @Published var commandPaletteRestoreFocusTarget: ContentView.CommandPaletteRestoreFocusTarget? + @Published var commandPaletteSearchCorpus: [CommandPaletteSearchCorpusEntry] = [] + @Published var commandPaletteSearchCorpusByID: [String: CommandPaletteSearchCorpusEntry] = [:] + @Published var commandPaletteSearchCommandsByID: [String: ContentView.CommandPaletteCommand] = [:] + @Published var cachedCommandPaletteResults: [ContentView.CommandPaletteSearchResult] = [] + @Published var commandPaletteVisibleResults: [ContentView.CommandPaletteSearchResult] = [] + @Published var commandPaletteVisibleResultsScope: ContentView.CommandPaletteListScope? + @Published var commandPaletteVisibleResultsFingerprint: Int? + @Published var cachedCommandPaletteScope: ContentView.CommandPaletteListScope? + @Published var cachedCommandPaletteFingerprint: Int? + @Published var commandPalettePendingDismissFocusTarget: ContentView.CommandPaletteRestoreFocusTarget? + @Published var commandPaletteRestoreTimeoutWorkItem: DispatchWorkItem? + @Published var commandPalettePendingTextSelectionBehavior: ContentView.CommandPaletteTextSelectionBehavior? + @Published var commandPaletteSearchTask: Task? + @Published var commandPaletteSearchRequestID: UInt64 = 0 + @Published var commandPaletteResolvedSearchRequestID: UInt64 = 0 + @Published var commandPaletteResolvedSearchScope: ContentView.CommandPaletteListScope? + @Published var commandPaletteResolvedSearchFingerprint: Int? + @Published var commandPaletteResolvedMatchingQuery = "" + @Published var commandPaletteTerminalOpenTargetAvailability: Set = [] + @Published var isCommandPaletteSearchPending = false + @Published var commandPalettePendingActivation: ContentView.CommandPalettePendingActivation? + @Published var commandPaletteResultsRevision: UInt64 = 0 + @Published var commandPaletteUsageHistoryByCommandId: [String: ContentView.CommandPaletteUsageEntry] = [:] + @AppStorage(CommandPaletteRenameSelectionSettings.selectAllOnFocusKey) + var commandPaletteRenameSelectAllOnFocus = CommandPaletteRenameSelectionSettings.defaultSelectAllOnFocus + @AppStorage(CommandPaletteSwitcherSearchSettings.searchAllSurfacesKey) + var commandPaletteSearchAllSurfaces = CommandPaletteSwitcherSearchSettings.defaultSearchAllSurfaces + @Published var commandPaletteShouldFocusWorkspaceDescriptionEditor = false +} diff --git a/Sources/ContentView.swift b/Sources/ContentView.swift index 87ca62e5b4a..cfc56e5321f 100644 --- a/Sources/ContentView.swift +++ b/Sources/ContentView.swift @@ -1240,50 +1240,193 @@ struct ContentView: View { @State private var isResizerBandActive = false @State private var isSidebarResizerCursorActive = false @State private var sidebarResizerCursorStabilizer: DispatchSourceTimer? - @State private var isCommandPalettePresented = false - @State private var commandPaletteQuery: String = "" - @State private var commandPaletteMode: CommandPaletteMode = .commands - @State private var commandPaletteRenameDraft: String = "" - @State private var commandPaletteWorkspaceDescriptionDraft: String = "" - @State private var commandPaletteWorkspaceDescriptionHeight: CGFloat = CommandPaletteMultilineTextEditorRepresentable.defaultMinimumHeight - @State private var commandPaletteSelectedResultIndex: Int = 0 - @State private var commandPaletteSelectionAnchorCommandID: String? - @State private var commandPaletteHoveredResultIndex: Int? - @State private var commandPaletteScrollTargetIndex: Int? - @State private var commandPaletteScrollTargetAnchor: UnitPoint? - @State private var commandPaletteRestoreFocusTarget: CommandPaletteRestoreFocusTarget? - @State private var commandPaletteSearchCorpus: [CommandPaletteSearchCorpusEntry] = [] - @State private var commandPaletteSearchCorpusByID: [String: CommandPaletteSearchCorpusEntry] = [:] - @State private var commandPaletteSearchCommandsByID: [String: CommandPaletteCommand] = [:] - @State private var cachedCommandPaletteResults: [CommandPaletteSearchResult] = [] - @State private var commandPaletteVisibleResults: [CommandPaletteSearchResult] = [] - @State private var commandPaletteVisibleResultsScope: CommandPaletteListScope? - @State private var commandPaletteVisibleResultsFingerprint: Int? - @State private var cachedCommandPaletteScope: CommandPaletteListScope? - @State private var cachedCommandPaletteFingerprint: Int? - @State private var commandPalettePendingDismissFocusTarget: CommandPaletteRestoreFocusTarget? - @State private var commandPaletteRestoreTimeoutWorkItem: DispatchWorkItem? - @State private var commandPalettePendingTextSelectionBehavior: CommandPaletteTextSelectionBehavior? - @State private var commandPaletteSearchTask: Task? - @State private var commandPaletteSearchRequestID: UInt64 = 0 - @State private var commandPaletteResolvedSearchRequestID: UInt64 = 0 - @State private var commandPaletteResolvedSearchScope: CommandPaletteListScope? - @State private var commandPaletteResolvedSearchFingerprint: Int? - @State private var commandPaletteResolvedMatchingQuery = "" - @State private var commandPaletteTerminalOpenTargetAvailability: Set = [] - @State private var isCommandPaletteSearchPending = false - @State private var commandPalettePendingActivation: CommandPalettePendingActivation? - @State private var commandPaletteResultsRevision: UInt64 = 0 - @State private var commandPaletteUsageHistoryByCommandId: [String: CommandPaletteUsageEntry] = [:] - @AppStorage(CommandPaletteRenameSelectionSettings.selectAllOnFocusKey) - private var commandPaletteRenameSelectAllOnFocus = CommandPaletteRenameSelectionSettings.defaultSelectAllOnFocus - @AppStorage(CommandPaletteSwitcherSearchSettings.searchAllSurfacesKey) - private var commandPaletteSearchAllSurfaces = CommandPaletteSwitcherSearchSettings.defaultSearchAllSurfaces - @State private var commandPaletteShouldFocusWorkspaceDescriptionEditor = false + @StateObject private var commandPaletteController = CommandPaletteController() + private var isCommandPalettePresented: Bool { + get { commandPaletteController.isCommandPalettePresented } + nonmutating set { commandPaletteController.isCommandPalettePresented = newValue } + } + private var commandPaletteQuery: String { + get { commandPaletteController.commandPaletteQuery } + nonmutating set { commandPaletteController.commandPaletteQuery = newValue } + } + private var commandPaletteMode: CommandPaletteMode { + get { commandPaletteController.commandPaletteMode } + nonmutating set { commandPaletteController.commandPaletteMode = newValue } + } + private var commandPaletteRenameDraft: String { + get { commandPaletteController.commandPaletteRenameDraft } + nonmutating set { commandPaletteController.commandPaletteRenameDraft = newValue } + } + private var commandPaletteWorkspaceDescriptionDraft: String { + get { commandPaletteController.commandPaletteWorkspaceDescriptionDraft } + nonmutating set { commandPaletteController.commandPaletteWorkspaceDescriptionDraft = newValue } + } + private var commandPaletteWorkspaceDescriptionHeight: CGFloat { + get { commandPaletteController.commandPaletteWorkspaceDescriptionHeight } + nonmutating set { commandPaletteController.commandPaletteWorkspaceDescriptionHeight = newValue } + } + private var commandPaletteSelectedResultIndex: Int { + get { commandPaletteController.commandPaletteSelectedResultIndex } + nonmutating set { commandPaletteController.commandPaletteSelectedResultIndex = newValue } + } + private var commandPaletteSelectionAnchorCommandID: String? { + get { commandPaletteController.commandPaletteSelectionAnchorCommandID } + nonmutating set { commandPaletteController.commandPaletteSelectionAnchorCommandID = newValue } + } + private var commandPaletteHoveredResultIndex: Int? { + get { commandPaletteController.commandPaletteHoveredResultIndex } + nonmutating set { commandPaletteController.commandPaletteHoveredResultIndex = newValue } + } + private var commandPaletteScrollTargetIndex: Int? { + get { commandPaletteController.commandPaletteScrollTargetIndex } + nonmutating set { commandPaletteController.commandPaletteScrollTargetIndex = newValue } + } + private var commandPaletteScrollTargetAnchor: UnitPoint? { + get { commandPaletteController.commandPaletteScrollTargetAnchor } + nonmutating set { commandPaletteController.commandPaletteScrollTargetAnchor = newValue } + } + private var commandPaletteRestoreFocusTarget: CommandPaletteRestoreFocusTarget? { + get { commandPaletteController.commandPaletteRestoreFocusTarget } + nonmutating set { commandPaletteController.commandPaletteRestoreFocusTarget = newValue } + } + private var commandPaletteSearchCorpus: [CommandPaletteSearchCorpusEntry] { + get { commandPaletteController.commandPaletteSearchCorpus } + nonmutating set { commandPaletteController.commandPaletteSearchCorpus = newValue } + } + private var commandPaletteSearchCorpusByID: [String: CommandPaletteSearchCorpusEntry] { + get { commandPaletteController.commandPaletteSearchCorpusByID } + nonmutating set { commandPaletteController.commandPaletteSearchCorpusByID = newValue } + } + private var commandPaletteSearchCommandsByID: [String: CommandPaletteCommand] { + get { commandPaletteController.commandPaletteSearchCommandsByID } + nonmutating set { commandPaletteController.commandPaletteSearchCommandsByID = newValue } + } + private var cachedCommandPaletteResults: [CommandPaletteSearchResult] { + get { commandPaletteController.cachedCommandPaletteResults } + nonmutating set { commandPaletteController.cachedCommandPaletteResults = newValue } + } + private var commandPaletteVisibleResults: [CommandPaletteSearchResult] { + get { commandPaletteController.commandPaletteVisibleResults } + nonmutating set { commandPaletteController.commandPaletteVisibleResults = newValue } + } + private var commandPaletteVisibleResultsScope: CommandPaletteListScope? { + get { commandPaletteController.commandPaletteVisibleResultsScope } + nonmutating set { commandPaletteController.commandPaletteVisibleResultsScope = newValue } + } + private var commandPaletteVisibleResultsFingerprint: Int? { + get { commandPaletteController.commandPaletteVisibleResultsFingerprint } + nonmutating set { commandPaletteController.commandPaletteVisibleResultsFingerprint = newValue } + } + private var cachedCommandPaletteScope: CommandPaletteListScope? { + get { commandPaletteController.cachedCommandPaletteScope } + nonmutating set { commandPaletteController.cachedCommandPaletteScope = newValue } + } + private var cachedCommandPaletteFingerprint: Int? { + get { commandPaletteController.cachedCommandPaletteFingerprint } + nonmutating set { commandPaletteController.cachedCommandPaletteFingerprint = newValue } + } + private var commandPalettePendingDismissFocusTarget: CommandPaletteRestoreFocusTarget? { + get { commandPaletteController.commandPalettePendingDismissFocusTarget } + nonmutating set { commandPaletteController.commandPalettePendingDismissFocusTarget = newValue } + } + private var commandPaletteRestoreTimeoutWorkItem: DispatchWorkItem? { + get { commandPaletteController.commandPaletteRestoreTimeoutWorkItem } + nonmutating set { commandPaletteController.commandPaletteRestoreTimeoutWorkItem = newValue } + } + private var commandPalettePendingTextSelectionBehavior: CommandPaletteTextSelectionBehavior? { + get { commandPaletteController.commandPalettePendingTextSelectionBehavior } + nonmutating set { commandPaletteController.commandPalettePendingTextSelectionBehavior = newValue } + } + private var commandPaletteSearchTask: Task? { + get { commandPaletteController.commandPaletteSearchTask } + nonmutating set { commandPaletteController.commandPaletteSearchTask = newValue } + } + private var commandPaletteSearchRequestID: UInt64 { + get { commandPaletteController.commandPaletteSearchRequestID } + nonmutating set { commandPaletteController.commandPaletteSearchRequestID = newValue } + } + private var commandPaletteResolvedSearchRequestID: UInt64 { + get { commandPaletteController.commandPaletteResolvedSearchRequestID } + nonmutating set { commandPaletteController.commandPaletteResolvedSearchRequestID = newValue } + } + private var commandPaletteResolvedSearchScope: CommandPaletteListScope? { + get { commandPaletteController.commandPaletteResolvedSearchScope } + nonmutating set { commandPaletteController.commandPaletteResolvedSearchScope = newValue } + } + private var commandPaletteResolvedSearchFingerprint: Int? { + get { commandPaletteController.commandPaletteResolvedSearchFingerprint } + nonmutating set { commandPaletteController.commandPaletteResolvedSearchFingerprint = newValue } + } + private var commandPaletteResolvedMatchingQuery: String { + get { commandPaletteController.commandPaletteResolvedMatchingQuery } + nonmutating set { commandPaletteController.commandPaletteResolvedMatchingQuery = newValue } + } + private var commandPaletteTerminalOpenTargetAvailability: Set { + get { commandPaletteController.commandPaletteTerminalOpenTargetAvailability } + nonmutating set { commandPaletteController.commandPaletteTerminalOpenTargetAvailability = newValue } + } + private var isCommandPaletteSearchPending: Bool { + get { commandPaletteController.isCommandPaletteSearchPending } + nonmutating set { commandPaletteController.isCommandPaletteSearchPending = newValue } + } + private var commandPalettePendingActivation: CommandPalettePendingActivation? { + get { commandPaletteController.commandPalettePendingActivation } + nonmutating set { commandPaletteController.commandPalettePendingActivation = newValue } + } + private var commandPaletteResultsRevision: UInt64 { + get { commandPaletteController.commandPaletteResultsRevision } + nonmutating set { commandPaletteController.commandPaletteResultsRevision = newValue } + } + private var commandPaletteUsageHistoryByCommandId: [String: CommandPaletteUsageEntry] { + get { commandPaletteController.commandPaletteUsageHistoryByCommandId } + nonmutating set { commandPaletteController.commandPaletteUsageHistoryByCommandId = newValue } + } + private var commandPaletteRenameSelectAllOnFocus: Bool { + get { commandPaletteController.commandPaletteRenameSelectAllOnFocus } + nonmutating set { commandPaletteController.commandPaletteRenameSelectAllOnFocus = newValue } + } + private var commandPaletteSearchAllSurfaces: Bool { + get { commandPaletteController.commandPaletteSearchAllSurfaces } + nonmutating set { commandPaletteController.commandPaletteSearchAllSurfaces = newValue } + } + private var commandPaletteShouldFocusWorkspaceDescriptionEditor: Bool { + get { commandPaletteController.commandPaletteShouldFocusWorkspaceDescriptionEditor } + nonmutating set { commandPaletteController.commandPaletteShouldFocusWorkspaceDescriptionEditor = newValue } + } + private var commandPaletteQueryBinding: Binding { + Binding( + get: { commandPaletteController.commandPaletteQuery }, + set: { commandPaletteController.commandPaletteQuery = $0 } + ) + } + private var commandPaletteRenameDraftBinding: Binding { + Binding( + get: { commandPaletteController.commandPaletteRenameDraft }, + set: { commandPaletteController.commandPaletteRenameDraft = $0 } + ) + } + private var commandPaletteWorkspaceDescriptionDraftBinding: Binding { + Binding( + get: { commandPaletteController.commandPaletteWorkspaceDescriptionDraft }, + set: { commandPaletteController.commandPaletteWorkspaceDescriptionDraft = $0 } + ) + } + private var commandPaletteWorkspaceDescriptionHeightBinding: Binding { + Binding( + get: { commandPaletteController.commandPaletteWorkspaceDescriptionHeight }, + set: { commandPaletteController.commandPaletteWorkspaceDescriptionHeight = $0 } + ) + } + private var commandPaletteShouldFocusWorkspaceDescriptionEditorBinding: Binding { + Binding( + get: { commandPaletteController.commandPaletteShouldFocusWorkspaceDescriptionEditor }, + set: { commandPaletteController.commandPaletteShouldFocusWorkspaceDescriptionEditor = $0 } + ) + } @FocusState private var isCommandPaletteSearchFocused: Bool @FocusState private var isCommandPaletteRenameFocused: Bool - private enum CommandPaletteMode { + enum CommandPaletteMode { case commands case renameInput(CommandPaletteRenameTarget) case renameConfirm(CommandPaletteRenameTarget, proposedName: String) @@ -1305,7 +1448,7 @@ struct ContentView: View { case command(commandID: String) } - private struct CommandPaletteRenameTarget: Equatable { + struct CommandPaletteRenameTarget: Equatable { enum Kind: Equatable { case workspace(workspaceId: UUID) case tab(workspaceId: UUID, panelId: UUID) @@ -1342,7 +1485,7 @@ struct ContentView: View { } } - private struct CommandPaletteWorkspaceDescriptionTarget: Equatable { + struct CommandPaletteWorkspaceDescriptionTarget: Equatable { let workspaceId: UUID let currentDescription: String @@ -1361,7 +1504,7 @@ struct ContentView: View { } } - private struct CommandPaletteRestoreFocusTarget { + struct CommandPaletteRestoreFocusTarget { let workspaceId: UUID let panelId: UUID let intent: PanelFocusIntent @@ -1372,7 +1515,7 @@ struct ContentView: View { case rename } - private enum CommandPaletteTextSelectionBehavior { + enum CommandPaletteTextSelectionBehavior { case caretAtEnd case selectAll } @@ -3312,7 +3455,7 @@ struct ContentView: View { HStack(spacing: 8) { CommandPaletteSearchFieldRepresentable( placeholder: commandPaletteSearchPlaceholder, - text: $commandPaletteQuery, + text: commandPaletteQueryBinding, isFocused: Binding( get: { isCommandPaletteSearchFocused }, set: { isCommandPaletteSearchFocused = $0 } @@ -3532,7 +3675,7 @@ struct ContentView: View { onDeleteBackward: handleCommandPaletteRenameDeleteBackward(modifiers:) ), placeholder: target.placeholder, - text: $commandPaletteRenameDraft, + text: commandPaletteRenameDraftBinding, onSubmit: { _ in continueRenameFlow(target: target) }, onEscape: { dismissCommandPalette() }, onInteraction: handleCommandPaletteRenameInputInteraction @@ -3616,12 +3759,12 @@ struct ContentView: View { localized: "command.editWorkspaceDescription.title", defaultValue: "Edit Workspace Description…" ), - focus: $commandPaletteShouldFocusWorkspaceDescriptionEditor, - measuredHeight: $commandPaletteWorkspaceDescriptionHeight, + focus: commandPaletteShouldFocusWorkspaceDescriptionEditorBinding, + measuredHeight: commandPaletteWorkspaceDescriptionHeightBinding, maxHeight: maxEditorHeight ), placeholder: target.placeholder, - text: $commandPaletteWorkspaceDescriptionDraft, + text: commandPaletteWorkspaceDescriptionDraftBinding, onSubmit: { proposedDescription in applyWorkspaceDescriptionFlow(target: target, proposedDescription: proposedDescription) }, @@ -3905,7 +4048,7 @@ struct ContentView: View { override func hitTest(_ point: NSPoint) -> NSView? { nil } } - private final class CommandPaletteMultilineTextView: NSTextView { + final class CommandPaletteMultilineTextView: NSTextView { var onHandleKeyEvent: ((NSEvent, NSTextView?) -> Bool)? var onDidBecomeFirstResponder: (() -> Void)? @@ -4033,7 +4176,7 @@ struct ContentView: View { } } - private final class CommandPaletteMultilineTextEditorView: NSView { + final class CommandPaletteMultilineTextEditorView: NSView { private static let font = NSFont.systemFont(ofSize: 13) private static let textInset = NSSize(width: 0, height: 2) static let defaultMinimumHeight: CGFloat = { @@ -4238,7 +4381,7 @@ struct ContentView: View { } } - private struct CommandPaletteMultilineTextEditorRepresentable: NSViewRepresentable { + struct CommandPaletteMultilineTextEditorRepresentable: NSViewRepresentable { static let defaultMinimumHeight = CommandPaletteMultilineTextEditorView.defaultMinimumHeight let placeholder: String From 0d6e80276c566a67813b04e82b0383c2889727c2 Mon Sep 17 00:00:00 2001 From: arzafran Date: Thu, 9 Jul 2026 14:22:30 -0300 Subject: [PATCH 19/19] refactor(workspace): extract Persistence/Layout/Bonsplit from Workspace.swift Workspace.swift (7430 lines) held the Workspace class plus several already-separate top-level extension blocks. Extracted three of those existing seams into their own files, moving content verbatim: - Workspace+Persistence.swift: session snapshot/restore (sessionSnapshot, restoreSessionSnapshot, and their layout/panel helpers) -- was a standalone `extension Workspace { ... }` block. - Workspace+Layout.swift: programa.json custom layout application (applyCustomLayout and its tree/pane helpers) -- was a standalone `extension Workspace { ... }` block. - Workspace+Bonsplit.swift: the `extension Workspace: BonsplitDelegate` conformance. Workspace.swift drops from 7430 to 5626 lines; it now holds the class declaration, its nested types, stored properties, and the methods that don't yet have a dedicated extension file. Access-level widening (required for cross-file visibility, since Swift's `private` is file-scoped even across extensions of the same type): - Blanket: every direct member of `final class Workspace` (nested types, stored properties, methods) was widened from `private` to the Swift default `internal`, mirroring the same treatment already applied to WorkspaceRemoteSessionController -- the class's entire private surface needed it to support extraction, so this was done as one mechanical pass rather than itemized per-symbol. - Individually noted, since they were outside that blanket sweep: - `SessionPaneRestoreEntry` (a private top-level struct, not a class member) -- used by the extracted persistence helpers. - `panels`, `panelCustomTitles`, `pinnedPanelIds`, `manualUnreadPanelIds`, `tmuxLayoutSnapshot` -- `private(set)` published properties whose setters are called from Workspace+Bonsplit.swift; widened to plain internal-settable `@Published var`. - `manualUnreadClearDelayAfterFocusFlash` -- a `nonisolated private static let` read from Workspace+Bonsplit.swift. - `applyTabSelection`, `beginNonFocusSplitFocusReassert`, `clearNonFocusSplitFocusReassert`, `markExplicitFocusIntent`, `matchesPendingNonFocusSplitFocusReassert` -- private methods declared inside the BonsplitDelegate extension itself but called from the remaining Workspace.swift class body. Verified behavior-identical by a non-blank-line multiset diff between the pre-split file and the four post-split files (normalizing only the access-modifier changes above); the only remaining differences were the added file-header comments, the per-file import blocks every new Swift file needs, and two now-dangling "// MARK:" section comments that were dropped since the content they marked moved to dedicated files. project.pbxproj updated: three new files registered (main app target only). The remaining seams called out in #98 for Workspace.swift -- theming, remote-connection glue, surface creation, and focus/geometry -- are not yet extracted; they live inside the still-5626-line class body rather than as pre-existing standalone extension blocks, so pulling them out safely needs the same per-symbol cross-reference verification done here, at a larger scale. Left for a follow-up pass; noted in issuesPartial. Refs #98. --- GhosttyTabs.xcodeproj/project.pbxproj | 12 + Sources/Workspace+Bonsplit.swift | 1062 +++ Sources/Workspace+Layout.swift | 230 + Sources/Workspace+Persistence.swift | 541 ++ Sources/Workspace.swift | 10644 ++++++++++-------------- 5 files changed, 6265 insertions(+), 6224 deletions(-) create mode 100644 Sources/Workspace+Bonsplit.swift create mode 100644 Sources/Workspace+Layout.swift create mode 100644 Sources/Workspace+Persistence.swift diff --git a/GhosttyTabs.xcodeproj/project.pbxproj b/GhosttyTabs.xcodeproj/project.pbxproj index de8d78f6291..3cd67140103 100644 --- a/GhosttyTabs.xcodeproj/project.pbxproj +++ b/GhosttyTabs.xcodeproj/project.pbxproj @@ -52,6 +52,9 @@ A5001290 /* MarkdownUI in Frameworks */ = {isa = PBXBuildFile; productRef = A5001291 /* MarkdownUI */; }; A5001405 /* PanelContentView.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5001415 /* PanelContentView.swift */; }; A5001406 /* Workspace.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5001416 /* Workspace.swift */; }; + NRWS00000000000000000028 /* Workspace+Persistence.swift in Sources */ = {isa = PBXBuildFile; fileRef = NRWS00000000000000000027 /* Workspace+Persistence.swift */; }; + NRWS00000000000000000030 /* Workspace+Layout.swift in Sources */ = {isa = PBXBuildFile; fileRef = NRWS00000000000000000029 /* Workspace+Layout.swift */; }; + NRWS00000000000000000032 /* Workspace+Bonsplit.swift in Sources */ = {isa = PBXBuildFile; fileRef = NRWS00000000000000000031 /* Workspace+Bonsplit.swift */; }; A5FF0012 /* WorkspaceRemoteSession.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5FF0002 /* WorkspaceRemoteSession.swift */; }; NRWS00000000000000000018 /* WorkspaceRemoteSessionController+ConnectionOrchestration.swift in Sources */ = {isa = PBXBuildFile; fileRef = NRWS00000000000000000017 /* WorkspaceRemoteSessionController+ConnectionOrchestration.swift */; }; NRWS00000000000000000020 /* WorkspaceRemoteSessionController+ProcessExecution.swift in Sources */ = {isa = PBXBuildFile; fileRef = NRWS00000000000000000019 /* WorkspaceRemoteSessionController+ProcessExecution.swift */; }; @@ -288,6 +291,9 @@ A5001418 /* MarkdownPanel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Panels/MarkdownPanel.swift; sourceTree = ""; }; A5001419 /* MarkdownPanelView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Panels/MarkdownPanelView.swift; sourceTree = ""; }; A5001416 /* Workspace.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Workspace.swift; sourceTree = ""; }; + NRWS00000000000000000027 /* Workspace+Persistence.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Workspace+Persistence.swift"; sourceTree = ""; }; + NRWS00000000000000000029 /* Workspace+Layout.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Workspace+Layout.swift"; sourceTree = ""; }; + NRWS00000000000000000031 /* Workspace+Bonsplit.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Workspace+Bonsplit.swift"; sourceTree = ""; }; A5FF0002 /* WorkspaceRemoteSession.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WorkspaceRemoteSession.swift; sourceTree = ""; }; NRWS00000000000000000017 /* WorkspaceRemoteSessionController+ConnectionOrchestration.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "WorkspaceRemoteSessionController+ConnectionOrchestration.swift"; sourceTree = ""; }; NRWS00000000000000000019 /* WorkspaceRemoteSessionController+ProcessExecution.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "WorkspaceRemoteSessionController+ProcessExecution.swift"; sourceTree = ""; }; @@ -541,6 +547,9 @@ A5FF0061 /* TabManager+GitMetadataPolling.swift */, A5001511 /* UITestRecorder.swift */, A5001416 /* Workspace.swift */, + NRWS00000000000000000027 /* Workspace+Persistence.swift */, + NRWS00000000000000000029 /* Workspace+Layout.swift */, + NRWS00000000000000000031 /* Workspace+Bonsplit.swift */, A5FF0002 /* WorkspaceRemoteSession.swift */, NRWS00000000000000000017 /* WorkspaceRemoteSessionController+ConnectionOrchestration.swift */, NRWS00000000000000000019 /* WorkspaceRemoteSessionController+ProcessExecution.swift */, @@ -883,6 +892,9 @@ A5FF0071 /* TabManager+GitMetadataPolling.swift in Sources */, A5001501 /* UITestRecorder.swift in Sources */, A5001406 /* Workspace.swift in Sources */, + NRWS00000000000000000028 /* Workspace+Persistence.swift in Sources */, + NRWS00000000000000000030 /* Workspace+Layout.swift in Sources */, + NRWS00000000000000000032 /* Workspace+Bonsplit.swift in Sources */, A5FF0012 /* WorkspaceRemoteSession.swift in Sources */, NRWS00000000000000000018 /* WorkspaceRemoteSessionController+ConnectionOrchestration.swift in Sources */, NRWS00000000000000000020 /* WorkspaceRemoteSessionController+ProcessExecution.swift in Sources */, diff --git a/Sources/Workspace+Bonsplit.swift b/Sources/Workspace+Bonsplit.swift new file mode 100644 index 00000000000..3cfcfe6e7b7 --- /dev/null +++ b/Sources/Workspace+Bonsplit.swift @@ -0,0 +1,1062 @@ +// Extracted from Workspace.swift (nuclear-review #98): BonsplitDelegate conformance. + +import Foundation +import SwiftUI +import AppKit +import Bonsplit +import Combine +import CryptoKit +import Darwin +import Network +import CoreText + +extension Workspace: BonsplitDelegate { + @MainActor + private func shouldCloseWorkspaceOnLastSurface(for tabId: TabID) -> Bool { + let manager = owningTabManager ?? AppDelegate.shared?.tabManagerFor(tabId: id) ?? AppDelegate.shared?.tabManager + guard panels.count <= 1, + panelIdFromSurfaceId(tabId) != nil, + let manager, + manager.tabs.contains(where: { $0.id == id }) else { + return false + } + return true + } + + @MainActor + private func confirmClosePanel(for tabId: TabID) async -> Bool { + let alert = NSAlert() + + alert.messageText = String(localized: "dialog.closeTab.title", defaultValue: "Close tab?") + + let panelName: String? = { + guard let panelId = panelIdFromSurfaceId(tabId) else { return nil } + if let custom = panelCustomTitles[panelId], !custom.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty { + return custom + } + if let title = panelTitles[panelId], !title.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty { + return title + } + if let dir = panelDirectories[panelId], !dir.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty { + return (dir as NSString).lastPathComponent + } + return nil + }() + + if let panelName { + alert.informativeText = String(localized: "dialog.closeTab.messageNamed", defaultValue: "This will close \"\(panelName)\".") + } else { + alert.informativeText = String(localized: "dialog.closeTab.message", defaultValue: "This will close the current tab.") + } + alert.alertStyle = .warning + alert.addButton(withTitle: String(localized: "dialog.closeTab.close", defaultValue: "Close")) + alert.addButton(withTitle: String(localized: "dialog.closeTab.cancel", defaultValue: "Cancel")) + + if let closeButton = alert.buttons.first { + closeButton.keyEquivalent = "\r" + closeButton.keyEquivalentModifierMask = [] + alert.window.defaultButtonCell = closeButton.cell as? NSButtonCell + alert.window.initialFirstResponder = closeButton + } + if let cancelButton = alert.buttons.dropFirst().first { + cancelButton.keyEquivalent = "\u{1b}" + } + + // Prefer a sheet if we can find a window, otherwise fall back to modal. + if let window = NSApp.keyWindow ?? NSApp.mainWindow { + return await withCheckedContinuation { continuation in + alert.beginSheetModal(for: window) { response in + continuation.resume(returning: response == .alertFirstButtonReturn) + } + } + } + + return alert.runModal() == .alertFirstButtonReturn + } + + /// Apply the side-effects of selecting a tab (unfocus others, focus this panel, update state). + /// bonsplit doesn't always emit didSelectTab for programmatic selection paths (e.g. createTab). + func applyTabSelection( + tabId: TabID, + inPane pane: PaneID, + reassertAppKitFocus: Bool = true, + focusIntent: PanelFocusIntent? = nil, + previousTerminalHostedView: GhosttySurfaceScrollView? = nil + ) { + pendingTabSelection = PendingTabSelectionRequest( + tabId: tabId, + pane: pane, + reassertAppKitFocus: reassertAppKitFocus, + focusIntent: focusIntent, + previousTerminalHostedView: previousTerminalHostedView + ) + guard !isApplyingTabSelection else { return } + isApplyingTabSelection = true + defer { + isApplyingTabSelection = false + pendingTabSelection = nil + } + + var iterations = 0 + while let request = pendingTabSelection { + pendingTabSelection = nil + iterations += 1 + if iterations > 8 { break } + applyTabSelectionNow( + tabId: request.tabId, + inPane: request.pane, + reassertAppKitFocus: request.reassertAppKitFocus, + focusIntent: request.focusIntent, + previousTerminalHostedView: request.previousTerminalHostedView + ) + } + } + + /// Hide browser portals for tabs that are no longer selected in the given pane. + private func hideBrowserPortalsForDeselectedTabs(inPane pane: PaneID, selectedTabId: TabID) { + for tab in bonsplitController.tabs(inPane: pane) { + guard tab.id != selectedTabId else { continue } + guard let panelId = panelIdFromSurfaceId(tab.id), + let browserPanel = panels[panelId] as? BrowserPanel else { continue } + browserPanel.hideBrowserPortalView(source: "tabDeselected") + } + } + + private func applyTabSelectionNow( + tabId: TabID, + inPane pane: PaneID, + reassertAppKitFocus: Bool, + focusIntent: PanelFocusIntent?, + previousTerminalHostedView: GhosttySurfaceScrollView? + ) { + let previousFocusedPanelId = focusedPanelId +#if DEBUG + let focusedPaneBefore = bonsplitController.focusedPaneId.map { String($0.id.uuidString.prefix(5)) } ?? "nil" + let selectedTabBefore = bonsplitController.focusedPaneId + .flatMap { bonsplitController.selectedTab(inPane: $0)?.id } + .map { String($0.uuid.uuidString.prefix(5)) } ?? "nil" + dlog( + "focus.split.apply.begin workspace=\(id.uuidString.prefix(5)) " + + "pane=\(pane.id.uuidString.prefix(5)) tab=\(tabId.uuid.uuidString.prefix(5)) " + + "focusedPane=\(focusedPaneBefore) selectedTab=\(selectedTabBefore) " + + "reassert=\(reassertAppKitFocus ? 1 : 0)" + ) +#endif + if bonsplitController.allPaneIds.contains(pane) { + if bonsplitController.focusedPaneId != pane { + bonsplitController.focusPane(pane) + } + if bonsplitController.tabs(inPane: pane).contains(where: { $0.id == tabId }), + bonsplitController.selectedTab(inPane: pane)?.id != tabId { + bonsplitController.selectTab(tabId) + } + } + + let focusedPane: PaneID + let selectedTabId: TabID + if let currentPane = bonsplitController.focusedPaneId, + let currentTabId = bonsplitController.selectedTab(inPane: currentPane)?.id { + focusedPane = currentPane + selectedTabId = currentTabId + } else if bonsplitController.tabs(inPane: pane).contains(where: { $0.id == tabId }) { + focusedPane = pane + selectedTabId = tabId + bonsplitController.focusPane(focusedPane) + bonsplitController.selectTab(selectedTabId) + } else { + return + } + + // Focus the selected panel, but keep the previously focused terminal active while a + // newly created split terminal is still unattached. + guard let selectedPanelId = panelIdFromSurfaceId(selectedTabId) else { + return + } + let effectiveFocusedPanelId = effectiveSelectedPanelId(inPane: focusedPane) ?? selectedPanelId + guard let panel = panels[effectiveFocusedPanelId] else { + return + } + + if debugStressPreloadSelectionDepth > 0 { + if let terminalPanel = panel as? TerminalPanel { + terminalPanel.requestViewReattach() + scheduleTerminalGeometryReconcile() + terminalPanel.surface.requestBackgroundSurfaceStartIfNeeded() + } + return + } + + if shouldTreatCurrentEventAsExplicitFocusIntent() { + markExplicitFocusIntent(on: effectiveFocusedPanelId) + } + let activationIntent = focusIntent ?? panel.preferredFocusIntentForActivation() + panel.prepareFocusIntentForActivation(activationIntent) + let panelId = effectiveFocusedPanelId + + syncPinnedStateForTab(selectedTabId, panelId: selectedPanelId) + syncUnreadBadgeStateForPanel(selectedPanelId) + + // Unfocus all other panels + for (id, p) in panels where id != effectiveFocusedPanelId { + p.unfocus() + } + + // Explicitly hide browser portals for deselected tabs in this pane. + // Bonsplit's keepAllAlive mode hides non-selected tabs via SwiftUI .opacity(0), + // but portal-hosted WKWebViews render at the window level in AppKit and are not + // affected by SwiftUI opacity. Without an explicit hide, the deselected browser's + // portal layer can remain visible above the newly selected tab. + hideBrowserPortalsForDeselectedTabs(inPane: focusedPane, selectedTabId: selectedTabId) + + if let focusWindow = activationWindow(for: panel) { + yieldForeignOwnedFocusIfNeeded( + in: focusWindow, + targetPanelId: panelId, + targetIntent: activationIntent + ) + } + + activatePanel( + panel, + focusIntent: activationIntent, + reassertAppKitFocus: reassertAppKitFocus + ) + let focusIntentAllowsBrowserOmnibarAutofocus = + shouldTreatCurrentEventAsExplicitFocusIntent() || + TerminalController.socketCommandAllowsInAppFocusMutations() + if let browserPanel = panel as? BrowserPanel, + shouldAllowBrowserOmnibarAutofocus(for: activationIntent), + 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. + if reassertAppKitFocus, let terminalPanel = panel as? TerminalPanel { + if shouldMoveTerminalSurfaceFocus(for: activationIntent), + !terminalPanel.hostedView.isSurfaceViewFirstResponder() { +#if DEBUG + let previousExists = previousTerminalHostedView != nil ? 1 : 0 + dlog( + "focus.split.moveFocus workspace=\(id.uuidString.prefix(5)) " + + "panel=\(panelId.uuidString.prefix(5)) previousExists=\(previousExists) " + + "to=\(panelId.uuidString.prefix(5))" + ) +#endif + terminalPanel.hostedView.moveFocus(from: previousTerminalHostedView) + } +#if DEBUG + dlog( + "focus.split.ensureFocus workspace=\(id.uuidString.prefix(5)) " + + "panel=\(panelId.uuidString.prefix(5)) pane=\(focusedPane.id.uuidString.prefix(5)) " + + "tab=\(selectedTabId.uuid.uuidString.prefix(5)) intent=\(String(describing: activationIntent))" + ) +#endif + terminalPanel.hostedView.ensureFocus(for: id, surfaceId: panelId) + } + + if shouldRestoreFocusIntentAfterActivation(activationIntent) { + _ = panel.restoreFocusIntent(activationIntent) + } + + // Update current directory if this is a terminal + if let dir = panelDirectories[panelId] { + currentDirectory = dir + } + gitBranch = panelGitBranches[panelId] + pullRequest = panelPullRequests[panelId] + + // Post notification + NotificationCenter.default.post( + name: .ghosttyDidFocusSurface, + object: nil, + userInfo: [ + GhosttyNotificationKey.tabId: self.id, + GhosttyNotificationKey.surfaceId: panelId + ] + ) +#if DEBUG + let prevPanelShort = previousFocusedPanelId.map { String($0.uuidString.prefix(5)) } ?? "nil" + dlog( + "focus.split.apply.end workspace=\(id.uuidString.prefix(5)) " + + "panel=\(panelId.uuidString.prefix(5)) type=\(String(describing: type(of: panel))) " + + "focusedPane=\(focusedPane.id.uuidString.prefix(5)) selectedTab=\(selectedTabId.uuid.uuidString.prefix(5)) " + + "prevPanel=\(prevPanelShort)" + ) +#endif + } + + private func activatePanel( + _ panel: any Panel, + focusIntent: PanelFocusIntent, + reassertAppKitFocus: Bool + ) { + if let terminalPanel = panel as? TerminalPanel { + let shouldFocusTerminalSurface = shouldMoveTerminalSurfaceFocus(for: focusIntent) + terminalPanel.surface.setFocus(shouldFocusTerminalSurface) + terminalPanel.hostedView.setActive(true) + if reassertAppKitFocus && shouldFocusTerminalSurface { + terminalPanel.focus() + } + return + } + + if let browserPanel = panel as? BrowserPanel { + guard shouldFocusBrowserWebView(for: focusIntent) else { return } + browserPanel.focus() + return + } + + if reassertAppKitFocus { + panel.focus() + } + } + + private func activationWindow(for panel: any Panel) -> NSWindow? { + if let terminalPanel = panel as? TerminalPanel { + return terminalPanel.hostedView.window ?? NSApp.keyWindow ?? NSApp.mainWindow + } + if let browserPanel = panel as? BrowserPanel { + return browserPanel.webView.window ?? browserPanel.portalAnchorView.window ?? NSApp.keyWindow ?? NSApp.mainWindow + } + return NSApp.keyWindow ?? NSApp.mainWindow + } + + private func yieldForeignOwnedFocusIfNeeded( + in window: NSWindow, + targetPanelId: UUID, + targetIntent: PanelFocusIntent + ) { + guard let firstResponder = window.firstResponder else { return } + + for (panelId, panel) in panels where panelId != targetPanelId { + guard let ownedIntent = panel.ownedFocusIntent(for: firstResponder, in: window) else { continue } +#if DEBUG + dlog( + "focus.handoff.begin workspace=\(id.uuidString.prefix(5)) " + + "fromPanel=\(panelId.uuidString.prefix(5)) toPanel=\(targetPanelId.uuidString.prefix(5)) " + + "fromIntent=\(String(describing: ownedIntent)) toIntent=\(String(describing: targetIntent))" + ) +#endif + _ = panel.yieldFocusIntent(ownedIntent, in: window) + return + } + } + + private func shouldMoveTerminalSurfaceFocus(for intent: PanelFocusIntent) -> Bool { + switch intent { + case .terminal(.findField): + return false + default: + return true + } + } + + private func shouldFocusBrowserWebView(for intent: PanelFocusIntent) -> Bool { + switch intent { + case .browser(.addressBar), .browser(.findField): + return false + default: + return true + } + } + + private func shouldAllowBrowserOmnibarAutofocus(for intent: PanelFocusIntent) -> Bool { + switch intent { + case .browser(.webView), .panel: + return true + default: + return false + } + } + + private func shouldRestoreFocusIntentAfterActivation(_ intent: PanelFocusIntent) -> Bool { + switch intent { + case .browser(.addressBar), .browser(.findField), .terminal(.findField): + return true + case .panel, .browser(.webView), .terminal(.surface): + return false + } + } + + func beginNonFocusSplitFocusReassert( + preferredPanelId: UUID, + splitPanelId: UUID + ) -> UInt64 { + nonFocusSplitFocusReassertGeneration &+= 1 + let generation = nonFocusSplitFocusReassertGeneration + pendingNonFocusSplitFocusReassert = PendingNonFocusSplitFocusReassert( + generation: generation, + preferredPanelId: preferredPanelId, + splitPanelId: splitPanelId + ) + return generation + } + + 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 + } + + 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 + } + } + + 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) + guard let idx = tabs.firstIndex(where: { $0.id == tab.id }) else { + postCloseSelectTabId.removeValue(forKey: tab.id) + return + } + + let target: TabID? = { + if idx + 1 < tabs.count { return tabs[idx + 1].id } + if idx > 0 { return tabs[idx - 1].id } + return nil + }() + + if let target { + postCloseSelectTabId[tab.id] = target + } else { + postCloseSelectTabId.removeValue(forKey: tab.id) + } + } + + let explicitUserClose = explicitUserCloseTabIds.remove(tab.id) != nil + + if forceCloseTabIds.contains(tab.id) { + stageClosedBrowserRestoreSnapshotIfNeeded(for: tab, inPane: pane) + recordPostCloseSelection() + return true + } + + if let panelId = panelIdFromSurfaceId(tab.id), + pinnedPanelIds.contains(panelId) { + clearStagedClosedBrowserRestoreSnapshot(for: tab.id) + NSSound.beep() + return false + } + + if explicitUserClose && shouldCloseWorkspaceOnLastSurface(for: tab.id) { + clearStagedClosedBrowserRestoreSnapshot(for: tab.id) + owningTabManager?.closeWorkspaceWithConfirmation(self) + return false + } + + // Check if the panel needs close confirmation + guard let panelId = panelIdFromSurfaceId(tab.id), + let terminalPanel = terminalPanel(for: panelId) else { + stageClosedBrowserRestoreSnapshotIfNeeded(for: tab, inPane: pane) + recordPostCloseSelection() + return true + } + + // If confirmation is required, Bonsplit will call into this delegate and we must return false. + // Show an app-level confirmation, then re-attempt the close with forceCloseTabIds to bypass + // this gating on the second pass. + if panelNeedsConfirmClose(panelId: panelId, fallbackNeedsConfirmClose: terminalPanel.needsConfirmClose()) { + clearStagedClosedBrowserRestoreSnapshot(for: tab.id) + if pendingCloseConfirmTabIds.contains(tab.id) { + return false + } + + pendingCloseConfirmTabIds.insert(tab.id) + let tabId = tab.id + DispatchQueue.main.async { [weak self] in + guard let self else { return } + Task { @MainActor in + defer { self.pendingCloseConfirmTabIds.remove(tabId) } + + // If the tab disappeared while we were scheduling, do nothing. + guard self.panelIdFromSurfaceId(tabId) != nil else { return } + + let confirmed = await self.confirmClosePanel(for: tabId) + guard confirmed else { return } + + self.forceCloseTabIds.insert(tabId) + self.bonsplitController.closeTab(tabId) + } + } + + return false + } + + clearStagedClosedBrowserRestoreSnapshot(for: tab.id) + recordPostCloseSelection() + return true + } + + func splitTabBar(_ controller: BonsplitController, didCloseTab tabId: TabID, fromPane pane: PaneID) { + 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 + NSLog("[Workspace] didCloseTab: no panelId for tabId") + #endif + scheduleTerminalGeometryReconcile() + if !isDetaching { + scheduleFocusReconcile() + } + return + } + + #if DEBUG + NSLog("[Workspace] didCloseTab panelId=\(panelId) remainingPanels=\(panels.count - 1) remainingPanes=\(controller.allPaneIds.count)") + #endif + + let panel = panels[panelId] + let transferredRemoteCleanupConfiguration = transferredRemoteCleanupConfigurationsByPanelId.removeValue(forKey: panelId) + + if isDetaching, let panel { + let browserPanel = panel as? BrowserPanel + let cachedTitle = panelTitles[panelId] + let transferFallbackTitle = cachedTitle ?? panel.displayTitle + pendingDetachedSurfaces[tabId] = DetachedSurfaceTransfer( + panelId: panelId, + panel: panel, + 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], + ttyName: surfaceTTYNames[panelId], + cachedTitle: cachedTitle, + customTitle: panelCustomTitles[panelId], + manuallyUnread: manualUnreadPanelIds.contains(panelId), + isRemoteTerminal: activeRemoteTerminalSurfaceIds.contains(panelId), + remoteRelayPort: activeRemoteTerminalSurfaceIds.contains(panelId) + ? remoteConfiguration?.relayPort + : nil, + remoteCleanupConfiguration: transferredRemoteCleanupConfiguration + ) + } else { + if let closedBrowserRestoreSnapshot { + onClosedBrowserPanel?(closedBrowserRestoreSnapshot) + } + panel?.close() + } + + panels.removeValue(forKey: panelId) + untrackRemoteTerminalSurface(panelId) + pendingRemoteTerminalChildExitSurfaceIds.remove(panelId) + surfaceIdToPanelId.removeValue(forKey: tabId) + removeSurfaceMetadata(panelId: panelId) + syncRemotePortScanTTYs() + recomputeListeningPorts() + clearRemoteConfigurationIfWorkspaceBecameLocal() + if !isDetaching, let transferredRemoteCleanupConfiguration { + Self.requestSSHControlMasterCleanupIfNeeded(configuration: transferredRemoteCleanupConfiguration) + } + + // 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 { + scheduleTerminalGeometryReconcile() + return + } + + let replacement = createReplacementTerminalPanel() + if let replacementTabId = surfaceIdFromPanelId(replacement.id), + let replacementPane = bonsplitController.allPaneIds.first { + bonsplitController.focusPane(replacementPane) + bonsplitController.selectTab(replacementTabId) + applyTabSelection(tabId: replacementTabId, inPane: replacementPane) + } + scheduleTerminalGeometryReconcile() + scheduleFocusReconcile() + return + } + + if let selectTabId, + bonsplitController.allPaneIds.contains(pane), + bonsplitController.tabs(inPane: pane).contains(where: { $0.id == selectTabId }), + bonsplitController.focusedPaneId == pane { + // Keep selection/focus convergence in the same close transaction to avoid a transient + // 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) + } + scheduleTerminalGeometryReconcile() + if !isDetaching { + scheduleFocusReconcile() + } + } + + func splitTabBar(_ controller: BonsplitController, didSelectTab tab: Bonsplit.Tab, inPane pane: PaneID) { + applyTabSelection(tabId: tab.id, inPane: pane) + } + + func splitTabBar(_ controller: BonsplitController, didMoveTab tab: Bonsplit.Tab, fromPane source: PaneID, toPane destination: PaneID) { +#if DEBUG + 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 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() + if !isDetachingCloseTransaction { + scheduleFocusReconcile() + } + } + + func splitTabBar(_ controller: BonsplitController, didFocusPane pane: PaneID) { + // When a pane is focused, focus its selected tab's panel + guard let tab = controller.selectedTab(inPane: pane) else { return } +#if DEBUG + FocusLogStore.shared.append( + "Workspace.didFocusPane paneId=\(pane.id.uuidString) tabId=\(tab.id) focusedPane=\(controller.focusedPaneId?.id.uuidString ?? "nil")" + ) +#endif + applyTabSelection(tabId: tab.id, inPane: pane) + + // Apply window background for terminal + if let panelId = panelIdFromSurfaceId(tab.id), + let terminalPanel = panels[panelId] as? TerminalPanel { + terminalPanel.applyWindowBackgroundIfActive() + } + } + + /// Canonical per-panel metadata teardown shared by every close path. + /// The single-surface and pane-close paths previously hand-copied this + /// list and drifted (pane close leaked inheritance font points and never + /// cleared notifications). Aggregate recomputes (syncRemotePortScanTTYs, + /// recomputeListeningPorts) stay at the call sites. + private func removeSurfaceMetadata(panelId: UUID) { + 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) + manualUnreadMarkedAt.removeValue(forKey: panelId) + panelSubscriptions.removeValue(forKey: panelId) + panelShellActivityStates.removeValue(forKey: panelId) + surfaceTTYNames.removeValue(forKey: panelId) + surfaceListeningPorts.removeValue(forKey: panelId) + restoredTerminalScrollbackByPanelId.removeValue(forKey: panelId) + terminalInheritanceFontPointsByPanelId.removeValue(forKey: panelId) + if lastTerminalConfigInheritancePanelId == panelId { + lastTerminalConfigInheritancePanelId = nil + } + PortScanner.shared.unregisterPanel(workspaceId: id, panelId: panelId) + AppDelegate.shared?.notificationStore?.clearNotifications(forTabId: id, surfaceId: panelId) + } + + func splitTabBar(_ controller: BonsplitController, didClosePane paneId: PaneID) { + let closedPanelIds = pendingPaneClosePanelIds.removeValue(forKey: paneId.id) ?? [] + let shouldScheduleFocusReconcile = !isDetachingCloseTransaction + + if !closedPanelIds.isEmpty { + for panelId in closedPanelIds { + panels[panelId]?.close() + panels.removeValue(forKey: panelId) + untrackRemoteTerminalSurface(panelId) + pendingRemoteTerminalChildExitSurfaceIds.remove(panelId) + removeSurfaceMetadata(panelId: panelId) + } + + syncRemotePortScanTTYs() + let closedSet = Set(closedPanelIds) + surfaceIdToPanelId = surfaceIdToPanelId.filter { !closedSet.contains($0.value) } + recomputeListeningPorts() + clearRemoteConfigurationIfWorkspaceBecameLocal() + + if let focusedPane = bonsplitController.focusedPaneId, + let focusedTabId = bonsplitController.selectedTab(inPane: focusedPane)?.id { + applyTabSelection(tabId: focusedTabId, inPane: focusedPane) + } else if shouldScheduleFocusReconcile { + scheduleFocusReconcile() + } + } + + scheduleTerminalGeometryReconcile() + if shouldScheduleFocusReconcile { + scheduleFocusReconcile() + } + } + + func splitTabBar(_ controller: BonsplitController, shouldClosePane pane: PaneID) -> Bool { + // Check if any panel in this pane needs close confirmation + let tabs = controller.tabs(inPane: pane) + for tab in tabs { + if forceCloseTabIds.contains(tab.id) { continue } + if let panelId = panelIdFromSurfaceId(tab.id), + let terminalPanel = terminalPanel(for: panelId), + panelNeedsConfirmClose(panelId: panelId, fallbackNeedsConfirmClose: terminalPanel.needsConfirmClose()) { + pendingPaneClosePanelIds.removeValue(forKey: pane.id) + return false + } + } + pendingPaneClosePanelIds[pane.id] = tabs.compactMap { panelIdFromSurfaceId($0.id) } + return true + } + + func splitTabBar(_ controller: BonsplitController, didSplitPane originalPane: PaneID, newPane: PaneID, orientation: SplitOrientation) { +#if DEBUG + let panelKindForTab: (TabID) -> String = { tabId in + guard let panelId = self.panelIdFromSurfaceId(tabId), + let panel = self.panels[panelId] else { return "placeholder" } + if panel is TerminalPanel { return "terminal" } + if panel is BrowserPanel { return "browser" } + return String(describing: type(of: panel)) + } + let paneKindSummary: (PaneID) -> String = { paneId in + let tabs = controller.tabs(inPane: paneId) + guard !tabs.isEmpty else { return "-" } + return tabs.map { tab in + String(panelKindForTab(tab.id).prefix(1)) + }.joined(separator: ",") + } + let originalSelectedKind = controller.selectedTab(inPane: originalPane).map { panelKindForTab($0.id) } ?? "none" + let newSelectedKind = controller.selectedTab(inPane: newPane).map { panelKindForTab($0.id) } ?? "none" + dlog( + "split.didSplit original=\(originalPane.id.uuidString.prefix(5)) new=\(newPane.id.uuidString.prefix(5)) " + + "orientation=\(orientation) programmatic=\(isProgrammaticSplit ? 1 : 0) " + + "originalTabs=\(controller.tabs(inPane: originalPane).count) newTabs=\(controller.tabs(inPane: newPane).count) " + + "originalSelected=\(originalSelectedKind) newSelected=\(newSelectedKind) " + + "originalKinds=[\(paneKindSummary(originalPane))] newKinds=[\(paneKindSummary(newPane))]" + ) +#endif + let rearmBrowserPortalHostReplacement: (PaneID, String) -> Void = { paneId, reason in + for tab in controller.tabs(inPane: paneId) { + guard let panelId = self.panelIdFromSurfaceId(tab.id), + let browserPanel = self.browserPanel(for: panelId) else { + continue + } + browserPanel.preparePortalHostReplacementForNextDistinctClaim( + inPane: paneId, + reason: reason + ) + } + } + rearmBrowserPortalHostReplacement(originalPane, "workspace.didSplit.original") + rearmBrowserPortalHostReplacement(newPane, "workspace.didSplit.new") + + // Only auto-create a terminal if the split came from bonsplit UI. + // Programmatic splits via newTerminalSplit() set isProgrammaticSplit and handle their own panels. + guard !isProgrammaticSplit else { + normalizePinnedTabs(in: originalPane) + normalizePinnedTabs(in: newPane) + scheduleTerminalGeometryReconcile() + return + } + + // If the new pane already has a tab, this split moved an existing tab (drag-to-split). + // + // In the "drag the only tab to split edge" case, bonsplit inserts a placeholder "Empty" + // tab in the source pane to avoid leaving it tabless. In cmux, this is undesirable: + // it creates a pane with no real surfaces and leaves an "Empty" tab in the tab bar. + // + // Replace placeholder-only source panes with a real terminal surface, then drop the + // placeholder tabs so the UI stays consistent and pane lists don't contain empties. + if !controller.tabs(inPane: newPane).isEmpty { + let originalTabs = controller.tabs(inPane: originalPane) + let hasRealSurface = originalTabs.contains { panelIdFromSurfaceId($0.id) != nil } +#if DEBUG + dlog( + "split.didSplit.drag original=\(originalPane.id.uuidString.prefix(5)) " + + "new=\(newPane.id.uuidString.prefix(5)) originalTabs=\(originalTabs.count) " + + "newTabs=\(controller.tabs(inPane: newPane).count) hasRealSurface=\(hasRealSurface ? 1 : 0) " + + "originalKinds=[\(paneKindSummary(originalPane))] newKinds=[\(paneKindSummary(newPane))]" + ) +#endif + if !hasRealSurface { + let placeholderTabs = originalTabs.filter { panelIdFromSurfaceId($0.id) == nil } +#if DEBUG + dlog( + "split.placeholderRepair pane=\(originalPane.id.uuidString.prefix(5)) " + + "action=reusePlaceholder placeholderCount=\(placeholderTabs.count)" + ) +#endif + if let replacementTab = placeholderTabs.first { + // 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 = inheritedTerminalConfig(inPane: originalPane) + + let replacementPanel = TerminalPanel( + workspaceId: id, + context: GHOSTTY_SURFACE_CONTEXT_SPLIT, + configTemplate: inheritedConfig, + portOrdinal: portOrdinal + ) + configureTerminalPanel(replacementPanel) + panels[replacementPanel.id] = replacementPanel + panelTitles[replacementPanel.id] = replacementPanel.displayTitle + seedTerminalInheritanceFontPoints(panelId: replacementPanel.id, configTemplate: inheritedConfig) + surfaceIdToPanelId[replacementTab.id] = replacementPanel.id + + bonsplitController.updateTab( + replacementTab.id, + title: replacementPanel.displayTitle, + icon: .some(replacementPanel.displayIcon), + iconImageData: .some(nil), + kind: .some(SurfaceKind.terminal), + hasCustomTitle: false, + isDirty: replacementPanel.isDirty, + showsNotificationBadge: false, + isLoading: false, + isPinned: false + ) + + for extraPlaceholder in placeholderTabs.dropFirst() { + bonsplitController.closeTab(extraPlaceholder.id) + } + } else { +#if DEBUG + dlog( + "split.placeholderRepair pane=\(originalPane.id.uuidString.prefix(5)) " + + "fallback=createTerminalAndDropPlaceholders" + ) +#endif + _ = newTerminalSurface(inPane: originalPane, focus: false) + for tab in controller.tabs(inPane: originalPane) { + if panelIdFromSurfaceId(tab.id) == nil { + bonsplitController.closeTab(tab.id) + } + } + } + } + normalizePinnedTabs(in: originalPane) + normalizePinnedTabs(in: newPane) + scheduleTerminalGeometryReconcile() + return + } + + // Mirror Cmd+D behavior: split buttons should always seed a terminal in the new pane. + // When the focused source is a browser, inherit terminal config from nearby terminals + // (or fall back to defaults) instead of leaving an empty selector pane. + let sourceTabId = controller.selectedTab(inPane: originalPane)?.id + let sourcePanelId = sourceTabId.flatMap { panelIdFromSurfaceId($0) } + +#if DEBUG + dlog( + "split.didSplit.autoCreate pane=\(newPane.id.uuidString.prefix(5)) " + + "fromPane=\(originalPane.id.uuidString.prefix(5)) sourcePanel=\(sourcePanelId.map { String($0.uuidString.prefix(5)) } ?? "none")" + ) +#endif + + let inheritedConfig = inheritedTerminalConfig( + preferredPanelId: sourcePanelId, + inPane: originalPane + ) + + let newPanel = TerminalPanel( + workspaceId: id, + context: GHOSTTY_SURFACE_CONTEXT_SPLIT, + configTemplate: inheritedConfig, + portOrdinal: portOrdinal + ) + configureTerminalPanel(newPanel) + panels[newPanel.id] = newPanel + panelTitles[newPanel.id] = newPanel.displayTitle + seedTerminalInheritanceFontPoints(panelId: newPanel.id, configTemplate: inheritedConfig) + + guard let newTabId = bonsplitController.createTab( + title: newPanel.displayTitle, + icon: newPanel.displayIcon, + kind: SurfaceKind.terminal, + isDirty: newPanel.isDirty, + isPinned: false, + inPane: newPane + ) else { + panels.removeValue(forKey: newPanel.id) + panelTitles.removeValue(forKey: newPanel.id) + terminalInheritanceFontPointsByPanelId.removeValue(forKey: newPanel.id) + return + } + + surfaceIdToPanelId[newTabId] = newPanel.id + normalizePinnedTabs(in: newPane) +#if DEBUG + dlog( + "split.didSplit.autoCreate.done pane=\(newPane.id.uuidString.prefix(5)) " + + "panel=\(newPanel.id.uuidString.prefix(5))" + ) +#endif + + // `createTab` selects the new tab but does not emit didSelectTab; schedule an explicit + // selection so our focus/unfocus logic runs after this delegate callback returns. + DispatchQueue.main.async { [weak self] in + guard let self else { return } + if self.bonsplitController.focusedPaneId == newPane { + self.bonsplitController.selectTab(newTabId) + } + self.scheduleTerminalGeometryReconcile() + self.scheduleFocusReconcile() + } + } + + func splitTabBar(_ controller: BonsplitController, didRequestNewTab kind: String, inPane pane: PaneID) { + switch kind { + case "terminal": + _ = newTerminalSurface(inPane: pane) + case "browser": + _ = newBrowserSurface(inPane: pane) + default: + _ = newTerminalSurface(inPane: pane) + } + } + + func splitTabBar(_ controller: BonsplitController, didRequestTabContextAction action: TabContextAction, for tab: Bonsplit.Tab, inPane pane: PaneID) { + switch action { + case .rename: + promptRenamePanel(tabId: tab.id) + case .clearName: + guard let panelId = panelIdFromSurfaceId(tab.id) else { return } + setPanelCustomTitle(panelId: panelId, title: nil) + case .closeToLeft: + closeTabs(tabIdsToLeft(of: tab.id, inPane: pane)) + case .closeToRight: + closeTabs(tabIdsToRight(of: tab.id, inPane: pane)) + case .closeOthers: + closeTabs(tabIdsToCloseOthers(of: tab.id, inPane: pane)) + case .move: + promptMovePanel(tabId: tab.id) + case .newTerminalToRight: + createTerminalToRight(of: tab.id, inPane: pane) + case .newBrowserToRight: + createBrowserToRight(of: tab.id, inPane: pane) + case .reload: + guard let panelId = panelIdFromSurfaceId(tab.id), + let browser = browserPanel(for: panelId) else { return } + browser.reload() + case .duplicate: + duplicateBrowserToRight(anchorTabId: tab.id, inPane: pane) + case .togglePin: + guard let panelId = panelIdFromSurfaceId(tab.id) else { return } + let shouldPin = !pinnedPanelIds.contains(panelId) + setPanelPinned(panelId: panelId, pinned: shouldPin) + case .markAsRead: + guard let panelId = panelIdFromSurfaceId(tab.id) else { return } + clearManualUnread(panelId: panelId) + case .markAsUnread: + guard let panelId = panelIdFromSurfaceId(tab.id) else { return } + markPanelUnread(panelId) + case .toggleZoom: + guard let panelId = panelIdFromSurfaceId(tab.id) else { return } + toggleSplitZoom(panelId: panelId) + @unknown default: + break + } + } + + func splitTabBar(_ controller: BonsplitController, didChangeGeometry snapshot: LayoutSnapshot) { + tmuxLayoutSnapshot = snapshot + scheduleTerminalGeometryReconcile() + if !isDetachingCloseTransaction { + scheduleFocusReconcile() + } + } + + // No post-close polling refresh loop: we rely on view invariants and Ghostty's wakeups. +} diff --git a/Sources/Workspace+Layout.swift b/Sources/Workspace+Layout.swift new file mode 100644 index 00000000000..93a6bacf0f4 --- /dev/null +++ b/Sources/Workspace+Layout.swift @@ -0,0 +1,230 @@ +// Extracted from Workspace.swift (nuclear-review #98): programa.json custom layout application (applyCustomLayout and its tree/pane helpers). + +import Foundation +import SwiftUI +import AppKit +import Bonsplit +import Combine +import CryptoKit +import Darwin +import Network +import CoreText + +extension Workspace { + + func applyCustomLayout(_ layout: ProgramaLayoutNode, baseCwd: String) { + guard let rootPaneId = bonsplitController.allPaneIds.first else { return } + + var leaves: [(paneId: PaneID, surfaces: [ProgramaSurfaceDefinition])] = [] + buildCustomLayoutTree(layout, inPane: rootPaneId, leaves: &leaves) + + // First leaf reuses the initial terminal created by addWorkspace; + // subsequent leaves were created via newTerminalSplit which also seeds + // a placeholder terminal. + var focusPanelId: UUID? + for leaf in leaves { + populateCustomPane(leaf.paneId, surfaces: leaf.surfaces, baseCwd: baseCwd, focusPanelId: &focusPanelId) + } + + let liveRoot = bonsplitController.treeSnapshot() + applyCustomDividerPositions(configNode: layout, liveNode: liveRoot) + + if let focusPanelId { + focusPanel(focusPanelId) + } + } + + private func buildCustomLayoutTree( + _ node: ProgramaLayoutNode, + inPane paneId: PaneID, + leaves: inout [(paneId: PaneID, surfaces: [ProgramaSurfaceDefinition])] + ) { + switch node { + case .pane(let pane): + leaves.append((paneId: paneId, surfaces: pane.surfaces)) + + case .split(let split): + guard split.children.count == 2 else { + NSLog("[ProgramaConfig] split node requires exactly 2 children, got %d", split.children.count) + leaves.append((paneId: paneId, surfaces: [])) + return + } + + var anchorPanelId = bonsplitController + .tabs(inPane: paneId) + .compactMap { panelIdFromSurfaceId($0.id) } + .first + + if anchorPanelId == nil { + anchorPanelId = newTerminalSurface(inPane: paneId, focus: false)?.id + } + + guard let anchorPanelId, + let newSplitPanel = newTerminalSplit( + from: anchorPanelId, + orientation: split.splitOrientation, + insertFirst: false, + focus: false + ), + let secondPaneId = self.paneId(forPanelId: newSplitPanel.id) else { + leaves.append((paneId: paneId, surfaces: [])) + return + } + + buildCustomLayoutTree(split.children[0], inPane: paneId, leaves: &leaves) + buildCustomLayoutTree(split.children[1], inPane: secondPaneId, leaves: &leaves) + } + } + + private func populateCustomPane( + _ paneId: PaneID, + surfaces: [ProgramaSurfaceDefinition], + baseCwd: String, + focusPanelId: inout UUID? + ) { + let existingPanelIds = bonsplitController + .tabs(inPane: paneId) + .compactMap { panelIdFromSurfaceId($0.id) } + + guard !surfaces.isEmpty else { return } + + let firstSurface = surfaces[0] + if let placeholderPanelId = existingPanelIds.first { + configureExistingSurface( + panelId: placeholderPanelId, + inPane: paneId, + surface: firstSurface, + baseCwd: baseCwd, + focusPanelId: &focusPanelId + ) + } + + for surfaceIndex in 1.. WorkspaceRemoteDaemonManifest? { + WorkspaceRemoteSessionController.remoteDaemonManifest(from: infoDictionary) + } + + nonisolated static func remoteDaemonCachedBinaryURL( + version: String, + goOS: String, + goArch: String, + fileManager: FileManager = .default + ) throws -> URL { + try WorkspaceRemoteSessionController.remoteDaemonCachedBinaryURL( + version: version, + goOS: goOS, + goArch: goArch, + fileManager: fileManager + ) + } + + func sessionSnapshot(includeScrollback: Bool) -> SessionWorkspaceSnapshot { + let tree = bonsplitController.treeSnapshot() + let layout = sessionLayoutSnapshot(from: tree) + + let orderedPanelIds = sidebarOrderedPanelIds() + var seen: Set = [] + var allPanelIds: [UUID] = [] + for panelId in orderedPanelIds where seen.insert(panelId).inserted { + allPanelIds.append(panelId) + } + for panelId in panels.keys.sorted(by: { $0.uuidString < $1.uuidString }) where seen.insert(panelId).inserted { + allPanelIds.append(panelId) + } + + let panelSnapshots = allPanelIds + .prefix(SessionPersistencePolicy.maxPanelsPerWorkspace) + .compactMap { sessionPanelSnapshot(panelId: $0, includeScrollback: includeScrollback) } + + let statusSnapshots = statusEntries.values + .sorted { lhs, rhs in lhs.key < rhs.key } + .map { entry in + SessionStatusEntrySnapshot( + key: entry.key, + value: entry.value, + icon: entry.icon, + color: entry.color, + timestamp: entry.timestamp.timeIntervalSince1970 + ) + } + let logSnapshots = logEntries.map { entry in + SessionLogEntrySnapshot( + message: entry.message, + level: entry.level.rawValue, + source: entry.source, + timestamp: entry.timestamp.timeIntervalSince1970 + ) + } + + let progressSnapshot = progress.map { progress in + SessionProgressSnapshot(value: progress.value, label: progress.label) + } + let gitBranchSnapshot = gitBranch.map { branch in + SessionGitBranchSnapshot(branch: branch.branch, isDirty: branch.isDirty) + } + + return SessionWorkspaceSnapshot( + processTitle: processTitle, + customTitle: customTitle, + customDescription: customDescription, + customColor: customColor, + isPinned: isPinned, + currentDirectory: currentDirectory, + focusedPanelId: focusedPanelId, + layout: layout, + panels: panelSnapshots, + statusEntries: statusSnapshots, + logEntries: logSnapshots, + progress: progressSnapshot, + gitBranch: gitBranchSnapshot + ) + } + + func restoreSessionSnapshot(_ snapshot: SessionWorkspaceSnapshot) { + restoredTerminalScrollbackByPanelId.removeAll(keepingCapacity: false) + + let normalizedCurrentDirectory = snapshot.currentDirectory.trimmingCharacters(in: .whitespacesAndNewlines) + if !normalizedCurrentDirectory.isEmpty { + currentDirectory = normalizedCurrentDirectory + } + + let panelSnapshotsById = Dictionary(uniqueKeysWithValues: snapshot.panels.map { ($0.id, $0) }) + let leafEntries = restoreSessionLayout(snapshot.layout) + var oldToNewPanelIds: [UUID: UUID] = [:] + + for entry in leafEntries { + restorePane( + entry.paneId, + snapshot: entry.snapshot, + panelSnapshotsById: panelSnapshotsById, + oldToNewPanelIds: &oldToNewPanelIds + ) + } + + pruneSurfaceMetadata(validSurfaceIds: Set(panels.keys)) + applySessionDividerPositions(snapshotNode: snapshot.layout, liveNode: bonsplitController.treeSnapshot()) + + applyProcessTitle(snapshot.processTitle) + setCustomTitle(snapshot.customTitle) + setCustomDescription(snapshot.customDescription) + setCustomColor(snapshot.customColor) + isPinned = snapshot.isPinned + + // Status entries and agent PIDs are ephemeral runtime state tied to running + // processes (e.g. claude_code "Running"). Don't restore them across app + // restarts because the processes that set them are gone. + statusEntries.removeAll() + agentPIDs.removeAll() + agentListeningPorts.removeAll() + logEntries = snapshot.logEntries.map { entry in + SidebarLogEntry( + message: entry.message, + level: SidebarLogLevel(rawValue: entry.level) ?? .info, + source: entry.source, + timestamp: Date(timeIntervalSince1970: entry.timestamp) + ) + } + progress = snapshot.progress.map { SidebarProgressState(value: $0.value, label: $0.label) } + gitBranch = snapshot.gitBranch.map { SidebarGitBranchState(branch: $0.branch, isDirty: $0.isDirty) } + + recomputeListeningPorts() + + if let focusedOldPanelId = snapshot.focusedPanelId, + let focusedNewPanelId = oldToNewPanelIds[focusedOldPanelId], + panels[focusedNewPanelId] != nil { + focusPanel(focusedNewPanelId) + } else if let fallbackFocusedPanelId = focusedPanelId, panels[fallbackFocusedPanelId] != nil { + focusPanel(fallbackFocusedPanelId) + } else { + scheduleFocusReconcile() + } + } + + private func sessionLayoutSnapshot(from node: ExternalTreeNode) -> SessionWorkspaceLayoutSnapshot { + switch node { + case .pane(let pane): + let panelIds = sessionPanelIDs(for: pane) + let selectedPanelId = pane.selectedTabId.flatMap(sessionPanelID(forExternalTabIDString:)) + return .pane( + SessionPaneLayoutSnapshot( + panelIds: panelIds, + selectedPanelId: selectedPanelId + ) + ) + case .split(let split): + return .split( + SessionSplitLayoutSnapshot( + orientation: split.orientation.lowercased() == "vertical" ? .vertical : .horizontal, + dividerPosition: split.dividerPosition, + first: sessionLayoutSnapshot(from: split.first), + second: sessionLayoutSnapshot(from: split.second) + ) + ) + } + } + + private func sessionPanelIDs(for pane: ExternalPaneNode) -> [UUID] { + var panelIds: [UUID] = [] + var seen = Set() + for tab in pane.tabs { + guard let panelId = sessionPanelID(forExternalTabIDString: tab.id) else { continue } + if seen.insert(panelId).inserted { + panelIds.append(panelId) + } + } + return panelIds + } + + private func sessionPanelID(forExternalTabIDString tabIDString: String) -> UUID? { + guard let tabUUID = UUID(uuidString: tabIDString) else { return nil } + for (surfaceId, panelId) in surfaceIdToPanelId { + guard let surfaceUUID = sessionSurfaceUUID(for: surfaceId) else { continue } + if surfaceUUID == tabUUID { + return panelId + } + } + return nil + } + + private func sessionSurfaceUUID(for surfaceId: TabID) -> UUID? { + struct EncodedSurfaceID: Decodable { + let id: UUID + } + + guard let data = try? JSONEncoder().encode(surfaceId), + let decoded = try? JSONDecoder().decode(EncodedSurfaceID.self, from: data) else { + return nil + } + return decoded.id + } + + private func sessionPanelSnapshot(panelId: UUID, includeScrollback: Bool) -> SessionPanelSnapshot? { + guard let panel = panels[panelId] else { return nil } + + let panelTitle = panelTitle(panelId: panelId) + let customTitle = panelCustomTitles[panelId] + let directory = panelDirectories[panelId] + let isPinned = pinnedPanelIds.contains(panelId) + let isManuallyUnread = manualUnreadPanelIds.contains(panelId) + let branchSnapshot = panelGitBranches[panelId].map { + SessionGitBranchSnapshot(branch: $0.branch, isDirty: $0.isDirty) + } + let listeningPorts: [Int] + if remoteDetectedSurfaceIds.contains(panelId) || isRemoteTerminalSurface(panelId) { + listeningPorts = [] + } else { + listeningPorts = (surfaceListeningPorts[panelId] ?? []).sorted() + } + let ttyName = surfaceTTYNames[panelId] + + let terminalSnapshot: SessionTerminalPanelSnapshot? + let browserSnapshot: SessionBrowserPanelSnapshot? + let markdownSnapshot: SessionMarkdownPanelSnapshot? + switch panel.panelType { + case .terminal: + guard let terminalPanel = panel as? TerminalPanel else { return nil } + let shouldPersistScrollback = terminalPanel.shouldPersistScrollbackForSessionSnapshot() + let capturedScrollback = includeScrollback && shouldPersistScrollback + ? TerminalController.shared.readTerminalTextForSnapshot( + terminalPanel: terminalPanel, + includeScrollback: true, + lineLimit: SessionPersistencePolicy.maxScrollbackLinesPerTerminal + ) + : nil + let resolvedScrollback = terminalSnapshotScrollback( + panelId: panelId, + capturedScrollback: capturedScrollback, + includeScrollback: includeScrollback, + allowFallbackScrollback: shouldPersistScrollback + ) + terminalSnapshot = SessionTerminalPanelSnapshot( + workingDirectory: panelDirectories[panelId], + scrollback: resolvedScrollback + ) + browserSnapshot = nil + markdownSnapshot = nil + case .browser: + guard let browserPanel = panel as? BrowserPanel else { return nil } + terminalSnapshot = nil + let historySnapshot = browserPanel.sessionNavigationHistorySnapshot() + browserSnapshot = SessionBrowserPanelSnapshot( + urlString: browserPanel.preferredURLStringForOmnibar(), + profileID: browserPanel.profileID, + shouldRenderWebView: browserPanel.shouldRenderWebView, + pageZoom: Double(browserPanel.currentPageZoomFactor()), + developerToolsVisible: browserPanel.isDeveloperToolsVisible(), + backHistoryURLStrings: historySnapshot.backHistoryURLStrings, + forwardHistoryURLStrings: historySnapshot.forwardHistoryURLStrings + ) + markdownSnapshot = nil + case .markdown: + guard let markdownPanel = panel as? MarkdownPanel else { return nil } + terminalSnapshot = nil + browserSnapshot = nil + markdownSnapshot = SessionMarkdownPanelSnapshot(filePath: markdownPanel.filePath) + } + + return SessionPanelSnapshot( + id: panelId, + type: panel.panelType, + title: panelTitle, + customTitle: customTitle, + directory: directory, + isPinned: isPinned, + isManuallyUnread: isManuallyUnread, + gitBranch: branchSnapshot, + listeningPorts: listeningPorts, + ttyName: ttyName, + terminal: terminalSnapshot, + browser: browserSnapshot, + markdown: markdownSnapshot + ) + } + + nonisolated static func resolvedSnapshotTerminalScrollback( + capturedScrollback: String?, + fallbackScrollback: String?, + allowFallbackScrollback: Bool = true + ) -> String? { + if let captured = SessionPersistencePolicy.truncatedScrollback(capturedScrollback) { + return captured + } + guard allowFallbackScrollback else { return nil } + return SessionPersistencePolicy.truncatedScrollback(fallbackScrollback) + } + + private func terminalSnapshotScrollback( + panelId: UUID, + capturedScrollback: String?, + includeScrollback: Bool, + allowFallbackScrollback: Bool = true + ) -> String? { + guard includeScrollback else { return nil } + let fallback = allowFallbackScrollback ? restoredTerminalScrollbackByPanelId[panelId] : nil + let resolved = Self.resolvedSnapshotTerminalScrollback( + capturedScrollback: capturedScrollback, + fallbackScrollback: fallback, + allowFallbackScrollback: allowFallbackScrollback + ) + if let resolved { + restoredTerminalScrollbackByPanelId[panelId] = resolved + } else { + restoredTerminalScrollbackByPanelId.removeValue(forKey: panelId) + } + return resolved + } + + private func restoreSessionLayout(_ layout: SessionWorkspaceLayoutSnapshot) -> [SessionPaneRestoreEntry] { + guard let rootPaneId = bonsplitController.allPaneIds.first else { + return [] + } + + var leaves: [SessionPaneRestoreEntry] = [] + restoreSessionLayoutNode(layout, inPane: rootPaneId, leaves: &leaves) + return leaves + } + + private func restoreSessionLayoutNode( + _ node: SessionWorkspaceLayoutSnapshot, + inPane paneId: PaneID, + leaves: inout [SessionPaneRestoreEntry] + ) { + switch node { + case .pane(let pane): + leaves.append(SessionPaneRestoreEntry(paneId: paneId, snapshot: pane)) + case .split(let split): + var anchorPanelId = bonsplitController + .tabs(inPane: paneId) + .compactMap { panelIdFromSurfaceId($0.id) } + .first + + if anchorPanelId == nil { + anchorPanelId = newTerminalSurface(inPane: paneId, focus: false)?.id + } + + guard let anchorPanelId, + let newSplitPanel = newTerminalSplit( + from: anchorPanelId, + orientation: split.orientation.splitOrientation, + insertFirst: false, + focus: false + ), + let secondPaneId = self.paneId(forPanelId: newSplitPanel.id) else { + leaves.append( + SessionPaneRestoreEntry( + paneId: paneId, + snapshot: SessionPaneLayoutSnapshot(panelIds: [], selectedPanelId: nil) + ) + ) + return + } + + restoreSessionLayoutNode(split.first, inPane: paneId, leaves: &leaves) + restoreSessionLayoutNode(split.second, inPane: secondPaneId, leaves: &leaves) + } + } + + private func restorePane( + _ paneId: PaneID, + snapshot: SessionPaneLayoutSnapshot, + panelSnapshotsById: [UUID: SessionPanelSnapshot], + oldToNewPanelIds: inout [UUID: UUID] + ) { + let existingPanelIds = bonsplitController + .tabs(inPane: paneId) + .compactMap { panelIdFromSurfaceId($0.id) } + let desiredOldPanelIds = snapshot.panelIds.filter { panelSnapshotsById[$0] != nil } + + var createdPanelIds: [UUID] = [] + for oldPanelId in desiredOldPanelIds { + guard let panelSnapshot = panelSnapshotsById[oldPanelId] else { continue } + guard let createdPanelId = createPanel(from: panelSnapshot, inPane: paneId) else { continue } + createdPanelIds.append(createdPanelId) + oldToNewPanelIds[oldPanelId] = createdPanelId + } + + guard !createdPanelIds.isEmpty else { return } + + for oldPanelId in existingPanelIds where !createdPanelIds.contains(oldPanelId) { + _ = closePanel(oldPanelId, force: true) + } + + for (index, panelId) in createdPanelIds.enumerated() { + _ = reorderSurface(panelId: panelId, toIndex: index) + } + + let selectedPanelId: UUID? = { + if let selectedOldId = snapshot.selectedPanelId { + return oldToNewPanelIds[selectedOldId] + } + return createdPanelIds.first + }() + + if let selectedPanelId, + let selectedTabId = surfaceIdFromPanelId(selectedPanelId) { + bonsplitController.focusPane(paneId) + bonsplitController.selectTab(selectedTabId) + } + } + + private func createPanel(from snapshot: SessionPanelSnapshot, inPane paneId: PaneID) -> UUID? { + switch snapshot.type { + case .terminal: + let workingDirectory = snapshot.terminal?.workingDirectory ?? snapshot.directory ?? currentDirectory + let replayEnvironment = SessionScrollbackReplayStore.replayEnvironment( + for: snapshot.terminal?.scrollback + ) + guard let terminalPanel = newTerminalSurface( + inPane: paneId, + focus: false, + workingDirectory: workingDirectory, + startupEnvironment: replayEnvironment + ) else { + return nil + } + let fallbackScrollback = SessionPersistencePolicy.truncatedScrollback(snapshot.terminal?.scrollback) + if let fallbackScrollback { + restoredTerminalScrollbackByPanelId[terminalPanel.id] = fallbackScrollback + } else { + restoredTerminalScrollbackByPanelId.removeValue(forKey: terminalPanel.id) + } + applySessionPanelMetadata(snapshot, toPanelId: terminalPanel.id) + return terminalPanel.id + case .browser: + guard let browserPanel = newBrowserSurface( + inPane: paneId, + url: nil, + focus: false, + preferredProfileID: snapshot.browser?.profileID + ) else { + return nil + } + applySessionPanelMetadata(snapshot, toPanelId: browserPanel.id) + return browserPanel.id + case .markdown: + guard let filePath = snapshot.markdown?.filePath, + let markdownPanel = newMarkdownSurface( + inPane: paneId, + filePath: filePath, + focus: false + ) else { + return nil + } + applySessionPanelMetadata(snapshot, toPanelId: markdownPanel.id) + return markdownPanel.id + } + } + + private func applySessionPanelMetadata(_ snapshot: SessionPanelSnapshot, toPanelId panelId: UUID) { + if let title = snapshot.title?.trimmingCharacters(in: .whitespacesAndNewlines), !title.isEmpty { + panelTitles[panelId] = title + } + + setPanelCustomTitle(panelId: panelId, title: snapshot.customTitle) + setPanelPinned(panelId: panelId, pinned: snapshot.isPinned) + + if snapshot.isManuallyUnread { + markPanelUnread(panelId) + } else { + clearManualUnread(panelId: panelId) + } + + if let directory = snapshot.directory?.trimmingCharacters(in: .whitespacesAndNewlines), !directory.isEmpty { + updatePanelDirectory(panelId: panelId, directory: directory) + } + + if let branch = snapshot.gitBranch { + panelGitBranches[panelId] = SidebarGitBranchState(branch: branch.branch, isDirty: branch.isDirty) + } else { + panelGitBranches.removeValue(forKey: panelId) + } + + surfaceListeningPorts[panelId] = Array(Set(snapshot.listeningPorts)).sorted() + + if let ttyName = snapshot.ttyName?.trimmingCharacters(in: .whitespacesAndNewlines), !ttyName.isEmpty { + surfaceTTYNames[panelId] = ttyName + } else { + surfaceTTYNames.removeValue(forKey: panelId) + } + syncRemotePortScanTTYs() + + if let browserSnapshot = snapshot.browser, + let browserPanel = browserPanel(for: panelId) { + let pageZoom = CGFloat(max(0.25, min(5.0, browserSnapshot.pageZoom))) + if pageZoom.isFinite { + _ = browserPanel.setPageZoomFactor(pageZoom) + } + + browserPanel.restoreSessionSnapshot(browserSnapshot) + + if browserSnapshot.developerToolsVisible { + _ = browserPanel.showDeveloperTools() + browserPanel.requestDeveloperToolsRefreshAfterNextAttach(reason: "session_restore") + } else { + _ = browserPanel.hideDeveloperTools() + } + } + } + + private func applySessionDividerPositions( + snapshotNode: SessionWorkspaceLayoutSnapshot, + liveNode: ExternalTreeNode + ) { + switch (snapshotNode, liveNode) { + case (.split(let snapshotSplit), .split(let liveSplit)): + if let splitID = UUID(uuidString: liveSplit.id) { + _ = bonsplitController.setDividerPosition( + CGFloat(snapshotSplit.dividerPosition), + forSplit: splitID, + fromExternal: true + ) + } + applySessionDividerPositions(snapshotNode: snapshotSplit.first, liveNode: liveSplit.first) + applySessionDividerPositions(snapshotNode: snapshotSplit.second, liveNode: liveSplit.second) + default: + return + } + } +} diff --git a/Sources/Workspace.swift b/Sources/Workspace.swift index 2b884479055..c631db164b6 100644 --- a/Sources/Workspace.swift +++ b/Sources/Workspace.swift @@ -170,7 +170,7 @@ enum SidebarMetadataFormat: String { case markdown } -private struct SessionPaneRestoreEntry { +struct SessionPaneRestoreEntry { let paneId: PaneID let snapshot: SessionPaneLayoutSnapshot } @@ -226,3019 +226,2268 @@ struct WorkspaceRemoteDaemonManifest: Decodable, Equatable { } } -extension Workspace { - nonisolated static let remoteDaemonManifestInfoKey = WorkspaceRemoteSessionController.remoteDaemonManifestInfoKey +enum SidebarLogLevel: String { + case info + case progress + case success + case warning + case error +} - nonisolated static func remoteDaemonManifest(from infoDictionary: [String: Any]?) -> WorkspaceRemoteDaemonManifest? { - WorkspaceRemoteSessionController.remoteDaemonManifest(from: infoDictionary) - } +struct SidebarLogEntry: Equatable { + let message: String + let level: SidebarLogLevel + let source: String? + let timestamp: Date +} - nonisolated static func remoteDaemonCachedBinaryURL( - version: String, - goOS: String, - goArch: String, - fileManager: FileManager = .default - ) throws -> URL { - try WorkspaceRemoteSessionController.remoteDaemonCachedBinaryURL( - version: version, - goOS: goOS, - goArch: goArch, - fileManager: fileManager - ) +struct SidebarProgressState: Equatable { + let value: Double + let label: String? +} + +struct SidebarGitBranchState: Equatable { + let branch: String + let isDirty: Bool +} + +private struct SidebarPanelObservationState: Equatable { + let panelIds: [UUID] + + init(panels: [UUID: any Panel]) { + panelIds = panels.keys.sorted { $0.uuidString < $1.uuidString } } +} - func sessionSnapshot(includeScrollback: Bool) -> SessionWorkspaceSnapshot { - let tree = bonsplitController.treeSnapshot() - let layout = sessionLayoutSnapshot(from: tree) +enum WorkspaceRemoteConnectionState: String { + case disconnected + case connecting + case connected + case error +} - let orderedPanelIds = sidebarOrderedPanelIds() - var seen: Set = [] - var allPanelIds: [UUID] = [] - for panelId in orderedPanelIds where seen.insert(panelId).inserted { - allPanelIds.append(panelId) - } - for panelId in panels.keys.sorted(by: { $0.uuidString < $1.uuidString }) where seen.insert(panelId).inserted { - allPanelIds.append(panelId) - } - - let panelSnapshots = allPanelIds - .prefix(SessionPersistencePolicy.maxPanelsPerWorkspace) - .compactMap { sessionPanelSnapshot(panelId: $0, includeScrollback: includeScrollback) } - - let statusSnapshots = statusEntries.values - .sorted { lhs, rhs in lhs.key < rhs.key } - .map { entry in - SessionStatusEntrySnapshot( - key: entry.key, - value: entry.value, - icon: entry.icon, - color: entry.color, - timestamp: entry.timestamp.timeIntervalSince1970 - ) - } - let logSnapshots = logEntries.map { entry in - SessionLogEntrySnapshot( - message: entry.message, - level: entry.level.rawValue, - source: entry.source, - timestamp: entry.timestamp.timeIntervalSince1970 - ) - } +enum WorkspaceRemoteDaemonState: String { + case unavailable + case bootstrapping + case ready + case error +} - let progressSnapshot = progress.map { progress in - SessionProgressSnapshot(value: progress.value, label: progress.label) - } - let gitBranchSnapshot = gitBranch.map { branch in - SessionGitBranchSnapshot(branch: branch.branch, isDirty: branch.isDirty) - } +struct WorkspaceRemoteDaemonStatus: Equatable { + var state: WorkspaceRemoteDaemonState = .unavailable + var detail: String? + var version: String? + var name: String? + var capabilities: [String] = [] + var remotePath: String? - return SessionWorkspaceSnapshot( - processTitle: processTitle, - customTitle: customTitle, - customDescription: customDescription, - customColor: customColor, - isPinned: isPinned, - currentDirectory: currentDirectory, - focusedPanelId: focusedPanelId, - layout: layout, - panels: panelSnapshots, - statusEntries: statusSnapshots, - logEntries: logSnapshots, - progress: progressSnapshot, - gitBranch: gitBranchSnapshot - ) + func payload() -> [String: Any] { + [ + "state": state.rawValue, + "detail": detail ?? NSNull(), + "version": version ?? NSNull(), + "name": name ?? NSNull(), + "capabilities": capabilities, + "remote_path": remotePath ?? NSNull(), + ] } +} - func restoreSessionSnapshot(_ snapshot: SessionWorkspaceSnapshot) { - restoredTerminalScrollbackByPanelId.removeAll(keepingCapacity: false) +struct WorkspaceRemoteConfiguration: Equatable { + let destination: String + let port: Int? + let identityFile: String? + let sshOptions: [String] + let localProxyPort: Int? + let relayPort: Int? + let relayID: String? + let relayToken: String? + let localSocketPath: String? + let terminalStartupCommand: String? + let foregroundAuthToken: String? - let normalizedCurrentDirectory = snapshot.currentDirectory.trimmingCharacters(in: .whitespacesAndNewlines) - if !normalizedCurrentDirectory.isEmpty { - currentDirectory = normalizedCurrentDirectory - } + init( + destination: String, + port: Int?, + identityFile: String?, + sshOptions: [String], + localProxyPort: Int?, + relayPort: Int?, + relayID: String?, + relayToken: String?, + localSocketPath: String?, + terminalStartupCommand: String?, + foregroundAuthToken: String? = nil + ) { + self.destination = destination + self.port = port + self.identityFile = identityFile + self.sshOptions = sshOptions + self.localProxyPort = localProxyPort + self.relayPort = relayPort + self.relayID = relayID + self.relayToken = relayToken + self.localSocketPath = localSocketPath + self.terminalStartupCommand = terminalStartupCommand + self.foregroundAuthToken = foregroundAuthToken + } + + var displayTarget: String { + guard let port else { return destination } + return "\(destination):\(port)" + } - let panelSnapshotsById = Dictionary(uniqueKeysWithValues: snapshot.panels.map { ($0.id, $0) }) - let leafEntries = restoreSessionLayout(snapshot.layout) - var oldToNewPanelIds: [UUID: UUID] = [:] + var proxyBrokerTransportKey: String { + let normalizedDestination = destination.trimmingCharacters(in: .whitespacesAndNewlines) + let normalizedPort = port.map(String.init) ?? "" + let normalizedIdentity = identityFile?.trimmingCharacters(in: .whitespacesAndNewlines) ?? "" + let normalizedLocalProxyPort = localProxyPort.map(String.init) ?? "" + let normalizedOptions = Self.proxyBrokerSSHOptions(sshOptions).joined(separator: "\u{1f}") + return [normalizedDestination, normalizedPort, normalizedIdentity, normalizedOptions, normalizedLocalProxyPort] + .joined(separator: "\u{1e}") + } - for entry in leafEntries { - restorePane( - entry.paneId, - snapshot: entry.snapshot, - panelSnapshotsById: panelSnapshotsById, - oldToNewPanelIds: &oldToNewPanelIds - ) + private static func proxyBrokerSSHOptions(_ options: [String]) -> [String] { + options.compactMap { option in + let trimmed = option.trimmingCharacters(in: .whitespacesAndNewlines) + guard !trimmed.isEmpty else { return nil } + return trimmed + }.filter { option in + proxyBrokerSSHOptionKey(option) != "controlpath" } + } - pruneSurfaceMetadata(validSurfaceIds: Set(panels.keys)) - applySessionDividerPositions(snapshotNode: snapshot.layout, liveNode: bonsplitController.treeSnapshot()) + private static func proxyBrokerSSHOptionKey(_ option: String) -> String? { + let trimmed = option.trimmingCharacters(in: .whitespacesAndNewlines) + guard !trimmed.isEmpty else { return nil } + return trimmed + .split(whereSeparator: { $0 == "=" || $0.isWhitespace }) + .first + .map(String.init)? + .lowercased() + } +} - applyProcessTitle(snapshot.processTitle) - setCustomTitle(snapshot.customTitle) - setCustomDescription(snapshot.customDescription) - setCustomColor(snapshot.customColor) - isPinned = snapshot.isPinned +enum SidebarPullRequestStatus: String { + case open + case merged + case closed +} - // Status entries and agent PIDs are ephemeral runtime state tied to running - // processes (e.g. claude_code "Running"). Don't restore them across app - // restarts because the processes that set them are gone. - statusEntries.removeAll() - agentPIDs.removeAll() - agentListeningPorts.removeAll() - logEntries = snapshot.logEntries.map { entry in - SidebarLogEntry( - message: entry.message, - level: SidebarLogLevel(rawValue: entry.level) ?? .info, - source: entry.source, - timestamp: Date(timeIntervalSince1970: entry.timestamp) - ) - } - progress = snapshot.progress.map { SidebarProgressState(value: $0.value, label: $0.label) } - gitBranch = snapshot.gitBranch.map { SidebarGitBranchState(branch: $0.branch, isDirty: $0.isDirty) } +enum SidebarPullRequestChecksStatus: String { + case pass + case fail + case pending +} - recomputeListeningPorts() +private func normalizedSidebarBranchName(_ branch: String?) -> String? { + guard let branch else { return nil } + let trimmed = branch.trimmingCharacters(in: .whitespacesAndNewlines) + return trimmed.isEmpty ? nil : trimmed +} - if let focusedOldPanelId = snapshot.focusedPanelId, - let focusedNewPanelId = oldToNewPanelIds[focusedOldPanelId], - panels[focusedNewPanelId] != nil { - focusPanel(focusedNewPanelId) - } else if let fallbackFocusedPanelId = focusedPanelId, panels[fallbackFocusedPanelId] != nil { - focusPanel(fallbackFocusedPanelId) - } else { - scheduleFocusReconcile() - } - } +struct SidebarPullRequestState: Equatable { + let number: Int + let label: String + let url: URL + let status: SidebarPullRequestStatus + let branch: String? + let checks: SidebarPullRequestChecksStatus? - private func sessionLayoutSnapshot(from node: ExternalTreeNode) -> SessionWorkspaceLayoutSnapshot { - switch node { - case .pane(let pane): - let panelIds = sessionPanelIDs(for: pane) - let selectedPanelId = pane.selectedTabId.flatMap(sessionPanelID(forExternalTabIDString:)) - return .pane( - SessionPaneLayoutSnapshot( - panelIds: panelIds, - selectedPanelId: selectedPanelId - ) - ) - case .split(let split): - return .split( - SessionSplitLayoutSnapshot( - orientation: split.orientation.lowercased() == "vertical" ? .vertical : .horizontal, - dividerPosition: split.dividerPosition, - first: sessionLayoutSnapshot(from: split.first), - second: sessionLayoutSnapshot(from: split.second) - ) - ) - } + init( + number: Int, + label: String, + url: URL, + status: SidebarPullRequestStatus, + branch: String? = nil, + checks: SidebarPullRequestChecksStatus? = nil + ) { + self.number = number + self.label = label + self.url = url + self.status = status + self.branch = normalizedSidebarBranchName(branch) + self.checks = checks } +} - private func sessionPanelIDs(for pane: ExternalPaneNode) -> [UUID] { - var panelIds: [UUID] = [] - var seen = Set() - for tab in pane.tabs { - guard let panelId = sessionPanelID(forExternalTabIDString: tab.id) else { continue } - if seen.insert(panelId).inserted { - panelIds.append(panelId) - } - } - return panelIds +enum SidebarBranchOrdering { + struct BranchEntry: Equatable { + let name: String + let isDirty: Bool } - private func sessionPanelID(forExternalTabIDString tabIDString: String) -> UUID? { - guard let tabUUID = UUID(uuidString: tabIDString) else { return nil } - for (surfaceId, panelId) in surfaceIdToPanelId { - guard let surfaceUUID = sessionSurfaceUUID(for: surfaceId) else { continue } - if surfaceUUID == tabUUID { - return panelId - } - } - return nil + struct BranchDirectoryEntry: Equatable { + let branch: String? + let isDirty: Bool + let directory: String? } - private func sessionSurfaceUUID(for surfaceId: TabID) -> UUID? { - struct EncodedSurfaceID: Decodable { - let id: UUID - } + fileprivate static func normalizedDirectory(_ text: String?) -> String? { + guard let text else { return nil } + let trimmed = text.trimmingCharacters(in: .whitespacesAndNewlines) + return trimmed.isEmpty ? nil : trimmed + } - guard let data = try? JSONEncoder().encode(surfaceId), - let decoded = try? JSONDecoder().decode(EncodedSurfaceID.self, from: data) else { + private static func relativePathFromTilde(_ directory: String) -> String? { + let normalized = normalizedDirectory(directory) + switch normalized { + case "~": + return "" + case let path? where path.hasPrefix("~/"): + return String(path.dropFirst(2)) + default: return nil } - return decoded.id } - private func sessionPanelSnapshot(panelId: UUID, includeScrollback: Bool) -> SessionPanelSnapshot? { - guard let panel = panels[panelId] else { return nil } + private static func commonHomeDirectoryPrefix(from absoluteDirectory: String) -> String? { + guard let normalized = normalizedDirectory(absoluteDirectory) else { return nil } + let standardized = NSString(string: normalized).standardizingPath + if standardized == "/root" || standardized.hasPrefix("/root/") { + return "/root" + } - let panelTitle = panelTitle(panelId: panelId) - let customTitle = panelCustomTitles[panelId] - let directory = panelDirectories[panelId] - let isPinned = pinnedPanelIds.contains(panelId) - let isManuallyUnread = manualUnreadPanelIds.contains(panelId) - let branchSnapshot = panelGitBranches[panelId].map { - SessionGitBranchSnapshot(branch: $0.branch, isDirty: $0.isDirty) + let components = NSString(string: standardized).pathComponents + if components.count >= 3, components[0] == "/", components[1] == "Users" { + return NSString.path(withComponents: Array(components.prefix(3))) } - let listeningPorts: [Int] - if remoteDetectedSurfaceIds.contains(panelId) || isRemoteTerminalSurface(panelId) { - listeningPorts = [] - } else { - listeningPorts = (surfaceListeningPorts[panelId] ?? []).sorted() + if components.count >= 3, components[0] == "/", components[1] == "home" { + return NSString.path(withComponents: Array(components.prefix(3))) + } + if components.count >= 4, components[0] == "/", components[1] == "var", components[2] == "home" { + return NSString.path(withComponents: Array(components.prefix(4))) } - let ttyName = surfaceTTYNames[panelId] - let terminalSnapshot: SessionTerminalPanelSnapshot? - let browserSnapshot: SessionBrowserPanelSnapshot? - let markdownSnapshot: SessionMarkdownPanelSnapshot? - switch panel.panelType { - case .terminal: - guard let terminalPanel = panel as? TerminalPanel else { return nil } - let shouldPersistScrollback = terminalPanel.shouldPersistScrollbackForSessionSnapshot() - let capturedScrollback = includeScrollback && shouldPersistScrollback - ? TerminalController.shared.readTerminalTextForSnapshot( - terminalPanel: terminalPanel, - includeScrollback: true, - lineLimit: SessionPersistencePolicy.maxScrollbackLinesPerTerminal - ) - : nil - let resolvedScrollback = terminalSnapshotScrollback( - panelId: panelId, - capturedScrollback: capturedScrollback, - includeScrollback: includeScrollback, - allowFallbackScrollback: shouldPersistScrollback - ) - terminalSnapshot = SessionTerminalPanelSnapshot( - workingDirectory: panelDirectories[panelId], - scrollback: resolvedScrollback - ) - browserSnapshot = nil - markdownSnapshot = nil - case .browser: - guard let browserPanel = panel as? BrowserPanel else { return nil } - terminalSnapshot = nil - let historySnapshot = browserPanel.sessionNavigationHistorySnapshot() - browserSnapshot = SessionBrowserPanelSnapshot( - urlString: browserPanel.preferredURLStringForOmnibar(), - profileID: browserPanel.profileID, - shouldRenderWebView: browserPanel.shouldRenderWebView, - pageZoom: Double(browserPanel.currentPageZoomFactor()), - developerToolsVisible: browserPanel.isDeveloperToolsVisible(), - backHistoryURLStrings: historySnapshot.backHistoryURLStrings, - forwardHistoryURLStrings: historySnapshot.forwardHistoryURLStrings - ) - markdownSnapshot = nil - case .markdown: - guard let markdownPanel = panel as? MarkdownPanel else { return nil } - terminalSnapshot = nil - browserSnapshot = nil - markdownSnapshot = SessionMarkdownPanelSnapshot(filePath: markdownPanel.filePath) - } - - return SessionPanelSnapshot( - id: panelId, - type: panel.panelType, - title: panelTitle, - customTitle: customTitle, - directory: directory, - isPinned: isPinned, - isManuallyUnread: isManuallyUnread, - gitBranch: branchSnapshot, - listeningPorts: listeningPorts, - ttyName: ttyName, - terminal: terminalSnapshot, - browser: browserSnapshot, - markdown: markdownSnapshot - ) + return nil } - nonisolated static func resolvedSnapshotTerminalScrollback( - capturedScrollback: String?, - fallbackScrollback: String?, - allowFallbackScrollback: Bool = true + private static func inferredHomeDirectory( + matchingTildeDirectory tildeDirectory: String, + absoluteDirectory: String ) -> String? { - if let captured = SessionPersistencePolicy.truncatedScrollback(capturedScrollback) { - return captured - } - guard allowFallbackScrollback else { return nil } - return SessionPersistencePolicy.truncatedScrollback(fallbackScrollback) - } - - private func terminalSnapshotScrollback( - panelId: UUID, - capturedScrollback: String?, - includeScrollback: Bool, - allowFallbackScrollback: Bool = true - ) -> String? { - guard includeScrollback else { return nil } - let fallback = allowFallbackScrollback ? restoredTerminalScrollbackByPanelId[panelId] : nil - let resolved = Self.resolvedSnapshotTerminalScrollback( - capturedScrollback: capturedScrollback, - fallbackScrollback: fallback, - allowFallbackScrollback: allowFallbackScrollback - ) - if let resolved { - restoredTerminalScrollbackByPanelId[panelId] = resolved + guard let relativePath = relativePathFromTilde(tildeDirectory), + let normalizedAbsolute = normalizedDirectory(absoluteDirectory) else { return nil } + let standardizedAbsolute = NSString(string: normalizedAbsolute).standardizingPath + let homeDirectory: String + if relativePath.isEmpty { + homeDirectory = standardizedAbsolute } else { - restoredTerminalScrollbackByPanelId.removeValue(forKey: panelId) - } - return resolved - } - - private func restoreSessionLayout(_ layout: SessionWorkspaceLayoutSnapshot) -> [SessionPaneRestoreEntry] { - guard let rootPaneId = bonsplitController.allPaneIds.first else { - return [] + let suffix = "/" + relativePath + guard standardizedAbsolute.hasSuffix(suffix) else { return nil } + homeDirectory = String(standardizedAbsolute.dropLast(suffix.count)) } - var leaves: [SessionPaneRestoreEntry] = [] - restoreSessionLayoutNode(layout, inPane: rootPaneId, leaves: &leaves) - return leaves + guard commonHomeDirectoryPrefix(from: homeDirectory) == homeDirectory else { return nil } + return homeDirectory } - private func restoreSessionLayoutNode( - _ node: SessionWorkspaceLayoutSnapshot, - inPane paneId: PaneID, - leaves: inout [SessionPaneRestoreEntry] - ) { - switch node { - case .pane(let pane): - leaves.append(SessionPaneRestoreEntry(paneId: paneId, snapshot: pane)) - case .split(let split): - var anchorPanelId = bonsplitController - .tabs(inPane: paneId) - .compactMap { panelIdFromSurfaceId($0.id) } - .first - - if anchorPanelId == nil { - anchorPanelId = newTerminalSurface(inPane: paneId, focus: false)?.id - } + fileprivate static func inferredRemoteHomeDirectory( + from directories: [String], + fallbackDirectory: String? + ) -> String? { + let candidates = directories + [fallbackDirectory].compactMap { $0 } + let tildeDirectories = candidates.compactMap { directory -> String? in + guard let normalized = normalizedDirectory(directory), + relativePathFromTilde(normalized) != nil else { return nil } + return normalized + } + let absoluteDirectories = candidates.compactMap { directory -> String? in + guard let normalized = normalizedDirectory(directory), normalized.hasPrefix("/") else { return nil } + return NSString(string: normalized).standardizingPath + } - guard let anchorPanelId, - let newSplitPanel = newTerminalSplit( - from: anchorPanelId, - orientation: split.orientation.splitOrientation, - insertFirst: false, - focus: false - ), - let secondPaneId = self.paneId(forPanelId: newSplitPanel.id) else { - leaves.append( - SessionPaneRestoreEntry( - paneId: paneId, - snapshot: SessionPaneLayoutSnapshot(panelIds: [], selectedPanelId: nil) + let inferredHomes = Set( + tildeDirectories.flatMap { tildeDirectory in + absoluteDirectories.compactMap { absoluteDirectory in + inferredHomeDirectory( + matchingTildeDirectory: tildeDirectory, + absoluteDirectory: absoluteDirectory ) - ) - return + } } + ) - restoreSessionLayoutNode(split.first, inPane: paneId, leaves: &leaves) - restoreSessionLayoutNode(split.second, inPane: secondPaneId, leaves: &leaves) + if inferredHomes.count == 1 { + return inferredHomes.first } - } - - private func restorePane( - _ paneId: PaneID, - snapshot: SessionPaneLayoutSnapshot, - panelSnapshotsById: [UUID: SessionPanelSnapshot], - oldToNewPanelIds: inout [UUID: UUID] - ) { - let existingPanelIds = bonsplitController - .tabs(inPane: paneId) - .compactMap { panelIdFromSurfaceId($0.id) } - let desiredOldPanelIds = snapshot.panelIds.filter { panelSnapshotsById[$0] != nil } - - var createdPanelIds: [UUID] = [] - for oldPanelId in desiredOldPanelIds { - guard let panelSnapshot = panelSnapshotsById[oldPanelId] else { continue } - guard let createdPanelId = createPanel(from: panelSnapshot, inPane: paneId) else { continue } - createdPanelIds.append(createdPanelId) - oldToNewPanelIds[oldPanelId] = createdPanelId + if !inferredHomes.isEmpty { + return nil } - guard !createdPanelIds.isEmpty else { return } - - for oldPanelId in existingPanelIds where !createdPanelIds.contains(oldPanelId) { - _ = closePanel(oldPanelId, force: true) - } + return absoluteDirectories.lazy.compactMap(commonHomeDirectoryPrefix(from:)).first + } - for (index, panelId) in createdPanelIds.enumerated() { - _ = reorderSurface(panelId: panelId, toIndex: index) + private static func expandedTildePath( + _ directory: String, + homeDirectoryForTildeExpansion: String? + ) -> String { + guard let relativePath = relativePathFromTilde(directory), + let homeDirectory = normalizedDirectory(homeDirectoryForTildeExpansion) else { + return directory } - - let selectedPanelId: UUID? = { - if let selectedOldId = snapshot.selectedPanelId { - return oldToNewPanelIds[selectedOldId] - } - return createdPanelIds.first - }() - - if let selectedPanelId, - let selectedTabId = surfaceIdFromPanelId(selectedPanelId) { - bonsplitController.focusPane(paneId) - bonsplitController.selectTab(selectedTabId) + if relativePath.isEmpty { + return homeDirectory } + return NSString(string: homeDirectory).appendingPathComponent(relativePath) } - private func createPanel(from snapshot: SessionPanelSnapshot, inPane paneId: PaneID) -> UUID? { - switch snapshot.type { - case .terminal: - let workingDirectory = snapshot.terminal?.workingDirectory ?? snapshot.directory ?? currentDirectory - let replayEnvironment = SessionScrollbackReplayStore.replayEnvironment( - for: snapshot.terminal?.scrollback - ) - guard let terminalPanel = newTerminalSurface( - inPane: paneId, - focus: false, - workingDirectory: workingDirectory, - startupEnvironment: replayEnvironment - ) else { - return nil - } - let fallbackScrollback = SessionPersistencePolicy.truncatedScrollback(snapshot.terminal?.scrollback) - if let fallbackScrollback { - restoredTerminalScrollbackByPanelId[terminalPanel.id] = fallbackScrollback - } else { - restoredTerminalScrollbackByPanelId.removeValue(forKey: terminalPanel.id) - } - applySessionPanelMetadata(snapshot, toPanelId: terminalPanel.id) - return terminalPanel.id - case .browser: - guard let browserPanel = newBrowserSurface( - inPane: paneId, - url: nil, - focus: false, - preferredProfileID: snapshot.browser?.profileID - ) else { - return nil - } - applySessionPanelMetadata(snapshot, toPanelId: browserPanel.id) - return browserPanel.id - case .markdown: - guard let filePath = snapshot.markdown?.filePath, - let markdownPanel = newMarkdownSurface( - inPane: paneId, - filePath: filePath, - focus: false - ) else { - return nil - } - applySessionPanelMetadata(snapshot, toPanelId: markdownPanel.id) - return markdownPanel.id - } + fileprivate static func canonicalDirectoryKey( + _ directory: String?, + homeDirectoryForTildeExpansion: String? + ) -> String? { + guard let directory = normalizedDirectory(directory) else { return nil } + let expanded = expandedTildePath( + directory, + homeDirectoryForTildeExpansion: homeDirectoryForTildeExpansion + ) + let standardized = NSString(string: expanded).standardizingPath + let cleaned = standardized.trimmingCharacters(in: .whitespacesAndNewlines) + return cleaned.isEmpty ? nil : cleaned } - private func applySessionPanelMetadata(_ snapshot: SessionPanelSnapshot, toPanelId panelId: UUID) { - if let title = snapshot.title?.trimmingCharacters(in: .whitespacesAndNewlines), !title.isEmpty { - panelTitles[panelId] = title - } - - setPanelCustomTitle(panelId: panelId, title: snapshot.customTitle) - setPanelPinned(panelId: panelId, pinned: snapshot.isPinned) - - if snapshot.isManuallyUnread { - markPanelUnread(panelId) - } else { - clearManualUnread(panelId: panelId) - } + private static func preferredDisplayedDirectory( + existing: String?, + replacement: String?, + homeDirectoryForTildeExpansion: String? + ) -> String? { + guard let replacement = normalizedDirectory(replacement) else { return existing } + guard let existing = normalizedDirectory(existing) else { return replacement } - if let directory = snapshot.directory?.trimmingCharacters(in: .whitespacesAndNewlines), !directory.isEmpty { - updatePanelDirectory(panelId: panelId, directory: directory) + let existingUsesTilde = relativePathFromTilde(existing) != nil + let replacementUsesTilde = relativePathFromTilde(replacement) != nil + if existingUsesTilde != replacementUsesTilde { + return replacementUsesTilde ? existing : replacement } - if let branch = snapshot.gitBranch { - panelGitBranches[panelId] = SidebarGitBranchState(branch: branch.branch, isDirty: branch.isDirty) - } else { - panelGitBranches.removeValue(forKey: panelId) + if canonicalDirectoryKey(existing, homeDirectoryForTildeExpansion: homeDirectoryForTildeExpansion) + == canonicalDirectoryKey( + replacement, + homeDirectoryForTildeExpansion: homeDirectoryForTildeExpansion + ) { + return existing } - surfaceListeningPorts[panelId] = Array(Set(snapshot.listeningPorts)).sorted() + return replacement + } - if let ttyName = snapshot.ttyName?.trimmingCharacters(in: .whitespacesAndNewlines), !ttyName.isEmpty { - surfaceTTYNames[panelId] = ttyName - } else { - surfaceTTYNames.removeValue(forKey: panelId) + static func orderedPaneIds(tree: ExternalTreeNode) -> [String] { + switch tree { + case .pane(let pane): + return [pane.id] + case .split(let split): + // Bonsplit split order matches visual order for both horizontal and vertical splits. + return orderedPaneIds(tree: split.first) + orderedPaneIds(tree: split.second) } - syncRemotePortScanTTYs() - - if let browserSnapshot = snapshot.browser, - let browserPanel = browserPanel(for: panelId) { - let pageZoom = CGFloat(max(0.25, min(5.0, browserSnapshot.pageZoom))) - if pageZoom.isFinite { - _ = browserPanel.setPageZoomFactor(pageZoom) - } + } - browserPanel.restoreSessionSnapshot(browserSnapshot) + static func orderedPanelIds( + tree: ExternalTreeNode, + paneTabs: [String: [UUID]], + fallbackPanelIds: [UUID] + ) -> [UUID] { + var ordered: [UUID] = [] + var seen: Set = [] - if browserSnapshot.developerToolsVisible { - _ = browserPanel.showDeveloperTools() - browserPanel.requestDeveloperToolsRefreshAfterNextAttach(reason: "session_restore") - } else { - _ = browserPanel.hideDeveloperTools() + for paneId in orderedPaneIds(tree: tree) { + for panelId in paneTabs[paneId] ?? [] { + if seen.insert(panelId).inserted { + ordered.append(panelId) + } } } - } - private func applySessionDividerPositions( - snapshotNode: SessionWorkspaceLayoutSnapshot, - liveNode: ExternalTreeNode - ) { - switch (snapshotNode, liveNode) { - case (.split(let snapshotSplit), .split(let liveSplit)): - if let splitID = UUID(uuidString: liveSplit.id) { - _ = bonsplitController.setDividerPosition( - CGFloat(snapshotSplit.dividerPosition), - forSplit: splitID, - fromExternal: true - ) + for panelId in fallbackPanelIds { + if seen.insert(panelId).inserted { + ordered.append(panelId) } - applySessionDividerPositions(snapshotNode: snapshotSplit.first, liveNode: liveSplit.first) - applySessionDividerPositions(snapshotNode: snapshotSplit.second, liveNode: liveSplit.second) - default: - return } - } -} - -// MARK: - programa.json custom layout -extension Workspace { + return ordered + } - func applyCustomLayout(_ layout: ProgramaLayoutNode, baseCwd: String) { - guard let rootPaneId = bonsplitController.allPaneIds.first else { return } + static func orderedUniqueBranches( + orderedPanelIds: [UUID], + panelBranches: [UUID: SidebarGitBranchState], + fallbackBranch: SidebarGitBranchState? + ) -> [BranchEntry] { + var orderedNames: [String] = [] + var branchDirty: [String: Bool] = [:] - var leaves: [(paneId: PaneID, surfaces: [ProgramaSurfaceDefinition])] = [] - buildCustomLayoutTree(layout, inPane: rootPaneId, leaves: &leaves) + for panelId in orderedPanelIds { + guard let state = panelBranches[panelId] else { continue } + let name = state.branch.trimmingCharacters(in: .whitespacesAndNewlines) + guard !name.isEmpty else { continue } - // First leaf reuses the initial terminal created by addWorkspace; - // subsequent leaves were created via newTerminalSplit which also seeds - // a placeholder terminal. - var focusPanelId: UUID? - for leaf in leaves { - populateCustomPane(leaf.paneId, surfaces: leaf.surfaces, baseCwd: baseCwd, focusPanelId: &focusPanelId) + if branchDirty[name] == nil { + orderedNames.append(name) + branchDirty[name] = state.isDirty + } else if state.isDirty { + branchDirty[name] = true + } } - let liveRoot = bonsplitController.treeSnapshot() - applyCustomDividerPositions(configNode: layout, liveNode: liveRoot) + if orderedNames.isEmpty, let fallbackBranch { + let name = fallbackBranch.branch.trimmingCharacters(in: .whitespacesAndNewlines) + if !name.isEmpty { + return [BranchEntry(name: name, isDirty: fallbackBranch.isDirty)] + } + } - if let focusPanelId { - focusPanel(focusPanelId) + return orderedNames.map { name in + BranchEntry(name: name, isDirty: branchDirty[name] ?? false) } } - private func buildCustomLayoutTree( - _ node: ProgramaLayoutNode, - inPane paneId: PaneID, - leaves: inout [(paneId: PaneID, surfaces: [ProgramaSurfaceDefinition])] - ) { - switch node { - case .pane(let pane): - leaves.append((paneId: paneId, surfaces: pane.surfaces)) - - case .split(let split): - guard split.children.count == 2 else { - NSLog("[ProgramaConfig] split node requires exactly 2 children, got %d", split.children.count) - leaves.append((paneId: paneId, surfaces: [])) - return + 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 } + } - var anchorPanelId = bonsplitController - .tabs(inPane: paneId) - .compactMap { panelIdFromSurfaceId($0.id) } - .first - - if anchorPanelId == nil { - anchorPanelId = newTerminalSurface(inPane: paneId, focus: false)?.id + func checksPriority(_ checks: SidebarPullRequestChecksStatus?) -> Int { + switch checks { + case .fail: return 3 + case .pending: return 2 + case .pass: return 1 + case nil: return 0 } + } - guard let anchorPanelId, - let newSplitPanel = newTerminalSplit( - from: anchorPanelId, - orientation: split.splitOrientation, - insertFirst: false, - focus: false - ), - let secondPaneId = self.paneId(forPanelId: newSplitPanel.id) else { - leaves.append((paneId: paneId, surfaces: [])) - return + func normalizedReviewURLKey(for url: URL) -> String { + guard var components = URLComponents(url: url, resolvingAgainstBaseURL: false) else { + return url.absoluteString } - buildCustomLayoutTree(split.children[0], inPane: paneId, leaves: &leaves) - buildCustomLayoutTree(split.children[1], inPane: secondPaneId, leaves: &leaves) + // Treat URL variants that differ only by query/fragment as the same review item. + 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)" } - } - - private func populateCustomPane( - _ paneId: PaneID, - surfaces: [ProgramaSurfaceDefinition], - baseCwd: String, - focusPanelId: inout UUID? - ) { - let existingPanelIds = bonsplitController - .tabs(inPane: paneId) - .compactMap { panelIdFromSurfaceId($0.id) } - - guard !surfaces.isEmpty else { return } - let firstSurface = surfaces[0] - if let placeholderPanelId = existingPanelIds.first { - configureExistingSurface( - panelId: placeholderPanelId, - inPane: paneId, - surface: firstSurface, - baseCwd: baseCwd, - focusPanelId: &focusPanelId - ) + func reviewKey(for state: SidebarPullRequestState) -> String { + "\(state.label.lowercased())#\(state.number)|\(normalizedReviewURLKey(for: state.url))" } - for surfaceIndex in 1.. statusPriority(existing.status) { + pullRequestsByKey[key] = state + } else if state.status == existing.status, + checksPriority(state.checks) > checksPriority(existing.checks) { + pullRequestsByKey[key] = state } } - } - - private func createNewSurface( - inPane paneId: PaneID, - surface: ProgramaSurfaceDefinition, - baseCwd: String, - focusPanelId: inout UUID? - ) { - switch surface.type { - case .terminal: - let resolvedCwd = ProgramaConfigStore.resolveCwd(surface.cwd, relativeTo: baseCwd) - if let panel = newTerminalSurface( - inPane: paneId, - focus: false, - workingDirectory: resolvedCwd, - startupEnvironment: surface.env ?? [:] - ) { - if let name = surface.name { setPanelCustomTitle(panelId: panel.id, title: name) } - if surface.focus == true { focusPanelId = panel.id } - if let command = surface.command { sendInputWhenReady(command + "\n", to: panel) } - } - case .browser: - let url = surface.url.flatMap { URL(string: $0) } - if let panel = newBrowserSurface(inPane: paneId, url: url, focus: false) { - if let name = surface.name { setPanelCustomTitle(panelId: panel.id, title: name) } - if surface.focus == true { focusPanelId = panel.id } - } + if orderedKeys.isEmpty, let fallbackPullRequest { + return [fallbackPullRequest] } + + return orderedKeys.compactMap { pullRequestsByKey[$0] } } - private func applyCustomDividerPositions( - configNode: ProgramaLayoutNode, - liveNode: ExternalTreeNode - ) { - switch (configNode, liveNode) { - case (.split(let configSplit), .split(let liveSplit)): - if let splitID = UUID(uuidString: liveSplit.id) { - _ = bonsplitController.setDividerPosition( - CGFloat(configSplit.clampedSplitPosition), - forSplit: splitID, - fromExternal: true - ) - } - if configSplit.children.count == 2 { - applyCustomDividerPositions(configNode: configSplit.children[0], liveNode: liveSplit.first) - applyCustomDividerPositions(configNode: configSplit.children[1], liveNode: liveSplit.second) - } - default: - break + static func orderedUniqueBranchDirectoryEntries( + orderedPanelIds: [UUID], + panelBranches: [UUID: SidebarGitBranchState], + panelDirectories: [UUID: String], + defaultDirectory: String?, + homeDirectoryForTildeExpansion: String?, + fallbackBranch: SidebarGitBranchState? + ) -> [BranchDirectoryEntry] { + struct EntryKey: Hashable { + let directory: String? + let branch: String? } - } - private func sendInputWhenReady(_ text: String, to panel: TerminalPanel) { - if panel.surface.surface != nil { - panel.sendInput(text) - return + struct MutableEntry { + var branch: String? + var isDirty: Bool + var directory: String? } - var resolved = false - var observer: NSObjectProtocol? + let normalized = normalizedDirectory + 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 - observer = NotificationCenter.default.addObserver( - forName: .terminalSurfaceDidBecomeReady, - object: panel.surface, - queue: .main - ) { [weak panel] _ in - guard !resolved, let panel else { return } - resolved = true - if let observer { NotificationCenter.default.removeObserver(observer) } - panel.sendInput(text) + 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]) + guard branch != nil || directory != nil else { continue } + + let panelDirty = panelBranch != nil + ? (panelBranches[panelId]?.isDirty ?? false) + : defaultBranchDirty + + let key: EntryKey + if let directoryKey = canonicalDirectoryKey( + directory, + homeDirectoryForTildeExpansion: homeDirectoryForTildeExpansion + ) { + // Keep one line per directory and allow the latest branch state to overwrite. + key = EntryKey(directory: directoryKey, branch: nil) + } else { + key = EntryKey(directory: nil, branch: branch) + } + + guard key.directory != nil || key.branch != nil else { continue } + + if var existing = entries[key] { + if key.directory != nil { + if let branch { + existing.branch = branch + existing.isDirty = panelDirty + } else if existing.branch == nil { + existing.isDirty = panelDirty + } + existing.directory = preferredDisplayedDirectory( + existing: existing.directory, + replacement: directory, + homeDirectoryForTildeExpansion: homeDirectoryForTildeExpansion + ) + entries[key] = existing + } else if panelDirty { + existing.isDirty = true + entries[key] = existing + } + } else { + order.append(key) + entries[key] = MutableEntry(branch: branch, isDirty: panelDirty, directory: directory) + } + } + + if order.isEmpty { + let fallbackDirectory = normalized(defaultDirectory) + if normalizedFallbackBranch != nil || fallbackDirectory != nil { + return [ + BranchDirectoryEntry( + branch: normalizedFallbackBranch, + isDirty: fallbackBranch?.isDirty ?? false, + directory: fallbackDirectory + ) + ] + } } - DispatchQueue.main.asyncAfter(deadline: .now() + 3.0) { - guard !resolved else { return } - resolved = true - if let observer { NotificationCenter.default.removeObserver(observer) } - NSLog("[ProgramaConfig] surface not ready after 3s, dropping command (%d chars)", text.count) + 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 SidebarLogLevel: String { - case info - case progress - case success - case warning - case error +struct ClosedBrowserPanelRestoreSnapshot { + let workspaceId: UUID + let url: URL? + let profileID: UUID? + let originalPaneId: UUID + let originalTabIndex: Int + let fallbackSplitOrientation: SplitOrientation? + let fallbackSplitInsertFirst: Bool + let fallbackAnchorPaneId: UUID? } -struct SidebarLogEntry: Equatable { - let message: String - let level: SidebarLogLevel - let source: String? - let timestamp: Date -} +/// 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 customDescription: String? + @Published var isPinned: Bool = false + @Published var customColor: String? // hex string, e.g. "#C0392B" + @Published var currentDirectory: String + private(set) var preferredBrowserProfileID: UUID? -struct SidebarProgressState: Equatable { - let value: Double - let label: String? -} + /// Ordinal for PROGRAMA_PORT range assignment (monotonically increasing per app session) + var portOrdinal: Int = 0 -struct SidebarGitBranchState: Equatable { - let branch: String - let isDirty: Bool -} + /// The bonsplit controller managing the split panes for this workspace + let bonsplitController: BonsplitController -private struct SidebarPanelObservationState: Equatable { - let panelIds: [UUID] + /// Mapping from bonsplit TabID to our Panel instances + @Published var panels: [UUID: any Panel] = [:] - init(panels: [UUID: any Panel]) { - panelIds = panels.keys.sorted { $0.uuidString < $1.uuidString } - } -} + /// Subscriptions for panel updates (e.g., browser title changes) + var panelSubscriptions: [UUID: AnyCancellable] = [:] -enum WorkspaceRemoteConnectionState: String { - case disconnected - case connecting - case connected - case error -} + /// When true, suppresses auto-creation in didSplitPane (programmatic splits handle their own panels) + var isProgrammaticSplit = false + var debugStressPreloadSelectionDepth = 0 -enum WorkspaceRemoteDaemonState: String { - case unavailable - case bootstrapping - case ready - case error -} + /// Last terminal panel used as an inheritance source (typically last focused terminal). + var lastTerminalConfigInheritancePanelId: UUID? + /// Last known terminal font points from inheritance sources. Used as fallback when + /// no live terminal surface is currently available. + var lastTerminalConfigInheritanceFontPoints: Float? + /// Per-panel inherited zoom lineage. Descendants reuse this root value unless + /// a panel is explicitly re-zoomed by the user. + var terminalInheritanceFontPointsByPanelId: [UUID: Float] = [:] -struct WorkspaceRemoteDaemonStatus: Equatable { - var state: WorkspaceRemoteDaemonState = .unavailable - var detail: String? - var version: String? - var name: String? - var capabilities: [String] = [] - var remotePath: String? + /// Callback used by TabManager to capture recently closed browser panels for Cmd+Shift+T restore. + var onClosedBrowserPanel: ((ClosedBrowserPanelRestoreSnapshot) -> Void)? + weak var owningTabManager: TabManager? - func payload() -> [String: Any] { - [ - "state": state.rawValue, - "detail": detail ?? NSNull(), - "version": version ?? NSNull(), - "name": name ?? NSNull(), - "capabilities": capabilities, - "remote_path": remotePath ?? NSNull(), - ] + + // Closing tabs mutates split layout immediately; terminal views handle their own AppKit + // layout/size synchronization. + + /// The currently focused pane's panel ID + var focusedPanelId: UUID? { + guard let paneId = bonsplitController.focusedPaneId, + let tab = bonsplitController.selectedTab(inPane: paneId) else { + return nil + } + return panelIdFromSurfaceId(tab.id) } -} -struct WorkspaceRemoteConfiguration: Equatable { - let destination: String - let port: Int? - let identityFile: String? - let sshOptions: [String] - let localProxyPort: Int? - let relayPort: Int? - let relayID: String? - let relayToken: String? - let localSocketPath: String? - let terminalStartupCommand: String? - let foregroundAuthToken: String? + /// The currently focused terminal panel (if any) + var focusedTerminalPanel: TerminalPanel? { + guard let panelId = focusedPanelId, + let panel = panels[panelId] as? TerminalPanel else { + return nil + } + return panel + } - init( - destination: String, - port: Int?, - identityFile: String?, - sshOptions: [String], - localProxyPort: Int?, - relayPort: Int?, - relayID: String?, - relayToken: String?, - localSocketPath: String?, - terminalStartupCommand: String?, - foregroundAuthToken: String? = nil - ) { - self.destination = destination - self.port = port - self.identityFile = identityFile - self.sshOptions = sshOptions - self.localProxyPort = localProxyPort - self.relayPort = relayPort - self.relayID = relayID - self.relayToken = relayToken - self.localSocketPath = localSocketPath - self.terminalStartupCommand = terminalStartupCommand - self.foregroundAuthToken = foregroundAuthToken - } - - var displayTarget: String { - guard let port else { return destination } - return "\(destination):\(port)" + func effectiveSelectedPanelId(inPane paneId: PaneID) -> UUID? { + bonsplitController.selectedTab(inPane: paneId).flatMap { panelIdFromSurfaceId($0.id) } } - var proxyBrokerTransportKey: String { - let normalizedDestination = destination.trimmingCharacters(in: .whitespacesAndNewlines) - let normalizedPort = port.map(String.init) ?? "" - let normalizedIdentity = identityFile?.trimmingCharacters(in: .whitespacesAndNewlines) ?? "" - let normalizedLocalProxyPort = localProxyPort.map(String.init) ?? "" - let normalizedOptions = Self.proxyBrokerSSHOptions(sshOptions).joined(separator: "\u{1f}") - return [normalizedDestination, normalizedPort, normalizedIdentity, normalizedOptions, normalizedLocalProxyPort] - .joined(separator: "\u{1e}") + enum FocusPanelTrigger { + case standard + case terminalFirstResponder } - private static func proxyBrokerSSHOptions(_ options: [String]) -> [String] { - options.compactMap { option in - let trimmed = option.trimmingCharacters(in: .whitespacesAndNewlines) - guard !trimmed.isEmpty else { return nil } - return trimmed - }.filter { option in - proxyBrokerSSHOptionKey(option) != "controlpath" - } - } + /// Published directory for each panel + @Published var panelDirectories: [UUID: String] = [:] + @Published var panelTitles: [UUID: String] = [:] + @Published var panelCustomTitles: [UUID: String] = [:] + @Published var pinnedPanelIds: Set = [] + @Published var manualUnreadPanelIds: Set = [] + @Published var tmuxLayoutSnapshot: LayoutSnapshot? + @Published private(set) var tmuxWorkspaceFlashPanelId: UUID? + @Published private(set) var tmuxWorkspaceFlashReason: WorkspaceAttentionFlashReason? + @Published private(set) var tmuxWorkspaceFlashToken: UInt64 = 0 + var manualUnreadMarkedAt: [UUID: Date] = [:] + nonisolated private static let manualUnreadFocusGraceInterval: TimeInterval = 0.2 + nonisolated 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]] = [:] + var agentListeningPorts: [Int] = [] + @Published var remoteConfiguration: WorkspaceRemoteConfiguration? + @Published var remoteConnectionState: WorkspaceRemoteConnectionState = .disconnected + @Published var remoteConnectionDetail: String? + @Published var remoteDaemonStatus: WorkspaceRemoteDaemonStatus = WorkspaceRemoteDaemonStatus() + @Published var remoteDetectedPorts: [Int] = [] + @Published var remoteForwardedPorts: [Int] = [] + @Published var remotePortConflicts: [Int] = [] + @Published var remoteProxyEndpoint: BrowserProxyEndpoint? + @Published var remoteHeartbeatCount: Int = 0 + @Published var remoteLastHeartbeatAt: Date? + @Published var listeningPorts: [Int] = [] + @Published private(set) var activeRemoteTerminalSessionCount: Int = 0 + var surfaceTTYNames: [UUID: String] = [:] + var remoteSessionController: WorkspaceRemoteSessionController? + var pendingRemoteForegroundAuthToken: String? + var activeRemoteSessionControllerID: UUID? + var remoteLastErrorFingerprint: String? + var remoteLastDaemonErrorFingerprint: String? + var remoteLastPortConflictFingerprint: String? + var remoteDetectedSurfaceIds: Set = [] + var activeRemoteTerminalSurfaceIds: Set = [] + var pendingRemoteTerminalChildExitSurfaceIds: Set = [] + + static let remoteErrorStatusKey = "remote.error" + static let remotePortConflictStatusKey = "remote.port_conflicts" + static let remoteNotificationCooldown: TimeInterval = 5 * 60 + static let sshControlMasterCleanupQueue = DispatchQueue( + label: "com.cmux.remote-ssh.control-master-cleanup", + qos: .utility + ) + static let remoteHeartbeatDateFormatter: ISO8601DateFormatter = { + let formatter = ISO8601DateFormatter() + formatter.formatOptions = [.withInternetDateTime, .withFractionalSeconds] + return formatter + }() + nonisolated(unsafe) static var runSSHControlMasterCommandOverrideForTesting: (([String]) -> Void)? + var panelShellActivityStates: [UUID: PanelShellActivityState] = [:] + /// PIDs associated with agent status entries (e.g. claude_code), keyed by status key. + /// Used for stale-session detection: if the PID is dead, the status entry is cleared. + var agentPIDs: [String: pid_t] = [:] + var restoredTerminalScrollbackByPanelId: [UUID: String] = [:] - private static func proxyBrokerSSHOptionKey(_ option: String) -> String? { - let trimmed = option.trimmingCharacters(in: .whitespacesAndNewlines) - guard !trimmed.isEmpty else { return nil } - return trimmed - .split(whereSeparator: { $0 == "=" || $0.isWhitespace }) - .first - .map(String.init)? - .lowercased() + func sidebarObservationSignal( + _ publisher: Published.Publisher + ) -> AnyPublisher { + publisher + .dropFirst() + .removeDuplicates() + .map { _ in () } + .eraseToAnyPublisher() } -} -enum SidebarPullRequestStatus: String { - case open - case merged - case closed -} + lazy var sidebarImmediateObservationPublisher: AnyPublisher = { + let publishers: [AnyPublisher] = [ + sidebarObservationSignal($title), + sidebarObservationSignal($customDescription), + sidebarObservationSignal($isPinned), + sidebarObservationSignal($customColor), + ] -enum SidebarPullRequestChecksStatus: String { - case pass - case fail - case pending -} + return Publishers.MergeMany(publishers).eraseToAnyPublisher() + }() -private func normalizedSidebarBranchName(_ branch: String?) -> String? { - guard let branch else { return nil } - let trimmed = branch.trimmingCharacters(in: .whitespacesAndNewlines) - return trimmed.isEmpty ? nil : trimmed -} + lazy var sidebarObservationPublisher: AnyPublisher = { + let publishers: [AnyPublisher] = [ + sidebarObservationSignal($currentDirectory), + $panels + .map(SidebarPanelObservationState.init) + .dropFirst() + .removeDuplicates() + .map { _ in () } + .eraseToAnyPublisher(), + sidebarObservationSignal($panelDirectories), + sidebarObservationSignal($statusEntries), + sidebarObservationSignal($metadataBlocks), + sidebarObservationSignal($logEntries), + sidebarObservationSignal($progress), + sidebarObservationSignal($gitBranch), + sidebarObservationSignal($panelGitBranches), + sidebarObservationSignal($pullRequest), + sidebarObservationSignal($panelPullRequests), + sidebarObservationSignal($remoteConfiguration), + sidebarObservationSignal($remoteConnectionState), + sidebarObservationSignal($remoteConnectionDetail), + sidebarObservationSignal($activeRemoteTerminalSessionCount), + sidebarObservationSignal($listeningPorts), + ] -struct SidebarPullRequestState: Equatable { - let number: Int - let label: String - let url: URL - let status: SidebarPullRequestStatus - let branch: String? - let checks: SidebarPullRequestChecksStatus? + return Publishers.MergeMany(publishers).eraseToAnyPublisher() + }() - init( - number: Int, - label: String, - url: URL, - status: SidebarPullRequestStatus, - branch: String? = nil, - checks: SidebarPullRequestChecksStatus? = nil - ) { - self.number = number - self.label = label - self.url = url - self.status = status - self.branch = normalizedSidebarBranchName(branch) - self.checks = checks + static func isProxyOnlyRemoteError(_ detail: String) -> Bool { + let lowered = detail.lowercased() + return lowered.contains("remote proxy") + || lowered.contains("proxy_unavailable") + || lowered.contains("local daemon proxy") + || lowered.contains("proxy failure") + || lowered.contains("daemon transport") } -} -enum SidebarBranchOrdering { - struct BranchEntry: Equatable { - let name: String - let isDirty: Bool + var preservesSSHTerminalConnection: Bool { + activeRemoteTerminalSessionCount > 0 + && remoteConfiguration?.terminalStartupCommand?.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty == false } - struct BranchDirectoryEntry: Equatable { - let branch: String? - let isDirty: Bool - let directory: String? + var hasProxyOnlyRemoteSidebarError: Bool { + guard let entry = statusEntries[Self.remoteErrorStatusKey]?.value else { return false } + return entry.lowercased().contains("remote proxy unavailable") } - fileprivate static func normalizedDirectory(_ text: String?) -> String? { - guard let text else { return nil } - let trimmed = text.trimmingCharacters(in: .whitespacesAndNewlines) - return trimmed.isEmpty ? nil : trimmed + func remoteNotificationCooldownKey(target: String) -> String? { + let rawTarget = (remoteConfiguration?.destination ?? target) + .trimmingCharacters(in: .whitespacesAndNewlines) + guard !rawTarget.isEmpty else { return nil } + let normalizedHost = rawTarget + .split(separator: "@", maxSplits: 1, omittingEmptySubsequences: false) + .last + .map(String.init)? + .trimmingCharacters(in: .whitespacesAndNewlines) + .lowercased() + guard let normalizedHost, !normalizedHost.isEmpty else { return nil } + return "remote-host:\(normalizedHost)" } - private static func relativePathFromTilde(_ directory: String) -> String? { - let normalized = normalizedDirectory(directory) - switch normalized { - case "~": - return "" - case let path? where path.hasPrefix("~/"): - return String(path.dropFirst(2)) - default: - return nil - } + var focusedSurfaceId: UUID? { focusedPanelId } + var surfaceDirectories: [UUID: String] { + get { panelDirectories } + set { panelDirectories = newValue } } - private static func commonHomeDirectoryPrefix(from absoluteDirectory: String) -> String? { - guard let normalized = normalizedDirectory(absoluteDirectory) else { return nil } - let standardized = NSString(string: normalized).standardizingPath - if standardized == "/root" || standardized.hasPrefix("/root/") { - return "/root" - } + var processTitle: String - let components = NSString(string: standardized).pathComponents - if components.count >= 3, components[0] == "/", components[1] == "Users" { - return NSString.path(withComponents: Array(components.prefix(3))) - } - if components.count >= 3, components[0] == "/", components[1] == "home" { - return NSString.path(withComponents: Array(components.prefix(3))) - } - if components.count >= 4, components[0] == "/", components[1] == "var", components[2] == "home" { - return NSString.path(withComponents: Array(components.prefix(4))) - } + enum SurfaceKind { + static let terminal = "terminal" + static let browser = "browser" + static let markdown = "markdown" + } - return nil + enum PanelShellActivityState: String { + case unknown + case promptIdle + case commandRunning } - private static func inferredHomeDirectory( - matchingTildeDirectory tildeDirectory: String, - absoluteDirectory: String - ) -> String? { - guard let relativePath = relativePathFromTilde(tildeDirectory), - let normalizedAbsolute = normalizedDirectory(absoluteDirectory) else { return nil } - let standardizedAbsolute = NSString(string: normalizedAbsolute).standardizingPath - let homeDirectory: String - if relativePath.isEmpty { - homeDirectory = standardizedAbsolute - } else { - let suffix = "/" + relativePath - guard standardizedAbsolute.hasSuffix(suffix) else { return nil } - homeDirectory = String(standardizedAbsolute.dropLast(suffix.count)) + nonisolated static func resolveCloseConfirmation( + shellActivityState: PanelShellActivityState?, + fallbackNeedsConfirmClose: Bool + ) -> Bool { + switch shellActivityState ?? .unknown { + case .promptIdle: + return false + case .commandRunning: + return true + case .unknown: + return fallbackNeedsConfirmClose } - - guard commonHomeDirectoryPrefix(from: homeDirectory) == homeDirectory else { return nil } - return homeDirectory } - fileprivate static func inferredRemoteHomeDirectory( - from directories: [String], - fallbackDirectory: String? - ) -> String? { - let candidates = directories + [fallbackDirectory].compactMap { $0 } - let tildeDirectories = candidates.compactMap { directory -> String? in - guard let normalized = normalizedDirectory(directory), - relativePathFromTilde(normalized) != nil else { return nil } - return normalized - } - let absoluteDirectories = candidates.compactMap { directory -> String? in - guard let normalized = normalizedDirectory(directory), normalized.hasPrefix("/") else { return nil } - return NSString(string: normalized).standardizingPath - } + // MARK: - Initialization - let inferredHomes = Set( - tildeDirectories.flatMap { tildeDirectory in - absoluteDirectories.compactMap { absoluteDirectory in - inferredHomeDirectory( - matchingTildeDirectory: tildeDirectory, - absoluteDirectory: absoluteDirectory - ) - } - } + static func currentSplitButtonTooltips() -> BonsplitConfiguration.SplitButtonTooltips { + BonsplitConfiguration.SplitButtonTooltips( + 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") ) - - if inferredHomes.count == 1 { - return inferredHomes.first - } - if !inferredHomes.isEmpty { - return nil - } - - return absoluteDirectories.lazy.compactMap(commonHomeDirectoryPrefix(from:)).first } - private static func expandedTildePath( - _ directory: String, - homeDirectoryForTildeExpansion: String? - ) -> String { - guard let relativePath = relativePathFromTilde(directory), - let homeDirectory = normalizedDirectory(homeDirectoryForTildeExpansion) else { - return directory - } - if relativePath.isEmpty { - return homeDirectory - } - return NSString(string: homeDirectory).appendingPathComponent(relativePath) + static func bonsplitAppearance(from config: GhosttyConfig) -> BonsplitConfiguration.Appearance { + bonsplitAppearance( + from: config.backgroundColor, + backgroundOpacity: config.backgroundOpacity + ) } - fileprivate static func canonicalDirectoryKey( - _ directory: String?, - homeDirectoryForTildeExpansion: String? - ) -> String? { - guard let directory = normalizedDirectory(directory) else { return nil } - let expanded = expandedTildePath( - directory, - homeDirectoryForTildeExpansion: homeDirectoryForTildeExpansion + static func bonsplitChromeHex(backgroundColor: NSColor, backgroundOpacity: Double) -> String { + let themedColor = GhosttyBackgroundTheme.color( + backgroundColor: backgroundColor, + opacity: backgroundOpacity ) - let standardized = NSString(string: expanded).standardizingPath - let cleaned = standardized.trimmingCharacters(in: .whitespacesAndNewlines) - return cleaned.isEmpty ? nil : cleaned + let includeAlpha = themedColor.alphaComponent < 0.999 + return themedColor.hexString(includeAlpha: includeAlpha) } - private static func preferredDisplayedDirectory( - existing: String?, - replacement: String?, - homeDirectoryForTildeExpansion: String? - ) -> String? { - guard let replacement = normalizedDirectory(replacement) else { return existing } - guard let existing = normalizedDirectory(existing) else { return replacement } - - let existingUsesTilde = relativePathFromTilde(existing) != nil - let replacementUsesTilde = relativePathFromTilde(replacement) != nil - if existingUsesTilde != replacementUsesTilde { - return replacementUsesTilde ? existing : replacement + /// Returns a clearly-perceptible divider hex derived from the chrome background hex. + /// Dark backgrounds are lightened ~28% toward white; light backgrounds are darkened ~20% toward + /// black. These factors are meaningfully stronger than bonsplit's built-in weak fallback (0.16/0.12 + /// tone at reduced alpha), ensuring the 1pt split divider is visible in both dark and light themes. + /// The result is always an opaque #RRGGBB string (no alpha), matching hexString(includeAlpha:false). + static func bonsplitDividerHex(fromChromeHex chromeHex: String) -> String { + // Parse #RRGGBB or #RRGGBBAA produced by hexString(includeAlpha:) + let stripped = chromeHex.hasPrefix("#") ? String(chromeHex.dropFirst()) : chromeHex + guard stripped.count >= 6, + let rByte = UInt8(stripped.prefix(2), radix: 16), + let gByte = UInt8(stripped.dropFirst(2).prefix(2), radix: 16), + let bByte = UInt8(stripped.dropFirst(4).prefix(2), radix: 16) + else { + return chromeHex // fallback: return unchanged on parse failure } - if canonicalDirectoryKey(existing, homeDirectoryForTildeExpansion: homeDirectoryForTildeExpansion) - == canonicalDirectoryKey( - replacement, - homeDirectoryForTildeExpansion: homeDirectoryForTildeExpansion - ) { - return existing - } + var r = CGFloat(rByte) / 255.0 + var g = CGFloat(gByte) / 255.0 + var b = CGFloat(bByte) / 255.0 - return replacement - } + // Perceptual luminance check (sRGB coefficients, no gamma expansion needed for light/dark) + let luminance = 0.2126 * r + 0.7152 * g + 0.0722 * b - static func orderedPaneIds(tree: ExternalTreeNode) -> [String] { - switch tree { - case .pane(let pane): - return [pane.id] - case .split(let split): - // Bonsplit split order matches visual order for both horizontal and vertical splits. - return orderedPaneIds(tree: split.first) + orderedPaneIds(tree: split.second) + if luminance < 0.5 { + // Dark background: lighten 28% toward white + r += (1.0 - r) * 0.28 + g += (1.0 - g) * 0.28 + b += (1.0 - b) * 0.28 + } else { + // Light background: darken 20% toward black + r *= 0.80 + g *= 0.80 + b *= 0.80 } - } - - 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) - } - } - } + let rOut = min(255, max(0, Int((r * 255).rounded()))) + let gOut = min(255, max(0, Int((g * 255).rounded()))) + let bOut = min(255, max(0, Int((b * 255).rounded()))) + return String(format: "#%02X%02X%02X", rOut, gOut, bOut) + } - for panelId in fallbackPanelIds { - if seen.insert(panelId).inserted { - ordered.append(panelId) - } - } + nonisolated static func resolvedChromeColors( + from backgroundColor: NSColor + ) -> BonsplitConfiguration.Appearance.ChromeColors { + .init(backgroundHex: backgroundColor.hexString()) + } - return ordered + static func bonsplitAppearance( + from backgroundColor: NSColor, + backgroundOpacity: Double + ) -> BonsplitConfiguration.Appearance { + let chromeHex = Self.bonsplitChromeHex( + backgroundColor: backgroundColor, + backgroundOpacity: backgroundOpacity + ) + return BonsplitConfiguration.Appearance( + splitButtonTooltips: Self.currentSplitButtonTooltips(), + enableAnimations: false, + chromeColors: .init( + backgroundHex: chromeHex, + borderHex: Self.bonsplitDividerHex(fromChromeHex: chromeHex) + ) + ) } - static func orderedUniqueBranches( - orderedPanelIds: [UUID], - panelBranches: [UUID: SidebarGitBranchState], - fallbackBranch: SidebarGitBranchState? - ) -> [BranchEntry] { - var orderedNames: [String] = [] - var branchDirty: [String: Bool] = [:] + func applyGhosttyChrome(from config: GhosttyConfig, reason: String = "unspecified") { + applyGhosttyChrome( + backgroundColor: config.backgroundColor, + backgroundOpacity: config.backgroundOpacity, + reason: reason + ) + } - for panelId in orderedPanelIds { - guard let state = panelBranches[panelId] else { continue } - let name = state.branch.trimmingCharacters(in: .whitespacesAndNewlines) - guard !name.isEmpty else { continue } + func applyGhosttyChrome(backgroundColor: NSColor, backgroundOpacity: Double, reason: String = "unspecified") { + let nextHex = Self.bonsplitChromeHex( + backgroundColor: backgroundColor, + backgroundOpacity: backgroundOpacity + ) + let currentChromeColors = bonsplitController.configuration.appearance.chromeColors + let isNoOp = currentChromeColors.backgroundHex == nextHex - if branchDirty[name] == nil { - orderedNames.append(name) - branchDirty[name] = state.isDirty - } else if state.isDirty { - branchDirty[name] = true - } + if GhosttyApp.shared.backgroundLogEnabled { + let currentBackgroundHex = currentChromeColors.backgroundHex ?? "nil" + GhosttyApp.shared.logBackground( + "theme apply workspace=\(id.uuidString) reason=\(reason) currentBg=\(currentBackgroundHex) nextBg=\(nextHex) noop=\(isNoOp)" + ) } - if orderedNames.isEmpty, let fallbackBranch { - let name = fallbackBranch.branch.trimmingCharacters(in: .whitespacesAndNewlines) - if !name.isEmpty { - return [BranchEntry(name: name, isDirty: fallbackBranch.isDirty)] - } + if isNoOp { + return } - - return orderedNames.map { name in - BranchEntry(name: name, isDirty: branchDirty[name] ?? false) + bonsplitController.configuration.appearance.chromeColors.backgroundHex = nextHex + bonsplitController.configuration.appearance.chromeColors.borderHex = Self.bonsplitDividerHex(fromChromeHex: nextHex) + if GhosttyApp.shared.backgroundLogEnabled { + GhosttyApp.shared.logBackground( + "theme applied workspace=\(id.uuidString) reason=\(reason) resultingBg=\(bonsplitController.configuration.appearance.chromeColors.backgroundHex ?? "nil")" + ) } } - 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 checksPriority(_ checks: SidebarPullRequestChecksStatus?) -> Int { - switch checks { - case .fail: return 3 - case .pending: return 2 - case .pass: return 1 - case nil: return 0 - } - } - - func normalizedReviewURLKey(for url: URL) -> String { - guard var components = URLComponents(url: url, resolvingAgainstBaseURL: false) else { - return url.absoluteString - } + init( + title: String = "Terminal", + workingDirectory: String? = nil, + portOrdinal: Int = 0, + configTemplate: ProgramaSurfaceConfigTemplate? = nil, + initialTerminalCommand: String? = nil, + initialTerminalEnvironment: [String: String] = [:] + ) { + self.id = UUID() + self.portOrdinal = portOrdinal + self.processTitle = title + self.title = title + self.customTitle = nil + self.customDescription = nil - // Treat URL variants that differ only by query/fragment as the same review item. - 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)" - } + let trimmedWorkingDirectory = workingDirectory?.trimmingCharacters(in: .whitespacesAndNewlines) ?? "" + let hasWorkingDirectory = !trimmedWorkingDirectory.isEmpty + self.currentDirectory = hasWorkingDirectory + ? trimmedWorkingDirectory + : FileManager.default.homeDirectoryForCurrentUser.path - func reviewKey(for state: SidebarPullRequestState) -> String { - "\(state.label.lowercased())#\(state.number)|\(normalizedReviewURLKey(for: state.url))" - } + // Configure bonsplit with keepAllAlive to preserve terminal state + // 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 config = BonsplitConfiguration( + allowSplits: true, + allowCloseTabs: true, + allowCloseLastPane: false, + allowTabReordering: true, + allowCrossPaneTabMove: true, + autoCloseEmptyPanes: true, + contentViewLifecycle: .keepAllAlive, + newTabPosition: .current, + appearance: appearance + ) + self.bonsplitController = BonsplitController(configuration: config) + bonsplitController.contextMenuShortcuts = Self.buildContextMenuShortcuts() - var orderedKeys: [String] = [] - var pullRequestsByKey: [String: SidebarPullRequestState] = [:] + // Remove the default "Welcome" tab that bonsplit creates + let welcomeTabIds = bonsplitController.allTabIds - 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 - } else if state.status == existing.status, - checksPriority(state.checks) > checksPriority(existing.checks) { - pullRequestsByKey[key] = state - } - } + // Create initial terminal panel + let terminalPanel = TerminalPanel( + workspaceId: id, + context: GHOSTTY_SURFACE_CONTEXT_TAB, + configTemplate: configTemplate, + workingDirectory: hasWorkingDirectory ? trimmedWorkingDirectory : nil, + portOrdinal: portOrdinal, + initialCommand: initialTerminalCommand, + initialEnvironmentOverrides: initialTerminalEnvironment + ) + configureTerminalPanel(terminalPanel) + panels[terminalPanel.id] = terminalPanel + panelTitles[terminalPanel.id] = terminalPanel.displayTitle + seedTerminalInheritanceFontPoints(panelId: terminalPanel.id, configTemplate: configTemplate) - if orderedKeys.isEmpty, let fallbackPullRequest { - return [fallbackPullRequest] + // Create initial tab in bonsplit and store the mapping + var initialTabId: TabID? + if let tabId = bonsplitController.createTab( + title: title, + icon: "terminal.fill", + kind: SurfaceKind.terminal, + isDirty: false, + isPinned: false + ) { + surfaceIdToPanelId[tabId] = terminalPanel.id + initialTabId = tabId } - return orderedKeys.compactMap { pullRequestsByKey[$0] } - } - - static func orderedUniqueBranchDirectoryEntries( - orderedPanelIds: [UUID], - panelBranches: [UUID: SidebarGitBranchState], - panelDirectories: [UUID: String], - defaultDirectory: String?, - homeDirectoryForTildeExpansion: String?, - fallbackBranch: SidebarGitBranchState? - ) -> [BranchDirectoryEntry] { - struct EntryKey: Hashable { - let directory: String? - let branch: String? + // Close the default Welcome tab(s) + for welcomeTabId in welcomeTabIds { + bonsplitController.closeTab(welcomeTabId) } - struct MutableEntry { - var branch: String? - var isDirty: Bool - var directory: String? + bonsplitController.onExternalTabDrop = { [weak self] request in + self?.handleExternalTabDrop(request) ?? false } - - let normalized = normalizedDirectory - let normalizedFallbackBranch = normalized(fallbackBranch?.branch) - let shouldUseFallbackBranchPerPanel = !orderedPanelIds.contains { - normalized(panelBranches[$0]?.branch) != nil + bonsplitController.onTabCloseRequest = { [weak self] tabId, _ in + self?.markExplicitClose(surfaceId: tabId) } - 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]) - guard branch != nil || directory != nil else { continue } - - let panelDirty = panelBranch != nil - ? (panelBranches[panelId]?.isDirty ?? false) - : defaultBranchDirty - - let key: EntryKey - if let directoryKey = canonicalDirectoryKey( - directory, - homeDirectoryForTildeExpansion: homeDirectoryForTildeExpansion - ) { - // Keep one line per directory and allow the latest branch state to overwrite. - key = EntryKey(directory: directoryKey, branch: nil) - } else { - key = EntryKey(directory: nil, branch: branch) - } - guard key.directory != nil || key.branch != nil else { continue } + // Set ourselves as delegate + bonsplitController.delegate = self - if var existing = entries[key] { - if key.directory != nil { - if let branch { - existing.branch = branch - existing.isDirty = panelDirty - } else if existing.branch == nil { - existing.isDirty = panelDirty + // Ensure bonsplit has a focused pane and our didSelectTab handler runs for the + // initial terminal. bonsplit's createTab selects internally but does not emit + // didSelectTab, and focusedPaneId can otherwise be nil until user interaction. + if let initialTabId { + // Focus the pane containing the initial tab (or the first pane as fallback). + let paneToFocus: PaneID? = { + for paneId in bonsplitController.allPaneIds { + if bonsplitController.tabs(inPane: paneId).contains(where: { $0.id == initialTabId }) { + return paneId } - existing.directory = preferredDisplayedDirectory( - existing: existing.directory, - replacement: directory, - homeDirectoryForTildeExpansion: homeDirectoryForTildeExpansion - ) - entries[key] = existing - } else if panelDirty { - existing.isDirty = true - entries[key] = existing } - } else { - order.append(key) - entries[key] = MutableEntry(branch: branch, isDirty: panelDirty, directory: directory) + return bonsplitController.allPaneIds.first + }() + if let paneToFocus { + bonsplitController.focusPane(paneToFocus) } + bonsplitController.selectTab(initialTabId) } + tmuxLayoutSnapshot = bonsplitController.layoutSnapshot() + } - if order.isEmpty { - let fallbackDirectory = normalized(defaultDirectory) - if normalizedFallbackBranch != nil || fallbackDirectory != nil { - return [ - BranchDirectoryEntry( - branch: normalizedFallbackBranch, - isDirty: fallbackBranch?.isDirty ?? false, - directory: fallbackDirectory - ) - ] - } - } + /// Initialize a workspace using a pre-warmed terminal panel from the surface pool. + /// The panel's surface is already running a shell process. + init( + claimedPanel: TerminalPanel, + title: String = "Terminal", + workingDirectory: String? = nil, + portOrdinal: Int = 0, + configTemplate: ProgramaSurfaceConfigTemplate? = nil + ) { + self.id = UUID() + self.portOrdinal = portOrdinal + self.processTitle = title + self.title = title + self.customTitle = nil + self.customDescription = nil - return order.compactMap { key in - guard let entry = entries[key] else { return nil } - return BranchDirectoryEntry( - branch: entry.branch, - isDirty: entry.isDirty, - directory: entry.directory - ) - } - } -} + let trimmedWorkingDirectory = workingDirectory?.trimmingCharacters(in: .whitespacesAndNewlines) ?? "" + let hasWorkingDirectory = !trimmedWorkingDirectory.isEmpty + self.currentDirectory = hasWorkingDirectory + ? trimmedWorkingDirectory + : FileManager.default.homeDirectoryForCurrentUser.path -struct ClosedBrowserPanelRestoreSnapshot { - let workspaceId: UUID - let url: URL? - let profileID: UUID? - 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 customDescription: String? - @Published var isPinned: Bool = false - @Published var customColor: String? // hex string, e.g. "#C0392B" - @Published var currentDirectory: String - private(set) var preferredBrowserProfileID: UUID? - - /// Ordinal for PROGRAMA_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 - private var debugStressPreloadSelectionDepth = 0 + let appearance = Self.bonsplitAppearance( + from: GhosttyApp.shared.defaultBackgroundColor, + backgroundOpacity: GhosttyApp.shared.defaultBackgroundOpacity + ) + let config = BonsplitConfiguration( + allowSplits: true, + allowCloseTabs: true, + allowCloseLastPane: false, + allowTabReordering: true, + allowCrossPaneTabMove: true, + autoCloseEmptyPanes: true, + contentViewLifecycle: .keepAllAlive, + newTabPosition: .current, + appearance: appearance + ) + self.bonsplitController = BonsplitController(configuration: config) + bonsplitController.contextMenuShortcuts = Self.buildContextMenuShortcuts() - /// Last terminal panel used as an inheritance source (typically last focused terminal). - private var lastTerminalConfigInheritancePanelId: UUID? - /// Last known terminal font points from inheritance sources. Used as fallback when - /// no live terminal surface is currently available. - private var lastTerminalConfigInheritanceFontPoints: Float? - /// Per-panel inherited zoom lineage. Descendants reuse this root value unless - /// a panel is explicitly re-zoomed by the user. - private var terminalInheritanceFontPointsByPanelId: [UUID: Float] = [:] + let welcomeTabIds = bonsplitController.allTabIds - /// Callback used by TabManager to capture recently closed browser panels for Cmd+Shift+T restore. - var onClosedBrowserPanel: ((ClosedBrowserPanelRestoreSnapshot) -> Void)? - weak var owningTabManager: TabManager? + // Use the pre-warmed panel, updating its workspace ID to ours + claimedPanel.updateWorkspaceId(id) + let terminalPanel = claimedPanel + configureTerminalPanel(terminalPanel) + panels[terminalPanel.id] = terminalPanel + panelTitles[terminalPanel.id] = terminalPanel.displayTitle + seedTerminalInheritanceFontPoints(panelId: terminalPanel.id, configTemplate: configTemplate) + var initialTabId: TabID? + if let tabId = bonsplitController.createTab( + title: title, + icon: "terminal.fill", + kind: SurfaceKind.terminal, + isDirty: false, + isPinned: false + ) { + surfaceIdToPanelId[tabId] = terminalPanel.id + initialTabId = tabId + } - // Closing tabs mutates split layout immediately; terminal views handle their own AppKit - // layout/size synchronization. + for welcomeTabId in welcomeTabIds { + bonsplitController.closeTab(welcomeTabId) + } - /// The currently focused pane's panel ID - var focusedPanelId: UUID? { - guard let paneId = bonsplitController.focusedPaneId, - let tab = bonsplitController.selectedTab(inPane: paneId) else { - return nil + bonsplitController.onExternalTabDrop = { [weak self] request in + self?.handleExternalTabDrop(request) ?? false + } + bonsplitController.onTabCloseRequest = { [weak self] tabId, _ in + self?.markExplicitClose(surfaceId: tabId) } - return panelIdFromSurfaceId(tab.id) - } - /// The currently focused terminal panel (if any) - var focusedTerminalPanel: TerminalPanel? { - guard let panelId = focusedPanelId, - let panel = panels[panelId] as? TerminalPanel else { - return nil + bonsplitController.delegate = self + + if let initialTabId { + let paneToFocus: PaneID? = { + for paneId in bonsplitController.allPaneIds { + if bonsplitController.tabs(inPane: paneId).contains(where: { $0.id == initialTabId }) { + return paneId + } + } + return bonsplitController.allPaneIds.first + }() + if let paneToFocus { + bonsplitController.focusPane(paneToFocus) + } + bonsplitController.selectTab(initialTabId) } - return panel + tmuxLayoutSnapshot = bonsplitController.layoutSnapshot() } - func effectiveSelectedPanelId(inPane paneId: PaneID) -> UUID? { - bonsplitController.selectedTab(inPane: paneId).flatMap { panelIdFromSurfaceId($0.id) } + deinit { + activeRemoteSessionControllerID = nil + remoteSessionController?.stop() } - enum FocusPanelTrigger { - case standard - case terminalFirstResponder + func refreshSplitButtonTooltips() { + let tooltips = Self.currentSplitButtonTooltips() + var configuration = bonsplitController.configuration + guard configuration.appearance.splitButtonTooltips != tooltips else { return } + configuration.appearance.splitButtonTooltips = tooltips + bonsplitController.configuration = configuration } - /// 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 private(set) var tmuxLayoutSnapshot: LayoutSnapshot? - @Published private(set) var tmuxWorkspaceFlashPanelId: UUID? - @Published private(set) var tmuxWorkspaceFlashReason: WorkspaceAttentionFlashReason? - @Published private(set) var tmuxWorkspaceFlashToken: UInt64 = 0 - private var manualUnreadMarkedAt: [UUID: Date] = [:] - nonisolated private static let manualUnreadFocusGraceInterval: TimeInterval = 0.2 - nonisolated private static let manualUnreadClearDelayAfterFocusFlash: TimeInterval = 0.2 - @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]] = [:] - var agentListeningPorts: [Int] = [] - @Published var remoteConfiguration: WorkspaceRemoteConfiguration? - @Published var remoteConnectionState: WorkspaceRemoteConnectionState = .disconnected - @Published var remoteConnectionDetail: String? - @Published var remoteDaemonStatus: WorkspaceRemoteDaemonStatus = WorkspaceRemoteDaemonStatus() - @Published var remoteDetectedPorts: [Int] = [] - @Published var remoteForwardedPorts: [Int] = [] - @Published var remotePortConflicts: [Int] = [] - @Published var remoteProxyEndpoint: BrowserProxyEndpoint? - @Published var remoteHeartbeatCount: Int = 0 - @Published var remoteLastHeartbeatAt: Date? - @Published var listeningPorts: [Int] = [] - @Published private(set) var activeRemoteTerminalSessionCount: Int = 0 - var surfaceTTYNames: [UUID: String] = [:] - private var remoteSessionController: WorkspaceRemoteSessionController? - private var pendingRemoteForegroundAuthToken: String? - var activeRemoteSessionControllerID: UUID? - private var remoteLastErrorFingerprint: String? - private var remoteLastDaemonErrorFingerprint: String? - private var remoteLastPortConflictFingerprint: String? - private var remoteDetectedSurfaceIds: Set = [] - private var activeRemoteTerminalSurfaceIds: Set = [] - private var pendingRemoteTerminalChildExitSurfaceIds: Set = [] - - private static let remoteErrorStatusKey = "remote.error" - private static let remotePortConflictStatusKey = "remote.port_conflicts" - private static let remoteNotificationCooldown: TimeInterval = 5 * 60 - private static let sshControlMasterCleanupQueue = DispatchQueue( - label: "com.cmux.remote-ssh.control-master-cleanup", - qos: .utility - ) - private static let remoteHeartbeatDateFormatter: ISO8601DateFormatter = { - let formatter = ISO8601DateFormatter() - formatter.formatOptions = [.withInternetDateTime, .withFractionalSeconds] - return formatter - }() - nonisolated(unsafe) static var runSSHControlMasterCommandOverrideForTesting: (([String]) -> Void)? - private var panelShellActivityStates: [UUID: PanelShellActivityState] = [:] - /// PIDs associated with agent status entries (e.g. claude_code), keyed by status key. - /// Used for stale-session detection: if the PID is dead, the status entry is cleared. - var agentPIDs: [String: pid_t] = [:] - private var restoredTerminalScrollbackByPanelId: [UUID: String] = [:] - - private func sidebarObservationSignal( - _ publisher: Published.Publisher - ) -> AnyPublisher { - publisher - .dropFirst() - .removeDuplicates() - .map { _ in () } - .eraseToAnyPublisher() - } + // MARK: - Surface ID to Panel ID Mapping - lazy var sidebarImmediateObservationPublisher: AnyPublisher = { - let publishers: [AnyPublisher] = [ - sidebarObservationSignal($title), - sidebarObservationSignal($customDescription), - sidebarObservationSignal($isPinned), - sidebarObservationSignal($customColor), - ] + /// Mapping from bonsplit TabID (surface ID) to panel UUID + var surfaceIdToPanelId: [TabID: UUID] = [:] - return Publishers.MergeMany(publishers).eraseToAnyPublisher() - }() + /// Tab IDs that are allowed to close even if they would normally require confirmation. + /// This is used by app-level confirmation prompts (e.g., Cmd+W "Close Tab?") so the + /// Bonsplit delegate doesn't block the close after the user already confirmed. + var forceCloseTabIds: Set = [] - lazy var sidebarObservationPublisher: AnyPublisher = { - let publishers: [AnyPublisher] = [ - sidebarObservationSignal($currentDirectory), - $panels - .map(SidebarPanelObservationState.init) - .dropFirst() - .removeDuplicates() - .map { _ in () } - .eraseToAnyPublisher(), - sidebarObservationSignal($panelDirectories), - sidebarObservationSignal($statusEntries), - sidebarObservationSignal($metadataBlocks), - sidebarObservationSignal($logEntries), - sidebarObservationSignal($progress), - sidebarObservationSignal($gitBranch), - sidebarObservationSignal($panelGitBranches), - sidebarObservationSignal($pullRequest), - sidebarObservationSignal($panelPullRequests), - sidebarObservationSignal($remoteConfiguration), - sidebarObservationSignal($remoteConnectionState), - sidebarObservationSignal($remoteConnectionDetail), - sidebarObservationSignal($activeRemoteTerminalSessionCount), - sidebarObservationSignal($listeningPorts), - ] + /// Tab IDs that are currently showing (or about to show) a close confirmation prompt. + /// Prevents repeated close gestures (e.g., middle-click spam) from stacking dialogs. + var pendingCloseConfirmTabIds: Set = [] - return Publishers.MergeMany(publishers).eraseToAnyPublisher() - }() + /// Tab IDs whose next close attempt should be treated as an explicit + /// workspace-close gesture from the user (the tab-strip X button, or Cmd+W when + /// the shortcut preference is set to close the workspace on the last surface), + /// rather than an internal close/move flow. + var explicitUserCloseTabIds: Set = [] - private static func isProxyOnlyRemoteError(_ detail: String) -> Bool { - let lowered = detail.lowercased() - return lowered.contains("remote proxy") - || lowered.contains("proxy_unavailable") - || lowered.contains("local daemon proxy") - || lowered.contains("proxy failure") - || lowered.contains("daemon transport") + /// 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. + 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. + var pendingPaneClosePanelIds: [UUID: [UUID]] = [:] + var pendingClosedBrowserRestoreSnapshots: [TabID: ClosedBrowserPanelRestoreSnapshot] = [:] + var isApplyingTabSelection = false + struct PendingTabSelectionRequest { + let tabId: TabID + let pane: PaneID + let reassertAppKitFocus: Bool + let focusIntent: PanelFocusIntent? + let previousTerminalHostedView: GhosttySurfaceScrollView? } - - private var preservesSSHTerminalConnection: Bool { - activeRemoteTerminalSessionCount > 0 - && remoteConfiguration?.terminalStartupCommand?.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty == false + var pendingTabSelection: PendingTabSelectionRequest? + var isReconcilingFocusState = false + var focusReconcileScheduled = false +#if DEBUG + private(set) var debugFocusReconcileScheduledDuringDetachCount: Int = 0 + var debugLastDidMoveTabTimestamp: TimeInterval = 0 + var debugDidMoveTabEventCount: UInt64 = 0 +#endif + var layoutFollowUpObservers: [NSObjectProtocol] = [] + var layoutFollowUpPanelsCancellable: AnyCancellable? + var layoutFollowUpTimeoutWorkItem: DispatchWorkItem? + var layoutFollowUpReason: String? + var layoutFollowUpTerminalFocusPanelId: UUID? + var layoutFollowUpBrowserPanelId: UUID? + var layoutFollowUpBrowserExitFocusPanelId: UUID? + var layoutFollowUpNeedsGeometryPass = false + var layoutFollowUpAttemptScheduled = false + var layoutFollowUpAttemptVersion: Int = 0 + var layoutFollowUpStalledAttemptCount = 0 + var isAttemptingLayoutFollowUp = false + var isNormalizingPinnedTabOrder = false + var pendingNonFocusSplitFocusReassert: PendingNonFocusSplitFocusReassert? + var nonFocusSplitFocusReassertGeneration: UInt64 = 0 + + struct PendingNonFocusSplitFocusReassert { + let generation: UInt64 + let preferredPanelId: UUID + let splitPanelId: UUID } - private var hasProxyOnlyRemoteSidebarError: Bool { - guard let entry = statusEntries[Self.remoteErrorStatusKey]?.value else { return false } - return entry.lowercased().contains("remote proxy unavailable") - } + struct DetachedSurfaceTransfer { + let panelId: UUID + let panel: any Panel + let title: String + let icon: String? + let iconImageData: Data? + let kind: String? + let isLoading: Bool + let isPinned: Bool + let directory: String? + let ttyName: String? + let cachedTitle: String? + let customTitle: String? + let manuallyUnread: Bool + let isRemoteTerminal: Bool + let remoteRelayPort: Int? + let remoteCleanupConfiguration: WorkspaceRemoteConfiguration? - private func remoteNotificationCooldownKey(target: String) -> String? { - let rawTarget = (remoteConfiguration?.destination ?? target) - .trimmingCharacters(in: .whitespacesAndNewlines) - guard !rawTarget.isEmpty else { return nil } - let normalizedHost = rawTarget - .split(separator: "@", maxSplits: 1, omittingEmptySubsequences: false) - .last - .map(String.init)? - .trimmingCharacters(in: .whitespacesAndNewlines) - .lowercased() - guard let normalizedHost, !normalizedHost.isEmpty else { return nil } - return "remote-host:\(normalizedHost)" + func withRemoteCleanupConfiguration(_ configuration: WorkspaceRemoteConfiguration?) -> Self { + Self( + panelId: panelId, + panel: panel, + title: title, + icon: icon, + iconImageData: iconImageData, + kind: kind, + isLoading: isLoading, + isPinned: isPinned, + directory: directory, + ttyName: ttyName, + cachedTitle: cachedTitle, + customTitle: customTitle, + manuallyUnread: manuallyUnread, + isRemoteTerminal: isRemoteTerminal, + remoteRelayPort: remoteRelayPort, + remoteCleanupConfiguration: configuration + ) + } } - var focusedSurfaceId: UUID? { focusedPanelId } - var surfaceDirectories: [UUID: String] { - get { panelDirectories } - set { panelDirectories = newValue } + var detachingTabIds: Set = [] + var pendingDetachedSurfaces: [TabID: DetachedSurfaceTransfer] = [:] + var activeDetachCloseTransactions: Int = 0 + var isDetachingCloseTransaction: Bool { activeDetachCloseTransactions > 0 } + var pendingRemoteSurfaceTTYName: String? + var pendingRemoteSurfaceTTYSurfaceId: UUID? + var pendingRemoteSurfacePortKickReason: WorkspaceRemoteSessionController.PortScanKickReason? + var pendingRemoteSurfacePortKickSurfaceId: UUID? + // When the last live remote terminal is detached out, the source workspace may be + // closed immediately after the move succeeds. That teardown must not shut down the + // shared SSH control master that is still serving the moved terminal. + var skipControlMasterCleanupAfterDetachedRemoteTransfer = false + var transferredRemoteCleanupConfigurationsByPanelId: [UUID: WorkspaceRemoteConfiguration] = [:] + +#if DEBUG + func debugElapsedMs(since start: TimeInterval) -> String { + let ms = (ProcessInfo.processInfo.systemUptime - start) * 1000 + return String(format: "%.2f", ms) } +#endif - private var processTitle: String + func panelIdFromSurfaceId(_ surfaceId: TabID) -> UUID? { + surfaceIdToPanelId[surfaceId] + } - private enum SurfaceKind { - static let terminal = "terminal" - static let browser = "browser" - static let markdown = "markdown" + func markExplicitClose(surfaceId: TabID) { + explicitUserCloseTabIds.insert(surfaceId) } - enum PanelShellActivityState: String { - case unknown - case promptIdle - case commandRunning + func surfaceIdFromPanelId(_ panelId: UUID) -> TabID? { + surfaceIdToPanelId.first { $0.value == panelId }?.key } - nonisolated static func resolveCloseConfirmation( - shellActivityState: PanelShellActivityState?, - fallbackNeedsConfirmClose: Bool - ) -> Bool { - switch shellActivityState ?? .unknown { - case .promptIdle: - return false - case .commandRunning: - return true - case .unknown: - return fallbackNeedsConfirmClose + func configureTerminalPanel(_ terminalPanel: TerminalPanel) { + terminalPanel.onRequestWorkspacePaneFlash = { [weak self, weak terminalPanel] reason in + guard let self, let terminalPanel else { return } + self.triggerWorkspacePaneFlash(panelId: terminalPanel.id, reason: reason) } } - // MARK: - Initialization - - private static func currentSplitButtonTooltips() -> BonsplitConfiguration.SplitButtonTooltips { - BonsplitConfiguration.SplitButtonTooltips( - 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") - ) + func triggerWorkspacePaneFlash(panelId: UUID, reason: WorkspaceAttentionFlashReason) { + tmuxWorkspaceFlashPanelId = panelId + tmuxWorkspaceFlashReason = reason + tmuxWorkspaceFlashToken &+= 1 } - 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 + func installBrowserPanelSubscription(_ browserPanel: BrowserPanel) { + let subscription = Publishers.CombineLatest3( + browserPanel.$pageTitle.removeDuplicates(), + browserPanel.$isLoading.removeDuplicates(), + browserPanel.$faviconPNGData.removeDuplicates(by: { $0 == $1 }) ) - let includeAlpha = themedColor.alphaComponent < 0.999 - return themedColor.hexString(includeAlpha: includeAlpha) - } - - /// Returns a clearly-perceptible divider hex derived from the chrome background hex. - /// Dark backgrounds are lightened ~28% toward white; light backgrounds are darkened ~20% toward - /// black. These factors are meaningfully stronger than bonsplit's built-in weak fallback (0.16/0.12 - /// tone at reduced alpha), ensuring the 1pt split divider is visible in both dark and light themes. - /// The result is always an opaque #RRGGBB string (no alpha), matching hexString(includeAlpha:false). - static func bonsplitDividerHex(fromChromeHex chromeHex: String) -> String { - // Parse #RRGGBB or #RRGGBBAA produced by hexString(includeAlpha:) - let stripped = chromeHex.hasPrefix("#") ? String(chromeHex.dropFirst()) : chromeHex - guard stripped.count >= 6, - let rByte = UInt8(stripped.prefix(2), radix: 16), - let gByte = UInt8(stripped.dropFirst(2).prefix(2), radix: 16), - let bByte = UInt8(stripped.dropFirst(4).prefix(2), radix: 16) - else { - return chromeHex // fallback: return unchanged on parse failure - } + .receive(on: DispatchQueue.main) + .sink { [weak self, weak browserPanel] _, isLoading, favicon in + guard let self = self, + let browserPanel = browserPanel, + let tabId = self.surfaceIdFromPanelId(browserPanel.id) else { return } + guard let existing = self.bonsplitController.tab(tabId) else { return } - var r = CGFloat(rByte) / 255.0 - var g = CGFloat(gByte) / 255.0 - var b = CGFloat(bByte) / 255.0 + let nextTitle = browserPanel.displayTitle + if self.panelTitles[browserPanel.id] != nextTitle { + self.panelTitles[browserPanel.id] = nextTitle + } + let resolvedTitle = self.resolvedPanelTitle(panelId: browserPanel.id, fallback: nextTitle) + let titleUpdate: String? = existing.title == resolvedTitle ? nil : resolvedTitle + let faviconUpdate: Data?? = existing.iconImageData == favicon ? nil : .some(favicon) + let loadingUpdate: Bool? = existing.isLoading == isLoading ? nil : isLoading - // Perceptual luminance check (sRGB coefficients, no gamma expansion needed for light/dark) - let luminance = 0.2126 * r + 0.7152 * g + 0.0722 * b + guard titleUpdate != nil || faviconUpdate != nil || loadingUpdate != nil else { return } + self.bonsplitController.updateTab( + tabId, + title: titleUpdate, + iconImageData: faviconUpdate, + hasCustomTitle: self.panelCustomTitles[browserPanel.id] != nil, + isLoading: loadingUpdate + ) + } + panelSubscriptions[browserPanel.id] = subscription + setPreferredBrowserProfileID(browserPanel.profileID) + } - if luminance < 0.5 { - // Dark background: lighten 28% toward white - r += (1.0 - r) * 0.28 - g += (1.0 - g) * 0.28 - b += (1.0 - b) * 0.28 - } else { - // Light background: darken 20% toward black - r *= 0.80 - g *= 0.80 - b *= 0.80 + func setPreferredBrowserProfileID(_ profileID: UUID?) { + guard let profileID else { + preferredBrowserProfileID = nil + return } + guard BrowserProfileStore.shared.profileDefinition(id: profileID) != nil else { return } + preferredBrowserProfileID = profileID + } - let rOut = min(255, max(0, Int((r * 255).rounded()))) - let gOut = min(255, max(0, Int((g * 255).rounded()))) - let bOut = min(255, max(0, Int((b * 255).rounded()))) - return String(format: "#%02X%02X%02X", rOut, gOut, bOut) + func resolvedNewBrowserProfileID( + preferredProfileID: UUID? = nil, + sourcePanelId: UUID? = nil + ) -> UUID { + if let preferredProfileID, + BrowserProfileStore.shared.profileDefinition(id: preferredProfileID) != nil { + return preferredProfileID + } + if let sourcePanelId, + let sourceBrowserPanel = browserPanel(for: sourcePanelId), + BrowserProfileStore.shared.profileDefinition(id: sourceBrowserPanel.profileID) != nil { + return sourceBrowserPanel.profileID + } + if let preferredBrowserProfileID, + BrowserProfileStore.shared.profileDefinition(id: preferredBrowserProfileID) != nil { + return preferredBrowserProfileID + } + return BrowserProfileStore.shared.effectiveLastUsedProfileID } - nonisolated static func resolvedChromeColors( - from backgroundColor: NSColor - ) -> BonsplitConfiguration.Appearance.ChromeColors { - .init(backgroundHex: backgroundColor.hexString()) + 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 static func bonsplitAppearance( - from backgroundColor: NSColor, - backgroundOpacity: Double - ) -> BonsplitConfiguration.Appearance { - let chromeHex = Self.bonsplitChromeHex( - backgroundColor: backgroundColor, - backgroundOpacity: backgroundOpacity - ) - return BonsplitConfiguration.Appearance( - splitButtonTooltips: Self.currentSplitButtonTooltips(), - enableAnimations: false, - chromeColors: .init( - backgroundHex: chromeHex, - borderHex: Self.bonsplitDividerHex(fromChromeHex: chromeHex) - ) + func browserRemoteWorkspaceStatusSnapshot() -> BrowserRemoteWorkspaceStatus? { + guard let target = remoteDisplayTarget else { return nil } + return BrowserRemoteWorkspaceStatus( + target: target, + connectionState: remoteConnectionState, + heartbeatCount: remoteHeartbeatCount, + lastHeartbeatAt: remoteLastHeartbeatAt ) } - func applyGhosttyChrome(from config: GhosttyConfig, reason: String = "unspecified") { - applyGhosttyChrome( - backgroundColor: config.backgroundColor, - backgroundOpacity: config.backgroundOpacity, - reason: reason - ) + func applyBrowserRemoteWorkspaceStatusToPanels() { + let snapshot = browserRemoteWorkspaceStatusSnapshot() + for panel in panels.values { + guard let browserPanel = panel as? BrowserPanel else { continue } + browserPanel.setRemoteWorkspaceStatus(snapshot) + } } - func applyGhosttyChrome(backgroundColor: NSColor, backgroundOpacity: Double, reason: String = "unspecified") { - let nextHex = Self.bonsplitChromeHex( - backgroundColor: backgroundColor, - backgroundOpacity: backgroundOpacity - ) - let currentChromeColors = bonsplitController.configuration.appearance.chromeColors - let isNoOp = currentChromeColors.backgroundHex == nextHex + // MARK: - Panel Access - if GhosttyApp.shared.backgroundLogEnabled { - let currentBackgroundHex = currentChromeColors.backgroundHex ?? "nil" - GhosttyApp.shared.logBackground( - "theme apply workspace=\(id.uuidString) reason=\(reason) currentBg=\(currentBackgroundHex) nextBg=\(nextHex) noop=\(isNoOp)" - ) + func panel(for surfaceId: TabID) -> (any Panel)? { + guard let panelId = panelIdFromSurfaceId(surfaceId) else { return nil } + return panels[panelId] + } + + func terminalPanel(for panelId: UUID) -> TerminalPanel? { + panels[panelId] as? TerminalPanel + } + + func browserPanel(for panelId: UUID) -> BrowserPanel? { + panels[panelId] as? BrowserPanel + } + + func markdownPanel(for panelId: UUID) -> MarkdownPanel? { + panels[panelId] as? MarkdownPanel + } + + func surfaceKind(for panel: any Panel) -> String { + switch panel.panelType { + case .terminal: + return SurfaceKind.terminal + case .browser: + return SurfaceKind.browser + case .markdown: + return SurfaceKind.markdown } + } - if isNoOp { - return + func resolvedPanelTitle(panelId: UUID, fallback: String) -> String { + let trimmedFallback = fallback.trimmingCharacters(in: .whitespacesAndNewlines) + let fallbackTitle = trimmedFallback.isEmpty ? "Tab" : trimmedFallback + if let custom = panelCustomTitles[panelId]?.trimmingCharacters(in: .whitespacesAndNewlines), + !custom.isEmpty { + return custom } - bonsplitController.configuration.appearance.chromeColors.backgroundHex = nextHex - bonsplitController.configuration.appearance.chromeColors.borderHex = Self.bonsplitDividerHex(fromChromeHex: nextHex) - if GhosttyApp.shared.backgroundLogEnabled { - GhosttyApp.shared.logBackground( - "theme applied workspace=\(id.uuidString) reason=\(reason) resultingBg=\(bonsplitController.configuration.appearance.chromeColors.backgroundHex ?? "nil")" + return fallbackTitle + } + + func syncPinnedStateForTab(_ tabId: TabID, panelId: UUID) { + let isPinned = pinnedPanelIds.contains(panelId) + if let panel = panels[panelId] { + bonsplitController.updateTab( + tabId, + kind: .some(surfaceKind(for: panel)), + isPinned: isPinned ) + } else { + bonsplitController.updateTab(tabId, isPinned: isPinned) } } - init( - title: String = "Terminal", - workingDirectory: String? = nil, - portOrdinal: Int = 0, - configTemplate: ProgramaSurfaceConfigTemplate? = nil, - initialTerminalCommand: String? = nil, - initialTerminalEnvironment: [String: String] = [:] - ) { - self.id = UUID() - self.portOrdinal = portOrdinal - self.processTitle = title - self.title = title - self.customTitle = nil - self.customDescription = nil - - let trimmedWorkingDirectory = workingDirectory?.trimmingCharacters(in: .whitespacesAndNewlines) ?? "" - let hasWorkingDirectory = !trimmedWorkingDirectory.isEmpty - self.currentDirectory = hasWorkingDirectory - ? trimmedWorkingDirectory - : FileManager.default.homeDirectoryForCurrentUser.path + func hasUnreadNotification(panelId: UUID) -> Bool { + AppDelegate.shared?.notificationStore?.hasVisibleNotificationIndicator(forTabId: id, surfaceId: panelId) ?? false + } - // Configure bonsplit with keepAllAlive to preserve terminal state - // 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 + func attentionPersistentState() -> WorkspaceAttentionPersistentState { + let notificationStore = AppDelegate.shared?.notificationStore + let unreadPanelIDs = Set( + panels.keys.filter { + notificationStore?.hasUnreadNotification(forTabId: id, surfaceId: $0) ?? false + } ) - let config = BonsplitConfiguration( - allowSplits: true, - allowCloseTabs: true, - allowCloseLastPane: false, - allowTabReordering: true, - allowCrossPaneTabMove: true, - autoCloseEmptyPanes: true, - contentViewLifecycle: .keepAllAlive, - newTabPosition: .current, - appearance: appearance + return WorkspaceAttentionPersistentState( + unreadPanelIDs: unreadPanelIDs, + focusedReadPanelID: notificationStore?.focusedReadIndicatorSurfaceId(forTabId: id), + manualUnreadPanelIDs: manualUnreadPanelIds ) - self.bonsplitController = BonsplitController(configuration: config) - bonsplitController.contextMenuShortcuts = Self.buildContextMenuShortcuts() - - // Remove the default "Welcome" tab that bonsplit creates - let welcomeTabIds = bonsplitController.allTabIds + } - // Create initial terminal panel - let terminalPanel = TerminalPanel( - workspaceId: id, - context: GHOSTTY_SURFACE_CONTEXT_TAB, - configTemplate: configTemplate, - workingDirectory: hasWorkingDirectory ? trimmedWorkingDirectory : nil, - portOrdinal: portOrdinal, - initialCommand: initialTerminalCommand, - initialEnvironmentOverrides: initialTerminalEnvironment + func requestAttentionFlash(panelId: UUID, reason: WorkspaceAttentionFlashReason) { + let decision = WorkspaceAttentionCoordinator.decideFlash( + targetPanelID: panelId, + reason: reason, + persistentState: attentionPersistentState() ) - configureTerminalPanel(terminalPanel) - panels[terminalPanel.id] = terminalPanel - panelTitles[terminalPanel.id] = terminalPanel.displayTitle - seedTerminalInheritanceFontPoints(panelId: terminalPanel.id, configTemplate: configTemplate) + guard decision.isAllowed else { return } + panels[panelId]?.triggerFlash(reason: reason) + } - // Create initial tab in bonsplit and store the mapping - var initialTabId: TabID? - if let tabId = bonsplitController.createTab( - title: title, - icon: "terminal.fill", - kind: SurfaceKind.terminal, - isDirty: false, - isPinned: false - ) { - surfaceIdToPanelId[tabId] = terminalPanel.id - initialTabId = tabId + func syncUnreadBadgeStateForPanel(_ panelId: UUID) { + guard let tabId = surfaceIdFromPanelId(panelId) else { return } + 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) + } - // Close the default Welcome tab(s) - for welcomeTabId in welcomeTabIds { - bonsplitController.closeTab(welcomeTabId) - } + func normalizePinnedTabs(in paneId: PaneID) { + guard !isNormalizingPinnedTabOrder else { return } + isNormalizingPinnedTabOrder = true + defer { isNormalizingPinnedTabOrder = false } - bonsplitController.onExternalTabDrop = { [weak self] request in - self?.handleExternalTabDrop(request) ?? false + let tabs = bonsplitController.tabs(inPane: paneId) + let pinnedTabs = tabs.filter { tab in + guard let panelId = panelIdFromSurfaceId(tab.id) else { return false } + return pinnedPanelIds.contains(panelId) } - bonsplitController.onTabCloseRequest = { [weak self] tabId, _ in - self?.markExplicitClose(surfaceId: tabId) + let unpinnedTabs = tabs.filter { tab in + guard let panelId = panelIdFromSurfaceId(tab.id) else { return true } + return !pinnedPanelIds.contains(panelId) } + let desiredOrder = pinnedTabs + unpinnedTabs - // Set ourselves as delegate - bonsplitController.delegate = self - - // Ensure bonsplit has a focused pane and our didSelectTab handler runs for the - // initial terminal. bonsplit's createTab selects internally but does not emit - // didSelectTab, and focusedPaneId can otherwise be nil until user interaction. - if let initialTabId { - // Focus the pane containing the initial tab (or the first pane as fallback). - let paneToFocus: PaneID? = { - for paneId in bonsplitController.allPaneIds { - if bonsplitController.tabs(inPane: paneId).contains(where: { $0.id == initialTabId }) { - return paneId - } - } - return bonsplitController.allPaneIds.first - }() - if let paneToFocus { - bonsplitController.focusPane(paneToFocus) + for (index, desiredTab) in desiredOrder.enumerated() { + let currentTabs = bonsplitController.tabs(inPane: paneId) + guard let currentIndex = currentTabs.firstIndex(where: { $0.id == desiredTab.id }) else { continue } + if currentIndex != index { + _ = bonsplitController.reorderTab(desiredTab.id, toIndex: index) } - bonsplitController.selectTab(initialTabId) } - tmuxLayoutSnapshot = bonsplitController.layoutSnapshot() } - /// Initialize a workspace using a pre-warmed terminal panel from the surface pool. - /// The panel's surface is already running a shell process. - init( - claimedPanel: TerminalPanel, - title: String = "Terminal", - workingDirectory: String? = nil, - portOrdinal: Int = 0, - configTemplate: ProgramaSurfaceConfigTemplate? = nil - ) { - self.id = UUID() - self.portOrdinal = portOrdinal - self.processTitle = title - self.title = title - self.customTitle = nil - self.customDescription = nil + func insertionIndexToRight(of anchorTabId: TabID, inPane paneId: PaneID) -> Int { + let tabs = bonsplitController.tabs(inPane: paneId) + guard let anchorIndex = tabs.firstIndex(where: { $0.id == anchorTabId }) else { return tabs.count } + let pinnedCount = tabs.reduce(into: 0) { count, tab in + if let panelId = panelIdFromSurfaceId(tab.id), pinnedPanelIds.contains(panelId) { + count += 1 + } + } + let rawTarget = min(anchorIndex + 1, tabs.count) + return max(rawTarget, pinnedCount) + } - let trimmedWorkingDirectory = workingDirectory?.trimmingCharacters(in: .whitespacesAndNewlines) ?? "" - let hasWorkingDirectory = !trimmedWorkingDirectory.isEmpty - self.currentDirectory = hasWorkingDirectory - ? trimmedWorkingDirectory - : FileManager.default.homeDirectoryForCurrentUser.path + func setPanelCustomTitle(panelId: UUID, title: String?) { + guard panels[panelId] != nil else { return } + let trimmed = title?.trimmingCharacters(in: .whitespacesAndNewlines) ?? "" + let previous = panelCustomTitles[panelId] + if trimmed.isEmpty { + guard previous != nil else { return } + panelCustomTitles.removeValue(forKey: panelId) + } else { + guard previous != trimmed else { return } + panelCustomTitles[panelId] = trimmed + } - let appearance = Self.bonsplitAppearance( - from: GhosttyApp.shared.defaultBackgroundColor, - backgroundOpacity: GhosttyApp.shared.defaultBackgroundOpacity + guard let panel = panels[panelId], let tabId = surfaceIdFromPanelId(panelId) else { return } + let baseTitle = panelTitles[panelId] ?? panel.displayTitle + bonsplitController.updateTab( + tabId, + title: resolvedPanelTitle(panelId: panelId, fallback: baseTitle), + hasCustomTitle: panelCustomTitles[panelId] != nil ) - let config = BonsplitConfiguration( - allowSplits: true, - allowCloseTabs: true, - allowCloseLastPane: false, - allowTabReordering: true, - allowCrossPaneTabMove: true, - autoCloseEmptyPanes: true, - contentViewLifecycle: .keepAllAlive, - newTabPosition: .current, - appearance: appearance - ) - self.bonsplitController = BonsplitController(configuration: config) - bonsplitController.contextMenuShortcuts = Self.buildContextMenuShortcuts() - - let welcomeTabIds = bonsplitController.allTabIds + } - // Use the pre-warmed panel, updating its workspace ID to ours - claimedPanel.updateWorkspaceId(id) - let terminalPanel = claimedPanel - configureTerminalPanel(terminalPanel) - panels[terminalPanel.id] = terminalPanel - panelTitles[terminalPanel.id] = terminalPanel.displayTitle - seedTerminalInheritanceFontPoints(panelId: terminalPanel.id, configTemplate: configTemplate) + func isPanelPinned(_ panelId: UUID) -> Bool { + pinnedPanelIds.contains(panelId) + } - var initialTabId: TabID? - if let tabId = bonsplitController.createTab( - title: title, - icon: "terminal.fill", - kind: SurfaceKind.terminal, - isDirty: false, - isPinned: false - ) { - surfaceIdToPanelId[tabId] = terminalPanel.id - initialTabId = tabId - } + func panelKind(panelId: UUID) -> String? { + guard let panel = panels[panelId] else { return nil } + return surfaceKind(for: panel) + } - for welcomeTabId in welcomeTabIds { - bonsplitController.closeTab(welcomeTabId) + func requestBackgroundTerminalSurfaceStartIfNeeded() { + for terminalPanel in panels.values.compactMap({ $0 as? TerminalPanel }) { + terminalPanel.surface.requestBackgroundSurfaceStartIfNeeded() } + } - bonsplitController.onExternalTabDrop = { [weak self] request in - self?.handleExternalTabDrop(request) ?? false - } - bonsplitController.onTabCloseRequest = { [weak self] tabId, _ in - self?.markExplicitClose(surfaceId: tabId) + @discardableResult + func preloadTerminalPanelForDebugStress( + tabId: TabID, + inPane paneId: PaneID + ) -> TerminalPanel? { + guard let panelId = panelIdFromSurfaceId(tabId), + let terminalPanel = panels[panelId] as? TerminalPanel else { + return nil } - bonsplitController.delegate = self + debugStressPreloadSelectionDepth += 1 + defer { debugStressPreloadSelectionDepth -= 1 } + let isVisibleSelection = + bonsplitController.focusedPaneId == paneId && + bonsplitController.selectedTab(inPane: paneId)?.id == tabId && + terminalPanel.hostedView.window != nil && + terminalPanel.hostedView.superview != nil - if let initialTabId { - let paneToFocus: PaneID? = { - for paneId in bonsplitController.allPaneIds { - if bonsplitController.tabs(inPane: paneId).contains(where: { $0.id == initialTabId }) { - return paneId - } - } - return bonsplitController.allPaneIds.first - }() - if let paneToFocus { - bonsplitController.focusPane(paneToFocus) - } - bonsplitController.selectTab(initialTabId) + if isVisibleSelection { + terminalPanel.requestViewReattach() + scheduleTerminalGeometryReconcile() } - tmuxLayoutSnapshot = bonsplitController.layoutSnapshot() + terminalPanel.surface.requestBackgroundSurfaceStartIfNeeded() + return terminalPanel } - deinit { - activeRemoteSessionControllerID = nil - remoteSessionController?.stop() + func scheduleDebugStressTerminalGeometryReconcile() { + scheduleTerminalGeometryReconcile() } - func refreshSplitButtonTooltips() { - let tooltips = Self.currentSplitButtonTooltips() - var configuration = bonsplitController.configuration - guard configuration.appearance.splitButtonTooltips != tooltips else { return } - configuration.appearance.splitButtonTooltips = tooltips - bonsplitController.configuration = configuration + func hasLoadedTerminalSurface() -> Bool { + let terminalPanels = panels.values.compactMap { $0 as? TerminalPanel } + guard !terminalPanels.isEmpty else { return true } + return terminalPanels.contains { $0.surface.surface != nil } } - // MARK: - Surface ID to Panel ID Mapping - - /// Mapping from bonsplit TabID (surface ID) to panel UUID - private var surfaceIdToPanelId: [TabID: UUID] = [:] + func panelTitle(panelId: UUID) -> String? { + guard let panel = panels[panelId] else { return nil } + let fallback = panelTitles[panelId] ?? panel.displayTitle + return resolvedPanelTitle(panelId: panelId, fallback: fallback) + } - /// Tab IDs that are allowed to close even if they would normally require confirmation. - /// This is used by app-level confirmation prompts (e.g., Cmd+W "Close Tab?") so the - /// Bonsplit delegate doesn't block the close after the user already confirmed. - private var forceCloseTabIds: Set = [] + func setPanelPinned(panelId: UUID, pinned: Bool) { + guard panels[panelId] != nil else { return } + let wasPinned = pinnedPanelIds.contains(panelId) + guard wasPinned != pinned else { return } + if pinned { + pinnedPanelIds.insert(panelId) + } else { + pinnedPanelIds.remove(panelId) + } - /// Tab IDs that are currently showing (or about to show) a close confirmation prompt. - /// Prevents repeated close gestures (e.g., middle-click spam) from stacking dialogs. - private var pendingCloseConfirmTabIds: Set = [] + guard let tabId = surfaceIdFromPanelId(panelId), + let paneId = paneId(forPanelId: panelId) else { return } + bonsplitController.updateTab(tabId, isPinned: pinned) + normalizePinnedTabs(in: paneId) + } - /// Tab IDs whose next close attempt should be treated as an explicit - /// workspace-close gesture from the user (the tab-strip X button, or Cmd+W when - /// the shortcut preference is set to close the workspace on the last surface), - /// rather than an internal close/move flow. - private var explicitUserCloseTabIds: Set = [] + func markPanelUnread(_ panelId: UUID) { + guard panels[panelId] != nil else { return } + guard manualUnreadPanelIds.insert(panelId).inserted else { return } + manualUnreadMarkedAt[panelId] = Date() + syncUnreadBadgeStateForPanel(panelId) + } - /// 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 struct PendingTabSelectionRequest { - let tabId: TabID - let pane: PaneID - let reassertAppKitFocus: Bool - let focusIntent: PanelFocusIntent? - let previousTerminalHostedView: GhosttySurfaceScrollView? + func markPanelRead(_ panelId: UUID) { + guard panels[panelId] != nil else { return } + AppDelegate.shared?.notificationStore?.markRead(forTabId: id, surfaceId: panelId) + clearManualUnread(panelId: panelId) } - private var pendingTabSelection: PendingTabSelectionRequest? - private var isReconcilingFocusState = false - 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 layoutFollowUpObservers: [NSObjectProtocol] = [] - private var layoutFollowUpPanelsCancellable: AnyCancellable? - private var layoutFollowUpTimeoutWorkItem: DispatchWorkItem? - private var layoutFollowUpReason: String? - private var layoutFollowUpTerminalFocusPanelId: UUID? - private var layoutFollowUpBrowserPanelId: UUID? - private var layoutFollowUpBrowserExitFocusPanelId: UUID? - private var layoutFollowUpNeedsGeometryPass = false - private var layoutFollowUpAttemptScheduled = false - private var layoutFollowUpAttemptVersion: Int = 0 - private var layoutFollowUpStalledAttemptCount = 0 - private var isAttemptingLayoutFollowUp = false - private var isNormalizingPinnedTabOrder = false - private var pendingNonFocusSplitFocusReassert: PendingNonFocusSplitFocusReassert? - private var nonFocusSplitFocusReassertGeneration: UInt64 = 0 - - private struct PendingNonFocusSplitFocusReassert { - let generation: UInt64 - let preferredPanelId: UUID - let splitPanelId: UUID + + func clearManualUnread(panelId: UUID) { + let didRemoveUnread = manualUnreadPanelIds.remove(panelId) != nil + manualUnreadMarkedAt.removeValue(forKey: panelId) + guard didRemoveUnread else { return } + syncUnreadBadgeStateForPanel(panelId) } - struct DetachedSurfaceTransfer { - let panelId: UUID - let panel: any Panel - let title: String - let icon: String? - let iconImageData: Data? - let kind: String? - let isLoading: Bool - let isPinned: Bool - let directory: String? - let ttyName: String? - let cachedTitle: String? - let customTitle: String? - let manuallyUnread: Bool - let isRemoteTerminal: Bool - let remoteRelayPort: Int? - let remoteCleanupConfiguration: WorkspaceRemoteConfiguration? + static func shouldClearManualUnread( + previousFocusedPanelId: UUID?, + nextFocusedPanelId: UUID, + isManuallyUnread: Bool, + markedAt: Date?, + now: Date = Date(), + sameTabGraceInterval: TimeInterval = manualUnreadFocusGraceInterval + ) -> Bool { + guard isManuallyUnread else { return false } - func withRemoteCleanupConfiguration(_ configuration: WorkspaceRemoteConfiguration?) -> Self { - Self( - panelId: panelId, - panel: panel, - title: title, - icon: icon, - iconImageData: iconImageData, - kind: kind, - isLoading: isLoading, - isPinned: isPinned, - directory: directory, - ttyName: ttyName, - cachedTitle: cachedTitle, - customTitle: customTitle, - manuallyUnread: manuallyUnread, - isRemoteTerminal: isRemoteTerminal, - remoteRelayPort: remoteRelayPort, - remoteCleanupConfiguration: configuration - ) + if let previousFocusedPanelId, previousFocusedPanelId != nextFocusedPanelId { + return true } - } - private var detachingTabIds: Set = [] - private var pendingDetachedSurfaces: [TabID: DetachedSurfaceTransfer] = [:] - private var activeDetachCloseTransactions: Int = 0 - private var isDetachingCloseTransaction: Bool { activeDetachCloseTransactions > 0 } - private var pendingRemoteSurfaceTTYName: String? - private var pendingRemoteSurfaceTTYSurfaceId: UUID? - private var pendingRemoteSurfacePortKickReason: WorkspaceRemoteSessionController.PortScanKickReason? - private var pendingRemoteSurfacePortKickSurfaceId: UUID? - // When the last live remote terminal is detached out, the source workspace may be - // closed immediately after the move succeeds. That teardown must not shut down the - // shared SSH control master that is still serving the moved terminal. - private var skipControlMasterCleanupAfterDetachedRemoteTransfer = false - private var transferredRemoteCleanupConfigurationsByPanelId: [UUID: WorkspaceRemoteConfiguration] = [:] + guard let markedAt else { return true } + return now.timeIntervalSince(markedAt) >= sameTabGraceInterval + } -#if DEBUG - private func debugElapsedMs(since start: TimeInterval) -> String { - let ms = (ProcessInfo.processInfo.systemUptime - start) * 1000 - return String(format: "%.2f", ms) + static func shouldShowUnreadIndicator(hasUnreadNotification: Bool, isManuallyUnread: Bool) -> Bool { + hasUnreadNotification || isManuallyUnread } -#endif - func panelIdFromSurfaceId(_ surfaceId: TabID) -> UUID? { - surfaceIdToPanelId[surfaceId] + // MARK: - Title Management + + var hasCustomTitle: Bool { + let trimmed = customTitle?.trimmingCharacters(in: .whitespacesAndNewlines) ?? "" + return !trimmed.isEmpty } - func markExplicitClose(surfaceId: TabID) { - explicitUserCloseTabIds.insert(surfaceId) + var hasCustomDescription: Bool { + Self.normalizedCustomDescription(customDescription) != nil } - func surfaceIdFromPanelId(_ panelId: UUID) -> TabID? { - surfaceIdToPanelId.first { $0.value == panelId }?.key + func applyProcessTitle(_ title: String) { + processTitle = title + guard customTitle == nil else { return } + self.title = title } - private func configureTerminalPanel(_ terminalPanel: TerminalPanel) { - terminalPanel.onRequestWorkspacePaneFlash = { [weak self, weak terminalPanel] reason in - guard let self, let terminalPanel else { return } - self.triggerWorkspacePaneFlash(panelId: terminalPanel.id, reason: reason) + func setCustomColor(_ hex: String?) { + if let hex { + customColor = WorkspaceTabColorSettings.normalizedHex(hex) + } else { + customColor = nil } } - private func triggerWorkspacePaneFlash(panelId: UUID, reason: WorkspaceAttentionFlashReason) { - tmuxWorkspaceFlashPanelId = panelId - tmuxWorkspaceFlashReason = reason - tmuxWorkspaceFlashToken &+= 1 + static func normalizedCustomDescription(_ description: String?) -> String? { + let normalizedLineEndings = description? + .replacingOccurrences(of: "\r\n", with: "\n") + .replacingOccurrences(of: "\r", with: "\n") + let trimmed = normalizedLineEndings?.trimmingCharacters(in: .whitespacesAndNewlines) ?? "" + guard !trimmed.isEmpty else { return nil } + return normalizedLineEndings } - - private func installBrowserPanelSubscription(_ browserPanel: BrowserPanel) { - let subscription = Publishers.CombineLatest3( - browserPanel.$pageTitle.removeDuplicates(), - browserPanel.$isLoading.removeDuplicates(), - browserPanel.$faviconPNGData.removeDuplicates(by: { $0 == $1 }) - ) - .receive(on: DispatchQueue.main) - .sink { [weak self, weak browserPanel] _, isLoading, favicon in - guard let self = self, - let browserPanel = browserPanel, - let tabId = self.surfaceIdFromPanelId(browserPanel.id) else { return } - guard let existing = self.bonsplitController.tab(tabId) else { return } - - let nextTitle = browserPanel.displayTitle - if self.panelTitles[browserPanel.id] != nextTitle { - self.panelTitles[browserPanel.id] = nextTitle - } - let resolvedTitle = self.resolvedPanelTitle(panelId: browserPanel.id, fallback: nextTitle) - let titleUpdate: String? = existing.title == resolvedTitle ? nil : resolvedTitle - let faviconUpdate: Data?? = existing.iconImageData == favicon ? nil : .some(favicon) - let loadingUpdate: Bool? = existing.isLoading == isLoading ? nil : isLoading - - guard titleUpdate != nil || faviconUpdate != nil || loadingUpdate != nil else { return } - self.bonsplitController.updateTab( - tabId, - title: titleUpdate, - iconImageData: faviconUpdate, - hasCustomTitle: self.panelCustomTitles[browserPanel.id] != nil, - isLoading: loadingUpdate - ) + func setCustomTitle(_ title: String?) { + let trimmed = title?.trimmingCharacters(in: .whitespacesAndNewlines) ?? "" + if trimmed.isEmpty { + customTitle = nil + self.title = processTitle + } else { + customTitle = trimmed + self.title = trimmed } - panelSubscriptions[browserPanel.id] = subscription - setPreferredBrowserProfileID(browserPanel.profileID) } - func setPreferredBrowserProfileID(_ profileID: UUID?) { - guard let profileID else { - preferredBrowserProfileID = nil - return - } - guard BrowserProfileStore.shared.profileDefinition(id: profileID) != nil else { return } - preferredBrowserProfileID = profileID + func setCustomDescription(_ description: String?) { + let normalizedDescription = Self.normalizedCustomDescription(description) +#if DEBUG + let inputNewlines = description?.reduce(into: 0) { count, character in + if character == "\n" { count += 1 } + } ?? 0 + let normalizedNewlines = normalizedDescription?.reduce(into: 0) { count, character in + if character == "\n" { count += 1 } + } ?? 0 + dlog( + "workspace.customDescription.update workspace=\(id.uuidString.prefix(8)) " + + "inputLen=\((description as NSString?)?.length ?? 0) " + + "inputNewlines=\(inputNewlines) " + + "normalizedLen=\((normalizedDescription as NSString?)?.length ?? 0) " + + "normalizedNewlines=\(normalizedNewlines) " + + "input=\"\(debugWorkspaceDescriptionPreview(description))\" " + + "normalized=\"\(debugWorkspaceDescriptionPreview(normalizedDescription))\"" + ) +#endif + customDescription = normalizedDescription } - private func resolvedNewBrowserProfileID( - preferredProfileID: UUID? = nil, - sourcePanelId: UUID? = nil - ) -> UUID { - if let preferredProfileID, - BrowserProfileStore.shared.profileDefinition(id: preferredProfileID) != nil { - return preferredProfileID - } - if let sourcePanelId, - let sourceBrowserPanel = browserPanel(for: sourcePanelId), - BrowserProfileStore.shared.profileDefinition(id: sourceBrowserPanel.profileID) != nil { - return sourceBrowserPanel.profileID + // MARK: - Directory Updates + + func updatePanelDirectory(panelId: UUID, directory: String) { + let trimmed = directory.trimmingCharacters(in: .whitespacesAndNewlines) + guard !trimmed.isEmpty else { return } + if panelDirectories[panelId] != trimmed { + panelDirectories[panelId] = trimmed } - if let preferredBrowserProfileID, - BrowserProfileStore.shared.profileDefinition(id: preferredBrowserProfileID) != nil { - return preferredBrowserProfileID + // Update current directory if this is the focused panel + if panelId == focusedPanelId, currentDirectory != trimmed { + currentDirectory = trimmed } - return BrowserProfileStore.shared.effectiveLastUsedProfileID } - 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 + /// Updates the shell-activity state for a panel. + /// + /// - Returns: `true` if the update was applied (panel exists and state changed), + /// `false` if it was a no-op (panel absent or state unchanged). + /// Callers that deduplicate reports MUST only record the state in their dedup + /// dict when this returns `true`; recording on `false` would suppress the next + /// identical report even though it was never actually applied. + @discardableResult + func updatePanelShellActivityState(panelId: UUID, state: PanelShellActivityState) -> Bool { + guard panels[panelId] != nil else { return false } + let previousState = panelShellActivityStates[panelId] ?? .unknown + guard previousState != state else { return false } + panelShellActivityStates[panelId] = state +#if DEBUG + dlog( + "surface.shellState workspace=\(id.uuidString.prefix(5)) " + + "panel=\(panelId.uuidString.prefix(5)) from=\(previousState.rawValue) to=\(state.rawValue)" + ) +#endif + return true } - private func browserRemoteWorkspaceStatusSnapshot() -> BrowserRemoteWorkspaceStatus? { - guard let target = remoteDisplayTarget else { return nil } - return BrowserRemoteWorkspaceStatus( - target: target, - connectionState: remoteConnectionState, - heartbeatCount: remoteHeartbeatCount, - lastHeartbeatAt: remoteLastHeartbeatAt + func panelNeedsConfirmClose(panelId: UUID, fallbackNeedsConfirmClose: Bool) -> Bool { + Self.resolveCloseConfirmation( + shellActivityState: panelShellActivityStates[panelId], + fallbackNeedsConfirmClose: fallbackNeedsConfirmClose ) } - private func applyBrowserRemoteWorkspaceStatusToPanels() { - let snapshot = browserRemoteWorkspaceStatusSnapshot() - for panel in panels.values { - guard let browserPanel = panel as? BrowserPanel else { continue } - browserPanel.setRemoteWorkspaceStatus(snapshot) + func updatePanelGitBranch(panelId: UUID, branch: String, isDirty: Bool) { + let state = SidebarGitBranchState(branch: branch, isDirty: isDirty) + let existing = panelGitBranches[panelId] + let branchChanged = existing?.branch != nil && existing?.branch != branch + if existing?.branch != branch || existing?.isDirty != isDirty { + panelGitBranches[panelId] = state } - } - - // MARK: - Panel Access - - func panel(for surfaceId: TabID) -> (any Panel)? { - guard let panelId = panelIdFromSurfaceId(surfaceId) else { return nil } - return panels[panelId] - } - - func terminalPanel(for panelId: UUID) -> TerminalPanel? { - panels[panelId] as? TerminalPanel - } - - func browserPanel(for panelId: UUID) -> BrowserPanel? { - panels[panelId] as? BrowserPanel - } - - func markdownPanel(for panelId: UUID) -> MarkdownPanel? { - panels[panelId] as? MarkdownPanel - } - - private func surfaceKind(for panel: any Panel) -> String { - switch panel.panelType { - case .terminal: - return SurfaceKind.terminal - case .browser: - return SurfaceKind.browser - case .markdown: - return SurfaceKind.markdown + if branchChanged { + if panelPullRequests[panelId] != nil { + panelPullRequests.removeValue(forKey: panelId) + } + if panelId == focusedPanelId, pullRequest != nil { + pullRequest = nil + } } - } - - private func resolvedPanelTitle(panelId: UUID, fallback: String) -> String { - let trimmedFallback = fallback.trimmingCharacters(in: .whitespacesAndNewlines) - let fallbackTitle = trimmedFallback.isEmpty ? "Tab" : trimmedFallback - if let custom = panelCustomTitles[panelId]?.trimmingCharacters(in: .whitespacesAndNewlines), - !custom.isEmpty { - return custom + if panelId == focusedPanelId, gitBranch != state { + gitBranch = state } - return fallbackTitle } - private func syncPinnedStateForTab(_ tabId: TabID, panelId: UUID) { - let isPinned = pinnedPanelIds.contains(panelId) - if let panel = panels[panelId] { - bonsplitController.updateTab( - tabId, - kind: .some(surfaceKind(for: panel)), - isPinned: isPinned - ) - } else { - bonsplitController.updateTab(tabId, isPinned: isPinned) + func clearPanelGitBranch(panelId: UUID) { + if panelGitBranches[panelId] != nil { + panelGitBranches.removeValue(forKey: panelId) } - } - - private func hasUnreadNotification(panelId: UUID) -> Bool { - AppDelegate.shared?.notificationStore?.hasVisibleNotificationIndicator(forTabId: id, surfaceId: panelId) ?? false - } - - private func attentionPersistentState() -> WorkspaceAttentionPersistentState { - let notificationStore = AppDelegate.shared?.notificationStore - let unreadPanelIDs = Set( - panels.keys.filter { - notificationStore?.hasUnreadNotification(forTabId: id, surfaceId: $0) ?? false + if panelPullRequests[panelId] != nil { + panelPullRequests.removeValue(forKey: panelId) + } + if panelId == focusedPanelId { + if gitBranch != nil { + gitBranch = nil + } + if pullRequest != nil { + pullRequest = nil } - ) - return WorkspaceAttentionPersistentState( - unreadPanelIDs: unreadPanelIDs, - focusedReadPanelID: notificationStore?.focusedReadIndicatorSurfaceId(forTabId: id), - manualUnreadPanelIDs: manualUnreadPanelIds - ) - } - - private func requestAttentionFlash(panelId: UUID, reason: WorkspaceAttentionFlashReason) { - let decision = WorkspaceAttentionCoordinator.decideFlash( - targetPanelID: panelId, - reason: reason, - persistentState: attentionPersistentState() - ) - guard decision.isAllowed else { return } - panels[panelId]?.triggerFlash(reason: reason) - } - - private func syncUnreadBadgeStateForPanel(_ panelId: UUID) { - guard let tabId = surfaceIdFromPanelId(panelId) else { return } - 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) } - private func normalizePinnedTabs(in paneId: PaneID) { - guard !isNormalizingPinnedTabOrder else { return } - isNormalizingPinnedTabOrder = true - defer { isNormalizingPinnedTabOrder = false } - - let tabs = bonsplitController.tabs(inPane: paneId) - let pinnedTabs = tabs.filter { tab in - guard let panelId = panelIdFromSurfaceId(tab.id) else { return false } - return pinnedPanelIds.contains(panelId) + func updatePanelPullRequest( + panelId: UUID, + number: Int, + label: String, + url: URL, + status: SidebarPullRequestStatus, + branch: String? = nil, + checks: SidebarPullRequestChecksStatus? = nil + ) { + let existing = panelPullRequests[panelId] + let normalizedBranch = normalizedSidebarBranchName(branch) + let currentPanelBranch = normalizedSidebarBranchName(panelGitBranches[panelId]?.branch) + let resolvedBranch: String? = { + if let normalizedBranch { + return normalizedBranch + } + if let currentPanelBranch { + return currentPanelBranch + } + guard let existing, + existing.number == number, + existing.label == label, + existing.url == url, + existing.status == status else { + return nil + } + return existing.branch + }() + let resolvedChecks: SidebarPullRequestChecksStatus? = { + if let checks { + return checks + } + guard let existing, + existing.number == number, + existing.label == label, + existing.url == url, + existing.status == status else { + return nil + } + return existing.checks + }() + let state = SidebarPullRequestState( + number: number, + label: label, + url: url, + status: status, + branch: resolvedBranch, + checks: resolvedChecks + ) + if existing != state { + panelPullRequests[panelId] = state } - let unpinnedTabs = tabs.filter { tab in - guard let panelId = panelIdFromSurfaceId(tab.id) else { return true } - return !pinnedPanelIds.contains(panelId) + if panelId == focusedPanelId, pullRequest != state { + pullRequest = state } - let desiredOrder = pinnedTabs + unpinnedTabs + } - for (index, desiredTab) in desiredOrder.enumerated() { - let currentTabs = bonsplitController.tabs(inPane: paneId) - guard let currentIndex = currentTabs.firstIndex(where: { $0.id == desiredTab.id }) else { continue } - if currentIndex != index { - _ = bonsplitController.reorderTab(desiredTab.id, toIndex: index) - } + func clearPanelPullRequest(panelId: UUID) { + if panelPullRequests[panelId] != nil { + panelPullRequests.removeValue(forKey: panelId) + } + if panelId == focusedPanelId, pullRequest != nil { + pullRequest = nil } } - private func insertionIndexToRight(of anchorTabId: TabID, inPane paneId: PaneID) -> Int { - let tabs = bonsplitController.tabs(inPane: paneId) - guard let anchorIndex = tabs.firstIndex(where: { $0.id == anchorTabId }) else { return tabs.count } - let pinnedCount = tabs.reduce(into: 0) { count, tab in - if let panelId = panelIdFromSurfaceId(tab.id), pinnedPanelIds.contains(panelId) { - count += 1 - } - } - let rawTarget = min(anchorIndex + 1, tabs.count) - return max(rawTarget, pinnedCount) + func resetSidebarContext(reason: String = "unspecified") { + statusEntries.removeAll() + agentPIDs.removeAll() + agentListeningPorts.removeAll() + logEntries.removeAll() + progress = nil + gitBranch = nil + panelGitBranches.removeAll() + pullRequest = nil + panelPullRequests.removeAll() + surfaceListeningPorts.removeAll() + listeningPorts.removeAll() + metadataBlocks.removeAll() + resetBrowserPanelsForContextChange(reason: reason) } - func setPanelCustomTitle(panelId: UUID, title: String?) { - guard panels[panelId] != nil else { return } - let trimmed = title?.trimmingCharacters(in: .whitespacesAndNewlines) ?? "" - let previous = panelCustomTitles[panelId] - if trimmed.isEmpty { - guard previous != nil else { return } - panelCustomTitles.removeValue(forKey: panelId) - } else { - guard previous != trimmed else { return } - panelCustomTitles[panelId] = trimmed - } + func resetBrowserPanelsForContextChange(reason: String) { + let browserPanels = panels.values.compactMap { $0 as? BrowserPanel } + guard !browserPanels.isEmpty else { return } - guard let panel = panels[panelId], let tabId = surfaceIdFromPanelId(panelId) else { return } - let baseTitle = panelTitles[panelId] ?? panel.displayTitle - bonsplitController.updateTab( - tabId, - title: resolvedPanelTitle(panelId: panelId, fallback: baseTitle), - hasCustomTitle: panelCustomTitles[panelId] != nil +#if DEBUG + dlog( + "workspace.contextReset.browserPanels workspace=\(id.uuidString.prefix(5)) " + + "reason=\(reason) count=\(browserPanels.count)" ) - } +#endif - func isPanelPinned(_ panelId: UUID) -> Bool { - pinnedPanelIds.contains(panelId) - } + for browserPanel in browserPanels { + browserPanel.resetForWorkspaceContextChange(reason: reason) + let nextTitle = browserPanel.displayTitle + _ = updatePanelTitle(panelId: browserPanel.id, title: nextTitle) - func panelKind(panelId: UUID) -> String? { - guard let panel = panels[panelId] else { return nil } - return surfaceKind(for: panel) - } + guard let tabId = surfaceIdFromPanelId(browserPanel.id), + let existing = bonsplitController.tab(tabId) else { + continue + } - func requestBackgroundTerminalSurfaceStartIfNeeded() { - for terminalPanel in panels.values.compactMap({ $0 as? TerminalPanel }) { - terminalPanel.surface.requestBackgroundSurfaceStartIfNeeded() + let faviconUpdate: Data?? = existing.iconImageData == nil ? nil : .some(nil) + let loadingUpdate: Bool? = existing.isLoading ? false : nil + + guard faviconUpdate != nil || loadingUpdate != nil else { + continue + } + + bonsplitController.updateTab( + tabId, + iconImageData: faviconUpdate, + hasCustomTitle: panelCustomTitles[browserPanel.id] != nil, + isLoading: loadingUpdate + ) } } @discardableResult - func preloadTerminalPanelForDebugStress( - tabId: TabID, - inPane paneId: PaneID - ) -> TerminalPanel? { - guard let panelId = panelIdFromSurfaceId(tabId), - let terminalPanel = panels[panelId] as? TerminalPanel else { - return nil - } + func updatePanelTitle(panelId: UUID, title: String) -> Bool { + let trimmed = title.trimmingCharacters(in: .whitespacesAndNewlines) + guard !trimmed.isEmpty else { return false } + var didMutate = false - debugStressPreloadSelectionDepth += 1 - defer { debugStressPreloadSelectionDepth -= 1 } - let isVisibleSelection = - bonsplitController.focusedPaneId == paneId && - bonsplitController.selectedTab(inPane: paneId)?.id == tabId && - terminalPanel.hostedView.window != nil && - terminalPanel.hostedView.superview != nil + if panelTitles[panelId] != trimmed { + panelTitles[panelId] = trimmed + didMutate = true + } - if isVisibleSelection { - terminalPanel.requestViewReattach() - scheduleTerminalGeometryReconcile() + // Update bonsplit tab title only when this panel's title changed. + if didMutate, + let tabId = surfaceIdFromPanelId(panelId), + let panel = panels[panelId] { + let baseTitle = panelTitles[panelId] ?? panel.displayTitle + let resolvedTitle = resolvedPanelTitle(panelId: panelId, fallback: baseTitle) + bonsplitController.updateTab( + tabId, + title: resolvedTitle, + hasCustomTitle: panelCustomTitles[panelId] != nil + ) } - terminalPanel.surface.requestBackgroundSurfaceStartIfNeeded() - return terminalPanel - } - func scheduleDebugStressTerminalGeometryReconcile() { - scheduleTerminalGeometryReconcile() - } + // If this is the only panel and no custom title, update workspace title + if panels.count == 1, customTitle == nil { + if self.title != trimmed { + self.title = trimmed + didMutate = true + } + if processTitle != trimmed { + processTitle = trimmed + } + } - func hasLoadedTerminalSurface() -> Bool { - let terminalPanels = panels.values.compactMap { $0 as? TerminalPanel } - guard !terminalPanels.isEmpty else { return true } - return terminalPanels.contains { $0.surface.surface != nil } + return didMutate } - func panelTitle(panelId: UUID) -> String? { - guard let panel = panels[panelId] else { return nil } - let fallback = panelTitles[panelId] ?? panel.displayTitle - return resolvedPanelTitle(panelId: panelId, fallback: fallback) + func pruneSurfaceMetadata(validSurfaceIds: Set) { + panelDirectories = panelDirectories.filter { validSurfaceIds.contains($0.key) } + panelTitles = panelTitles.filter { validSurfaceIds.contains($0.key) } + panelCustomTitles = panelCustomTitles.filter { validSurfaceIds.contains($0.key) } + 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) } + remoteDetectedSurfaceIds = remoteDetectedSurfaceIds.filter { validSurfaceIds.contains($0) } + panelShellActivityStates = panelShellActivityStates.filter { validSurfaceIds.contains($0.key) } + panelPullRequests = panelPullRequests.filter { validSurfaceIds.contains($0.key) } + syncRemotePortScanTTYs() + recomputeListeningPorts() } - func setPanelPinned(panelId: UUID, pinned: Bool) { - guard panels[panelId] != nil else { return } - let wasPinned = pinnedPanelIds.contains(panelId) - guard wasPinned != pinned else { return } - if pinned { - pinnedPanelIds.insert(panelId) - } else { - pinnedPanelIds.remove(panelId) + func recomputeListeningPorts() { + let unique = Set(surfaceListeningPorts.values.flatMap { $0 }) + .union(agentListeningPorts) + .union(remoteDetectedPorts) + .union(remoteForwardedPorts) + let next = unique.sorted() + if listeningPorts != next { + listeningPorts = next } - - guard let tabId = surfaceIdFromPanelId(panelId), - let paneId = paneId(forPanelId: panelId) else { return } - bonsplitController.updateTab(tabId, isPinned: pinned) - normalizePinnedTabs(in: paneId) } - func markPanelUnread(_ panelId: UUID) { - guard panels[panelId] != nil else { return } - guard manualUnreadPanelIds.insert(panelId).inserted else { return } - manualUnreadMarkedAt[panelId] = Date() - syncUnreadBadgeStateForPanel(panelId) - } - - func markPanelRead(_ panelId: UUID) { - guard panels[panelId] != nil else { return } - AppDelegate.shared?.notificationStore?.markRead(forTabId: id, surfaceId: panelId) - clearManualUnread(panelId: panelId) - } + func sidebarOrderedPanelIds() -> [UUID] { + let paneTabs: [String: [UUID]] = Dictionary( + uniqueKeysWithValues: bonsplitController.allPaneIds.map { paneId in + let panelIds = bonsplitController + .tabs(inPane: paneId) + .compactMap { panelIdFromSurfaceId($0.id) } + return (paneId.id.uuidString, panelIds) + } + ) - func clearManualUnread(panelId: UUID) { - let didRemoveUnread = manualUnreadPanelIds.remove(panelId) != nil - manualUnreadMarkedAt.removeValue(forKey: panelId) - guard didRemoveUnread else { return } - syncUnreadBadgeStateForPanel(panelId) + let fallbackPanelIds = panels.keys.sorted { $0.uuidString < $1.uuidString } + let tree = bonsplitController.treeSnapshot() + return SidebarBranchOrdering.orderedPanelIds( + tree: tree, + paneTabs: paneTabs, + fallbackPanelIds: fallbackPanelIds + ) } - static func shouldClearManualUnread( - previousFocusedPanelId: UUID?, - nextFocusedPanelId: UUID, - isManuallyUnread: Bool, - markedAt: Date?, - now: Date = Date(), - sameTabGraceInterval: TimeInterval = manualUnreadFocusGraceInterval - ) -> Bool { - guard isManuallyUnread else { return false } + func normalizedSidebarDirectory(_ directory: String?) -> String? { + guard let directory else { return nil } + let trimmed = directory.trimmingCharacters(in: .whitespacesAndNewlines) + return trimmed.isEmpty ? nil : trimmed + } - if let previousFocusedPanelId, previousFocusedPanelId != nextFocusedPanelId { - return true + func sidebarHomeDirectoryForCanonicalization( + resolvedPanelDirectories: [UUID: String] + ) -> String? { + if isRemoteWorkspace { + return SidebarBranchOrdering.inferredRemoteHomeDirectory( + from: Array(resolvedPanelDirectories.values), + fallbackDirectory: normalizedSidebarDirectory(currentDirectory) + ) } + return FileManager.default.homeDirectoryForCurrentUser.path + } - guard let markedAt else { return true } - return now.timeIntervalSince(markedAt) >= sameTabGraceInterval + func sidebarResolvedDirectory(for panelId: UUID) -> String? { + if let directory = normalizedSidebarDirectory(panelDirectories[panelId]) { + return directory + } + if let requestedDirectory = normalizedSidebarDirectory( + terminalPanel(for: panelId)?.requestedWorkingDirectory + ) { + return requestedDirectory + } + guard panelId == focusedPanelId else { return nil } + return normalizedSidebarDirectory(currentDirectory) } - static func shouldShowUnreadIndicator(hasUnreadNotification: Bool, isManuallyUnread: Bool) -> Bool { - hasUnreadNotification || isManuallyUnread + func sidebarResolvedPanelDirectories(orderedPanelIds: [UUID]) -> [UUID: String] { + var resolved: [UUID: String] = [:] + for panelId in orderedPanelIds { + if let directory = sidebarResolvedDirectory(for: panelId) { + resolved[panelId] = directory + } + } + return resolved } - // MARK: - Title Management + func sidebarDirectoriesInDisplayOrder(orderedPanelIds: [UUID]) -> [String] { + let resolvedDirectories = sidebarResolvedPanelDirectories(orderedPanelIds: orderedPanelIds) + let homeDirectoryForCanonicalization = sidebarHomeDirectoryForCanonicalization( + resolvedPanelDirectories: resolvedDirectories + ) + var ordered: [String] = [] + var seen: Set = [] - var hasCustomTitle: Bool { - let trimmed = customTitle?.trimmingCharacters(in: .whitespacesAndNewlines) ?? "" - return !trimmed.isEmpty + for panelId in orderedPanelIds { + guard let directory = resolvedDirectories[panelId], + let key = SidebarBranchOrdering.canonicalDirectoryKey( + directory, + homeDirectoryForTildeExpansion: homeDirectoryForCanonicalization + ) else { continue } + if seen.insert(key).inserted { + ordered.append(directory) + } + } + + if ordered.isEmpty, let fallbackDirectory = normalizedSidebarDirectory(currentDirectory) { + return [fallbackDirectory] + } + + return ordered } - var hasCustomDescription: Bool { - Self.normalizedCustomDescription(customDescription) != nil + func sidebarDirectoriesInDisplayOrder() -> [String] { + sidebarDirectoriesInDisplayOrder(orderedPanelIds: sidebarOrderedPanelIds()) } - func applyProcessTitle(_ title: String) { - processTitle = title - guard customTitle == nil else { return } - self.title = title + func sidebarGitBranchesInDisplayOrder(orderedPanelIds: [UUID]) -> [SidebarGitBranchState] { + SidebarBranchOrdering + .orderedUniqueBranches( + orderedPanelIds: orderedPanelIds, + panelBranches: panelGitBranches, + fallbackBranch: gitBranch + ) + .map { SidebarGitBranchState(branch: $0.name, isDirty: $0.isDirty) } } - func setCustomColor(_ hex: String?) { - if let hex { - customColor = WorkspaceTabColorSettings.normalizedHex(hex) - } else { - customColor = nil - } + func sidebarGitBranchesInDisplayOrder() -> [SidebarGitBranchState] { + sidebarGitBranchesInDisplayOrder(orderedPanelIds: sidebarOrderedPanelIds()) } - private static func normalizedCustomDescription(_ description: String?) -> String? { - let normalizedLineEndings = description? - .replacingOccurrences(of: "\r\n", with: "\n") - .replacingOccurrences(of: "\r", with: "\n") - let trimmed = normalizedLineEndings?.trimmingCharacters(in: .whitespacesAndNewlines) ?? "" - guard !trimmed.isEmpty else { return nil } - return normalizedLineEndings + func sidebarBranchDirectoryEntriesInDisplayOrder( + orderedPanelIds: [UUID] + ) -> [SidebarBranchOrdering.BranchDirectoryEntry] { + let resolvedDirectories = sidebarResolvedPanelDirectories(orderedPanelIds: orderedPanelIds) + return SidebarBranchOrdering.orderedUniqueBranchDirectoryEntries( + orderedPanelIds: orderedPanelIds, + panelBranches: panelGitBranches, + panelDirectories: resolvedDirectories, + defaultDirectory: normalizedSidebarDirectory(currentDirectory), + homeDirectoryForTildeExpansion: sidebarHomeDirectoryForCanonicalization( + resolvedPanelDirectories: resolvedDirectories + ), + fallbackBranch: gitBranch + ) } - func setCustomTitle(_ title: String?) { - let trimmed = title?.trimmingCharacters(in: .whitespacesAndNewlines) ?? "" - if trimmed.isEmpty { - customTitle = nil - self.title = processTitle - } else { - customTitle = trimmed - self.title = trimmed - } + func sidebarBranchDirectoryEntriesInDisplayOrder() -> [SidebarBranchOrdering.BranchDirectoryEntry] { + sidebarBranchDirectoryEntriesInDisplayOrder(orderedPanelIds: sidebarOrderedPanelIds()) } - func setCustomDescription(_ description: String?) { - let normalizedDescription = Self.normalizedCustomDescription(description) -#if DEBUG - let inputNewlines = description?.reduce(into: 0) { count, character in - if character == "\n" { count += 1 } - } ?? 0 - let normalizedNewlines = normalizedDescription?.reduce(into: 0) { count, character in - if character == "\n" { count += 1 } - } ?? 0 - dlog( - "workspace.customDescription.update workspace=\(id.uuidString.prefix(8)) " + - "inputLen=\((description as NSString?)?.length ?? 0) " + - "inputNewlines=\(inputNewlines) " + - "normalizedLen=\((normalizedDescription as NSString?)?.length ?? 0) " + - "normalizedNewlines=\(normalizedNewlines) " + - "input=\"\(debugWorkspaceDescriptionPreview(description))\" " + - "normalized=\"\(debugWorkspaceDescriptionPreview(normalizedDescription))\"" + func sidebarPullRequestsInDisplayOrder(orderedPanelIds: [UUID]) -> [SidebarPullRequestState] { + let validPanelPullRequests = panelPullRequests.filter { panelId, state in + guard let pullRequestBranch = normalizedSidebarBranchName(state.branch) else { + return true + } + return normalizedSidebarBranchName(panelGitBranches[panelId]?.branch) == pullRequestBranch + } + return SidebarBranchOrdering.orderedUniquePullRequests( + orderedPanelIds: orderedPanelIds, + panelPullRequests: validPanelPullRequests, + fallbackPullRequest: nil ) -#endif - customDescription = normalizedDescription } - // MARK: - Directory Updates + func sidebarPullRequestsInDisplayOrder() -> [SidebarPullRequestState] { + sidebarPullRequestsInDisplayOrder(orderedPanelIds: sidebarOrderedPanelIds()) + } - func updatePanelDirectory(panelId: UUID, directory: String) { - let trimmed = directory.trimmingCharacters(in: .whitespacesAndNewlines) - guard !trimmed.isEmpty else { return } - if panelDirectories[panelId] != trimmed { - panelDirectories[panelId] = trimmed - } - // Update current directory if this is the focused panel - if panelId == focusedPanelId, currentDirectory != trimmed { - currentDirectory = trimmed + func sidebarStatusEntriesInDisplayOrder() -> [SidebarStatusEntry] { + statusEntries.values.sorted { lhs, rhs in + if lhs.priority != rhs.priority { return lhs.priority > rhs.priority } + if lhs.timestamp != rhs.timestamp { return lhs.timestamp > rhs.timestamp } + return lhs.key < rhs.key } } - /// Updates the shell-activity state for a panel. - /// - /// - Returns: `true` if the update was applied (panel exists and state changed), - /// `false` if it was a no-op (panel absent or state unchanged). - /// Callers that deduplicate reports MUST only record the state in their dedup - /// dict when this returns `true`; recording on `false` would suppress the next - /// identical report even though it was never actually applied. - @discardableResult - func updatePanelShellActivityState(panelId: UUID, state: PanelShellActivityState) -> Bool { - guard panels[panelId] != nil else { return false } - let previousState = panelShellActivityStates[panelId] ?? .unknown - guard previousState != state else { return false } - panelShellActivityStates[panelId] = state -#if DEBUG - dlog( - "surface.shellState workspace=\(id.uuidString.prefix(5)) " + - "panel=\(panelId.uuidString.prefix(5)) from=\(previousState.rawValue) to=\(state.rawValue)" - ) -#endif - return true + func sidebarMetadataBlocksInDisplayOrder() -> [SidebarMetadataBlock] { + metadataBlocks.values.sorted { lhs, rhs in + if lhs.priority != rhs.priority { return lhs.priority > rhs.priority } + if lhs.timestamp != rhs.timestamp { return lhs.timestamp > rhs.timestamp } + return lhs.key < rhs.key + } } - func panelNeedsConfirmClose(panelId: UUID, fallbackNeedsConfirmClose: Bool) -> Bool { - Self.resolveCloseConfirmation( - shellActivityState: panelShellActivityStates[panelId], - fallbackNeedsConfirmClose: fallbackNeedsConfirmClose - ) + var isRemoteWorkspace: Bool { + remoteConfiguration != nil } - func updatePanelGitBranch(panelId: UUID, branch: String, isDirty: Bool) { - let state = SidebarGitBranchState(branch: branch, isDirty: isDirty) - let existing = panelGitBranches[panelId] - let branchChanged = existing?.branch != nil && existing?.branch != branch - if existing?.branch != branch || existing?.isDirty != isDirty { - panelGitBranches[panelId] = state - } - if branchChanged { - if panelPullRequests[panelId] != nil { - panelPullRequests.removeValue(forKey: panelId) - } - if panelId == focusedPanelId, pullRequest != nil { - pullRequest = nil - } - } - if panelId == focusedPanelId, gitBranch != state { - gitBranch = state - } + @MainActor + func isRemoteTerminalSurface(_ panelId: UUID) -> Bool { + activeRemoteTerminalSurfaceIds.contains(panelId) } - func clearPanelGitBranch(panelId: UUID) { - if panelGitBranches[panelId] != nil { - panelGitBranches.removeValue(forKey: panelId) - } - if panelPullRequests[panelId] != nil { - panelPullRequests.removeValue(forKey: panelId) - } - if panelId == focusedPanelId { - if gitBranch != nil { - gitBranch = nil - } - if pullRequest != nil { - pullRequest = nil - } - } - } - - func updatePanelPullRequest( - panelId: UUID, - number: Int, - label: String, - url: URL, - status: SidebarPullRequestStatus, - branch: String? = nil, - checks: SidebarPullRequestChecksStatus? = nil - ) { - let existing = panelPullRequests[panelId] - let normalizedBranch = normalizedSidebarBranchName(branch) - let currentPanelBranch = normalizedSidebarBranchName(panelGitBranches[panelId]?.branch) - let resolvedBranch: String? = { - if let normalizedBranch { - return normalizedBranch - } - if let currentPanelBranch { - return currentPanelBranch - } - guard let existing, - existing.number == number, - existing.label == label, - existing.url == url, - existing.status == status else { - return nil - } - return existing.branch - }() - let resolvedChecks: SidebarPullRequestChecksStatus? = { - if let checks { - return checks - } - guard let existing, - existing.number == number, - existing.label == label, - existing.url == url, - existing.status == status else { - return nil - } - return existing.checks - }() - let state = SidebarPullRequestState( - number: number, - label: label, - url: url, - status: status, - branch: resolvedBranch, - checks: resolvedChecks - ) - if existing != state { - panelPullRequests[panelId] = state - } - if panelId == focusedPanelId, pullRequest != state { - pullRequest = state - } - } - - func clearPanelPullRequest(panelId: UUID) { - if panelPullRequests[panelId] != nil { - panelPullRequests.removeValue(forKey: panelId) - } - if panelId == focusedPanelId, pullRequest != nil { - pullRequest = nil - } - } - - func resetSidebarContext(reason: String = "unspecified") { - statusEntries.removeAll() - agentPIDs.removeAll() - agentListeningPorts.removeAll() - logEntries.removeAll() - progress = nil - gitBranch = nil - panelGitBranches.removeAll() - pullRequest = nil - panelPullRequests.removeAll() - surfaceListeningPorts.removeAll() - listeningPorts.removeAll() - metadataBlocks.removeAll() - resetBrowserPanelsForContextChange(reason: reason) - } - - func resetBrowserPanelsForContextChange(reason: String) { - let browserPanels = panels.values.compactMap { $0 as? BrowserPanel } - guard !browserPanels.isEmpty else { return } - -#if DEBUG - dlog( - "workspace.contextReset.browserPanels workspace=\(id.uuidString.prefix(5)) " + - "reason=\(reason) count=\(browserPanels.count)" - ) -#endif - - for browserPanel in browserPanels { - browserPanel.resetForWorkspaceContextChange(reason: reason) - let nextTitle = browserPanel.displayTitle - _ = updatePanelTitle(panelId: browserPanel.id, title: nextTitle) - - guard let tabId = surfaceIdFromPanelId(browserPanel.id), - let existing = bonsplitController.tab(tabId) else { - continue - } - - let faviconUpdate: Data?? = existing.iconImageData == nil ? nil : .some(nil) - let loadingUpdate: Bool? = existing.isLoading ? false : nil - - guard faviconUpdate != nil || loadingUpdate != nil else { - continue - } - - bonsplitController.updateTab( - tabId, - iconImageData: faviconUpdate, - hasCustomTitle: panelCustomTitles[browserPanel.id] != nil, - isLoading: loadingUpdate - ) - } + @MainActor + func shouldDemoteWorkspaceAfterChildExit(surfaceId: UUID) -> Bool { + isRemoteWorkspace || pendingRemoteTerminalChildExitSurfaceIds.contains(surfaceId) } - @discardableResult - func updatePanelTitle(panelId: UUID, title: String) -> Bool { - let trimmed = title.trimmingCharacters(in: .whitespacesAndNewlines) - guard !trimmed.isEmpty else { return false } - var didMutate = false - - if panelTitles[panelId] != trimmed { - panelTitles[panelId] = trimmed - didMutate = true - } - - // Update bonsplit tab title only when this panel's title changed. - if didMutate, - let tabId = surfaceIdFromPanelId(panelId), - let panel = panels[panelId] { - let baseTitle = panelTitles[panelId] ?? panel.displayTitle - let resolvedTitle = resolvedPanelTitle(panelId: panelId, fallback: baseTitle) - bonsplitController.updateTab( - tabId, - title: resolvedTitle, - hasCustomTitle: panelCustomTitles[panelId] != nil - ) - } - - // If this is the only panel and no custom title, update workspace title - if panels.count == 1, customTitle == nil { - if self.title != trimmed { - self.title = trimmed - didMutate = true - } - if processTitle != trimmed { - processTitle = trimmed - } - } - - return didMutate + var remoteDisplayTarget: String? { + remoteConfiguration?.displayTarget } - func pruneSurfaceMetadata(validSurfaceIds: Set) { - panelDirectories = panelDirectories.filter { validSurfaceIds.contains($0.key) } - panelTitles = panelTitles.filter { validSurfaceIds.contains($0.key) } - panelCustomTitles = panelCustomTitles.filter { validSurfaceIds.contains($0.key) } - 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) } - remoteDetectedSurfaceIds = remoteDetectedSurfaceIds.filter { validSurfaceIds.contains($0) } - panelShellActivityStates = panelShellActivityStates.filter { validSurfaceIds.contains($0.key) } - panelPullRequests = panelPullRequests.filter { validSurfaceIds.contains($0.key) } - syncRemotePortScanTTYs() - recomputeListeningPorts() + var hasActiveRemoteTerminalSessions: Bool { + activeRemoteTerminalSessionCount > 0 } - func recomputeListeningPorts() { - let unique = Set(surfaceListeningPorts.values.flatMap { $0 }) - .union(agentListeningPorts) - .union(remoteDetectedPorts) - .union(remoteForwardedPorts) - let next = unique.sorted() - if listeningPorts != next { - listeningPorts = next + @MainActor + func uploadDroppedFilesForRemoteTerminal( + _ fileURLs: [URL], + operation: TerminalImageTransferOperation, + completion: @escaping (Result<[String], Error>) -> Void + ) { + guard let controller = remoteSessionController else { + completion(.failure(RemoteDropUploadError.unavailable)) + return } + controller.uploadDroppedFiles(fileURLs, operation: operation, completion: completion) } - func sidebarOrderedPanelIds() -> [UUID] { - let paneTabs: [String: [UUID]] = Dictionary( - uniqueKeysWithValues: bonsplitController.allPaneIds.map { paneId in - let panelIds = bonsplitController - .tabs(inPane: paneId) - .compactMap { panelIdFromSurfaceId($0.id) } - return (paneId.id.uuidString, panelIds) - } - ) - - let fallbackPanelIds = panels.keys.sorted { $0.uuidString < $1.uuidString } - let tree = bonsplitController.treeSnapshot() - return SidebarBranchOrdering.orderedPanelIds( - tree: tree, - paneTabs: paneTabs, - fallbackPanelIds: fallbackPanelIds + @MainActor + func uploadDroppedFilesForRemoteTerminal( + _ fileURLs: [URL], + completion: @escaping (Result<[String], Error>) -> Void + ) { + uploadDroppedFilesForRemoteTerminal( + fileURLs, + operation: TerminalImageTransferOperation(), + completion: completion ) } - private func normalizedSidebarDirectory(_ directory: String?) -> String? { - guard let directory else { return nil } - let trimmed = directory.trimmingCharacters(in: .whitespacesAndNewlines) - return trimmed.isEmpty ? nil : trimmed + func syncRemotePortScanTTYs() { + guard isRemoteWorkspace else { return } + remoteSessionController?.updateRemotePortScanTTYs(surfaceTTYNames) } - private func sidebarHomeDirectoryForCanonicalization( - resolvedPanelDirectories: [UUID: String] - ) -> String? { - if isRemoteWorkspace { - return SidebarBranchOrdering.inferredRemoteHomeDirectory( - from: Array(resolvedPanelDirectories.values), - fallbackDirectory: normalizedSidebarDirectory(currentDirectory) - ) - } - return FileManager.default.homeDirectoryForCurrentUser.path - } - - private func sidebarResolvedDirectory(for panelId: UUID) -> String? { - if let directory = normalizedSidebarDirectory(panelDirectories[panelId]) { - return directory - } - if let requestedDirectory = normalizedSidebarDirectory( - terminalPanel(for: panelId)?.requestedWorkingDirectory - ) { - return requestedDirectory - } - guard panelId == focusedPanelId else { return nil } - return normalizedSidebarDirectory(currentDirectory) - } - - private func sidebarResolvedPanelDirectories(orderedPanelIds: [UUID]) -> [UUID: String] { - var resolved: [UUID: String] = [:] - for panelId in orderedPanelIds { - if let directory = sidebarResolvedDirectory(for: panelId) { - resolved[panelId] = directory - } - } - return resolved - } - - func sidebarDirectoriesInDisplayOrder(orderedPanelIds: [UUID]) -> [String] { - let resolvedDirectories = sidebarResolvedPanelDirectories(orderedPanelIds: orderedPanelIds) - let homeDirectoryForCanonicalization = sidebarHomeDirectoryForCanonicalization( - resolvedPanelDirectories: resolvedDirectories - ) - var ordered: [String] = [] - var seen: Set = [] - - for panelId in orderedPanelIds { - guard let directory = resolvedDirectories[panelId], - let key = SidebarBranchOrdering.canonicalDirectoryKey( - directory, - homeDirectoryForTildeExpansion: homeDirectoryForCanonicalization - ) else { continue } - if seen.insert(key).inserted { - ordered.append(directory) - } - } - - if ordered.isEmpty, let fallbackDirectory = normalizedSidebarDirectory(currentDirectory) { - return [fallbackDirectory] - } - - return ordered - } - - func sidebarDirectoriesInDisplayOrder() -> [String] { - sidebarDirectoriesInDisplayOrder(orderedPanelIds: sidebarOrderedPanelIds()) - } - - func sidebarGitBranchesInDisplayOrder(orderedPanelIds: [UUID]) -> [SidebarGitBranchState] { - SidebarBranchOrdering - .orderedUniqueBranches( - orderedPanelIds: orderedPanelIds, - panelBranches: panelGitBranches, - fallbackBranch: gitBranch - ) - .map { SidebarGitBranchState(branch: $0.name, isDirty: $0.isDirty) } - } - - func sidebarGitBranchesInDisplayOrder() -> [SidebarGitBranchState] { - sidebarGitBranchesInDisplayOrder(orderedPanelIds: sidebarOrderedPanelIds()) - } - - func sidebarBranchDirectoryEntriesInDisplayOrder( - orderedPanelIds: [UUID] - ) -> [SidebarBranchOrdering.BranchDirectoryEntry] { - let resolvedDirectories = sidebarResolvedPanelDirectories(orderedPanelIds: orderedPanelIds) - return SidebarBranchOrdering.orderedUniqueBranchDirectoryEntries( - orderedPanelIds: orderedPanelIds, - panelBranches: panelGitBranches, - panelDirectories: resolvedDirectories, - defaultDirectory: normalizedSidebarDirectory(currentDirectory), - homeDirectoryForTildeExpansion: sidebarHomeDirectoryForCanonicalization( - resolvedPanelDirectories: resolvedDirectories - ), - fallbackBranch: gitBranch - ) - } - - func sidebarBranchDirectoryEntriesInDisplayOrder() -> [SidebarBranchOrdering.BranchDirectoryEntry] { - sidebarBranchDirectoryEntriesInDisplayOrder(orderedPanelIds: sidebarOrderedPanelIds()) - } - - func sidebarPullRequestsInDisplayOrder(orderedPanelIds: [UUID]) -> [SidebarPullRequestState] { - let validPanelPullRequests = panelPullRequests.filter { panelId, state in - guard let pullRequestBranch = normalizedSidebarBranchName(state.branch) else { - return true - } - return normalizedSidebarBranchName(panelGitBranches[panelId]?.branch) == pullRequestBranch - } - return SidebarBranchOrdering.orderedUniquePullRequests( - orderedPanelIds: orderedPanelIds, - panelPullRequests: validPanelPullRequests, - fallbackPullRequest: nil - ) - } - - func sidebarPullRequestsInDisplayOrder() -> [SidebarPullRequestState] { - sidebarPullRequestsInDisplayOrder(orderedPanelIds: sidebarOrderedPanelIds()) - } - - func sidebarStatusEntriesInDisplayOrder() -> [SidebarStatusEntry] { - statusEntries.values.sorted { lhs, rhs in - if lhs.priority != rhs.priority { return lhs.priority > rhs.priority } - if lhs.timestamp != rhs.timestamp { return lhs.timestamp > rhs.timestamp } - return lhs.key < rhs.key - } - } - - func sidebarMetadataBlocksInDisplayOrder() -> [SidebarMetadataBlock] { - metadataBlocks.values.sorted { lhs, rhs in - if lhs.priority != rhs.priority { return lhs.priority > rhs.priority } - if lhs.timestamp != rhs.timestamp { return lhs.timestamp > rhs.timestamp } - return lhs.key < rhs.key - } - } - - var isRemoteWorkspace: Bool { - remoteConfiguration != nil - } - - @MainActor - func isRemoteTerminalSurface(_ panelId: UUID) -> Bool { - activeRemoteTerminalSurfaceIds.contains(panelId) - } - - @MainActor - func shouldDemoteWorkspaceAfterChildExit(surfaceId: UUID) -> Bool { - isRemoteWorkspace || pendingRemoteTerminalChildExitSurfaceIds.contains(surfaceId) - } - - var remoteDisplayTarget: String? { - remoteConfiguration?.displayTarget - } - - var hasActiveRemoteTerminalSessions: Bool { - activeRemoteTerminalSessionCount > 0 - } - - @MainActor - func uploadDroppedFilesForRemoteTerminal( - _ fileURLs: [URL], - operation: TerminalImageTransferOperation, - completion: @escaping (Result<[String], Error>) -> Void - ) { - guard let controller = remoteSessionController else { - completion(.failure(RemoteDropUploadError.unavailable)) - return - } - controller.uploadDroppedFiles(fileURLs, operation: operation, completion: completion) - } - - @MainActor - func uploadDroppedFilesForRemoteTerminal( - _ fileURLs: [URL], - completion: @escaping (Result<[String], Error>) -> Void - ) { - uploadDroppedFilesForRemoteTerminal( - fileURLs, - operation: TerminalImageTransferOperation(), - completion: completion - ) - } - - func syncRemotePortScanTTYs() { - guard isRemoteWorkspace else { return } - remoteSessionController?.updateRemotePortScanTTYs(surfaceTTYNames) - } - - func kickRemotePortScan(panelId: UUID, reason: WorkspaceRemoteSessionController.PortScanKickReason = .command) { - guard isRemoteWorkspace else { return } - syncRemotePortScanTTYs() - remoteSessionController?.kickRemotePortScan(panelId: panelId, reason: reason) + func kickRemotePortScan(panelId: UUID, reason: WorkspaceRemoteSessionController.PortScanKickReason = .command) { + guard isRemoteWorkspace else { return } + syncRemotePortScanTTYs() + remoteSessionController?.kickRemotePortScan(panelId: panelId, reason: reason) } func remoteStatusPayload() -> [String: Any] { @@ -3268,4163 +2517,3110 @@ final class Workspace: Identifiable, ObservableObject { ] if let endpoint = remoteProxyEndpoint { payload["proxy"] = [ - "state": "ready", - "host": endpoint.host, - "port": endpoint.port, - "schemes": ["socks5", "http_connect"], - "url": "socks5://\(endpoint.host):\(endpoint.port)", - ] - } else { - let proxyState: String - if hasProxyOnlyRemoteSidebarError { - proxyState = "error" - } else { - switch remoteConnectionState { - case .connecting: - proxyState = "connecting" - case .error: - proxyState = "error" - default: - proxyState = "unavailable" - } - } - payload["proxy"] = [ - "state": proxyState, - "host": NSNull(), - "port": NSNull(), - "schemes": ["socks5", "http_connect"], - "url": NSNull(), - "error_code": proxyState == "error" ? "proxy_unavailable" : NSNull(), - ] - } - if let remoteConfiguration { - payload["destination"] = remoteConfiguration.destination - payload["port"] = remoteConfiguration.port ?? NSNull() - payload["has_identity_file"] = remoteConfiguration.identityFile != nil - payload["has_ssh_options"] = !remoteConfiguration.sshOptions.isEmpty - payload["local_proxy_port"] = remoteConfiguration.localProxyPort ?? NSNull() - } else { - payload["destination"] = NSNull() - payload["port"] = NSNull() - payload["has_identity_file"] = false - payload["has_ssh_options"] = false - payload["local_proxy_port"] = NSNull() - } - return payload - } - - func configureRemoteConnection(_ configuration: WorkspaceRemoteConfiguration, autoConnect: Bool = true) { - // Capture before resetRemoteState() nulls pendingRemoteForegroundAuthToken. - let foregroundAuthToken = Self.normalizedForegroundAuthToken(configuration.foregroundAuthToken) - let shouldAutoConnect = - autoConnect - || (foregroundAuthToken != nil && foregroundAuthToken == pendingRemoteForegroundAuthToken) - - remoteConfiguration = configuration - resetRemoteState() - // Seed after the reset so a reconfigure of an already-connected workspace doesn't see - // stale per-panel bookkeeping from the previous destination and skip seeding. Refs #83. - seedInitialRemoteTerminalSessionIfNeeded(configuration: configuration) - recomputeListeningPorts() - applyRemoteProxyEndpointUpdate(nil) - applyBrowserRemoteWorkspaceStatusToPanels() - - guard shouldAutoConnect else { - remoteConnectionState = .disconnected - applyBrowserRemoteWorkspaceStatusToPanels() - return - } - - remoteConnectionState = .connecting - applyBrowserRemoteWorkspaceStatusToPanels() - let controllerID = UUID() - let controller = WorkspaceRemoteSessionController( - workspace: self, - configuration: configuration, - controllerID: controllerID - ) - activeRemoteSessionControllerID = controllerID - remoteSessionController = controller - syncRemotePortScanTTYs() - controller.start() - } - - func reconnectRemoteConnection() { - guard let configuration = remoteConfiguration else { return } - configureRemoteConnection(configuration, autoConnect: true) - } - - private static func normalizedForegroundAuthToken(_ token: String?) -> String? { - guard let token else { return nil } - let trimmed = token.trimmingCharacters(in: .whitespacesAndNewlines) - return trimmed.isEmpty ? nil : trimmed - } - - func notifyRemoteForegroundAuthenticationReady(token: String? = nil) { - guard let foregroundAuthToken = Self.normalizedForegroundAuthToken(token) else { - return - } - - guard let remoteConfiguration else { - pendingRemoteForegroundAuthToken = foregroundAuthToken - return - } - - guard Self.normalizedForegroundAuthToken(remoteConfiguration.foregroundAuthToken) == foregroundAuthToken else { - return - } - - pendingRemoteForegroundAuthToken = nil - guard remoteConnectionState == .disconnected else { return } - reconnectRemoteConnection() - } - - func disconnectRemoteConnection(clearConfiguration: Bool = false) { - let shouldCleanupControlMaster = - clearConfiguration - && !isDetachingCloseTransaction - && pendingDetachedSurfaces.isEmpty - && !skipControlMasterCleanupAfterDetachedRemoteTransfer - let configurationForCleanup = shouldCleanupControlMaster ? remoteConfiguration : nil - resetRemoteState() - remoteConnectionState = .disconnected - if clearConfiguration { - remoteConfiguration = nil - skipControlMasterCleanupAfterDetachedRemoteTransfer = false - } - applyRemoteProxyEndpointUpdate(nil) - applyBrowserRemoteWorkspaceStatusToPanels() - recomputeListeningPorts() - if let configurationForCleanup { - Self.requestSSHControlMasterCleanupIfNeeded(configuration: configurationForCleanup) - } - } - - /// Resets all per-connection and per-panel remote-session bookkeeping. This is the - /// complete union of what `configureRemoteConnection` and `disconnectRemoteConnection` - /// each need to clear before establishing (or tearing down) a remote connection, so a - /// reconfigure to a new destination can't leave stale state from the previous one. Refs #83. - private func resetRemoteState() { - skipControlMasterCleanupAfterDetachedRemoteTransfer = false - let previousController = remoteSessionController - activeRemoteSessionControllerID = nil - remoteSessionController = nil - previousController?.stop() - pendingRemoteForegroundAuthToken = nil - activeRemoteTerminalSurfaceIds.removeAll() - activeRemoteTerminalSessionCount = 0 - pendingRemoteSurfaceTTYName = nil - pendingRemoteSurfaceTTYSurfaceId = nil - pendingRemoteSurfacePortKickReason = nil - pendingRemoteSurfacePortKickSurfaceId = nil - clearRemoteDetectedSurfacePorts() - remoteDetectedPorts = [] - remoteForwardedPorts = [] - remotePortConflicts = [] - remoteProxyEndpoint = nil - remoteHeartbeatCount = 0 - remoteLastHeartbeatAt = nil - remoteConnectionDetail = nil - remoteDaemonStatus = WorkspaceRemoteDaemonStatus() - statusEntries.removeValue(forKey: Self.remoteErrorStatusKey) - statusEntries.removeValue(forKey: Self.remotePortConflictStatusKey) - remoteLastErrorFingerprint = nil - remoteLastDaemonErrorFingerprint = nil - remoteLastPortConflictFingerprint = nil - } - - private func clearRemoteConfigurationIfWorkspaceBecameLocal() { - guard !isDetachingCloseTransaction, panels.isEmpty, remoteConfiguration != nil else { return } - disconnectRemoteConnection(clearConfiguration: true) - } - - private func seedInitialRemoteTerminalSessionIfNeeded(configuration: WorkspaceRemoteConfiguration) { - guard configuration.terminalStartupCommand?.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty == false else { - return - } - guard activeRemoteTerminalSurfaceIds.isEmpty else { return } - let terminalIds = panels.compactMap { panelId, panel in - panel is TerminalPanel ? panelId : nil - } - guard terminalIds.count == 1, let initialPanelId = terminalIds.first else { return } - trackRemoteTerminalSurface(initialPanelId) - } - - private func trackRemoteTerminalSurface(_ panelId: UUID) { - skipControlMasterCleanupAfterDetachedRemoteTransfer = false - pendingRemoteTerminalChildExitSurfaceIds.remove(panelId) - transferredRemoteCleanupConfigurationsByPanelId.removeValue(forKey: panelId) - guard activeRemoteTerminalSurfaceIds.insert(panelId).inserted else { return } - activeRemoteTerminalSessionCount = activeRemoteTerminalSurfaceIds.count - applyPendingRemoteSurfaceTTYIfNeeded(to: panelId) - _ = applyPendingRemoteSurfacePortKickIfNeeded(to: panelId) - } - - private func untrackRemoteTerminalSurface(_ panelId: UUID) { - guard activeRemoteTerminalSurfaceIds.remove(panelId) != nil else { return } - activeRemoteTerminalSessionCount = activeRemoteTerminalSurfaceIds.count - guard !isDetachingCloseTransaction else { return } - maybeDemoteRemoteWorkspaceAfterSSHSessionEnded() - } - - private func maybeDemoteRemoteWorkspaceAfterSSHSessionEnded() { - guard activeRemoteTerminalSurfaceIds.isEmpty, remoteConfiguration != nil else { return } - let hasBrowserPanels = panels.values.contains { $0 is BrowserPanel } - if !hasBrowserPanels { - if remoteConnectionState == .error || remoteDaemonStatus.state == .error || remoteConnectionState == .connecting { - return - } - disconnectRemoteConnection(clearConfiguration: true) - } - } - - @MainActor - func rememberPendingRemoteSurfaceTTY(_ ttyName: String, requestedSurfaceId: UUID?) { - let trimmedTTY = ttyName.trimmingCharacters(in: .whitespacesAndNewlines) - guard !trimmedTTY.isEmpty else { return } - pendingRemoteSurfaceTTYName = trimmedTTY - pendingRemoteSurfaceTTYSurfaceId = requestedSurfaceId - } - - @MainActor - func rememberPendingRemoteSurfacePortKick( - reason: WorkspaceRemoteSessionController.PortScanKickReason, - requestedSurfaceId: UUID? - ) { - pendingRemoteSurfacePortKickReason = reason - pendingRemoteSurfacePortKickSurfaceId = requestedSurfaceId - } - - @MainActor - private func applyPendingRemoteSurfaceTTYIfNeeded(to panelId: UUID) { - guard let ttyName = pendingRemoteSurfaceTTYName?.trimmingCharacters(in: .whitespacesAndNewlines), - !ttyName.isEmpty else { - return - } - if let requestedSurfaceId = pendingRemoteSurfaceTTYSurfaceId, requestedSurfaceId != panelId { - return - } - surfaceTTYNames[panelId] = ttyName - pendingRemoteSurfaceTTYName = nil - pendingRemoteSurfaceTTYSurfaceId = nil - syncRemotePortScanTTYs() - if !applyPendingRemoteSurfacePortKickIfNeeded(to: panelId) { - kickRemotePortScan(panelId: panelId, reason: .command) - } - } - - @MainActor - @discardableResult - func applyPendingRemoteSurfacePortKickIfNeeded(to panelId: UUID) -> Bool { - guard let reason = pendingRemoteSurfacePortKickReason else { - return false - } - if let requestedSurfaceId = pendingRemoteSurfacePortKickSurfaceId, - requestedSurfaceId != panelId { - return false - } - guard let ttyName = surfaceTTYNames[panelId]?.trimmingCharacters(in: .whitespacesAndNewlines), - !ttyName.isEmpty else { - return false - } - _ = ttyName - pendingRemoteSurfacePortKickReason = nil - pendingRemoteSurfacePortKickSurfaceId = nil - kickRemotePortScan(panelId: panelId, reason: reason) - return true - } - - @MainActor - func applyBootstrapRemoteTTY(_ ttyName: String) { - let trimmedTTY = ttyName.trimmingCharacters(in: .whitespacesAndNewlines) - guard !trimmedTTY.isEmpty else { return } - - let candidateSurfaceId: UUID? = { - if let focusedPanelId, activeRemoteTerminalSurfaceIds.contains(focusedPanelId) { - return focusedPanelId - } - if activeRemoteTerminalSurfaceIds.count == 1 { - return activeRemoteTerminalSurfaceIds.first - } - return nil - }() - - guard let candidateSurfaceId else { - rememberPendingRemoteSurfaceTTY(trimmedTTY, requestedSurfaceId: nil) - return - } - - surfaceTTYNames[candidateSurfaceId] = trimmedTTY - syncRemotePortScanTTYs() - if !applyPendingRemoteSurfacePortKickIfNeeded(to: candidateSurfaceId) { - kickRemotePortScan(panelId: candidateSurfaceId, reason: .command) - } - } - - private func cleanupTransferredRemoteConnectionIfNeeded(surfaceId: UUID, relayPort: Int?) -> Bool { - guard let relayPort, - relayPort > 0, - let cleanupConfiguration = transferredRemoteCleanupConfigurationsByPanelId[surfaceId], - cleanupConfiguration.relayPort == relayPort else { - return false - } - transferredRemoteCleanupConfigurationsByPanelId.removeValue(forKey: surfaceId) - Self.requestSSHControlMasterCleanupIfNeeded(configuration: cleanupConfiguration) - return true - } - - func markRemoteTerminalSessionEnded(surfaceId: UUID, relayPort: Int?) { - if cleanupTransferredRemoteConnectionIfNeeded(surfaceId: surfaceId, relayPort: relayPort) { - return - } - guard let relayPort, - relayPort > 0, - remoteConfiguration?.relayPort == relayPort else { - return - } - pendingRemoteTerminalChildExitSurfaceIds.insert(surfaceId) - untrackRemoteTerminalSurface(surfaceId) - } - - func teardownRemoteConnection() { - disconnectRemoteConnection(clearConfiguration: true) - } - - private static func requestSSHControlMasterCleanupIfNeeded(configuration: WorkspaceRemoteConfiguration) { - guard let arguments = sshControlMasterCleanupArguments(configuration: configuration) else { return } - if let override = runSSHControlMasterCommandOverrideForTesting { - override(arguments) - return - } - - sshControlMasterCleanupQueue.async { - let process = Process() - process.executableURL = URL(fileURLWithPath: "/usr/bin/ssh") - process.arguments = arguments - process.standardInput = FileHandle.nullDevice - process.standardOutput = FileHandle.nullDevice - process.standardError = FileHandle.nullDevice - let exitSemaphore = DispatchSemaphore(value: 0) - process.terminationHandler = { _ in - exitSemaphore.signal() - } - - do { - try process.run() - if exitSemaphore.wait(timeout: .now() + 5) == .timedOut { - if process.isRunning { - process.terminate() - } - _ = exitSemaphore.wait(timeout: .now() + 1) - } - } catch { - return - } - } - } - - private static func sshControlMasterCleanupArguments(configuration: WorkspaceRemoteConfiguration) -> [String]? { - let sshOptions = normalizedSSHControlCleanupOptions(configuration.sshOptions) - var arguments: [String] = [ - "-o", "BatchMode=yes", - "-o", "ControlMaster=no", - ] - if let port = configuration.port { - arguments += ["-p", String(port)] - } - if let identityFile = configuration.identityFile?.trimmingCharacters(in: .whitespacesAndNewlines), - !identityFile.isEmpty { - arguments += ["-i", identityFile] - } - for option in sshOptions { - arguments += ["-o", option] - } - arguments += ["-O", "exit", configuration.destination] - return arguments - } - - private static func normalizedSSHControlCleanupOptions(_ options: [String]) -> [String] { - let disallowedKeys: Set = ["controlmaster", "controlpersist"] - return options.compactMap { option in - let trimmed = option.trimmingCharacters(in: .whitespacesAndNewlines) - guard !trimmed.isEmpty else { return nil } - guard let key = sshOptionKeyForControlCleanup(trimmed) else { return nil } - return disallowedKeys.contains(key) ? nil : trimmed - } - } - - private static func sshOptionKeyForControlCleanup(_ option: String) -> String? { - let trimmed = option.trimmingCharacters(in: .whitespacesAndNewlines) - guard !trimmed.isEmpty else { return nil } - return trimmed - .split(whereSeparator: { $0 == "=" || $0.isWhitespace }) - .first - .map(String.init)? - .lowercased() - } - - func applyRemoteConnectionStateUpdate( - _ state: WorkspaceRemoteConnectionState, - detail: String?, - target: String - ) { - let trimmedDetail = detail?.trimmingCharacters(in: .whitespacesAndNewlines) - let proxyOnlyError = trimmedDetail.map(Self.isProxyOnlyRemoteError) ?? false - let preserveConnectedStateForRetry = - state == .connecting && preservesSSHTerminalConnection && hasProxyOnlyRemoteSidebarError - let effectiveState: WorkspaceRemoteConnectionState - if state == .error && proxyOnlyError && preservesSSHTerminalConnection { - effectiveState = .connected - } else if preserveConnectedStateForRetry { - effectiveState = .connected - } else { - effectiveState = state - } - - remoteConnectionState = effectiveState - remoteConnectionDetail = detail - applyBrowserRemoteWorkspaceStatusToPanels() - - if let trimmedDetail, !trimmedDetail.isEmpty, (state == .error || proxyOnlyError) { - let statusPrefix = proxyOnlyError ? "Remote proxy unavailable" : "SSH error" - let statusIcon = proxyOnlyError ? "exclamationmark.triangle.fill" : "network.slash" - let notificationTitle = proxyOnlyError ? "Remote Proxy Unavailable" : "Remote SSH Error" - let logSource = proxyOnlyError ? "remote-proxy" : "remote" - statusEntries[Self.remoteErrorStatusKey] = SidebarStatusEntry( - key: Self.remoteErrorStatusKey, - value: "\(statusPrefix) (\(target)): \(trimmedDetail)", - icon: statusIcon, - color: nil, - timestamp: Date() - ) - - let fingerprint = "connection:\(trimmedDetail)" - if remoteLastErrorFingerprint != fingerprint { - remoteLastErrorFingerprint = fingerprint - appendSidebarLog( - message: "\(statusPrefix) (\(target)): \(trimmedDetail)", - level: .error, - source: logSource - ) - AppDelegate.shared?.notificationStore?.addNotification( - tabId: id, - surfaceId: nil, - title: notificationTitle, - subtitle: target, - body: trimmedDetail, - cooldownKey: remoteNotificationCooldownKey(target: target), - cooldownInterval: Self.remoteNotificationCooldown - ) - } - return - } - - if state == .connected { - statusEntries.removeValue(forKey: Self.remoteErrorStatusKey) - remoteLastErrorFingerprint = nil - } - } - - func applyRemoteDaemonStatusUpdate(_ status: WorkspaceRemoteDaemonStatus, target: String) { - remoteDaemonStatus = status - applyBrowserRemoteWorkspaceStatusToPanels() - guard status.state == .error else { - remoteLastDaemonErrorFingerprint = nil - return - } - let trimmedDetail = status.detail?.trimmingCharacters(in: .whitespacesAndNewlines) ?? "remote daemon error" - let fingerprint = "daemon:\(trimmedDetail)" - guard remoteLastDaemonErrorFingerprint != fingerprint else { return } - remoteLastDaemonErrorFingerprint = fingerprint - appendSidebarLog( - message: "Remote daemon error (\(target)): \(trimmedDetail)", - level: .error, - source: "remote-daemon" - ) - } - - func applyRemoteProxyEndpointUpdate(_ endpoint: BrowserProxyEndpoint?) { - remoteProxyEndpoint = endpoint - for panel in panels.values { - guard let browserPanel = panel as? BrowserPanel else { continue } - browserPanel.setRemoteProxyEndpoint(endpoint) - } - applyBrowserRemoteWorkspaceStatusToPanels() - } - - func applyRemoteHeartbeatUpdate(count: Int, lastSeenAt: Date?) { - remoteHeartbeatCount = max(0, count) - remoteLastHeartbeatAt = lastSeenAt - applyBrowserRemoteWorkspaceStatusToPanels() - } - - func applyRemoteDetectedSurfacePortsSnapshot( - detectedByPanel: [UUID: [Int]], - detected: [Int], - forwarded: [Int], - conflicts: [Int], - target: String - ) { - let trackedSurfaceIds = Set(detectedByPanel.keys) - for panelId in remoteDetectedSurfaceIds.subtracting(trackedSurfaceIds) { - surfaceListeningPorts.removeValue(forKey: panelId) - } - remoteDetectedSurfaceIds = trackedSurfaceIds - - for (panelId, ports) in detectedByPanel { - if ports.isEmpty { - surfaceListeningPorts.removeValue(forKey: panelId) - } else { - surfaceListeningPorts[panelId] = ports - } - } - - remoteDetectedPorts = detected - remoteForwardedPorts = forwarded - remotePortConflicts = conflicts - recomputeListeningPorts() - - if conflicts.isEmpty { - statusEntries.removeValue(forKey: Self.remotePortConflictStatusKey) - remoteLastPortConflictFingerprint = nil - return - } - - let conflictsList = conflicts.map { ":\($0)" }.joined(separator: ", ") - statusEntries[Self.remotePortConflictStatusKey] = SidebarStatusEntry( - key: Self.remotePortConflictStatusKey, - value: "SSH port conflicts (\(target)): \(conflictsList)", - icon: "exclamationmark.triangle.fill", - color: nil, - timestamp: Date() - ) - - let fingerprint = conflicts.map(String.init).joined(separator: ",") - guard remoteLastPortConflictFingerprint != fingerprint else { return } - remoteLastPortConflictFingerprint = fingerprint - appendSidebarLog( - message: "Port conflicts while forwarding \(target): \(conflictsList)", - level: .warning, - source: "remote-forward" - ) - } - - private func clearRemoteDetectedSurfacePorts() { - for panelId in remoteDetectedSurfaceIds { - surfaceListeningPorts.removeValue(forKey: panelId) - } - remoteDetectedSurfaceIds.removeAll() - } - - private func appendSidebarLog(message: String, level: SidebarLogLevel, source: String?) { - let trimmed = message.trimmingCharacters(in: .whitespacesAndNewlines) - guard !trimmed.isEmpty else { return } - logEntries.append(SidebarLogEntry(message: trimmed, level: level, source: source, timestamp: Date())) - let configuredLimit = UserDefaults.standard.object(forKey: "sidebarMaxLogEntries") as? Int ?? 50 - let limit = max(1, min(500, configuredLimit)) - if logEntries.count > limit { - logEntries.removeFirst(logEntries.count - limit) - } - } - - // MARK: - Panel Operations - - private func seedTerminalInheritanceFontPoints( - panelId: UUID, - configTemplate: ProgramaSurfaceConfigTemplate? - ) { - guard let fontPoints = configTemplate?.fontSize, fontPoints > 0 else { return } - terminalInheritanceFontPointsByPanelId[panelId] = fontPoints - lastTerminalConfigInheritanceFontPoints = fontPoints - } - - private func resolvedTerminalInheritanceFontPoints( - for terminalPanel: TerminalPanel, - sourceSurface: ghostty_surface_t, - inheritedConfig: ProgramaSurfaceConfigTemplate - ) -> Float? { - let runtimePoints = programaCurrentSurfaceFontSizePoints(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.fontSize > 0 { - return inheritedConfig.fontSize - } - return runtimePoints - } - - private func rememberTerminalConfigInheritanceSource(_ terminalPanel: TerminalPanel) { - lastTerminalConfigInheritancePanelId = terminalPanel.id - if terminalPanel.surface.isSurfaceLive, - let sourceSurface = terminalPanel.surface.surface, - let runtimePoints = programaCurrentSurfaceFontSizePoints(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 - ) -> [TerminalPanel] { - var candidates: [TerminalPanel] = [] - var seen: Set = [] - - func appendCandidate(_ panel: TerminalPanel?) { - guard let panel, seen.insert(panel.id).inserted else { return } - candidates.append(panel) - } - - if let preferredPanelId, - let terminalPanel = terminalPanel(for: preferredPanelId) { - appendCandidate(terminalPanel) - } - - 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) { - guard let panelId = panelIdFromSurfaceId(tab.id), - let terminalPanel = terminalPanel(for: panelId) else { continue } - appendCandidate(terminalPanel) - } - } - - for terminalPanel in panels.values - .compactMap({ $0 as? TerminalPanel }) - .sorted(by: { $0.id.uuidString < $1.id.uuidString }) { - appendCandidate(terminalPanel) - } - - return candidates - } - - /// Picks the first terminal panel candidate used as the inheritance source. - func terminalPanelForConfigInheritance( - preferredPanelId: UUID? = nil, - inPane preferredPaneId: PaneID? = nil - ) -> TerminalPanel? { - terminalPanelConfigInheritanceCandidates( - preferredPanelId: preferredPanelId, - inPane: preferredPaneId - ).first - } - - private func inheritedTerminalConfig( - preferredPanelId: UUID? = nil, - inPane preferredPaneId: PaneID? = nil - ) -> ProgramaSurfaceConfigTemplate? { - // Walk candidates in priority order and use the first panel that still exposes - // a runtime surface pointer. - for terminalPanel in terminalPanelConfigInheritanceCandidates( - preferredPanelId: preferredPanelId, - inPane: preferredPaneId - ) { - // Pin the panel and its TerminalSurface wrapper for the duration of - // this iteration. The raw ghostty_surface_t extracted below is owned - // by `surface` (the TerminalSurface) — ARC must not release it while - // ghostty_surface_inherited_config or programaCurrentSurfaceFontSizePoints - // is still reading through the pointer. - let surface = terminalPanel.surface - guard let sourceSurface = surface.surface else { continue } - var config = programaInheritedSurfaceConfig( - sourceSurface: sourceSurface, - context: GHOSTTY_SURFACE_CONTEXT_SPLIT - ) - if let rootedFontPoints = resolvedTerminalInheritanceFontPoints( - for: terminalPanel, - sourceSurface: sourceSurface, - inheritedConfig: config - ), rootedFontPoints > 0 { - config.fontSize = rootedFontPoints - terminalInheritanceFontPointsByPanelId[terminalPanel.id] = rootedFontPoints - } - // Prevent ARC from releasing panel/surface before the C calls above complete. - withExtendedLifetime((terminalPanel, surface)) {} - rememberTerminalConfigInheritanceSource(terminalPanel) - if config.fontSize > 0 { - lastTerminalConfigInheritanceFontPoints = config.fontSize - } - return config - } - - if let fallbackFontPoints = lastTerminalConfigInheritanceFontPoints { - var config = ProgramaSurfaceConfigTemplate() - config.fontSize = 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 - func newTerminalSplit( - from panelId: UUID, - orientation: SplitOrientation, - insertFirst: Bool = false, - focus: Bool = true - ) -> TerminalPanel? { - // Find the pane containing the source panel - guard let sourceTabId = surfaceIdFromPanelId(panelId) else { return nil } - var sourcePaneId: PaneID? - for paneId in bonsplitController.allPaneIds { - let tabs = bonsplitController.tabs(inPane: paneId) - if tabs.contains(where: { $0.id == sourceTabId }) { - sourcePaneId = paneId - break - } - } - - guard let paneId = sourcePaneId else { return nil } - let inheritedConfig = inheritedTerminalConfig(preferredPanelId: panelId, inPane: paneId) - let remoteTerminalStartupCommand = remoteTerminalStartupCommand() - - // Inherit working directory: prefer the source panel's reported cwd, - // then its requested startup cwd if shell integration has not reported - // back yet, and finally fall back to the workspace's current directory. - let splitWorkingDirectory: String? = { - if let panelDirectory = panelDirectories[panelId]?.trimmingCharacters(in: .whitespacesAndNewlines), - !panelDirectory.isEmpty { - return panelDirectory - } - if let requestedWorkingDirectory = terminalPanel(for: panelId)? - .requestedWorkingDirectory? - .trimmingCharacters(in: .whitespacesAndNewlines), - !requestedWorkingDirectory.isEmpty { - return requestedWorkingDirectory - } - let workspaceDirectory = currentDirectory.trimmingCharacters(in: .whitespacesAndNewlines) - return workspaceDirectory.isEmpty ? nil : workspaceDirectory - }() -#if DEBUG - dlog( - "split.cwd panelId=\(panelId.uuidString.prefix(5)) panelDir=\(panelDirectories[panelId] ?? "nil") requestedDir=\(terminalPanel(for: panelId)?.requestedWorkingDirectory ?? "nil") currentDir=\(currentDirectory) resolved=\(splitWorkingDirectory ?? "nil")" - ) -#endif - - // Create the new terminal panel. - let newPanel = TerminalPanel( - workspaceId: id, - context: GHOSTTY_SURFACE_CONTEXT_SPLIT, - configTemplate: inheritedConfig, - workingDirectory: splitWorkingDirectory, - portOrdinal: portOrdinal, - initialCommand: remoteTerminalStartupCommand - ) - configureTerminalPanel(newPanel) - panels[newPanel.id] = newPanel - panelTitles[newPanel.id] = newPanel.displayTitle - if remoteTerminalStartupCommand != nil { - trackRemoteTerminalSurface(newPanel.id) - } - 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). - let newTab = Bonsplit.Tab( - title: newPanel.displayTitle, - icon: newPanel.displayIcon, - kind: SurfaceKind.terminal, - isDirty: newPanel.isDirty, - isPinned: false - ) - 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 - - // 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) - if remoteTerminalStartupCommand != nil { - untrackRemoteTerminalSurface(newPanel.id) + "state": "ready", + "host": endpoint.host, + "port": endpoint.port, + "schemes": ["socks5", "http_connect"], + "url": "socks5://\(endpoint.host):\(endpoint.port)", + ] + } else { + let proxyState: String + if hasProxyOnlyRemoteSidebarError { + proxyState = "error" + } else { + switch remoteConnectionState { + case .connecting: + proxyState = "connecting" + case .error: + proxyState = "error" + default: + proxyState = "unavailable" + } } - terminalInheritanceFontPointsByPanelId.removeValue(forKey: newPanel.id) - return nil + payload["proxy"] = [ + "state": proxyState, + "host": NSNull(), + "port": NSNull(), + "schemes": ["socks5", "http_connect"], + "url": NSNull(), + "error_code": proxyState == "error" ? "proxy_unavailable" : NSNull(), + ] + } + if let remoteConfiguration { + payload["destination"] = remoteConfiguration.destination + payload["port"] = remoteConfiguration.port ?? NSNull() + payload["has_identity_file"] = remoteConfiguration.identityFile != nil + payload["has_ssh_options"] = !remoteConfiguration.sshOptions.isEmpty + payload["local_proxy_port"] = remoteConfiguration.localProxyPort ?? NSNull() + } else { + payload["destination"] = NSNull() + payload["port"] = NSNull() + payload["has_identity_file"] = false + payload["has_ssh_options"] = false + payload["local_proxy_port"] = NSNull() } + return payload + } -#if DEBUG - dlog("split.created pane=\(paneId.id.uuidString.prefix(5)) orientation=\(orientation)") -#endif + func configureRemoteConnection(_ configuration: WorkspaceRemoteConfiguration, autoConnect: Bool = true) { + // Capture before resetRemoteState() nulls pendingRemoteForegroundAuthToken. + let foregroundAuthToken = Self.normalizedForegroundAuthToken(configuration.foregroundAuthToken) + let shouldAutoConnect = + autoConnect + || (foregroundAuthToken != nil && foregroundAuthToken == pendingRemoteForegroundAuthToken) - // 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 - ) + remoteConfiguration = configuration + resetRemoteState() + // Seed after the reset so a reconfigure of an already-connected workspace doesn't see + // stale per-panel bookkeeping from the previous destination and skip seeding. Refs #83. + seedInitialRemoteTerminalSessionIfNeeded(configuration: configuration) + recomputeListeningPorts() + applyRemoteProxyEndpointUpdate(nil) + applyBrowserRemoteWorkspaceStatusToPanels() + + guard shouldAutoConnect else { + remoteConnectionState = .disconnected + applyBrowserRemoteWorkspaceStatusToPanels() + return } - owningTabManager?.scheduleInitialWorkspaceGitMetadataRefreshIfPossible( - workspaceId: id, - panelId: newPanel.id, - reason: "splitCreate" + remoteConnectionState = .connecting + applyBrowserRemoteWorkspaceStatusToPanels() + let controllerID = UUID() + let controller = WorkspaceRemoteSessionController( + workspace: self, + configuration: configuration, + controllerID: controllerID ) + activeRemoteSessionControllerID = controllerID + remoteSessionController = controller + syncRemotePortScanTTYs() + controller.start() + } - return newPanel + func reconnectRemoteConnection() { + guard let configuration = remoteConfiguration else { return } + configureRemoteConnection(configuration, autoConnect: true) } - /// 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), - /// true = force focus/selection of the new surface, - /// false = never focus (used for internal placeholder repair paths). - @discardableResult - func newTerminalSurface( - inPane paneId: PaneID, - focus: Bool? = nil, - workingDirectory: String? = nil, - startupEnvironment: [String: String] = [:] - ) -> TerminalPanel? { - let shouldFocusNewTab = focus ?? (bonsplitController.focusedPaneId == paneId) - let previousFocusedPanelId = focusedPanelId - let previousHostedView = focusedTerminalPanel?.hostedView + static func normalizedForegroundAuthToken(_ token: String?) -> String? { + guard let token else { return nil } + let trimmed = token.trimmingCharacters(in: .whitespacesAndNewlines) + return trimmed.isEmpty ? nil : trimmed + } - let inheritedConfig = inheritedTerminalConfig(inPane: paneId) - let remoteTerminalStartupCommand = remoteTerminalStartupCommand() + func notifyRemoteForegroundAuthenticationReady(token: String? = nil) { + guard let foregroundAuthToken = Self.normalizedForegroundAuthToken(token) else { + return + } - // Create new terminal panel - let newPanel = TerminalPanel( - workspaceId: id, - context: GHOSTTY_SURFACE_CONTEXT_SPLIT, - configTemplate: inheritedConfig, - workingDirectory: workingDirectory, - portOrdinal: portOrdinal, - initialCommand: remoteTerminalStartupCommand, - additionalEnvironment: startupEnvironment - ) - configureTerminalPanel(newPanel) - panels[newPanel.id] = newPanel - panelTitles[newPanel.id] = newPanel.displayTitle - if remoteTerminalStartupCommand != nil { - trackRemoteTerminalSurface(newPanel.id) + guard let remoteConfiguration else { + pendingRemoteForegroundAuthToken = foregroundAuthToken + return } - seedTerminalInheritanceFontPoints(panelId: newPanel.id, configTemplate: inheritedConfig) - // Create tab in bonsplit - guard let newTabId = bonsplitController.createTab( - title: newPanel.displayTitle, - icon: newPanel.displayIcon, - kind: SurfaceKind.terminal, - isDirty: newPanel.isDirty, - isPinned: false, - inPane: paneId - ) else { - panels.removeValue(forKey: newPanel.id) - panelTitles.removeValue(forKey: newPanel.id) - if remoteTerminalStartupCommand != nil { - untrackRemoteTerminalSurface(newPanel.id) - } - terminalInheritanceFontPointsByPanelId.removeValue(forKey: newPanel.id) - return nil + guard Self.normalizedForegroundAuthToken(remoteConfiguration.foregroundAuthToken) == foregroundAuthToken else { + return } - surfaceIdToPanelId[newTabId] = newPanel.id + pendingRemoteForegroundAuthToken = nil + guard remoteConnectionState == .disconnected else { return } + reconnectRemoteConnection() + } - // bonsplit's createTab may not reliably emit didSelectTab, and its internal selection - // updates can be deferred. Force a deterministic selection + focus path so the new - // surface becomes interactive immediately (no "frozen until pane switch" state). - if shouldFocusNewTab { - bonsplitController.focusPane(paneId) - bonsplitController.selectTab(newTabId) - newPanel.focus() - applyTabSelection(tabId: newTabId, inPane: paneId) - } else { - preserveFocusAfterNonFocusSplit( - preferredPanelId: previousFocusedPanelId, - splitPanelId: newPanel.id, - previousHostedView: previousHostedView - ) + func disconnectRemoteConnection(clearConfiguration: Bool = false) { + let shouldCleanupControlMaster = + clearConfiguration + && !isDetachingCloseTransaction + && pendingDetachedSurfaces.isEmpty + && !skipControlMasterCleanupAfterDetachedRemoteTransfer + let configurationForCleanup = shouldCleanupControlMaster ? remoteConfiguration : nil + resetRemoteState() + remoteConnectionState = .disconnected + if clearConfiguration { + remoteConfiguration = nil + skipControlMasterCleanupAfterDetachedRemoteTransfer = false + } + applyRemoteProxyEndpointUpdate(nil) + applyBrowserRemoteWorkspaceStatusToPanels() + recomputeListeningPorts() + if let configurationForCleanup { + Self.requestSSHControlMasterCleanupIfNeeded(configuration: configurationForCleanup) } + } - owningTabManager?.scheduleInitialWorkspaceGitMetadataRefreshIfPossible( - workspaceId: id, - panelId: newPanel.id, - reason: "surfaceCreate" - ) - return newPanel + /// Resets all per-connection and per-panel remote-session bookkeeping. This is the + /// complete union of what `configureRemoteConnection` and `disconnectRemoteConnection` + /// each need to clear before establishing (or tearing down) a remote connection, so a + /// reconfigure to a new destination can't leave stale state from the previous one. Refs #83. + func resetRemoteState() { + skipControlMasterCleanupAfterDetachedRemoteTransfer = false + let previousController = remoteSessionController + activeRemoteSessionControllerID = nil + remoteSessionController = nil + previousController?.stop() + pendingRemoteForegroundAuthToken = nil + activeRemoteTerminalSurfaceIds.removeAll() + activeRemoteTerminalSessionCount = 0 + pendingRemoteSurfaceTTYName = nil + pendingRemoteSurfaceTTYSurfaceId = nil + pendingRemoteSurfacePortKickReason = nil + pendingRemoteSurfacePortKickSurfaceId = nil + clearRemoteDetectedSurfacePorts() + remoteDetectedPorts = [] + remoteForwardedPorts = [] + remotePortConflicts = [] + remoteProxyEndpoint = nil + remoteHeartbeatCount = 0 + remoteLastHeartbeatAt = nil + remoteConnectionDetail = nil + remoteDaemonStatus = WorkspaceRemoteDaemonStatus() + statusEntries.removeValue(forKey: Self.remoteErrorStatusKey) + statusEntries.removeValue(forKey: Self.remotePortConflictStatusKey) + remoteLastErrorFingerprint = nil + remoteLastDaemonErrorFingerprint = nil + remoteLastPortConflictFingerprint = nil + } + + func clearRemoteConfigurationIfWorkspaceBecameLocal() { + guard !isDetachingCloseTransaction, panels.isEmpty, remoteConfiguration != nil else { return } + disconnectRemoteConnection(clearConfiguration: true) + } + + func seedInitialRemoteTerminalSessionIfNeeded(configuration: WorkspaceRemoteConfiguration) { + guard configuration.terminalStartupCommand?.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty == false else { + return + } + guard activeRemoteTerminalSurfaceIds.isEmpty else { return } + let terminalIds = panels.compactMap { panelId, panel in + panel is TerminalPanel ? panelId : nil + } + guard terminalIds.count == 1, let initialPanelId = terminalIds.first else { return } + trackRemoteTerminalSurface(initialPanelId) + } + + func trackRemoteTerminalSurface(_ panelId: UUID) { + skipControlMasterCleanupAfterDetachedRemoteTransfer = false + pendingRemoteTerminalChildExitSurfaceIds.remove(panelId) + transferredRemoteCleanupConfigurationsByPanelId.removeValue(forKey: panelId) + guard activeRemoteTerminalSurfaceIds.insert(panelId).inserted else { return } + activeRemoteTerminalSessionCount = activeRemoteTerminalSurfaceIds.count + applyPendingRemoteSurfaceTTYIfNeeded(to: panelId) + _ = applyPendingRemoteSurfacePortKickIfNeeded(to: panelId) } - private func remoteTerminalStartupCommand() -> String? { - guard let command = remoteConfiguration?.terminalStartupCommand? - .trimmingCharacters(in: .whitespacesAndNewlines), - !command.isEmpty else { - return nil - } - return command + func untrackRemoteTerminalSurface(_ panelId: UUID) { + guard activeRemoteTerminalSurfaceIds.remove(panelId) != nil else { return } + activeRemoteTerminalSessionCount = activeRemoteTerminalSurfaceIds.count + guard !isDetachingCloseTransaction else { return } + maybeDemoteRemoteWorkspaceAfterSSHSessionEnded() } - /// Create a new browser panel split - @discardableResult - func newBrowserSplit( - from panelId: UUID, - orientation: SplitOrientation, - insertFirst: Bool = false, - url: URL? = nil, - preferredProfileID: UUID? = nil, - focus: Bool = true - ) -> BrowserPanel? { - // Find the pane containing the source panel - guard let sourceTabId = surfaceIdFromPanelId(panelId) else { return nil } - var sourcePaneId: PaneID? - for paneId in bonsplitController.allPaneIds { - let tabs = bonsplitController.tabs(inPane: paneId) - if tabs.contains(where: { $0.id == sourceTabId }) { - sourcePaneId = paneId - break + func maybeDemoteRemoteWorkspaceAfterSSHSessionEnded() { + guard activeRemoteTerminalSurfaceIds.isEmpty, remoteConfiguration != nil else { return } + let hasBrowserPanels = panels.values.contains { $0 is BrowserPanel } + if !hasBrowserPanels { + if remoteConnectionState == .error || remoteDaemonStatus.state == .error || remoteConnectionState == .connecting { + return } + disconnectRemoteConnection(clearConfiguration: true) } + } - guard let paneId = sourcePaneId else { return nil } - - // Create browser panel - let browserPanel = BrowserPanel( - workspaceId: id, - profileID: resolvedNewBrowserProfileID( - preferredProfileID: preferredProfileID, - sourcePanelId: panelId - ), - initialURL: url, - proxyEndpoint: remoteProxyEndpoint, - isRemoteWorkspace: isRemoteWorkspace, - remoteWebsiteDataStoreIdentifier: isRemoteWorkspace ? id : nil - ) - panels[browserPanel.id] = browserPanel - panelTitles[browserPanel.id] = browserPanel.displayTitle + @MainActor + func rememberPendingRemoteSurfaceTTY(_ ttyName: String, requestedSurfaceId: UUID?) { + let trimmedTTY = ttyName.trimmingCharacters(in: .whitespacesAndNewlines) + guard !trimmedTTY.isEmpty else { return } + pendingRemoteSurfaceTTYName = trimmedTTY + pendingRemoteSurfaceTTYSurfaceId = requestedSurfaceId + } - // Pre-generate the bonsplit tab ID so the mapping exists before the split lands. - let newTab = Bonsplit.Tab( - title: browserPanel.displayTitle, - icon: browserPanel.displayIcon, - kind: SurfaceKind.browser, - isDirty: browserPanel.isDirty, - isLoading: browserPanel.isLoading, - isPinned: false - ) - surfaceIdToPanelId[newTab.id] = browserPanel.id - let previousFocusedPanelId = focusedPanelId + @MainActor + func rememberPendingRemoteSurfacePortKick( + reason: WorkspaceRemoteSessionController.PortScanKickReason, + requestedSurfaceId: UUID? + ) { + pendingRemoteSurfacePortKickReason = reason + pendingRemoteSurfacePortKickSurfaceId = requestedSurfaceId + } - // 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 + @MainActor + func applyPendingRemoteSurfaceTTYIfNeeded(to panelId: UUID) { + guard let ttyName = pendingRemoteSurfaceTTYName?.trimmingCharacters(in: .whitespacesAndNewlines), + !ttyName.isEmpty else { + return } - setPreferredBrowserProfileID(browserPanel.profileID) - - // 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 - ) + if let requestedSurfaceId = pendingRemoteSurfaceTTYSurfaceId, requestedSurfaceId != panelId { + return + } + surfaceTTYNames[panelId] = ttyName + pendingRemoteSurfaceTTYName = nil + pendingRemoteSurfaceTTYSurfaceId = nil + syncRemotePortScanTTYs() + if !applyPendingRemoteSurfacePortKickIfNeeded(to: panelId) { + kickRemotePortScan(panelId: panelId, reason: .command) } - - installBrowserPanelSubscription(browserPanel) - browserPanel.setRemoteWorkspaceStatus(browserRemoteWorkspaceStatusSnapshot()) - - return browserPanel } - /// Create a new browser surface in the specified pane. - /// - Parameter focus: nil = focus only if the target pane is already focused (default UI behavior), - /// true = force focus/selection of the new surface, - /// false = never focus (used for internal placeholder repair paths). + @MainActor @discardableResult - func newBrowserSurface( - inPane paneId: PaneID, - url: URL? = nil, - focus: Bool? = nil, - insertAtEnd: Bool = false, - preferredProfileID: UUID? = nil, - bypassInsecureHTTPHostOnce: String? = nil - ) -> BrowserPanel? { - let shouldFocusNewTab = focus ?? (bonsplitController.focusedPaneId == paneId) - let sourcePanelId = effectiveSelectedPanelId(inPane: paneId) - let previousFocusedPanelId = focusedPanelId - let previousHostedView = focusedTerminalPanel?.hostedView + func applyPendingRemoteSurfacePortKickIfNeeded(to panelId: UUID) -> Bool { + guard let reason = pendingRemoteSurfacePortKickReason else { + return false + } + if let requestedSurfaceId = pendingRemoteSurfacePortKickSurfaceId, + requestedSurfaceId != panelId { + return false + } + guard let ttyName = surfaceTTYNames[panelId]?.trimmingCharacters(in: .whitespacesAndNewlines), + !ttyName.isEmpty else { + return false + } + _ = ttyName + pendingRemoteSurfacePortKickReason = nil + pendingRemoteSurfacePortKickSurfaceId = nil + kickRemotePortScan(panelId: panelId, reason: reason) + return true + } - let browserPanel = BrowserPanel( - workspaceId: id, - profileID: resolvedNewBrowserProfileID( - preferredProfileID: preferredProfileID, - sourcePanelId: sourcePanelId - ), - initialURL: url, - bypassInsecureHTTPHostOnce: bypassInsecureHTTPHostOnce, - proxyEndpoint: remoteProxyEndpoint, - isRemoteWorkspace: isRemoteWorkspace, - remoteWebsiteDataStoreIdentifier: isRemoteWorkspace ? id : nil - ) - panels[browserPanel.id] = browserPanel - panelTitles[browserPanel.id] = browserPanel.displayTitle + @MainActor + func applyBootstrapRemoteTTY(_ ttyName: String) { + let trimmedTTY = ttyName.trimmingCharacters(in: .whitespacesAndNewlines) + guard !trimmedTTY.isEmpty else { return } - guard let newTabId = bonsplitController.createTab( - title: browserPanel.displayTitle, - icon: browserPanel.displayIcon, - kind: SurfaceKind.browser, - isDirty: browserPanel.isDirty, - isLoading: browserPanel.isLoading, - isPinned: false, - inPane: paneId - ) else { - panels.removeValue(forKey: browserPanel.id) - panelTitles.removeValue(forKey: browserPanel.id) + let candidateSurfaceId: UUID? = { + if let focusedPanelId, activeRemoteTerminalSurfaceIds.contains(focusedPanelId) { + return focusedPanelId + } + if activeRemoteTerminalSurfaceIds.count == 1 { + return activeRemoteTerminalSurfaceIds.first + } return nil - } - - surfaceIdToPanelId[newTabId] = browserPanel.id - setPreferredBrowserProfileID(browserPanel.profileID) + }() - // Keyboard/browser-open paths want "new tab at end" regardless of global new-tab placement. - if insertAtEnd { - let targetIndex = max(0, bonsplitController.tabs(inPane: paneId).count - 1) - _ = bonsplitController.reorderTab(newTabId, toIndex: targetIndex) + guard let candidateSurfaceId else { + rememberPendingRemoteSurfaceTTY(trimmedTTY, requestedSurfaceId: nil) + return } - // Match terminal behavior: enforce deterministic selection + focus. - if shouldFocusNewTab { - bonsplitController.focusPane(paneId) - bonsplitController.selectTab(newTabId) - browserPanel.focus() - applyTabSelection(tabId: newTabId, inPane: paneId) - } else { - preserveFocusAfterNonFocusSplit( - preferredPanelId: previousFocusedPanelId, - splitPanelId: browserPanel.id, - previousHostedView: previousHostedView - ) + surfaceTTYNames[candidateSurfaceId] = trimmedTTY + syncRemotePortScanTTYs() + if !applyPendingRemoteSurfacePortKickIfNeeded(to: candidateSurfaceId) { + kickRemotePortScan(panelId: candidateSurfaceId, reason: .command) } + } - installBrowserPanelSubscription(browserPanel) - browserPanel.setRemoteWorkspaceStatus(browserRemoteWorkspaceStatusSnapshot()) - - return browserPanel + func cleanupTransferredRemoteConnectionIfNeeded(surfaceId: UUID, relayPort: Int?) -> Bool { + guard let relayPort, + relayPort > 0, + let cleanupConfiguration = transferredRemoteCleanupConfigurationsByPanelId[surfaceId], + cleanupConfiguration.relayPort == relayPort else { + return false + } + transferredRemoteCleanupConfigurationsByPanelId.removeValue(forKey: surfaceId) + Self.requestSSHControlMasterCleanupIfNeeded(configuration: cleanupConfiguration) + return true } - func newMarkdownSplit( - from panelId: UUID, - orientation: SplitOrientation, - insertFirst: Bool = false, - filePath: String, - focus: Bool = true - ) -> MarkdownPanel? { - guard let sourceTabId = surfaceIdFromPanelId(panelId) else { return nil } - var sourcePaneId: PaneID? - for paneId in bonsplitController.allPaneIds { - let tabs = bonsplitController.tabs(inPane: paneId) - if tabs.contains(where: { $0.id == sourceTabId }) { - sourcePaneId = paneId - break - } + func markRemoteTerminalSessionEnded(surfaceId: UUID, relayPort: Int?) { + if cleanupTransferredRemoteConnectionIfNeeded(surfaceId: surfaceId, relayPort: relayPort) { + return + } + guard let relayPort, + relayPort > 0, + remoteConfiguration?.relayPort == relayPort else { + return } + pendingRemoteTerminalChildExitSurfaceIds.insert(surfaceId) + untrackRemoteTerminalSurface(surfaceId) + } - guard let paneId = sourcePaneId else { return nil } - - let markdownPanel = MarkdownPanel(workspaceId: id, filePath: filePath) - panels[markdownPanel.id] = markdownPanel - panelTitles[markdownPanel.id] = markdownPanel.displayTitle - - let newTab = Bonsplit.Tab( - title: markdownPanel.displayTitle, - icon: markdownPanel.displayIcon, - kind: SurfaceKind.markdown, - isDirty: markdownPanel.isDirty, - isLoading: false, - isPinned: false - ) - surfaceIdToPanelId[newTab.id] = markdownPanel.id - let previousFocusedPanelId = focusedPanelId + func teardownRemoteConnection() { + disconnectRemoteConnection(clearConfiguration: true) + } - 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: markdownPanel.id) - panelTitles.removeValue(forKey: markdownPanel.id) - return nil + static func requestSSHControlMasterCleanupIfNeeded(configuration: WorkspaceRemoteConfiguration) { + guard let arguments = sshControlMasterCleanupArguments(configuration: configuration) else { return } + if let override = runSSHControlMasterCommandOverrideForTesting { + override(arguments) + return } - let previousHostedView = focusedTerminalPanel?.hostedView - if focus { - previousHostedView?.suppressReparentFocus() - focusPanel(markdownPanel.id) - DispatchQueue.main.asyncAfter(deadline: .now() + 0.05) { - previousHostedView?.clearSuppressReparentFocus() + sshControlMasterCleanupQueue.async { + let process = Process() + process.executableURL = URL(fileURLWithPath: "/usr/bin/ssh") + process.arguments = arguments + process.standardInput = FileHandle.nullDevice + process.standardOutput = FileHandle.nullDevice + process.standardError = FileHandle.nullDevice + let exitSemaphore = DispatchSemaphore(value: 0) + process.terminationHandler = { _ in + exitSemaphore.signal() + } + + do { + try process.run() + if exitSemaphore.wait(timeout: .now() + 5) == .timedOut { + if process.isRunning { + process.terminate() + } + _ = exitSemaphore.wait(timeout: .now() + 1) + } + } catch { + return } - } else { - preserveFocusAfterNonFocusSplit( - preferredPanelId: previousFocusedPanelId, - splitPanelId: markdownPanel.id, - previousHostedView: previousHostedView - ) } + } - installMarkdownPanelSubscription(markdownPanel) - return markdownPanel + static func sshControlMasterCleanupArguments(configuration: WorkspaceRemoteConfiguration) -> [String]? { + let sshOptions = normalizedSSHControlCleanupOptions(configuration.sshOptions) + var arguments: [String] = [ + "-o", "BatchMode=yes", + "-o", "ControlMaster=no", + ] + if let port = configuration.port { + arguments += ["-p", String(port)] + } + if let identityFile = configuration.identityFile?.trimmingCharacters(in: .whitespacesAndNewlines), + !identityFile.isEmpty { + arguments += ["-i", identityFile] + } + for option in sshOptions { + arguments += ["-o", option] + } + arguments += ["-O", "exit", configuration.destination] + return arguments } - @discardableResult - func newMarkdownSurface( - inPane paneId: PaneID, - filePath: String, - focus: Bool? = nil - ) -> MarkdownPanel? { - let shouldFocusNewTab = focus ?? (bonsplitController.focusedPaneId == paneId) - let previousFocusedPanelId = focusedPanelId - let previousHostedView = focusedTerminalPanel?.hostedView + static func normalizedSSHControlCleanupOptions(_ options: [String]) -> [String] { + let disallowedKeys: Set = ["controlmaster", "controlpersist"] + return options.compactMap { option in + let trimmed = option.trimmingCharacters(in: .whitespacesAndNewlines) + guard !trimmed.isEmpty else { return nil } + guard let key = sshOptionKeyForControlCleanup(trimmed) else { return nil } + return disallowedKeys.contains(key) ? nil : trimmed + } + } - let markdownPanel = MarkdownPanel(workspaceId: id, filePath: filePath) - panels[markdownPanel.id] = markdownPanel - panelTitles[markdownPanel.id] = markdownPanel.displayTitle + static func sshOptionKeyForControlCleanup(_ option: String) -> String? { + let trimmed = option.trimmingCharacters(in: .whitespacesAndNewlines) + guard !trimmed.isEmpty else { return nil } + return trimmed + .split(whereSeparator: { $0 == "=" || $0.isWhitespace }) + .first + .map(String.init)? + .lowercased() + } - guard let newTabId = bonsplitController.createTab( - title: markdownPanel.displayTitle, - icon: markdownPanel.displayIcon, - kind: SurfaceKind.markdown, - isDirty: markdownPanel.isDirty, - isLoading: false, - isPinned: false, - inPane: paneId - ) else { - panels.removeValue(forKey: markdownPanel.id) - panelTitles.removeValue(forKey: markdownPanel.id) - return nil + func applyRemoteConnectionStateUpdate( + _ state: WorkspaceRemoteConnectionState, + detail: String?, + target: String + ) { + let trimmedDetail = detail?.trimmingCharacters(in: .whitespacesAndNewlines) + let proxyOnlyError = trimmedDetail.map(Self.isProxyOnlyRemoteError) ?? false + let preserveConnectedStateForRetry = + state == .connecting && preservesSSHTerminalConnection && hasProxyOnlyRemoteSidebarError + let effectiveState: WorkspaceRemoteConnectionState + if state == .error && proxyOnlyError && preservesSSHTerminalConnection { + effectiveState = .connected + } else if preserveConnectedStateForRetry { + effectiveState = .connected + } else { + effectiveState = state } - surfaceIdToPanelId[newTabId] = markdownPanel.id - if shouldFocusNewTab { - bonsplitController.focusPane(paneId) - bonsplitController.selectTab(newTabId) - applyTabSelection(tabId: newTabId, inPane: paneId) - } else { - preserveFocusAfterNonFocusSplit( - preferredPanelId: previousFocusedPanelId, - splitPanelId: markdownPanel.id, - previousHostedView: previousHostedView + remoteConnectionState = effectiveState + remoteConnectionDetail = detail + applyBrowserRemoteWorkspaceStatusToPanels() + + if let trimmedDetail, !trimmedDetail.isEmpty, (state == .error || proxyOnlyError) { + let statusPrefix = proxyOnlyError ? "Remote proxy unavailable" : "SSH error" + let statusIcon = proxyOnlyError ? "exclamationmark.triangle.fill" : "network.slash" + let notificationTitle = proxyOnlyError ? "Remote Proxy Unavailable" : "Remote SSH Error" + let logSource = proxyOnlyError ? "remote-proxy" : "remote" + statusEntries[Self.remoteErrorStatusKey] = SidebarStatusEntry( + key: Self.remoteErrorStatusKey, + value: "\(statusPrefix) (\(target)): \(trimmedDetail)", + icon: statusIcon, + color: nil, + timestamp: Date() ) + + let fingerprint = "connection:\(trimmedDetail)" + if remoteLastErrorFingerprint != fingerprint { + remoteLastErrorFingerprint = fingerprint + appendSidebarLog( + message: "\(statusPrefix) (\(target)): \(trimmedDetail)", + level: .error, + source: logSource + ) + AppDelegate.shared?.notificationStore?.addNotification( + tabId: id, + surfaceId: nil, + title: notificationTitle, + subtitle: target, + body: trimmedDetail, + cooldownKey: remoteNotificationCooldownKey(target: target), + cooldownInterval: Self.remoteNotificationCooldown + ) + } + return } - installMarkdownPanelSubscription(markdownPanel) - return markdownPanel + if state == .connected { + statusEntries.removeValue(forKey: Self.remoteErrorStatusKey) + remoteLastErrorFingerprint = nil + } } - /// 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() + func applyRemoteDaemonStatusUpdate(_ status: WorkspaceRemoteDaemonStatus, target: String) { + remoteDaemonStatus = status + applyBrowserRemoteWorkspaceStatusToPanels() + guard status.state == .error else { + remoteLastDaemonErrorFingerprint = nil + return } + let trimmedDetail = status.detail?.trimmingCharacters(in: .whitespacesAndNewlines) ?? "remote daemon error" + let fingerprint = "daemon:\(trimmedDetail)" + guard remoteLastDaemonErrorFingerprint != fingerprint else { return } + remoteLastDaemonErrorFingerprint = fingerprint + appendSidebarLog( + message: "Remote daemon error (\(target)): \(trimmedDetail)", + level: .error, + source: "remote-daemon" + ) + } - panels.removeAll(keepingCapacity: false) - surfaceIdToPanelId.removeAll(keepingCapacity: false) - panelSubscriptions.removeAll(keepingCapacity: false) - pendingRemoteTerminalChildExitSurfaceIds.removeAll(keepingCapacity: false) - pruneSurfaceMetadata(validSurfaceIds: []) - restoredTerminalScrollbackByPanelId.removeAll(keepingCapacity: false) - terminalInheritanceFontPointsByPanelId.removeAll(keepingCapacity: false) - lastTerminalConfigInheritancePanelId = nil - lastTerminalConfigInheritanceFontPoints = nil + func applyRemoteProxyEndpointUpdate(_ endpoint: BrowserProxyEndpoint?) { + remoteProxyEndpoint = endpoint + for panel in panels.values { + guard let browserPanel = panel as? BrowserPanel else { continue } + browserPanel.setRemoteProxyEndpoint(endpoint) + } + applyBrowserRemoteWorkspaceStatusToPanels() + } + + func applyRemoteHeartbeatUpdate(count: Int, lastSeenAt: Date?) { + remoteHeartbeatCount = max(0, count) + remoteLastHeartbeatAt = lastSeenAt + applyBrowserRemoteWorkspaceStatusToPanels() } - /// Close a panel. - /// Returns true when a bonsplit tab close request was issued. - func closePanel(_ panelId: UUID, force: Bool = false) -> Bool { - if let tabId = surfaceIdFromPanelId(panelId) { - if force { - forceCloseTabIds.insert(tabId) + func applyRemoteDetectedSurfacePortsSnapshot( + detectedByPanel: [UUID: [Int]], + detected: [Int], + forwarded: [Int], + conflicts: [Int], + target: String + ) { + let trackedSurfaceIds = Set(detectedByPanel.keys) + for panelId in remoteDetectedSurfaceIds.subtracting(trackedSurfaceIds) { + surfaceListeningPorts.removeValue(forKey: panelId) + } + remoteDetectedSurfaceIds = trackedSurfaceIds + + for (panelId, ports) in detectedByPanel { + if ports.isEmpty { + surfaceListeningPorts.removeValue(forKey: panelId) + } else { + surfaceListeningPorts[panelId] = ports } - // Close the tab in bonsplit (this triggers delegate callback) - return bonsplitController.closeTab(tabId) } - // Mapping can transiently drift during split-tree mutations. If the target panel is - // currently focused (or is the active terminal first responder), close whichever tab - // bonsplit marks selected in that focused pane. - let firstResponderPanelId = cmuxOwningGhosttyView( - for: NSApp.keyWindow?.firstResponder ?? NSApp.mainWindow?.firstResponder - )?.terminalSurface?.id - let targetIsActive = focusedPanelId == panelId || firstResponderPanelId == panelId - guard targetIsActive, - let focusedPane = bonsplitController.focusedPaneId, - let selected = bonsplitController.selectedTab(inPane: focusedPane) else { -#if DEBUG - dlog( - "surface.close.fallback.skip panel=\(panelId.uuidString.prefix(5)) " + - "focusedPanel=\(focusedPanelId?.uuidString.prefix(5) ?? "nil") " + - "firstResponderPanel=\(firstResponderPanelId?.uuidString.prefix(5) ?? "nil") " + - "focusedPane=\(bonsplitController.focusedPaneId?.id.uuidString.prefix(5) ?? "nil")" - ) -#endif - return false - } + remoteDetectedPorts = detected + remoteForwardedPorts = forwarded + remotePortConflicts = conflicts + recomputeListeningPorts() - if force { - forceCloseTabIds.insert(selected.id) + if conflicts.isEmpty { + statusEntries.removeValue(forKey: Self.remotePortConflictStatusKey) + remoteLastPortConflictFingerprint = nil + return } - let closed = bonsplitController.closeTab(selected.id) -#if DEBUG - dlog( - "surface.close.fallback panel=\(panelId.uuidString.prefix(5)) " + - "selectedTab=\(String(describing: selected.id).prefix(5)) " + - "closed=\(closed ? 1 : 0)" + + let conflictsList = conflicts.map { ":\($0)" }.joined(separator: ", ") + statusEntries[Self.remotePortConflictStatusKey] = SidebarStatusEntry( + key: Self.remotePortConflictStatusKey, + value: "SSH port conflicts (\(target)): \(conflictsList)", + icon: "exclamationmark.triangle.fill", + color: nil, + timestamp: Date() + ) + + let fingerprint = conflicts.map(String.init).joined(separator: ",") + guard remoteLastPortConflictFingerprint != fingerprint else { return } + remoteLastPortConflictFingerprint = fingerprint + appendSidebarLog( + message: "Port conflicts while forwarding \(target): \(conflictsList)", + level: .warning, + source: "remote-forward" ) -#endif - return closed } - func paneId(forPanelId panelId: UUID) -> PaneID? { - guard let tabId = surfaceIdFromPanelId(panelId) else { return nil } - return bonsplitController.allPaneIds.first { paneId in - bonsplitController.tabs(inPane: paneId).contains(where: { $0.id == tabId }) + func clearRemoteDetectedSurfacePorts() { + for panelId in remoteDetectedSurfaceIds { + surfaceListeningPorts.removeValue(forKey: panelId) } + remoteDetectedSurfaceIds.removeAll() } - func indexInPane(forPanelId panelId: UUID) -> Int? { - guard let tabId = surfaceIdFromPanelId(panelId), - let paneId = paneId(forPanelId: panelId) else { return nil } - return bonsplitController.tabs(inPane: paneId).firstIndex(where: { $0.id == tabId }) + func appendSidebarLog(message: String, level: SidebarLogLevel, source: String?) { + let trimmed = message.trimmingCharacters(in: .whitespacesAndNewlines) + guard !trimmed.isEmpty else { return } + logEntries.append(SidebarLogEntry(message: trimmed, level: level, source: source, timestamp: Date())) + let configuredLimit = UserDefaults.standard.object(forKey: "sidebarMaxLogEntries") as? Int ?? 50 + let limit = max(1, min(500, configuredLimit)) + if logEntries.count > limit { + logEntries.removeFirst(logEntries.count - limit) + } } - /// Returns the nearest right-side sibling pane for browser placement. - /// The search is local to the source pane's ancestry in the split tree: - /// use the closest horizontal ancestor where the source is in the first (left) branch. - func preferredBrowserTargetPane(fromPanelId panelId: UUID) -> PaneID? { - guard let sourcePane = paneId(forPanelId: panelId) else { return nil } - let sourcePaneId = sourcePane.id.uuidString - let tree = bonsplitController.treeSnapshot() - guard let path = browserPathToPane(targetPaneId: sourcePaneId, node: tree) else { return nil } - - let layout = bonsplitController.layoutSnapshot() - let paneFrameById = Dictionary(uniqueKeysWithValues: layout.panes.map { ($0.paneId, $0.frame) }) - let sourceFrame = paneFrameById[sourcePaneId] - let sourceCenterY = sourceFrame.map { $0.y + ($0.height * 0.5) } ?? 0 - let sourceRightX = sourceFrame.map { $0.x + $0.width } ?? 0 - - for crumb in path { - guard crumb.split.orientation == "horizontal", crumb.branch == .first else { continue } - var candidateNodes: [ExternalPaneNode] = [] - browserCollectPaneNodes(node: crumb.split.second, into: &candidateNodes) - if candidateNodes.isEmpty { continue } - - let sorted = candidateNodes.sorted { lhs, rhs in - let lhsDy = abs((lhs.frame.y + (lhs.frame.height * 0.5)) - sourceCenterY) - let rhsDy = abs((rhs.frame.y + (rhs.frame.height * 0.5)) - sourceCenterY) - if lhsDy != rhsDy { return lhsDy < rhsDy } + // MARK: - Panel Operations - let lhsDx = abs(lhs.frame.x - sourceRightX) - let rhsDx = abs(rhs.frame.x - sourceRightX) - if lhsDx != rhsDx { return lhsDx < rhsDx } + func seedTerminalInheritanceFontPoints( + panelId: UUID, + configTemplate: ProgramaSurfaceConfigTemplate? + ) { + guard let fontPoints = configTemplate?.fontSize, fontPoints > 0 else { return } + terminalInheritanceFontPointsByPanelId[panelId] = fontPoints + lastTerminalConfigInheritanceFontPoints = fontPoints + } - if lhs.frame.x != rhs.frame.x { return lhs.frame.x < rhs.frame.x } - return lhs.id < rhs.id + func resolvedTerminalInheritanceFontPoints( + for terminalPanel: TerminalPanel, + sourceSurface: ghostty_surface_t, + inheritedConfig: ProgramaSurfaceConfigTemplate + ) -> Float? { + let runtimePoints = programaCurrentSurfaceFontSizePoints(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.fontSize > 0 { + return inheritedConfig.fontSize + } + return runtimePoints + } - for candidate in sorted { - guard let candidateUUID = UUID(uuidString: candidate.id), - candidateUUID != sourcePane.id, - let pane = bonsplitController.allPaneIds.first(where: { $0.id == candidateUUID }) else { - continue - } - return pane + func rememberTerminalConfigInheritanceSource(_ terminalPanel: TerminalPanel) { + lastTerminalConfigInheritancePanelId = terminalPanel.id + if terminalPanel.surface.isSurfaceLive, + let sourceSurface = terminalPanel.surface.surface, + let runtimePoints = programaCurrentSurfaceFontSizePoints(sourceSurface) { + let existing = terminalInheritanceFontPointsByPanelId[terminalPanel.id] + if existing == nil || abs((existing ?? runtimePoints) - runtimePoints) > 0.05 { + terminalInheritanceFontPointsByPanelId[terminalPanel.id] = runtimePoints } + lastTerminalConfigInheritanceFontPoints = + terminalInheritanceFontPointsByPanelId[terminalPanel.id] ?? runtimePoints } + } - return nil + func lastRememberedTerminalPanelForConfigInheritance() -> TerminalPanel? { + guard let panelId = lastTerminalConfigInheritancePanelId else { return nil } + return terminalPanel(for: panelId) } - /// Returns the top-right pane in the current split tree. - /// When a workspace is already split, sidebar PR opens should reuse an existing pane - /// instead of creating additional right splits. - func topRightBrowserReusePane() -> PaneID? { - let paneIds = bonsplitController.allPaneIds - guard paneIds.count > 1 else { return nil } + func lastRememberedTerminalFontPointsForConfigInheritance() -> Float? { + lastTerminalConfigInheritanceFontPoints + } - let paneById = Dictionary(uniqueKeysWithValues: paneIds.map { ($0.id.uuidString, $0) }) - var paneBounds: [String: CGRect] = [:] - browserCollectNormalizedPaneBounds( - node: bonsplitController.treeSnapshot(), - availableRect: CGRect(x: 0, y: 0, width: 1, height: 1), - into: &paneBounds - ) + /// 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. + func terminalPanelConfigInheritanceCandidates( + preferredPanelId: UUID? = nil, + inPane preferredPaneId: PaneID? = nil + ) -> [TerminalPanel] { + var candidates: [TerminalPanel] = [] + var seen: Set = [] - guard !paneBounds.isEmpty else { - return paneIds.sorted { $0.id.uuidString < $1.id.uuidString }.first + func appendCandidate(_ panel: TerminalPanel?) { + guard let panel, seen.insert(panel.id).inserted else { return } + candidates.append(panel) } - let epsilon = 0.000_1 - let rightMostX = paneBounds.values.map(\.maxX).max() ?? 0 - - let sortedCandidates = paneBounds - .filter { _, rect in abs(rect.maxX - rightMostX) <= epsilon } - .sorted { lhs, rhs in - if abs(lhs.value.minY - rhs.value.minY) > epsilon { - return lhs.value.minY < rhs.value.minY - } - if abs(lhs.value.minX - rhs.value.minX) > epsilon { - return lhs.value.minX > rhs.value.minX - } - return lhs.key < rhs.key - } - - for candidate in sortedCandidates { - if let pane = paneById[candidate.key] { - return pane - } + if let preferredPanelId, + let terminalPanel = terminalPanel(for: preferredPanelId) { + appendCandidate(terminalPanel) } - return paneIds.sorted { $0.id.uuidString < $1.id.uuidString }.first - } + if let preferredPaneId, + let selectedSurfaceId = bonsplitController.selectedTab(inPane: preferredPaneId)?.id, + let selectedPanelId = panelIdFromSurfaceId(selectedSurfaceId), + let selectedTerminalPanel = terminalPanel(for: selectedPanelId) { + appendCandidate(selectedTerminalPanel) + } - private enum BrowserPaneBranch { - case first - case second - } + if let focusedTerminalPanel { + appendCandidate(focusedTerminalPanel) + } - private struct BrowserPaneBreadcrumb { - let split: ExternalSplitNode - let branch: BrowserPaneBranch - } + if let rememberedTerminalPanel = lastRememberedTerminalPanelForConfigInheritance() { + appendCandidate(rememberedTerminalPanel) + } - private func browserPathToPane(targetPaneId: String, node: ExternalTreeNode) -> [BrowserPaneBreadcrumb]? { - switch node { - case .pane(let paneNode): - return paneNode.id == targetPaneId ? [] : nil - case .split(let splitNode): - if var path = browserPathToPane(targetPaneId: targetPaneId, node: splitNode.first) { - path.append(BrowserPaneBreadcrumb(split: splitNode, branch: .first)) - return path - } - if var path = browserPathToPane(targetPaneId: targetPaneId, node: splitNode.second) { - path.append(BrowserPaneBreadcrumb(split: splitNode, branch: .second)) - return path + if let preferredPaneId { + for tab in bonsplitController.tabs(inPane: preferredPaneId) { + guard let panelId = panelIdFromSurfaceId(tab.id), + let terminalPanel = terminalPanel(for: panelId) else { continue } + appendCandidate(terminalPanel) } - return nil } - } - private func browserCollectPaneNodes(node: ExternalTreeNode, into output: inout [ExternalPaneNode]) { - switch node { - case .pane(let paneNode): - output.append(paneNode) - case .split(let splitNode): - browserCollectPaneNodes(node: splitNode.first, into: &output) - browserCollectPaneNodes(node: splitNode.second, into: &output) + for terminalPanel in panels.values + .compactMap({ $0 as? TerminalPanel }) + .sorted(by: { $0.id.uuidString < $1.id.uuidString }) { + appendCandidate(terminalPanel) } + + return candidates } - private func browserCollectNormalizedPaneBounds( - node: ExternalTreeNode, - availableRect: CGRect, - into output: inout [String: CGRect] - ) { - switch node { - case .pane(let paneNode): - output[paneNode.id] = availableRect - case .split(let splitNode): - let divider = min(max(splitNode.dividerPosition, 0), 1) - let firstRect: CGRect - let secondRect: CGRect + /// Picks the first terminal panel candidate used as the inheritance source. + func terminalPanelForConfigInheritance( + preferredPanelId: UUID? = nil, + inPane preferredPaneId: PaneID? = nil + ) -> TerminalPanel? { + terminalPanelConfigInheritanceCandidates( + preferredPanelId: preferredPanelId, + inPane: preferredPaneId + ).first + } - if splitNode.orientation.lowercased() == "vertical" { - // Stacked split: first = top, second = bottom - firstRect = CGRect( - x: availableRect.minX, - y: availableRect.minY, - width: availableRect.width, - height: availableRect.height * divider - ) - secondRect = CGRect( - x: availableRect.minX, - y: availableRect.minY + (availableRect.height * divider), - width: availableRect.width, - height: availableRect.height * (1 - divider) - ) - } else { - // Side-by-side split: first = left, second = right - firstRect = CGRect( - x: availableRect.minX, - y: availableRect.minY, - width: availableRect.width * divider, - height: availableRect.height - ) - secondRect = CGRect( - x: availableRect.minX + (availableRect.width * divider), - y: availableRect.minY, - width: availableRect.width * (1 - divider), - height: availableRect.height - ) + func inheritedTerminalConfig( + preferredPanelId: UUID? = nil, + inPane preferredPaneId: PaneID? = nil + ) -> ProgramaSurfaceConfigTemplate? { + // Walk candidates in priority order and use the first panel that still exposes + // a runtime surface pointer. + for terminalPanel in terminalPanelConfigInheritanceCandidates( + preferredPanelId: preferredPanelId, + inPane: preferredPaneId + ) { + // Pin the panel and its TerminalSurface wrapper for the duration of + // this iteration. The raw ghostty_surface_t extracted below is owned + // by `surface` (the TerminalSurface) — ARC must not release it while + // ghostty_surface_inherited_config or programaCurrentSurfaceFontSizePoints + // is still reading through the pointer. + let surface = terminalPanel.surface + guard let sourceSurface = surface.surface else { continue } + var config = programaInheritedSurfaceConfig( + sourceSurface: sourceSurface, + context: GHOSTTY_SURFACE_CONTEXT_SPLIT + ) + if let rootedFontPoints = resolvedTerminalInheritanceFontPoints( + for: terminalPanel, + sourceSurface: sourceSurface, + inheritedConfig: config + ), rootedFontPoints > 0 { + config.fontSize = rootedFontPoints + terminalInheritanceFontPointsByPanelId[terminalPanel.id] = rootedFontPoints + } + // Prevent ARC from releasing panel/surface before the C calls above complete. + withExtendedLifetime((terminalPanel, surface)) {} + rememberTerminalConfigInheritanceSource(terminalPanel) + if config.fontSize > 0 { + lastTerminalConfigInheritanceFontPoints = config.fontSize } + return config + } - browserCollectNormalizedPaneBounds(node: splitNode.first, availableRect: firstRect, into: &output) - browserCollectNormalizedPaneBounds(node: splitNode.second, availableRect: secondRect, into: &output) + if let fallbackFontPoints = lastTerminalConfigInheritanceFontPoints { + var config = ProgramaSurfaceConfigTemplate() + config.fontSize = fallbackFontPoints +#if DEBUG + dlog( + "zoom.inherit fallback=lastKnownFont context=split font=\(String(format: "%.2f", fallbackFontPoints))" + ) +#endif + return config } - } - private struct BrowserCloseFallbackPlan { - let orientation: SplitOrientation - let insertFirst: Bool - let anchorPaneId: UUID? + return nil } - private func stageClosedBrowserRestoreSnapshotIfNeeded(for tab: Bonsplit.Tab, inPane pane: PaneID) { - guard let panelId = panelIdFromSurfaceId(tab.id), - let browserPanel = browserPanel(for: panelId), - let tabIndex = bonsplitController.tabs(inPane: pane).firstIndex(where: { $0.id == tab.id }) else { - pendingClosedBrowserRestoreSnapshots.removeValue(forKey: tab.id) - return + /// Create a new split with a terminal panel + @discardableResult + func newTerminalSplit( + from panelId: UUID, + orientation: SplitOrientation, + insertFirst: Bool = false, + focus: Bool = true + ) -> TerminalPanel? { + // Find the pane containing the source panel + guard let sourceTabId = surfaceIdFromPanelId(panelId) else { return nil } + var sourcePaneId: PaneID? + for paneId in bonsplitController.allPaneIds { + let tabs = bonsplitController.tabs(inPane: paneId) + if tabs.contains(where: { $0.id == sourceTabId }) { + sourcePaneId = paneId + break + } } - let fallbackPlan = browserCloseFallbackPlan( - forPaneId: pane.id.uuidString, - in: bonsplitController.treeSnapshot() + guard let paneId = sourcePaneId else { return nil } + let inheritedConfig = inheritedTerminalConfig(preferredPanelId: panelId, inPane: paneId) + let remoteTerminalStartupCommand = remoteTerminalStartupCommand() + + // Inherit working directory: prefer the source panel's reported cwd, + // then its requested startup cwd if shell integration has not reported + // back yet, and finally fall back to the workspace's current directory. + let splitWorkingDirectory: String? = { + if let panelDirectory = panelDirectories[panelId]?.trimmingCharacters(in: .whitespacesAndNewlines), + !panelDirectory.isEmpty { + return panelDirectory + } + if let requestedWorkingDirectory = terminalPanel(for: panelId)? + .requestedWorkingDirectory? + .trimmingCharacters(in: .whitespacesAndNewlines), + !requestedWorkingDirectory.isEmpty { + return requestedWorkingDirectory + } + let workspaceDirectory = currentDirectory.trimmingCharacters(in: .whitespacesAndNewlines) + return workspaceDirectory.isEmpty ? nil : workspaceDirectory + }() +#if DEBUG + dlog( + "split.cwd panelId=\(panelId.uuidString.prefix(5)) panelDir=\(panelDirectories[panelId] ?? "nil") requestedDir=\(terminalPanel(for: panelId)?.requestedWorkingDirectory ?? "nil") currentDir=\(currentDirectory) resolved=\(splitWorkingDirectory ?? "nil")" ) - let resolvedURL = browserPanel.currentURL - ?? browserPanel.preferredURLStringForOmnibar().flatMap(URL.init(string:)) +#endif - pendingClosedBrowserRestoreSnapshots[tab.id] = ClosedBrowserPanelRestoreSnapshot( + // Create the new terminal panel. + let newPanel = TerminalPanel( workspaceId: id, - url: resolvedURL, - profileID: browserPanel.profileID, - originalPaneId: pane.id, - originalTabIndex: tabIndex, - fallbackSplitOrientation: fallbackPlan?.orientation, - fallbackSplitInsertFirst: fallbackPlan?.insertFirst ?? false, - fallbackAnchorPaneId: fallbackPlan?.anchorPaneId + context: GHOSTTY_SURFACE_CONTEXT_SPLIT, + configTemplate: inheritedConfig, + workingDirectory: splitWorkingDirectory, + portOrdinal: portOrdinal, + initialCommand: remoteTerminalStartupCommand ) - } - - private func clearStagedClosedBrowserRestoreSnapshot(for tabId: TabID) { - pendingClosedBrowserRestoreSnapshots.removeValue(forKey: tabId) - } + configureTerminalPanel(newPanel) + panels[newPanel.id] = newPanel + panelTitles[newPanel.id] = newPanel.displayTitle + if remoteTerminalStartupCommand != nil { + trackRemoteTerminalSurface(newPanel.id) + } + seedTerminalInheritanceFontPoints(panelId: newPanel.id, configTemplate: inheritedConfig) - private func browserCloseFallbackPlan( - forPaneId targetPaneId: String, - in node: ExternalTreeNode - ) -> BrowserCloseFallbackPlan? { - switch node { - case .pane: - return nil - case .split(let splitNode): - if case .pane(let firstPane) = splitNode.first, firstPane.id == targetPaneId { - return BrowserCloseFallbackPlan( - orientation: splitNode.orientation.lowercased() == "vertical" ? .vertical : .horizontal, - insertFirst: true, - anchorPaneId: browserNearestPaneId( - in: splitNode.second, - targetCenter: browserPaneCenter(firstPane) - ) - ) - } + // 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). + let newTab = Bonsplit.Tab( + title: newPanel.displayTitle, + icon: newPanel.displayIcon, + kind: SurfaceKind.terminal, + isDirty: newPanel.isDirty, + isPinned: false + ) + surfaceIdToPanelId[newTab.id] = newPanel.id + let previousFocusedPanelId = focusedPanelId - if case .pane(let secondPane) = splitNode.second, secondPane.id == targetPaneId { - return BrowserCloseFallbackPlan( - orientation: splitNode.orientation.lowercased() == "vertical" ? .vertical : .horizontal, - insertFirst: false, - anchorPaneId: browserNearestPaneId( - in: splitNode.first, - targetCenter: browserPaneCenter(secondPane) - ) - ) - } + // 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 - if let nested = browserCloseFallbackPlan(forPaneId: targetPaneId, in: splitNode.first) { - return nested + // 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) + if remoteTerminalStartupCommand != nil { + untrackRemoteTerminalSurface(newPanel.id) } - return browserCloseFallbackPlan(forPaneId: targetPaneId, in: splitNode.second) - } - } - - private func browserPaneCenter(_ pane: ExternalPaneNode) -> (x: Double, y: Double) { - ( - x: pane.frame.x + (pane.frame.width * 0.5), - y: pane.frame.y + (pane.frame.height * 0.5) - ) - } + terminalInheritanceFontPointsByPanelId.removeValue(forKey: newPanel.id) + return nil + } - private func browserNearestPaneId( - in node: ExternalTreeNode, - targetCenter: (x: Double, y: Double)? - ) -> UUID? { - var panes: [ExternalPaneNode] = [] - browserCollectPaneNodes(node: node, into: &panes) - guard !panes.isEmpty else { return nil } +#if DEBUG + dlog("split.created pane=\(paneId.id.uuidString.prefix(5)) orientation=\(orientation)") +#endif - let bestPane: ExternalPaneNode? - if let targetCenter { - bestPane = panes.min { lhs, rhs in - let lhsCenter = browserPaneCenter(lhs) - let rhsCenter = browserPaneCenter(rhs) - let lhsDistance = pow(lhsCenter.x - targetCenter.x, 2) + pow(lhsCenter.y - targetCenter.y, 2) - let rhsDistance = pow(rhsCenter.x - targetCenter.x, 2) + pow(rhsCenter.y - targetCenter.y, 2) - if lhsDistance != rhsDistance { - return lhsDistance < rhsDistance - } - return lhs.id < rhs.id + // 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 { - bestPane = panes.first + preserveFocusAfterNonFocusSplit( + preferredPanelId: previousFocusedPanelId, + splitPanelId: newPanel.id, + previousHostedView: previousHostedView + ) } - guard let bestPane else { return nil } - return UUID(uuidString: bestPane.id) + owningTabManager?.scheduleInitialWorkspaceGitMetadataRefreshIfPossible( + workspaceId: id, + panelId: newPanel.id, + reason: "splitCreate" + ) + + 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), + /// true = force focus/selection of the new surface, + /// false = never focus (used for internal placeholder repair paths). @discardableResult - func moveSurface(panelId: UUID, toPane paneId: PaneID, atIndex index: Int? = nil, focus: Bool = true) -> Bool { - guard let tabId = surfaceIdFromPanelId(panelId) else { return false } - guard bonsplitController.allPaneIds.contains(paneId) else { return false } - guard bonsplitController.moveTab(tabId, toPane: paneId, atIndex: index) else { return false } + func newTerminalSurface( + inPane paneId: PaneID, + focus: Bool? = nil, + workingDirectory: String? = nil, + startupEnvironment: [String: String] = [:] + ) -> TerminalPanel? { + let shouldFocusNewTab = focus ?? (bonsplitController.focusedPaneId == paneId) + let previousFocusedPanelId = focusedPanelId + let previousHostedView = focusedTerminalPanel?.hostedView - if focus { - bonsplitController.focusPane(paneId) - bonsplitController.selectTab(tabId) - focusPanel(panelId) - } else { - scheduleFocusReconcile() + let inheritedConfig = inheritedTerminalConfig(inPane: paneId) + let remoteTerminalStartupCommand = remoteTerminalStartupCommand() + + // Create new terminal panel + let newPanel = TerminalPanel( + workspaceId: id, + context: GHOSTTY_SURFACE_CONTEXT_SPLIT, + configTemplate: inheritedConfig, + workingDirectory: workingDirectory, + portOrdinal: portOrdinal, + initialCommand: remoteTerminalStartupCommand, + additionalEnvironment: startupEnvironment + ) + configureTerminalPanel(newPanel) + panels[newPanel.id] = newPanel + panelTitles[newPanel.id] = newPanel.displayTitle + if remoteTerminalStartupCommand != nil { + trackRemoteTerminalSurface(newPanel.id) } - scheduleTerminalGeometryReconcile() - return true - } + seedTerminalInheritanceFontPoints(panelId: newPanel.id, configTemplate: inheritedConfig) - @discardableResult - func reorderSurface(panelId: UUID, toIndex index: Int) -> Bool { - guard let tabId = surfaceIdFromPanelId(panelId) else { return false } - guard bonsplitController.reorderTab(tabId, toIndex: index) else { return false } + // Create tab in bonsplit + guard let newTabId = bonsplitController.createTab( + title: newPanel.displayTitle, + icon: newPanel.displayIcon, + kind: SurfaceKind.terminal, + isDirty: newPanel.isDirty, + isPinned: false, + inPane: paneId + ) else { + panels.removeValue(forKey: newPanel.id) + panelTitles.removeValue(forKey: newPanel.id) + if remoteTerminalStartupCommand != nil { + untrackRemoteTerminalSurface(newPanel.id) + } + terminalInheritanceFontPointsByPanelId.removeValue(forKey: newPanel.id) + return nil + } - if let paneId = paneId(forPanelId: panelId) { - applyTabSelection(tabId: tabId, inPane: paneId) + surfaceIdToPanelId[newTabId] = newPanel.id + + // bonsplit's createTab may not reliably emit didSelectTab, and its internal selection + // updates can be deferred. Force a deterministic selection + focus path so the new + // surface becomes interactive immediately (no "frozen until pane switch" state). + if shouldFocusNewTab { + bonsplitController.focusPane(paneId) + bonsplitController.selectTab(newTabId) + newPanel.focus() + applyTabSelection(tabId: newTabId, inPane: paneId) } else { - scheduleFocusReconcile() + preserveFocusAfterNonFocusSplit( + preferredPanelId: previousFocusedPanelId, + splitPanelId: newPanel.id, + previousHostedView: previousHostedView + ) } - scheduleTerminalGeometryReconcile() - return true - } - func detachSurface(panelId: UUID) -> DetachedSurfaceTransfer? { - guard let tabId = surfaceIdFromPanelId(panelId) else { return nil } - guard panels[panelId] != nil else { return nil } - let shouldSkipControlMasterCleanupAfterDetach = - activeRemoteTerminalSurfaceIds.contains(panelId) - && activeRemoteTerminalSurfaceIds.count == 1 -#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)" + owningTabManager?.scheduleInitialWorkspaceGitMetadataRefreshIfPossible( + workspaceId: id, + panelId: newPanel.id, + reason: "surfaceCreate" ) -#endif + return newPanel + } - 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 + func remoteTerminalStartupCommand() -> String? { + guard let command = remoteConfiguration?.terminalStartupCommand? + .trimmingCharacters(in: .whitespacesAndNewlines), + !command.isEmpty else { return nil } + return command + } - var detached = pendingDetachedSurfaces.removeValue(forKey: tabId) - if shouldSkipControlMasterCleanupAfterDetach, let detachedTransfer = detached, detachedTransfer.isRemoteTerminal { - skipControlMasterCleanupAfterDetachedRemoteTransfer = true - if detachedTransfer.remoteCleanupConfiguration == nil { - detached = detachedTransfer.withRemoteCleanupConfiguration(remoteConfiguration) + /// Create a new browser panel split + @discardableResult + func newBrowserSplit( + from panelId: UUID, + orientation: SplitOrientation, + insertFirst: Bool = false, + url: URL? = nil, + preferredProfileID: UUID? = nil, + focus: Bool = true + ) -> BrowserPanel? { + // Find the pane containing the source panel + guard let sourceTabId = surfaceIdFromPanelId(panelId) else { return nil } + var sourcePaneId: PaneID? + for paneId in bonsplitController.allPaneIds { + let tabs = bonsplitController.tabs(inPane: paneId) + if tabs.contains(where: { $0.id == sourceTabId }) { + sourcePaneId = paneId + break } } -#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))" + + guard let paneId = sourcePaneId else { return nil } + + // Create browser panel + let browserPanel = BrowserPanel( + workspaceId: id, + profileID: resolvedNewBrowserProfileID( + preferredProfileID: preferredProfileID, + sourcePanelId: panelId + ), + initialURL: url, + proxyEndpoint: remoteProxyEndpoint, + isRemoteWorkspace: isRemoteWorkspace, + remoteWebsiteDataStoreIdentifier: isRemoteWorkspace ? id : nil ) -#endif - return detached - } + panels[browserPanel.id] = browserPanel + panelTitles[browserPanel.id] = browserPanel.displayTitle - @discardableResult - func attachDetachedSurface( - _ detached: DetachedSurfaceTransfer, - inPane paneId: PaneID, - atIndex index: Int? = nil, - focus: Bool = true - ) -> UUID? { -#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)" + // Pre-generate the bonsplit tab ID so the mapping exists before the split lands. + let newTab = Bonsplit.Tab( + title: browserPanel.displayTitle, + icon: browserPanel.displayIcon, + kind: SurfaceKind.browser, + isDirty: browserPanel.isDirty, + isLoading: browserPanel.isLoading, + isPinned: false ) -#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 + 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 } + setPreferredBrowserProfileID(browserPanel.profileID) - panels[detached.panelId] = detached.panel - if let terminalPanel = detached.panel as? TerminalPanel { - terminalPanel.updateWorkspaceId(id) - } else if let browserPanel = detached.panel as? BrowserPanel { - browserPanel.reattachToWorkspace( - id, - isRemoteWorkspace: isRemoteWorkspace, - remoteWebsiteDataStoreIdentifier: isRemoteWorkspace ? id : nil, - proxyEndpoint: remoteProxyEndpoint, - remoteStatus: browserRemoteWorkspaceStatusSnapshot() + // 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) } - if let directory = detached.directory { - panelDirectories[detached.panelId] = directory - } - if let ttyName = detached.ttyName?.trimmingCharacters(in: .whitespacesAndNewlines), !ttyName.isEmpty { - surfaceTTYNames[detached.panelId] = ttyName - } else { - surfaceTTYNames.removeValue(forKey: detached.panelId) - } - syncRemotePortScanTTYs() - if let cachedTitle = detached.cachedTitle { - panelTitles[detached.panelId] = cachedTitle - } - if let customTitle = detached.customTitle { - panelCustomTitles[detached.panelId] = customTitle - } - if detached.isPinned { - pinnedPanelIds.insert(detached.panelId) - } else { - pinnedPanelIds.remove(detached.panelId) - } - if detached.manuallyUnread { - manualUnreadPanelIds.insert(detached.panelId) - manualUnreadMarkedAt[detached.panelId] = .distantPast - } else { - manualUnreadPanelIds.remove(detached.panelId) - manualUnreadMarkedAt.removeValue(forKey: detached.panelId) - } + installBrowserPanelSubscription(browserPanel) + browserPanel.setRemoteWorkspaceStatus(browserRemoteWorkspaceStatusSnapshot()) + + return browserPanel + } + + /// Create a new browser surface in the specified pane. + /// - Parameter focus: nil = focus only if the target pane is already focused (default UI behavior), + /// true = force focus/selection of the new surface, + /// false = never focus (used for internal placeholder repair paths). + @discardableResult + func newBrowserSurface( + inPane paneId: PaneID, + url: URL? = nil, + focus: Bool? = nil, + insertAtEnd: Bool = false, + preferredProfileID: UUID? = nil, + bypassInsecureHTTPHostOnce: String? = nil + ) -> BrowserPanel? { + let shouldFocusNewTab = focus ?? (bonsplitController.focusedPaneId == paneId) + let sourcePanelId = effectiveSelectedPanelId(inPane: paneId) + let previousFocusedPanelId = focusedPanelId + let previousHostedView = focusedTerminalPanel?.hostedView + + let browserPanel = BrowserPanel( + workspaceId: id, + profileID: resolvedNewBrowserProfileID( + preferredProfileID: preferredProfileID, + sourcePanelId: sourcePanelId + ), + initialURL: url, + bypassInsecureHTTPHostOnce: bypassInsecureHTTPHostOnce, + proxyEndpoint: remoteProxyEndpoint, + isRemoteWorkspace: isRemoteWorkspace, + remoteWebsiteDataStoreIdentifier: isRemoteWorkspace ? id : nil + ) + panels[browserPanel.id] = browserPanel + panelTitles[browserPanel.id] = browserPanel.displayTitle guard let newTabId = bonsplitController.createTab( - title: detached.title, - hasCustomTitle: detached.customTitle?.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty == false, - icon: detached.icon, - iconImageData: detached.iconImageData, - kind: detached.kind, - isDirty: detached.panel.isDirty, - isLoading: detached.isLoading, - isPinned: detached.isPinned, + title: browserPanel.displayTitle, + icon: browserPanel.displayIcon, + kind: SurfaceKind.browser, + isDirty: browserPanel.isDirty, + isLoading: browserPanel.isLoading, + isPinned: false, inPane: paneId ) else { - panels.removeValue(forKey: detached.panelId) - panelDirectories.removeValue(forKey: detached.panelId) - surfaceTTYNames.removeValue(forKey: detached.panelId) - syncRemotePortScanTTYs() - panelTitles.removeValue(forKey: detached.panelId) - 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 + panels.removeValue(forKey: browserPanel.id) + panelTitles.removeValue(forKey: browserPanel.id) return nil } - surfaceIdToPanelId[newTabId] = detached.panelId - let didAdoptWorkspaceRemoteTracking = - detached.isRemoteTerminal - && detached.remoteRelayPort == remoteConfiguration?.relayPort - if didAdoptWorkspaceRemoteTracking { - trackRemoteTerminalSurface(detached.panelId) - } - if let cleanupConfiguration = detached.remoteCleanupConfiguration { - if didAdoptWorkspaceRemoteTracking { - transferredRemoteCleanupConfigurationsByPanelId.removeValue(forKey: detached.panelId) - } else { - transferredRemoteCleanupConfigurationsByPanelId[detached.panelId] = cleanupConfiguration - } - } else { - transferredRemoteCleanupConfigurationsByPanelId.removeValue(forKey: detached.panelId) - } - if let index { - _ = bonsplitController.reorderTab(newTabId, toIndex: index) + surfaceIdToPanelId[newTabId] = browserPanel.id + setPreferredBrowserProfileID(browserPanel.profileID) + + // Keyboard/browser-open paths want "new tab at end" regardless of global new-tab placement. + if insertAtEnd { + let targetIndex = max(0, bonsplitController.tabs(inPane: paneId).count - 1) + _ = bonsplitController.reorderTab(newTabId, toIndex: targetIndex) } - syncPinnedStateForTab(newTabId, panelId: detached.panelId) - syncUnreadBadgeStateForPanel(detached.panelId) - normalizePinnedTabs(in: paneId) - if focus { + // Match terminal behavior: enforce deterministic selection + focus. + if shouldFocusNewTab { bonsplitController.focusPane(paneId) bonsplitController.selectTab(newTabId) - detached.panel.focus() + browserPanel.focus() applyTabSelection(tabId: newTabId, inPane: paneId) } else { - scheduleFocusReconcile() + preserveFocusAfterNonFocusSplit( + preferredPanelId: previousFocusedPanelId, + splitPanelId: browserPanel.id, + previousHostedView: previousHostedView + ) } - 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 + installBrowserPanelSubscription(browserPanel) + browserPanel.setRemoteWorkspaceStatus(browserRemoteWorkspaceStatusSnapshot()) + + return browserPanel } - // MARK: - Focus Management - private func preserveFocusAfterNonFocusSplit( - preferredPanelId: UUID?, - splitPanelId: UUID, - previousHostedView: GhosttySurfaceScrollView? - ) { - guard let preferredPanelId, panels[preferredPanelId] != nil else { - clearNonFocusSplitFocusReassert() - scheduleFocusReconcile() - return + func newMarkdownSplit( + from panelId: UUID, + orientation: SplitOrientation, + insertFirst: Bool = false, + filePath: String, + focus: Bool = true + ) -> MarkdownPanel? { + guard let sourceTabId = surfaceIdFromPanelId(panelId) else { return nil } + var sourcePaneId: PaneID? + for paneId in bonsplitController.allPaneIds { + let tabs = bonsplitController.tabs(inPane: paneId) + if tabs.contains(where: { $0.id == sourceTabId }) { + sourcePaneId = paneId + break + } } - let generation = beginNonFocusSplitFocusReassert( - preferredPanelId: preferredPanelId, - splitPanelId: splitPanelId - ) + guard let paneId = sourcePaneId else { return nil } - // 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, - splitPanelId: splitPanelId, - previousHostedView: previousHostedView, - allowPreviousHostedView: true - ) + let markdownPanel = MarkdownPanel(workspaceId: id, filePath: filePath) + panels[markdownPanel.id] = markdownPanel + panelTitles[markdownPanel.id] = markdownPanel.displayTitle - DispatchQueue.main.async { [weak self] in - guard let self else { return } - self.reassertFocusAfterNonFocusSplit( - generation: generation, - preferredPanelId: preferredPanelId, - splitPanelId: splitPanelId, - previousHostedView: previousHostedView, - allowPreviousHostedView: false - ) + let newTab = Bonsplit.Tab( + title: markdownPanel.displayTitle, + icon: markdownPanel.displayIcon, + kind: SurfaceKind.markdown, + isDirty: markdownPanel.isDirty, + isLoading: false, + isPinned: false + ) + surfaceIdToPanelId[newTab.id] = markdownPanel.id + let previousFocusedPanelId = focusedPanelId - DispatchQueue.main.async { [weak self] in - guard let self else { return } - self.reassertFocusAfterNonFocusSplit( - generation: generation, - preferredPanelId: preferredPanelId, - splitPanelId: splitPanelId, - previousHostedView: previousHostedView, - allowPreviousHostedView: false - ) - self.scheduleFocusReconcile() - self.clearNonFocusSplitFocusReassert(generation: generation) + 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: markdownPanel.id) + panelTitles.removeValue(forKey: markdownPanel.id) + return nil + } + + let previousHostedView = focusedTerminalPanel?.hostedView + if focus { + previousHostedView?.suppressReparentFocus() + focusPanel(markdownPanel.id) + DispatchQueue.main.asyncAfter(deadline: .now() + 0.05) { + previousHostedView?.clearSuppressReparentFocus() } + } else { + preserveFocusAfterNonFocusSplit( + preferredPanelId: previousFocusedPanelId, + splitPanelId: markdownPanel.id, + previousHostedView: previousHostedView + ) } + + installMarkdownPanelSubscription(markdownPanel) + return markdownPanel } - private func reassertFocusAfterNonFocusSplit( - generation: UInt64, - preferredPanelId: UUID, - splitPanelId: UUID, - previousHostedView: GhosttySurfaceScrollView?, - allowPreviousHostedView: Bool - ) { - guard matchesPendingNonFocusSplitFocusReassert( - generation: generation, - preferredPanelId: preferredPanelId, - splitPanelId: splitPanelId - ) else { - return - } + @discardableResult + func newMarkdownSurface( + inPane paneId: PaneID, + filePath: String, + focus: Bool? = nil + ) -> MarkdownPanel? { + let shouldFocusNewTab = focus ?? (bonsplitController.focusedPaneId == paneId) + let previousFocusedPanelId = focusedPanelId + let previousHostedView = focusedTerminalPanel?.hostedView - guard panels[preferredPanelId] != nil else { - clearNonFocusSplitFocusReassert(generation: generation) - return + let markdownPanel = MarkdownPanel(workspaceId: id, filePath: filePath) + panels[markdownPanel.id] = markdownPanel + panelTitles[markdownPanel.id] = markdownPanel.displayTitle + + guard let newTabId = bonsplitController.createTab( + title: markdownPanel.displayTitle, + icon: markdownPanel.displayIcon, + kind: SurfaceKind.markdown, + isDirty: markdownPanel.isDirty, + isLoading: false, + isPinned: false, + inPane: paneId + ) else { + panels.removeValue(forKey: markdownPanel.id) + panelTitles.removeValue(forKey: markdownPanel.id) + return nil } - if focusedPanelId == splitPanelId { - focusPanel( - preferredPanelId, - previousHostedView: allowPreviousHostedView ? previousHostedView : nil + surfaceIdToPanelId[newTabId] = markdownPanel.id + if shouldFocusNewTab { + bonsplitController.focusPane(paneId) + bonsplitController.selectTab(newTabId) + applyTabSelection(tabId: newTabId, inPane: paneId) + } else { + preserveFocusAfterNonFocusSplit( + preferredPanelId: previousFocusedPanelId, + splitPanelId: markdownPanel.id, + previousHostedView: previousHostedView ) - return } - guard focusedPanelId == preferredPanelId, - let terminalPanel = terminalPanel(for: preferredPanelId) else { - return - } - terminalPanel.hostedView.ensureFocus(for: id, surfaceId: preferredPanelId) + installMarkdownPanelSubscription(markdownPanel) + return markdownPanel } - func focusPanel( - _ panelId: UUID, - previousHostedView: GhosttySurfaceScrollView? = nil, - trigger: FocusPanelTrigger = .standard - ) { - markExplicitFocusIntent(on: panelId) -#if DEBUG - let pane = bonsplitController.focusedPaneId?.id.uuidString.prefix(5) ?? "nil" - 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 + /// 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() + } - // Capture the currently focused terminal view so we can explicitly move AppKit first - // responder when focusing another terminal (helps avoid "highlighted but typing goes to - // another pane" after heavy split/tab mutations). - // When a caller passes an explicit previousHostedView (e.g. during split creation where - // bonsplit has already mutated focusedPaneId), prefer it over the derived value. - let previousTerminalHostedView = previousHostedView ?? focusedTerminalPanel?.hostedView + panels.removeAll(keepingCapacity: false) + surfaceIdToPanelId.removeAll(keepingCapacity: false) + panelSubscriptions.removeAll(keepingCapacity: false) + pendingRemoteTerminalChildExitSurfaceIds.removeAll(keepingCapacity: false) + pruneSurfaceMetadata(validSurfaceIds: []) + restoredTerminalScrollbackByPanelId.removeAll(keepingCapacity: false) + terminalInheritanceFontPointsByPanelId.removeAll(keepingCapacity: false) + lastTerminalConfigInheritancePanelId = nil + lastTerminalConfigInheritanceFontPoints = nil + } - // `selectTab` does not necessarily move bonsplit's focused pane. For programmatic focus - // (socket API, notification click, etc.), ensure the target tab's pane becomes focused - // so `focusedPanelId` and follow-on focus logic are coherent. - let targetPaneId = bonsplitController.allPaneIds.first(where: { paneId in - bonsplitController.tabs(inPane: paneId).contains(where: { $0.id == tabId }) - }) - let selectionAlreadyConverged: Bool = { - guard let targetPaneId else { return false } - return bonsplitController.focusedPaneId == targetPaneId && - bonsplitController.selectedTab(inPane: targetPaneId)?.id == tabId - }() - let shouldSuppressReentrantRefocus = trigger == .terminalFirstResponder && selectionAlreadyConverged -#if DEBUG - let targetPaneShort = targetPaneId.map { String($0.id.uuidString.prefix(5)) } ?? "nil" - let focusedPaneShort = bonsplitController.focusedPaneId.map { String($0.id.uuidString.prefix(5)) } ?? "nil" - let selectedTabShort = bonsplitController.focusedPaneId - .flatMap { bonsplitController.selectedTab(inPane: $0)?.id } - .map { String($0.uuid.uuidString.prefix(5)) } ?? "nil" - let currentPanelShort = currentlyFocusedPanelId.map { String($0.uuidString.prefix(5)) } ?? "nil" - dlog( - "focus.panel.begin workspace=\(id.uuidString.prefix(5)) " + - "panel=\(panelId.uuidString.prefix(5)) trigger=\(String(describing: trigger)) " + - "targetPane=\(targetPaneShort) focusedPane=\(focusedPaneShort) selectedTab=\(selectedTabShort) " + - "converged=\(selectionAlreadyConverged ? 1 : 0) " + - "currentPanel=\(currentPanelShort)" - ) - if shouldSuppressReentrantRefocus { - dlog( - "focus.panel.skipReentrant panel=\(panelId.uuidString.prefix(5)) " + - "reason=firstResponderAlreadyConverged" - ) + /// Close a panel. + /// Returns true when a bonsplit tab close request was issued. + func closePanel(_ panelId: UUID, force: Bool = false) -> Bool { + if let tabId = surfaceIdFromPanelId(panelId) { + if force { + forceCloseTabIds.insert(tabId) + } + // Close the tab in bonsplit (this triggers delegate callback) + return bonsplitController.closeTab(tabId) } -#endif - if let targetPaneId, !selectionAlreadyConverged { + // Mapping can transiently drift during split-tree mutations. If the target panel is + // currently focused (or is the active terminal first responder), close whichever tab + // bonsplit marks selected in that focused pane. + let firstResponderPanelId = cmuxOwningGhosttyView( + for: NSApp.keyWindow?.firstResponder ?? NSApp.mainWindow?.firstResponder + )?.terminalSurface?.id + let targetIsActive = focusedPanelId == panelId || firstResponderPanelId == panelId + guard targetIsActive, + let focusedPane = bonsplitController.focusedPaneId, + let selected = bonsplitController.selectedTab(inPane: focusedPane) else { #if DEBUG dlog( - "focus.panel.focusPane workspace=\(id.uuidString.prefix(5)) " + - "panel=\(panelId.uuidString.prefix(5)) pane=\(targetPaneId.id.uuidString.prefix(5))" + "surface.close.fallback.skip panel=\(panelId.uuidString.prefix(5)) " + + "focusedPanel=\(focusedPanelId?.uuidString.prefix(5) ?? "nil") " + + "firstResponderPanel=\(firstResponderPanelId?.uuidString.prefix(5) ?? "nil") " + + "focusedPane=\(bonsplitController.focusedPaneId?.id.uuidString.prefix(5) ?? "nil")" ) #endif - bonsplitController.focusPane(targetPaneId) + return false } - if !selectionAlreadyConverged { + if force { + forceCloseTabIds.insert(selected.id) + } + let closed = bonsplitController.closeTab(selected.id) #if DEBUG - dlog( - "focus.panel.selectTab workspace=\(id.uuidString.prefix(5)) " + - "panel=\(panelId.uuidString.prefix(5)) tab=\(tabId.uuid.uuidString.prefix(5))" - ) + dlog( + "surface.close.fallback panel=\(panelId.uuidString.prefix(5)) " + + "selectedTab=\(String(describing: selected.id).prefix(5)) " + + "closed=\(closed ? 1 : 0)" + ) #endif - bonsplitController.selectTab(tabId) - } - - if let targetPaneId { - let activationIntent = panels[panelId]?.preferredFocusIntentForActivation() - applyTabSelection( - tabId: tabId, - inPane: targetPaneId, - reassertAppKitFocus: !shouldSuppressReentrantRefocus, - focusIntent: activationIntent, - previousTerminalHostedView: previousTerminalHostedView - ) - } + return closed + } - if let browserPanel = panels[panelId] as? BrowserPanel { - maybeAutoFocusBrowserAddressBarOnPanelFocus(browserPanel, trigger: trigger) + func paneId(forPanelId panelId: UUID) -> PaneID? { + guard let tabId = surfaceIdFromPanelId(panelId) else { return nil } + return bonsplitController.allPaneIds.first { paneId in + bonsplitController.tabs(inPane: paneId).contains(where: { $0.id == tabId }) } + } - if trigger == .terminalFirstResponder, - panels[panelId] is TerminalPanel { - beginEventDrivenLayoutFollowUp( - reason: "workspace.focusPanel.terminal", - terminalFocusPanelId: panelId - ) - } + func indexInPane(forPanelId panelId: UUID) -> Int? { + guard let tabId = surfaceIdFromPanelId(panelId), + let paneId = paneId(forPanelId: panelId) else { return nil } + return bonsplitController.tabs(inPane: paneId).firstIndex(where: { $0.id == tabId }) } - private func maybeAutoFocusBrowserAddressBarOnPanelFocus( - _ browserPanel: BrowserPanel, - trigger: FocusPanelTrigger - ) { - guard trigger == .standard else { return } - guard !isCommandPaletteVisibleForWorkspaceWindow() else { return } - guard !browserPanel.shouldSuppressOmnibarAutofocus() else { return } - guard browserPanel.isShowingNewTabPage || browserPanel.preferredURLStringForOmnibar() == nil else { return } + /// Returns the nearest right-side sibling pane for browser placement. + /// The search is local to the source pane's ancestry in the split tree: + /// use the closest horizontal ancestor where the source is in the first (left) branch. + func preferredBrowserTargetPane(fromPanelId panelId: UUID) -> PaneID? { + guard let sourcePane = paneId(forPanelId: panelId) else { return nil } + let sourcePaneId = sourcePane.id.uuidString + let tree = bonsplitController.treeSnapshot() + guard let path = browserPathToPane(targetPaneId: sourcePaneId, node: tree) else { return nil } + + let layout = bonsplitController.layoutSnapshot() + let paneFrameById = Dictionary(uniqueKeysWithValues: layout.panes.map { ($0.paneId, $0.frame) }) + let sourceFrame = paneFrameById[sourcePaneId] + let sourceCenterY = sourceFrame.map { $0.y + ($0.height * 0.5) } ?? 0 + let sourceRightX = sourceFrame.map { $0.x + $0.width } ?? 0 + + for crumb in path { + guard crumb.split.orientation == "horizontal", crumb.branch == .first else { continue } + var candidateNodes: [ExternalPaneNode] = [] + browserCollectPaneNodes(node: crumb.split.second, into: &candidateNodes) + if candidateNodes.isEmpty { continue } + + let sorted = candidateNodes.sorted { lhs, rhs in + let lhsDy = abs((lhs.frame.y + (lhs.frame.height * 0.5)) - sourceCenterY) + let rhsDy = abs((rhs.frame.y + (rhs.frame.height * 0.5)) - sourceCenterY) + if lhsDy != rhsDy { return lhsDy < rhsDy } - _ = browserPanel.requestAddressBarFocus() - NotificationCenter.default.post(name: .browserFocusAddressBar, object: browserPanel.id) - } + let lhsDx = abs(lhs.frame.x - sourceRightX) + let rhsDx = abs(rhs.frame.x - sourceRightX) + if lhsDx != rhsDx { return lhsDx < rhsDx } - private func isCommandPaletteVisibleForWorkspaceWindow() -> Bool { - guard let app = AppDelegate.shared else { - return false - } + if lhs.frame.x != rhs.frame.x { return lhs.frame.x < rhs.frame.x } + return lhs.id < rhs.id + } - if let manager = app.tabManagerFor(tabId: id), - let windowId = app.windowId(for: manager), - let window = app.mainWindow(for: windowId), - app.isCommandPaletteVisible(for: window) { - return true + for candidate in sorted { + guard let candidateUUID = UUID(uuidString: candidate.id), + candidateUUID != sourcePane.id, + let pane = bonsplitController.allPaneIds.first(where: { $0.id == candidateUUID }) else { + continue + } + return pane + } } - if let keyWindow = NSApp.keyWindow, app.isCommandPaletteVisible(for: keyWindow) { - return true - } - if let mainWindow = NSApp.mainWindow, app.isCommandPaletteVisible(for: mainWindow) { - return true - } - return false + return nil } - func moveFocus(direction: NavigationDirection) { - // If a pane is zoomed, un-zoom before navigating so the target - // pane becomes visible — matches tmux behavior (#1605). - if bonsplitController.isSplitZoomed { - _ = clearSplitZoom(reason: "workspace.moveFocus") - } - let previousFocusedPanelId = focusedPanelId + /// Returns the top-right pane in the current split tree. + /// When a workspace is already split, sidebar PR opens should reuse an existing pane + /// instead of creating additional right splits. + func topRightBrowserReusePane() -> PaneID? { + let paneIds = bonsplitController.allPaneIds + guard paneIds.count > 1 else { return nil } - // Unfocus the currently-focused panel before navigating. - if let prevPanelId = previousFocusedPanelId, let prev = panels[prevPanelId] { - prev.unfocus() + let paneById = Dictionary(uniqueKeysWithValues: paneIds.map { ($0.id.uuidString, $0) }) + var paneBounds: [String: CGRect] = [:] + browserCollectNormalizedPaneBounds( + node: bonsplitController.treeSnapshot(), + availableRect: CGRect(x: 0, y: 0, width: 1, height: 1), + into: &paneBounds + ) + + guard !paneBounds.isEmpty else { + return paneIds.sorted { $0.id.uuidString < $1.id.uuidString }.first } - bonsplitController.navigateFocus(direction: direction) + let epsilon = 0.000_1 + let rightMostX = paneBounds.values.map(\.maxX).max() ?? 0 - // Always reconcile selection/focus after navigation so AppKit first-responder and - // bonsplit's focused pane stay aligned, even through split tree mutations. - if let paneId = bonsplitController.focusedPaneId, - let tabId = bonsplitController.selectedTab(inPane: paneId)?.id { - applyTabSelection(tabId: tabId, inPane: paneId) + let sortedCandidates = paneBounds + .filter { _, rect in abs(rect.maxX - rightMostX) <= epsilon } + .sorted { lhs, rhs in + if abs(lhs.value.minY - rhs.value.minY) > epsilon { + return lhs.value.minY < rhs.value.minY + } + if abs(lhs.value.minX - rhs.value.minX) > epsilon { + return lhs.value.minX > rhs.value.minX + } + return lhs.key < rhs.key + } + + for candidate in sortedCandidates { + if let pane = paneById[candidate.key] { + return pane + } } + return paneIds.sorted { $0.id.uuidString < $1.id.uuidString }.first } - // MARK: - Surface Navigation + enum BrowserPaneBranch { + case first + case second + } - /// Select the next surface in the currently focused pane - func selectNextSurface() { - bonsplitController.selectNextTab() + struct BrowserPaneBreadcrumb { + let split: ExternalSplitNode + let branch: BrowserPaneBranch + } - if let paneId = bonsplitController.focusedPaneId, - let tabId = bonsplitController.selectedTab(inPane: paneId)?.id { - applyTabSelection(tabId: tabId, inPane: paneId) + func browserPathToPane(targetPaneId: String, node: ExternalTreeNode) -> [BrowserPaneBreadcrumb]? { + switch node { + case .pane(let paneNode): + return paneNode.id == targetPaneId ? [] : nil + case .split(let splitNode): + if var path = browserPathToPane(targetPaneId: targetPaneId, node: splitNode.first) { + path.append(BrowserPaneBreadcrumb(split: splitNode, branch: .first)) + return path + } + if var path = browserPathToPane(targetPaneId: targetPaneId, node: splitNode.second) { + path.append(BrowserPaneBreadcrumb(split: splitNode, branch: .second)) + return path + } + return nil } } - /// Select the previous surface in the currently focused pane - func selectPreviousSurface() { - bonsplitController.selectPreviousTab() - - if let paneId = bonsplitController.focusedPaneId, - let tabId = bonsplitController.selectedTab(inPane: paneId)?.id { - applyTabSelection(tabId: tabId, inPane: paneId) + func browserCollectPaneNodes(node: ExternalTreeNode, into output: inout [ExternalPaneNode]) { + switch node { + case .pane(let paneNode): + output.append(paneNode) + case .split(let splitNode): + browserCollectPaneNodes(node: splitNode.first, into: &output) + browserCollectPaneNodes(node: splitNode.second, into: &output) } } - /// Select a surface by index in the currently focused pane - func selectSurface(at index: Int) { - guard let focusedPaneId = bonsplitController.focusedPaneId else { return } - let tabs = bonsplitController.tabs(inPane: focusedPaneId) - guard index >= 0 && index < tabs.count else { return } - bonsplitController.selectTab(tabs[index].id) + func browserCollectNormalizedPaneBounds( + node: ExternalTreeNode, + availableRect: CGRect, + into output: inout [String: CGRect] + ) { + switch node { + case .pane(let paneNode): + output[paneNode.id] = availableRect + case .split(let splitNode): + let divider = min(max(splitNode.dividerPosition, 0), 1) + let firstRect: CGRect + let secondRect: CGRect - if let tabId = bonsplitController.selectedTab(inPane: focusedPaneId)?.id { - applyTabSelection(tabId: tabId, inPane: focusedPaneId) + if splitNode.orientation.lowercased() == "vertical" { + // Stacked split: first = top, second = bottom + firstRect = CGRect( + x: availableRect.minX, + y: availableRect.minY, + width: availableRect.width, + height: availableRect.height * divider + ) + secondRect = CGRect( + x: availableRect.minX, + y: availableRect.minY + (availableRect.height * divider), + width: availableRect.width, + height: availableRect.height * (1 - divider) + ) + } else { + // Side-by-side split: first = left, second = right + firstRect = CGRect( + x: availableRect.minX, + y: availableRect.minY, + width: availableRect.width * divider, + height: availableRect.height + ) + secondRect = CGRect( + x: availableRect.minX + (availableRect.width * divider), + y: availableRect.minY, + width: availableRect.width * (1 - divider), + height: availableRect.height + ) + } + + browserCollectNormalizedPaneBounds(node: splitNode.first, availableRect: firstRect, into: &output) + browserCollectNormalizedPaneBounds(node: splitNode.second, availableRect: secondRect, into: &output) } } - /// Select the last surface in the currently focused pane - func selectLastSurface() { - guard let focusedPaneId = bonsplitController.focusedPaneId else { return } - let tabs = bonsplitController.tabs(inPane: focusedPaneId) - guard let last = tabs.last else { return } - bonsplitController.selectTab(last.id) + struct BrowserCloseFallbackPlan { + let orientation: SplitOrientation + let insertFirst: Bool + let anchorPaneId: UUID? + } - if let tabId = bonsplitController.selectedTab(inPane: focusedPaneId)?.id { - applyTabSelection(tabId: tabId, inPane: focusedPaneId) + func stageClosedBrowserRestoreSnapshotIfNeeded(for tab: Bonsplit.Tab, inPane pane: PaneID) { + guard let panelId = panelIdFromSurfaceId(tab.id), + let browserPanel = browserPanel(for: panelId), + let tabIndex = bonsplitController.tabs(inPane: pane).firstIndex(where: { $0.id == tab.id }) else { + pendingClosedBrowserRestoreSnapshots.removeValue(forKey: tab.id) + return } + + let fallbackPlan = browserCloseFallbackPlan( + forPaneId: pane.id.uuidString, + in: bonsplitController.treeSnapshot() + ) + let resolvedURL = browserPanel.currentURL + ?? browserPanel.preferredURLStringForOmnibar().flatMap(URL.init(string:)) + + pendingClosedBrowserRestoreSnapshots[tab.id] = ClosedBrowserPanelRestoreSnapshot( + workspaceId: id, + url: resolvedURL, + profileID: browserPanel.profileID, + originalPaneId: pane.id, + originalTabIndex: tabIndex, + fallbackSplitOrientation: fallbackPlan?.orientation, + fallbackSplitInsertFirst: fallbackPlan?.insertFirst ?? false, + fallbackAnchorPaneId: fallbackPlan?.anchorPaneId + ) } - /// Create a new terminal surface in the currently focused pane - @discardableResult - func newTerminalSurfaceInFocusedPane(focus: Bool? = nil) -> TerminalPanel? { - guard let focusedPaneId = bonsplitController.focusedPaneId else { return nil } - return newTerminalSurface(inPane: focusedPaneId, focus: focus) + func clearStagedClosedBrowserRestoreSnapshot(for tabId: TabID) { + pendingClosedBrowserRestoreSnapshots.removeValue(forKey: tabId) } - @discardableResult - func clearSplitZoom(reason: String = "workspace.clearSplitZoom") -> Bool { - // Capture the zoomed pane's browser panel (if any) before clearing zoom, - // so we can prime its portal host replacement afterward. - let zoomedBrowser: (paneId: PaneID, panel: BrowserPanel)? = { - guard let zoomedPaneId = bonsplitController.zoomedPaneId, - let tabId = bonsplitController.selectedTab(inPane: zoomedPaneId)?.id, - let panelId = panelIdFromSurfaceId(tabId), - let browser = browserPanel(for: panelId) else { return nil } - return (zoomedPaneId, browser) - }() + func browserCloseFallbackPlan( + forPaneId targetPaneId: String, + in node: ExternalTreeNode + ) -> BrowserCloseFallbackPlan? { + switch node { + case .pane: + return nil + case .split(let splitNode): + if case .pane(let firstPane) = splitNode.first, firstPane.id == targetPaneId { + return BrowserCloseFallbackPlan( + orientation: splitNode.orientation.lowercased() == "vertical" ? .vertical : .horizontal, + insertFirst: true, + anchorPaneId: browserNearestPaneId( + in: splitNode.second, + targetCenter: browserPaneCenter(firstPane) + ) + ) + } + + if case .pane(let secondPane) = splitNode.second, secondPane.id == targetPaneId { + return BrowserCloseFallbackPlan( + orientation: splitNode.orientation.lowercased() == "vertical" ? .vertical : .horizontal, + insertFirst: false, + anchorPaneId: browserNearestPaneId( + in: splitNode.first, + targetCenter: browserPaneCenter(secondPane) + ) + ) + } - guard bonsplitController.clearPaneZoom() else { return false } - if let zoomedBrowser { - zoomedBrowser.panel.preparePortalHostReplacementForNextDistinctClaim( - inPane: zoomedBrowser.paneId, - reason: reason - ) + if let nested = browserCloseFallbackPlan(forPaneId: targetPaneId, in: splitNode.first) { + return nested + } + return browserCloseFallbackPlan(forPaneId: targetPaneId, in: splitNode.second) } - reconcileTerminalPortalVisibilityForCurrentRenderedLayout() - reconcileBrowserPortalVisibilityForCurrentRenderedLayout(reason: reason) - beginEventDrivenLayoutFollowUp(reason: reason, includeGeometry: true) - return true } - @discardableResult - func toggleSplitZoom(panelId: UUID) -> Bool { - let wasSplitZoomed = bonsplitController.isSplitZoomed - guard let paneId = paneId(forPanelId: panelId) else { return false } - guard bonsplitController.togglePaneZoom(inPane: paneId) else { return false } - focusPanel(panelId) - if !bonsplitController.isSplitZoomed { - // Un-zooming: use centralized reconciliation - reconcileTerminalPortalVisibilityForCurrentRenderedLayout() - reconcileBrowserPortalVisibilityForCurrentRenderedLayout(reason: "workspace.toggleSplitZoom") - } - if let browserPanel = browserPanel(for: panelId) { - browserPanel.preparePortalHostReplacementForNextDistinctClaim( - inPane: paneId, - reason: "workspace.toggleSplitZoom" - ) - } - beginEventDrivenLayoutFollowUp( - reason: "workspace.toggleSplitZoom", - browserPanelId: browserPanel(for: panelId) != nil ? panelId : nil, - browserExitFocusPanelId: (wasSplitZoomed && !bonsplitController.isSplitZoomed) ? panelId : nil, - includeGeometry: true + func browserPaneCenter(_ pane: ExternalPaneNode) -> (x: Double, y: Double) { + ( + x: pane.frame.x + (pane.frame.width * 0.5), + y: pane.frame.y + (pane.frame.height * 0.5) ) - return true } - // MARK: - Context Menu Shortcuts + func browserNearestPaneId( + in node: ExternalTreeNode, + targetCenter: (x: Double, y: Double)? + ) -> UUID? { + var panes: [ExternalPaneNode] = [] + browserCollectPaneNodes(node: node, into: &panes) + guard !panes.isEmpty else { return nil } - static func buildContextMenuShortcuts() -> [TabContextAction: KeyboardShortcut] { - var shortcuts: [TabContextAction: KeyboardShortcut] = [:] - let mappings: [(TabContextAction, KeyboardShortcutSettings.Action)] = [ - (.rename, .renameTab), - (.toggleZoom, .toggleSplitZoom), - (.newTerminalToRight, .newSurface), - ] - for (contextAction, settingsAction) in mappings { - let stored = KeyboardShortcutSettings.shortcut(for: settingsAction) - if let key = stored.keyEquivalent { - shortcuts[contextAction] = KeyboardShortcut(key, modifiers: stored.eventModifiers) + let bestPane: ExternalPaneNode? + if let targetCenter { + bestPane = panes.min { lhs, rhs in + let lhsCenter = browserPaneCenter(lhs) + let rhsCenter = browserPaneCenter(rhs) + let lhsDistance = pow(lhsCenter.x - targetCenter.x, 2) + pow(lhsCenter.y - targetCenter.y, 2) + let rhsDistance = pow(rhsCenter.x - targetCenter.x, 2) + pow(rhsCenter.y - targetCenter.y, 2) + if lhsDistance != rhsDistance { + return lhsDistance < rhsDistance + } + return lhs.id < rhs.id } + } else { + bestPane = panes.first } - return shortcuts - } - - // MARK: - Flash/Notification Support - func triggerFocusFlash(panelId: UUID) { - requestAttentionFlash(panelId: panelId, reason: .navigation) + guard let bestPane else { return nil } + return UUID(uuidString: bestPane.id) } - func triggerNotificationFocusFlash( - panelId: UUID, - requiresSplit: Bool = false, - shouldFocus: Bool = true - ) { - guard terminalPanel(for: panelId) != nil else { return } - if shouldFocus { + @discardableResult + func moveSurface(panelId: UUID, toPane paneId: PaneID, atIndex index: Int? = nil, focus: Bool = true) -> Bool { + guard let tabId = surfaceIdFromPanelId(panelId) else { return false } + guard bonsplitController.allPaneIds.contains(paneId) else { return false } + guard bonsplitController.moveTab(tabId, toPane: paneId, atIndex: index) else { return false } + + if focus { + bonsplitController.focusPane(paneId) + bonsplitController.selectTab(tabId) focusPanel(panelId) + } else { + scheduleFocusReconcile() } - let isSplit = bonsplitController.allPaneIds.count > 1 || panels.count > 1 - if requiresSplit && !isSplit { - return - } - requestAttentionFlash(panelId: panelId, reason: .notificationArrival) + scheduleTerminalGeometryReconcile() + return true } - func triggerNotificationDismissFlash(panelId: UUID) { - guard terminalPanel(for: panelId) != nil else { return } - requestAttentionFlash(panelId: panelId, reason: .notificationDismiss) - } + @discardableResult + func reorderSurface(panelId: UUID, toIndex index: Int) -> Bool { + guard let tabId = surfaceIdFromPanelId(panelId) else { return false } + guard bonsplitController.reorderTab(tabId, toIndex: index) else { return false } - func triggerDebugFlash(panelId: UUID) { - guard panels[panelId] != nil else { return } - focusPanel(panelId) - requestAttentionFlash(panelId: panelId, reason: .debug) + if let paneId = paneId(forPanelId: panelId) { + applyTabSelection(tabId: tabId, inPane: paneId) + } else { + scheduleFocusReconcile() + } + scheduleTerminalGeometryReconcile() + return true } - // MARK: - Portal Lifecycle + func detachSurface(panelId: UUID) -> DetachedSurfaceTransfer? { + guard let tabId = surfaceIdFromPanelId(panelId) else { return nil } + guard panels[panelId] != nil else { return nil } + let shouldSkipControlMasterCleanupAfterDetach = + activeRemoteTerminalSurfaceIds.contains(panelId) + && activeRemoteTerminalSurfaceIds.count == 1 +#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 - /// Hide all terminal portal views for this workspace. - /// Called before the workspace is unmounted to prevent portal-hosted terminal - /// views from covering browser panes in the newly selected workspace. - func hideAllTerminalPortalViews() { - for panel in panels.values { - guard let terminal = panel as? TerminalPanel else { continue } - terminal.hostedView.setVisibleInUI(false) - TerminalWindowPortalRegistry.hideHostedView(terminal.hostedView) + 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 } - } - func hideAllBrowserPortalViews() { - for panel in panels.values { - guard let browser = panel as? BrowserPanel else { continue } - browser.hideBrowserPortalView(source: "workspaceRetire") + var detached = pendingDetachedSurfaces.removeValue(forKey: tabId) + if shouldSkipControlMasterCleanupAfterDetach, let detachedTransfer = detached, detachedTransfer.isRemoteTerminal { + skipControlMasterCleanupAfterDetachedRemoteTransfer = true + if detachedTransfer.remoteCleanupConfiguration == nil { + detached = detachedTransfer.withRemoteCleanupConfiguration(remoteConfiguration) + } } +#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 } - // MARK: - Utility - - /// 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: inheritedConfig, - portOrdinal: portOrdinal + func attachDetachedSurface( + _ detached: DetachedSurfaceTransfer, + inPane paneId: PaneID, + atIndex index: Int? = nil, + focus: Bool = true + ) -> UUID? { +#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)" ) - configureTerminalPanel(newPanel) - panels[newPanel.id] = newPanel - panelTitles[newPanel.id] = newPanel.displayTitle - seedTerminalInheritanceFontPoints(panelId: newPanel.id, configTemplate: inheritedConfig) +#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 + } - // Create tab in bonsplit - if let newTabId = bonsplitController.createTab( - title: newPanel.displayTitle, - icon: newPanel.displayIcon, - kind: SurfaceKind.terminal, - isDirty: newPanel.isDirty, - isPinned: false - ) { - surfaceIdToPanelId[newTabId] = newPanel.id + panels[detached.panelId] = detached.panel + if let terminalPanel = detached.panel as? TerminalPanel { + terminalPanel.updateWorkspaceId(id) + } else if let browserPanel = detached.panel as? BrowserPanel { + browserPanel.reattachToWorkspace( + id, + isRemoteWorkspace: isRemoteWorkspace, + remoteWebsiteDataStoreIdentifier: isRemoteWorkspace ? id : nil, + proxyEndpoint: remoteProxyEndpoint, + remoteStatus: browserRemoteWorkspaceStatusSnapshot() + ) + installBrowserPanelSubscription(browserPanel) } - return newPanel - } - - /// Check if any panel needs close confirmation - func needsConfirmClose() -> Bool { - for (panelId, panel) in panels { - if let terminalPanel = panel as? TerminalPanel, - panelNeedsConfirmClose(panelId: panelId, fallbackNeedsConfirmClose: terminalPanel.needsConfirmClose()) { - return true - } + if let directory = detached.directory { + panelDirectories[detached.panelId] = directory } - return false - } - - private func reconcileFocusState() { - guard !isReconcilingFocusState else { return } - isReconcilingFocusState = true - defer { isReconcilingFocusState = false } - - // Source of truth: bonsplit focused pane + selected tab. - // AppKit first responder must converge to this model state, not the other way around. - var targetPanelId: UUID? - - if let focusedPane = bonsplitController.focusedPaneId, - let focusedTab = bonsplitController.selectedTab(inPane: focusedPane), - let mappedPanelId = panelIdFromSurfaceId(focusedTab.id), - panels[mappedPanelId] != nil { - targetPanelId = mappedPanelId + if let ttyName = detached.ttyName?.trimmingCharacters(in: .whitespacesAndNewlines), !ttyName.isEmpty { + surfaceTTYNames[detached.panelId] = ttyName } else { - for pane in bonsplitController.allPaneIds { - guard let selectedTab = bonsplitController.selectedTab(inPane: pane), - let mappedPanelId = panelIdFromSurfaceId(selectedTab.id), - panels[mappedPanelId] != nil else { continue } - bonsplitController.focusPane(pane) - bonsplitController.selectTab(selectedTab.id) - targetPanelId = mappedPanelId - break - } + surfaceTTYNames.removeValue(forKey: detached.panelId) } - - if targetPanelId == nil, let fallbackPanelId = panels.keys.first { - targetPanelId = fallbackPanelId - if let fallbackTabId = surfaceIdFromPanelId(fallbackPanelId), - let fallbackPane = bonsplitController.allPaneIds.first(where: { paneId in - bonsplitController.tabs(inPane: paneId).contains(where: { $0.id == fallbackTabId }) - }) { - bonsplitController.focusPane(fallbackPane) - bonsplitController.selectTab(fallbackTabId) - } + syncRemotePortScanTTYs() + if let cachedTitle = detached.cachedTitle { + panelTitles[detached.panelId] = cachedTitle } - - guard let targetPanelId, let targetPanel = panels[targetPanelId] else { return } - - for (panelId, panel) in panels where panelId != targetPanelId { - panel.unfocus() + if let customTitle = detached.customTitle { + panelCustomTitles[detached.panelId] = customTitle } - - targetPanel.focus() - if let terminalPanel = targetPanel as? TerminalPanel { - terminalPanel.hostedView.ensureFocus(for: id, surfaceId: targetPanelId) + if detached.isPinned { + pinnedPanelIds.insert(detached.panelId) + } else { + pinnedPanelIds.remove(detached.panelId) } - if let dir = panelDirectories[targetPanelId] { - currentDirectory = dir + if detached.manuallyUnread { + manualUnreadPanelIds.insert(detached.panelId) + manualUnreadMarkedAt[detached.panelId] = .distantPast + } else { + manualUnreadPanelIds.remove(detached.panelId) + manualUnreadMarkedAt.removeValue(forKey: detached.panelId) } - gitBranch = panelGitBranches[targetPanelId] - pullRequest = panelPullRequests[targetPanelId] - } - /// Reconcile focus/first-responder convergence. - /// Coalesce to the next main-queue turn so bonsplit selection/pane mutations settle first. - private func scheduleFocusReconcile() { + guard let newTabId = bonsplitController.createTab( + title: detached.title, + hasCustomTitle: detached.customTitle?.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty == false, + icon: detached.icon, + iconImageData: detached.iconImageData, + kind: detached.kind, + isDirty: detached.panel.isDirty, + isLoading: detached.isLoading, + isPinned: detached.isPinned, + inPane: paneId + ) else { + panels.removeValue(forKey: detached.panelId) + panelDirectories.removeValue(forKey: detached.panelId) + surfaceTTYNames.removeValue(forKey: detached.panelId) + syncRemotePortScanTTYs() + panelTitles.removeValue(forKey: detached.panelId) + 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 - if isDetachingCloseTransaction { - debugFocusReconcileScheduledDuringDetachCount += 1 - } + dlog( + "split.attach.fail ws=\(id.uuidString.prefix(5)) panel=\(detached.panelId.uuidString.prefix(5)) " + + "reason=createTabFailed elapsedMs=\(debugElapsedMs(since: attachStart))" + ) #endif - guard !focusReconcileScheduled else { return } - focusReconcileScheduled = true - DispatchQueue.main.async { [weak self] in - guard let self else { return } - self.focusReconcileScheduled = false - self.reconcileFocusState() + return nil } - } - private func beginEventDrivenLayoutFollowUp( - reason: String, - browserPanelId: UUID? = nil, - browserExitFocusPanelId: UUID? = nil, - terminalFocusPanelId: UUID? = nil, - includeGeometry: Bool = false - ) { - layoutFollowUpReason = reason - if let browserPanelId { - layoutFollowUpBrowserPanelId = browserPanelId + surfaceIdToPanelId[newTabId] = detached.panelId + let didAdoptWorkspaceRemoteTracking = + detached.isRemoteTerminal + && detached.remoteRelayPort == remoteConfiguration?.relayPort + if didAdoptWorkspaceRemoteTracking { + trackRemoteTerminalSurface(detached.panelId) } - if let browserExitFocusPanelId { - layoutFollowUpBrowserExitFocusPanelId = browserExitFocusPanelId + if let cleanupConfiguration = detached.remoteCleanupConfiguration { + if didAdoptWorkspaceRemoteTracking { + transferredRemoteCleanupConfigurationsByPanelId.removeValue(forKey: detached.panelId) + } else { + transferredRemoteCleanupConfigurationsByPanelId[detached.panelId] = cleanupConfiguration + } + } else { + transferredRemoteCleanupConfigurationsByPanelId.removeValue(forKey: detached.panelId) } - if let terminalFocusPanelId { - layoutFollowUpTerminalFocusPanelId = terminalFocusPanelId + if let index { + _ = bonsplitController.reorderTab(newTabId, toIndex: index) } - layoutFollowUpNeedsGeometryPass = layoutFollowUpNeedsGeometryPass || includeGeometry - layoutFollowUpStalledAttemptCount = 0 - // Invalidate any pending retry whose delay was computed from a stale stall count. - // Incrementing the version causes old closures to exit early; clearing the flag - // allows scheduleLayoutFollowUpAttempt() below to enqueue a fresh asyncAfter(0). - layoutFollowUpAttemptVersion &+= 1 - layoutFollowUpAttemptScheduled = false + syncPinnedStateForTab(newTabId, panelId: detached.panelId) + syncUnreadBadgeStateForPanel(detached.panelId) + normalizePinnedTabs(in: paneId) - if layoutFollowUpTimeoutWorkItem == nil { - installLayoutFollowUpObservers() + if focus { + bonsplitController.focusPane(paneId) + bonsplitController.selectTab(newTabId) + detached.panel.focus() + applyTabSelection(tabId: newTabId, inPane: paneId) + } else { + scheduleFocusReconcile() } - refreshLayoutFollowUpTimeout() - // Use async scheduling instead of a synchronous call here. beginEventDrivenLayoutFollowUp - // is often invoked from splitTabBar(_:didChangeGeometry:), which fires from inside - // SwiftUI's .onChange(of: geometry) during an active layout pass. Calling - // attemptEventDrivenLayoutFollowUp() synchronously in that context causes - // flushWorkspaceWindowLayouts() → displayIfNeeded() to be called re-entrantly, - // incrementing AppKit's per-window constraint-pass counter on every display cycle - // until it exceeds the limit and crashes with NSGenericException. - // scheduleLayoutFollowUpAttempt() defers via asyncAfter(0) so the flush always - // happens after the current layout pass completes. - scheduleLayoutFollowUpAttempt() - } + scheduleTerminalGeometryReconcile() - private func installLayoutFollowUpObservers() { - guard layoutFollowUpTimeoutWorkItem == nil else { return } +#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 - let enqueueAttempt: () -> Void = { [weak self] in - self?.scheduleLayoutFollowUpAttempt() + func preserveFocusAfterNonFocusSplit( + preferredPanelId: UUID?, + splitPanelId: UUID, + previousHostedView: GhosttySurfaceScrollView? + ) { + guard let preferredPanelId, panels[preferredPanelId] != nil else { + clearNonFocusSplitFocusReassert() + scheduleFocusReconcile() + return } - layoutFollowUpObservers.append(NotificationCenter.default.addObserver( - forName: NSWindow.didUpdateNotification, - object: nil, - queue: .main - ) { _ in - enqueueAttempt() - }) - layoutFollowUpObservers.append(NotificationCenter.default.addObserver( - forName: .terminalSurfaceDidBecomeReady, - object: nil, - queue: .main - ) { _ in - enqueueAttempt() - }) - layoutFollowUpObservers.append(NotificationCenter.default.addObserver( - forName: .terminalSurfaceHostedViewDidMoveToWindow, - object: nil, - queue: .main - ) { _ in - enqueueAttempt() - }) - layoutFollowUpObservers.append(NotificationCenter.default.addObserver( - forName: .terminalPortalVisibilityDidChange, - object: nil, - queue: .main - ) { _ in - enqueueAttempt() - }) - layoutFollowUpObservers.append(NotificationCenter.default.addObserver( - forName: .browserPortalRegistryDidChange, - object: nil, - queue: .main - ) { _ in - enqueueAttempt() - }) - layoutFollowUpObservers.append(NotificationCenter.default.addObserver( - forName: .ghosttyDidBecomeFirstResponderSurface, - object: nil, - queue: .main - ) { _ in - enqueueAttempt() - }) - layoutFollowUpObservers.append(NotificationCenter.default.addObserver( - forName: .browserDidBecomeFirstResponderWebView, - object: nil, - queue: .main - ) { _ in - enqueueAttempt() - }) - layoutFollowUpPanelsCancellable = $panels - .map { _ in () } - .sink { _ in - enqueueAttempt() + let generation = beginNonFocusSplitFocusReassert( + preferredPanelId: preferredPanelId, + 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, + splitPanelId: splitPanelId, + previousHostedView: previousHostedView, + allowPreviousHostedView: true + ) + + DispatchQueue.main.async { [weak self] in + guard let self else { return } + self.reassertFocusAfterNonFocusSplit( + generation: generation, + preferredPanelId: preferredPanelId, + splitPanelId: splitPanelId, + previousHostedView: previousHostedView, + allowPreviousHostedView: false + ) + + DispatchQueue.main.async { [weak self] in + guard let self else { return } + self.reassertFocusAfterNonFocusSplit( + generation: generation, + preferredPanelId: preferredPanelId, + splitPanelId: splitPanelId, + previousHostedView: previousHostedView, + allowPreviousHostedView: false + ) + self.scheduleFocusReconcile() + self.clearNonFocusSplitFocusReassert(generation: generation) } + } } - private func refreshLayoutFollowUpTimeout() { - layoutFollowUpTimeoutWorkItem?.cancel() - let workItem = DispatchWorkItem { [weak self] in - self?.clearLayoutFollowUp() + func reassertFocusAfterNonFocusSplit( + generation: UInt64, + preferredPanelId: UUID, + splitPanelId: UUID, + previousHostedView: GhosttySurfaceScrollView?, + allowPreviousHostedView: Bool + ) { + guard matchesPendingNonFocusSplitFocusReassert( + generation: generation, + preferredPanelId: preferredPanelId, + splitPanelId: splitPanelId + ) else { + return } - layoutFollowUpTimeoutWorkItem = workItem - DispatchQueue.main.asyncAfter(deadline: .now() + 2.0, execute: workItem) - } - private func clearLayoutFollowUp() { - layoutFollowUpTimeoutWorkItem?.cancel() - layoutFollowUpTimeoutWorkItem = nil - layoutFollowUpObservers.forEach { NotificationCenter.default.removeObserver($0) } - layoutFollowUpObservers.removeAll() - layoutFollowUpPanelsCancellable?.cancel() - layoutFollowUpPanelsCancellable = nil - layoutFollowUpReason = nil - layoutFollowUpTerminalFocusPanelId = nil - layoutFollowUpBrowserPanelId = nil - layoutFollowUpBrowserExitFocusPanelId = nil - layoutFollowUpNeedsGeometryPass = false - layoutFollowUpAttemptVersion &+= 1 - layoutFollowUpAttemptScheduled = false - layoutFollowUpStalledAttemptCount = 0 - } + guard panels[preferredPanelId] != nil else { + clearNonFocusSplitFocusReassert(generation: generation) + return + } - private func scheduleLayoutFollowUpAttempt() { - guard layoutFollowUpTimeoutWorkItem != nil else { return } - guard !layoutFollowUpAttemptScheduled else { return } + if focusedPanelId == splitPanelId { + focusPanel( + preferredPanelId, + previousHostedView: allowPreviousHostedView ? previousHostedView : nil + ) + return + } - layoutFollowUpAttemptScheduled = true - let delay = layoutFollowUpBackoffDelay() - let version = layoutFollowUpAttemptVersion - DispatchQueue.main.asyncAfter(deadline: .now() + delay) { [weak self] in - guard let self else { return } - guard self.layoutFollowUpAttemptVersion == version else { return } - self.layoutFollowUpAttemptScheduled = false - self.attemptEventDrivenLayoutFollowUp() + guard focusedPanelId == preferredPanelId, + let terminalPanel = terminalPanel(for: preferredPanelId) else { + return } + terminalPanel.hostedView.ensureFocus(for: id, surfaceId: preferredPanelId) } - private func layoutFollowUpBackoffDelay() -> TimeInterval { - guard layoutFollowUpStalledAttemptCount > 0 else { return 0 } - let baseDelay: TimeInterval = 0.01 - let exponent = min(layoutFollowUpStalledAttemptCount - 1, 5) - return min(0.25, baseDelay * pow(2.0, Double(exponent))) - } + func focusPanel( + _ panelId: UUID, + previousHostedView: GhosttySurfaceScrollView? = nil, + trigger: FocusPanelTrigger = .standard + ) { + markExplicitFocusIntent(on: panelId) +#if DEBUG + let pane = bonsplitController.focusedPaneId?.id.uuidString.prefix(5) ?? "nil" + 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 - private func flushWorkspaceWindowLayouts() { - for window in NSApp.windows { - window.contentView?.layoutSubtreeIfNeeded() - window.contentView?.displayIfNeeded() + // Capture the currently focused terminal view so we can explicitly move AppKit first + // responder when focusing another terminal (helps avoid "highlighted but typing goes to + // another pane" after heavy split/tab mutations). + // When a caller passes an explicit previousHostedView (e.g. during split creation where + // bonsplit has already mutated focusedPaneId), prefer it over the derived value. + let previousTerminalHostedView = previousHostedView ?? focusedTerminalPanel?.hostedView + + // `selectTab` does not necessarily move bonsplit's focused pane. For programmatic focus + // (socket API, notification click, etc.), ensure the target tab's pane becomes focused + // so `focusedPanelId` and follow-on focus logic are coherent. + let targetPaneId = bonsplitController.allPaneIds.first(where: { paneId in + bonsplitController.tabs(inPane: paneId).contains(where: { $0.id == tabId }) + }) + let selectionAlreadyConverged: Bool = { + guard let targetPaneId else { return false } + return bonsplitController.focusedPaneId == targetPaneId && + bonsplitController.selectedTab(inPane: targetPaneId)?.id == tabId + }() + let shouldSuppressReentrantRefocus = trigger == .terminalFirstResponder && selectionAlreadyConverged +#if DEBUG + let targetPaneShort = targetPaneId.map { String($0.id.uuidString.prefix(5)) } ?? "nil" + let focusedPaneShort = bonsplitController.focusedPaneId.map { String($0.id.uuidString.prefix(5)) } ?? "nil" + let selectedTabShort = bonsplitController.focusedPaneId + .flatMap { bonsplitController.selectedTab(inPane: $0)?.id } + .map { String($0.uuid.uuidString.prefix(5)) } ?? "nil" + let currentPanelShort = currentlyFocusedPanelId.map { String($0.uuidString.prefix(5)) } ?? "nil" + dlog( + "focus.panel.begin workspace=\(id.uuidString.prefix(5)) " + + "panel=\(panelId.uuidString.prefix(5)) trigger=\(String(describing: trigger)) " + + "targetPane=\(targetPaneShort) focusedPane=\(focusedPaneShort) selectedTab=\(selectedTabShort) " + + "converged=\(selectionAlreadyConverged ? 1 : 0) " + + "currentPanel=\(currentPanelShort)" + ) + if shouldSuppressReentrantRefocus { + dlog( + "focus.panel.skipReentrant panel=\(panelId.uuidString.prefix(5)) " + + "reason=firstResponderAlreadyConverged" + ) } - } +#endif - private func browserPortalAnchorReady(for browserPanel: BrowserPanel) -> Bool { - let anchorView = browserPanel.portalAnchorView - return - anchorView.window != nil && - anchorView.superview != nil && - anchorView.bounds.width > 1 && - anchorView.bounds.height > 1 - } + if let targetPaneId, !selectionAlreadyConverged { +#if DEBUG + dlog( + "focus.panel.focusPane workspace=\(id.uuidString.prefix(5)) " + + "panel=\(panelId.uuidString.prefix(5)) pane=\(targetPaneId.id.uuidString.prefix(5))" + ) +#endif + bonsplitController.focusPane(targetPaneId) + } - private func browserPortalReady(for browserPanel: BrowserPanel) -> Bool { - browserPortalAnchorReady(for: browserPanel) && - browserPanel.webView.window != nil && - browserPanel.webView.superview != nil && - BrowserWindowPortalRegistry.isWebView(browserPanel.webView, boundTo: browserPanel.portalAnchorView) - } + if !selectionAlreadyConverged { +#if DEBUG + dlog( + "focus.panel.selectTab workspace=\(id.uuidString.prefix(5)) " + + "panel=\(panelId.uuidString.prefix(5)) tab=\(tabId.uuid.uuidString.prefix(5))" + ) +#endif + bonsplitController.selectTab(tabId) + } - private func browserSplitZoomExitFocusNeedsFollowUp(panelId: UUID) -> Bool { - guard let browserPanel = browserPanel(for: panelId), - let paneId = paneId(forPanelId: panelId), - let tabId = surfaceIdFromPanelId(panelId) else { - return false + if let targetPaneId { + let activationIntent = panels[panelId]?.preferredFocusIntentForActivation() + applyTabSelection( + tabId: tabId, + inPane: targetPaneId, + reassertAppKitFocus: !shouldSuppressReentrantRefocus, + focusIntent: activationIntent, + previousTerminalHostedView: previousTerminalHostedView + ) } - let selectionConverged = - bonsplitController.focusedPaneId == paneId && - bonsplitController.selectedTab(inPane: paneId)?.id == tabId - return !selectionConverged || !browserPortalAnchorReady(for: browserPanel) - } - private func terminalFocusNeedsFollowUp() -> Bool { - guard let panelId = layoutFollowUpTerminalFocusPanelId, - let terminalPanel = terminalPanel(for: panelId) else { - return false + if let browserPanel = panels[panelId] as? BrowserPanel { + maybeAutoFocusBrowserAddressBarOnPanelFocus(browserPanel, trigger: trigger) } - return focusedPanelId != panelId || !terminalPanel.hostedView.isSurfaceViewFirstResponder() - } - private func browserPanelNeedsFollowUp() -> Bool { - guard let panelId = layoutFollowUpBrowserPanelId, - let browserPanel = browserPanel(for: panelId) else { - return false + if trigger == .terminalFirstResponder, + panels[panelId] is TerminalPanel { + beginEventDrivenLayoutFollowUp( + reason: "workspace.focusPanel.terminal", + terminalFocusPanelId: panelId + ) } - return !browserPortalReady(for: browserPanel) } - private func attemptEventDrivenLayoutFollowUp() { - guard layoutFollowUpTimeoutWorkItem != nil, !isAttemptingLayoutFollowUp else { return } - isAttemptingLayoutFollowUp = true - defer { isAttemptingLayoutFollowUp = false } + func maybeAutoFocusBrowserAddressBarOnPanelFocus( + _ browserPanel: BrowserPanel, + trigger: FocusPanelTrigger + ) { + guard trigger == .standard else { return } + guard !isCommandPaletteVisibleForWorkspaceWindow() else { return } + guard !browserPanel.shouldSuppressOmnibarAutofocus() else { return } + guard browserPanel.isShowingNewTabPage || browserPanel.preferredURLStringForOmnibar() == nil else { return } - flushWorkspaceWindowLayouts() + _ = browserPanel.requestAddressBarFocus() + NotificationCenter.default.post(name: .browserFocusAddressBar, object: browserPanel.id) + } - let geometryPendingBefore = layoutFollowUpNeedsGeometryPass - let terminalPortalPendingBefore = terminalPortalVisibilityNeedsFollowUp() - let browserVisibilityPendingBefore = browserPortalVisibilityNeedsFollowUp() - let terminalFocusPendingBefore = terminalFocusNeedsFollowUp() - let browserPanelPendingBefore = browserPanelNeedsFollowUp() - let browserExitPendingBefore = layoutFollowUpBrowserExitFocusPanelId != nil + func isCommandPaletteVisibleForWorkspaceWindow() -> Bool { + guard let app = AppDelegate.shared else { + return false + } - if layoutFollowUpNeedsGeometryPass { - layoutFollowUpNeedsGeometryPass = reconcileTerminalGeometryPass() + if let manager = app.tabManagerFor(tabId: id), + let windowId = app.windowId(for: manager), + let window = app.mainWindow(for: windowId), + app.isCommandPaletteVisible(for: window) { + return true + } + + if let keyWindow = NSApp.keyWindow, app.isCommandPaletteVisible(for: keyWindow) { + return true + } + if let mainWindow = NSApp.mainWindow, app.isCommandPaletteVisible(for: mainWindow) { + return true } + return false + } - if let terminalFocusPanelId = layoutFollowUpTerminalFocusPanelId { - if let terminalPanel = terminalPanel(for: terminalFocusPanelId), - focusedPanelId == terminalFocusPanelId { - terminalPanel.hostedView.ensureFocus(for: id, surfaceId: terminalFocusPanelId) - if terminalPanel.hostedView.isSurfaceViewFirstResponder() { - layoutFollowUpTerminalFocusPanelId = nil - } - } else if terminalPanel(for: terminalFocusPanelId) == nil { - layoutFollowUpTerminalFocusPanelId = nil - } + func moveFocus(direction: NavigationDirection) { + // If a pane is zoomed, un-zoom before navigating so the target + // pane becomes visible — matches tmux behavior (#1605). + if bonsplitController.isSplitZoomed { + _ = clearSplitZoom(reason: "workspace.moveFocus") } + let previousFocusedPanelId = focusedPanelId - reconcileTerminalPortalVisibilityForCurrentRenderedLayout() - let terminalPortalPending = terminalPortalVisibilityNeedsFollowUp() + // Unfocus the currently-focused panel before navigating. + if let prevPanelId = previousFocusedPanelId, let prev = panels[prevPanelId] { + prev.unfocus() + } - let reason = layoutFollowUpReason ?? "workspace.layout" - reconcileBrowserPortalVisibilityForCurrentRenderedLayout(reason: reason) - let browserVisibilityPending = browserPortalVisibilityNeedsFollowUp() + bonsplitController.navigateFocus(direction: direction) - if let browserPanelId = layoutFollowUpBrowserPanelId { - if let browserPanel = browserPanel(for: browserPanelId) { - let anchorReady = browserPortalAnchorReady(for: browserPanel) - let wasReady = browserPortalReady(for: browserPanel) - if anchorReady && !wasReady { - BrowserWindowPortalRegistry.synchronizeForAnchor(browserPanel.portalAnchorView) - } - let isReady = browserPortalReady(for: browserPanel) - if isReady, - (!wasReady || BrowserWindowPortalRegistry.debugSnapshot(for: browserPanel.webView)?.containerHidden == true) { - BrowserWindowPortalRegistry.refresh( - webView: browserPanel.webView, - reason: reason - ) - } - if isReady { - layoutFollowUpBrowserPanelId = nil - } - } else { - layoutFollowUpBrowserPanelId = nil - } + // Always reconcile selection/focus after navigation so AppKit first-responder and + // bonsplit's focused pane stay aligned, even through split tree mutations. + if let paneId = bonsplitController.focusedPaneId, + let tabId = bonsplitController.selectedTab(inPane: paneId)?.id { + applyTabSelection(tabId: tabId, inPane: paneId) } - if let browserExitFocusPanelId = layoutFollowUpBrowserExitFocusPanelId { - if browserSplitZoomExitFocusNeedsFollowUp(panelId: browserExitFocusPanelId) { - if browserPanel(for: browserExitFocusPanelId) != nil { - focusPanel(browserExitFocusPanelId) - scheduleFocusReconcile() - } else { - layoutFollowUpBrowserExitFocusPanelId = nil - } - } else { - layoutFollowUpBrowserExitFocusPanelId = nil - } + } + + // MARK: - Surface Navigation + + /// Select the next surface in the currently focused pane + func selectNextSurface() { + bonsplitController.selectNextTab() + + if let paneId = bonsplitController.focusedPaneId, + let tabId = bonsplitController.selectedTab(inPane: paneId)?.id { + applyTabSelection(tabId: tabId, inPane: paneId) } + } - let terminalFocusPending = terminalFocusNeedsFollowUp() - let browserPanelPending = browserPanelNeedsFollowUp() - let browserExitPending = layoutFollowUpBrowserExitFocusPanelId != nil - let needsMoreWork = - layoutFollowUpNeedsGeometryPass || - terminalPortalPending || - browserVisibilityPending || - terminalFocusPending || - browserPanelPending || - browserExitPending + /// Select the previous surface in the currently focused pane + func selectPreviousSurface() { + bonsplitController.selectPreviousTab() - if !needsMoreWork { - clearLayoutFollowUp() - return + if let paneId = bonsplitController.focusedPaneId, + let tabId = bonsplitController.selectedTab(inPane: paneId)?.id { + applyTabSelection(tabId: tabId, inPane: paneId) } + } - let didMakeProgress = - (geometryPendingBefore && !layoutFollowUpNeedsGeometryPass) || - (terminalPortalPendingBefore && !terminalPortalPending) || - (browserVisibilityPendingBefore && !browserVisibilityPending) || - (terminalFocusPendingBefore && !terminalFocusPending) || - (browserPanelPendingBefore && !browserPanelPending) || - (browserExitPendingBefore && !browserExitPending) + /// Select a surface by index in the currently focused pane + func selectSurface(at index: Int) { + guard let focusedPaneId = bonsplitController.focusedPaneId else { return } + let tabs = bonsplitController.tabs(inPane: focusedPaneId) + guard index >= 0 && index < tabs.count else { return } + bonsplitController.selectTab(tabs[index].id) - if didMakeProgress { - layoutFollowUpStalledAttemptCount = 0 - scheduleLayoutFollowUpAttempt() - } else { - layoutFollowUpStalledAttemptCount += 1 + if let tabId = bonsplitController.selectedTab(inPane: focusedPaneId)?.id { + applyTabSelection(tabId: tabId, inPane: focusedPaneId) } } - /// Reconcile remaining terminal view geometries after split topology changes. - /// This keeps AppKit bounds and Ghostty surface sizes in sync in the next runloop turn. - private func reconcileTerminalGeometryPass() -> Bool { - var needsFollowUpPass = false + /// Select the last surface in the currently focused pane + func selectLastSurface() { + guard let focusedPaneId = bonsplitController.focusedPaneId else { return } + let tabs = bonsplitController.tabs(inPane: focusedPaneId) + guard let last = tabs.last else { return } + bonsplitController.selectTab(last.id) - // Flush pending AppKit layout first so terminal-host bounds reflect latest split topology. - for window in NSApp.windows { - window.contentView?.layoutSubtreeIfNeeded() + if let tabId = bonsplitController.selectedTab(inPane: focusedPaneId)?.id { + applyTabSelection(tabId: tabId, inPane: focusedPaneId) } + } - for panel in panels.values { - guard let terminalPanel = panel as? TerminalPanel else { continue } - let hostedView = terminalPanel.hostedView - let hasUsableBounds = hostedView.bounds.width > 1 && hostedView.bounds.height > 1 - let hasSurface = terminalPanel.surface.surface != nil - let isAttached = hostedView.window != nil && hostedView.superview != nil + /// Create a new terminal surface in the currently focused pane + @discardableResult + func newTerminalSurfaceInFocusedPane(focus: Bool? = nil) -> TerminalPanel? { + guard let focusedPaneId = bonsplitController.focusedPaneId else { return nil } + return newTerminalSurface(inPane: focusedPaneId, focus: focus) + } - // Split close/reparent churn can transiently detach a surviving terminal view. - // Force one SwiftUI representable update so the portal binding reattaches it. - if !isAttached || !hasUsableBounds || !hasSurface { - terminalPanel.requestViewReattach() - needsFollowUpPass = true - } + @discardableResult + func clearSplitZoom(reason: String = "workspace.clearSplitZoom") -> Bool { + // Capture the zoomed pane's browser panel (if any) before clearing zoom, + // so we can prime its portal host replacement afterward. + let zoomedBrowser: (paneId: PaneID, panel: BrowserPanel)? = { + guard let zoomedPaneId = bonsplitController.zoomedPaneId, + let tabId = bonsplitController.selectedTab(inPane: zoomedPaneId)?.id, + let panelId = panelIdFromSurfaceId(tabId), + let browser = browserPanel(for: panelId) else { return nil } + return (zoomedPaneId, browser) + }() - hostedView.reconcileGeometryNow() - // Re-check surface after reconcileGeometryNow() which can trigger AppKit - // layout and view lifecycle changes that free surfaces (#432). - if terminalPanel.surface.surface != nil { - terminalPanel.surface.forceRefresh() - } - if terminalPanel.surface.surface == nil, isAttached && hasUsableBounds { - terminalPanel.surface.requestBackgroundSurfaceStartIfNeeded() - needsFollowUpPass = true - } + guard bonsplitController.clearPaneZoom() else { return false } + if let zoomedBrowser { + zoomedBrowser.panel.preparePortalHostReplacementForNextDistinctClaim( + inPane: zoomedBrowser.paneId, + reason: reason + ) } - - return needsFollowUpPass + reconcileTerminalPortalVisibilityForCurrentRenderedLayout() + reconcileBrowserPortalVisibilityForCurrentRenderedLayout(reason: reason) + beginEventDrivenLayoutFollowUp(reason: reason, includeGeometry: true) + return true } - private func scheduleTerminalGeometryReconcile() { + @discardableResult + func toggleSplitZoom(panelId: UUID) -> Bool { + let wasSplitZoomed = bonsplitController.isSplitZoomed + guard let paneId = paneId(forPanelId: panelId) else { return false } + guard bonsplitController.togglePaneZoom(inPane: paneId) else { return false } + focusPanel(panelId) + if !bonsplitController.isSplitZoomed { + // Un-zooming: use centralized reconciliation + reconcileTerminalPortalVisibilityForCurrentRenderedLayout() + reconcileBrowserPortalVisibilityForCurrentRenderedLayout(reason: "workspace.toggleSplitZoom") + } + if let browserPanel = browserPanel(for: panelId) { + browserPanel.preparePortalHostReplacementForNextDistinctClaim( + inPane: paneId, + reason: "workspace.toggleSplitZoom" + ) + } beginEventDrivenLayoutFollowUp( - reason: "workspace.geometry", + reason: "workspace.toggleSplitZoom", + browserPanelId: browserPanel(for: panelId) != nil ? panelId : nil, + browserExitFocusPanelId: (wasSplitZoomed && !bonsplitController.isSplitZoomed) ? panelId : nil, includeGeometry: true ) + return true } - private func renderedVisiblePanelIdsForCurrentLayout() -> Set { - let renderedPaneIds = bonsplitController.zoomedPaneId.map { [$0] } ?? bonsplitController.allPaneIds - var visiblePanelIds: Set = [] + // MARK: - Context Menu Shortcuts - for paneId in renderedPaneIds { - let selectedTab = bonsplitController.selectedTab(inPane: paneId) ?? bonsplitController.tabs(inPane: paneId).first - guard let selectedTab, - let panelId = panelIdFromSurfaceId(selectedTab.id), - panels[panelId] != nil else { - continue + static func buildContextMenuShortcuts() -> [TabContextAction: KeyboardShortcut] { + var shortcuts: [TabContextAction: KeyboardShortcut] = [:] + let mappings: [(TabContextAction, KeyboardShortcutSettings.Action)] = [ + (.rename, .renameTab), + (.toggleZoom, .toggleSplitZoom), + (.newTerminalToRight, .newSurface), + ] + for (contextAction, settingsAction) in mappings { + let stored = KeyboardShortcutSettings.shortcut(for: settingsAction) + if let key = stored.keyEquivalent { + shortcuts[contextAction] = KeyboardShortcut(key, modifiers: stored.eventModifiers) } - visiblePanelIds.insert(panelId) } + return shortcuts + } - if let focusedPanelId, - panels[focusedPanelId] != nil, - let focusedPaneId = paneId(forPanelId: focusedPanelId), - renderedPaneIds.contains(where: { $0.id == focusedPaneId.id }) { - visiblePanelIds.insert(focusedPanelId) + // MARK: - Flash/Notification Support + + func triggerFocusFlash(panelId: UUID) { + requestAttentionFlash(panelId: panelId, reason: .navigation) + } + + func triggerNotificationFocusFlash( + panelId: UUID, + requiresSplit: Bool = false, + shouldFocus: Bool = true + ) { + guard terminalPanel(for: panelId) != nil else { return } + if shouldFocus { + focusPanel(panelId) + } + let isSplit = bonsplitController.allPaneIds.count > 1 || panels.count > 1 + if requiresSplit && !isSplit { + return } + requestAttentionFlash(panelId: panelId, reason: .notificationArrival) + } - return visiblePanelIds + func triggerNotificationDismissFlash(panelId: UUID) { + guard terminalPanel(for: panelId) != nil else { return } + requestAttentionFlash(panelId: panelId, reason: .notificationDismiss) } - @discardableResult - private func reconcileTerminalPortalVisibilityForCurrentRenderedLayout() -> Bool { - let visiblePanelIds = renderedVisiblePanelIdsForCurrentLayout() - var didChange = false + func triggerDebugFlash(panelId: UUID) { + guard panels[panelId] != nil else { return } + focusPanel(panelId) + requestAttentionFlash(panelId: panelId, reason: .debug) + } + + // MARK: - Portal Lifecycle + /// Hide all terminal portal views for this workspace. + /// Called before the workspace is unmounted to prevent portal-hosted terminal + /// views from covering browser panes in the newly selected workspace. + func hideAllTerminalPortalViews() { for panel in panels.values { - guard let terminalPanel = panel as? TerminalPanel else { continue } - let shouldBeVisible = visiblePanelIds.contains(terminalPanel.id) - if terminalPanel.hostedView.debugPortalVisibleInUI != shouldBeVisible { - terminalPanel.hostedView.setVisibleInUI(shouldBeVisible) - didChange = true - } - let shouldBeActive = shouldBeVisible && focusedPanelId == terminalPanel.id - if terminalPanel.hostedView.debugPortalActive != shouldBeActive { - terminalPanel.hostedView.setActive(shouldBeActive) - didChange = true - } - TerminalWindowPortalRegistry.updateEntryVisibility( - for: terminalPanel.hostedView, - visibleInUI: shouldBeVisible - ) + guard let terminal = panel as? TerminalPanel else { continue } + terminal.hostedView.setVisibleInUI(false) + TerminalWindowPortalRegistry.hideHostedView(terminal.hostedView) } - - return didChange } - private func terminalPortalVisibilityNeedsFollowUp() -> Bool { - let visiblePanelIds = renderedVisiblePanelIdsForCurrentLayout() - + func hideAllBrowserPortalViews() { for panel in panels.values { - guard let terminalPanel = panel as? TerminalPanel else { continue } - let shouldBeVisible = visiblePanelIds.contains(terminalPanel.id) - let hostedView = terminalPanel.hostedView - - if shouldBeVisible { - if hostedView.isHidden || hostedView.window == nil || hostedView.superview == nil { - return true - } - } else if !hostedView.isHidden { - return true - } + guard let browser = panel as? BrowserPanel else { continue } + browser.hideBrowserPortalView(source: "workspaceRetire") } - - return false } + // MARK: - Utility + + /// Create a new terminal panel (used when replacing the last panel) @discardableResult - private func reconcileBrowserPortalVisibilityForCurrentRenderedLayout(reason: String) -> Bool { - let visiblePanelIds = renderedVisiblePanelIdsForCurrentLayout() - var didChange = false + func createReplacementTerminalPanel() -> TerminalPanel { + let inheritedConfig = inheritedTerminalConfig( + preferredPanelId: focusedPanelId, + inPane: bonsplitController.focusedPaneId + ) + let newPanel = TerminalPanel( + workspaceId: id, + context: GHOSTTY_SURFACE_CONTEXT_TAB, + configTemplate: inheritedConfig, + portOrdinal: portOrdinal + ) + configureTerminalPanel(newPanel) + panels[newPanel.id] = newPanel + panelTitles[newPanel.id] = newPanel.displayTitle + seedTerminalInheritanceFontPoints(panelId: newPanel.id, configTemplate: inheritedConfig) - for panel in panels.values { - guard let browserPanel = panel as? BrowserPanel else { continue } - let shouldBeVisible = visiblePanelIds.contains(browserPanel.id) - let anchorView = browserPanel.portalAnchorView - let snapshot = BrowserWindowPortalRegistry.debugSnapshot(for: browserPanel.webView) - if shouldBeVisible { - if snapshot?.visibleInUI == false { - BrowserWindowPortalRegistry.updateEntryVisibility( - for: browserPanel.webView, - visibleInUI: true, - zPriority: 2 - ) - didChange = true - } - let anchorReady = browserPortalAnchorReady(for: browserPanel) - let portalReady = browserPortalReady(for: browserPanel) - if anchorReady && !portalReady { - BrowserWindowPortalRegistry.synchronizeForAnchor(anchorView) - if browserPortalReady(for: browserPanel) { - BrowserWindowPortalRegistry.refresh( - webView: browserPanel.webView, - reason: reason - ) - didChange = true - } - } else if anchorReady && snapshot?.containerHidden == true { - BrowserWindowPortalRegistry.refresh( - webView: browserPanel.webView, - reason: reason - ) - didChange = true - } - } else { - let portalNeedsHide = - snapshot?.visibleInUI == true || - snapshot?.containerHidden == false - if portalNeedsHide { - if snapshot?.visibleInUI == true { - BrowserWindowPortalRegistry.updateEntryVisibility( - for: browserPanel.webView, - visibleInUI: false, - zPriority: 0 - ) - } - BrowserWindowPortalRegistry.hide( - webView: browserPanel.webView, - source: reason - ) - didChange = true - } - } + // Create tab in bonsplit + if let newTabId = bonsplitController.createTab( + title: newPanel.displayTitle, + icon: newPanel.displayIcon, + kind: SurfaceKind.terminal, + isDirty: newPanel.isDirty, + isPinned: false + ) { + surfaceIdToPanelId[newTabId] = newPanel.id } - return didChange + return newPanel } - private func browserPortalVisibilityNeedsFollowUp() -> Bool { - let visiblePanelIds = renderedVisiblePanelIdsForCurrentLayout() - - for panel in panels.values { - guard let browserPanel = panel as? BrowserPanel else { continue } - guard visiblePanelIds.contains(browserPanel.id) else { continue } - let anchorView = browserPanel.portalAnchorView - let anchorReady = - anchorView.window != nil && - anchorView.superview != nil && - anchorView.bounds.width > 1 && - anchorView.bounds.height > 1 - if !anchorReady || - browserPanel.webView.window == nil || - browserPanel.webView.superview == nil || - !BrowserWindowPortalRegistry.isWebView(browserPanel.webView, boundTo: anchorView) { + /// Check if any panel needs close confirmation + func needsConfirmClose() -> Bool { + for (panelId, panel) in panels { + if let terminalPanel = panel as? TerminalPanel, + panelNeedsConfirmClose(panelId: panelId, fallbackNeedsConfirmClose: terminalPanel.needsConfirmClose()) { return true } } - return false } - private func scheduleMovedTerminalRefresh(panelId: UUID) { - guard terminalPanel(for: panelId) != nil else { return } + func reconcileFocusState() { + guard !isReconcilingFocusState else { return } + isReconcilingFocusState = true + defer { isReconcilingFocusState = false } - // Force an NSViewRepresentable update after drag/move reparenting. This keeps - // portal host binding current when a pane auto-closes during tab moves. - terminalPanel(for: panelId)?.requestViewReattach() + // Source of truth: bonsplit focused pane + selected tab. + // AppKit first responder must converge to this model state, not the other way around. + var targetPanelId: UUID? - 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 } - panel.hostedView.reconcileGeometryNow() - if panel.surface.surface != nil { - panel.surface.forceRefresh() - } - if panel.surface.surface == nil { - panel.surface.requestBackgroundSurfaceStartIfNeeded() - } + if let focusedPane = bonsplitController.focusedPaneId, + let focusedTab = bonsplitController.selectedTab(inPane: focusedPane), + let mappedPanelId = panelIdFromSurfaceId(focusedTab.id), + panels[mappedPanelId] != nil { + targetPanelId = mappedPanelId + } else { + for pane in bonsplitController.allPaneIds { + guard let selectedTab = bonsplitController.selectedTab(inPane: pane), + let mappedPanelId = panelIdFromSurfaceId(selectedTab.id), + panels[mappedPanelId] != nil else { continue } + bonsplitController.focusPane(pane) + bonsplitController.selectTab(selectedTab.id) + targetPanelId = mappedPanelId + break } } - // Run once immediately and once on the next turn so rapid split close/reparent - // sequences still get a post-layout redraw. - runRefreshPass(0) - runRefreshPass(0.03) - } - - private func closeTabs(_ tabIds: [TabID], skipPinned: Bool = true) { - for tabId in tabIds { - if skipPinned, - let panelId = panelIdFromSurfaceId(tabId), - pinnedPanelIds.contains(panelId) { - continue + if targetPanelId == nil, let fallbackPanelId = panels.keys.first { + targetPanelId = fallbackPanelId + if let fallbackTabId = surfaceIdFromPanelId(fallbackPanelId), + let fallbackPane = bonsplitController.allPaneIds.first(where: { paneId in + bonsplitController.tabs(inPane: paneId).contains(where: { $0.id == fallbackTabId }) + }) { + bonsplitController.focusPane(fallbackPane) + bonsplitController.selectTab(fallbackTabId) } - _ = bonsplitController.closeTab(tabId) } - } - - private func tabIdsToLeft(of anchorTabId: TabID, inPane paneId: PaneID) -> [TabID] { - let tabs = bonsplitController.tabs(inPane: paneId) - guard let index = tabs.firstIndex(where: { $0.id == anchorTabId }) else { return [] } - return Array(tabs.prefix(index).map(\.id)) - } - - private func tabIdsToRight(of anchorTabId: TabID, inPane paneId: PaneID) -> [TabID] { - let tabs = bonsplitController.tabs(inPane: paneId) - guard let index = tabs.firstIndex(where: { $0.id == anchorTabId }), - index + 1 < tabs.count else { return [] } - return Array(tabs.suffix(from: index + 1).map(\.id)) - } - private func tabIdsToCloseOthers(of anchorTabId: TabID, inPane paneId: PaneID) -> [TabID] { - bonsplitController.tabs(inPane: paneId) - .map(\.id) - .filter { $0 != anchorTabId } - } + guard let targetPanelId, let targetPanel = panels[targetPanelId] else { return } - private func createTerminalToRight(of anchorTabId: TabID, inPane paneId: PaneID) { - let targetIndex = insertionIndexToRight(of: anchorTabId, inPane: paneId) - guard let newPanel = newTerminalSurface(inPane: paneId, focus: true) else { return } - _ = reorderSurface(panelId: newPanel.id, toIndex: targetIndex) - } + for (panelId, panel) in panels where panelId != targetPanelId { + panel.unfocus() + } - private func createBrowserToRight(of anchorTabId: TabID, inPane paneId: PaneID, url: URL? = nil) { - let targetIndex = insertionIndexToRight(of: anchorTabId, inPane: paneId) - let preferredProfileID = panelIdFromSurfaceId(anchorTabId).flatMap { browserPanel(for: $0)?.profileID } - guard let newPanel = newBrowserSurface( - inPane: paneId, - url: url, - focus: true, - preferredProfileID: preferredProfileID - ) else { return } - _ = reorderSurface(panelId: newPanel.id, toIndex: targetIndex) + targetPanel.focus() + if let terminalPanel = targetPanel as? TerminalPanel { + terminalPanel.hostedView.ensureFocus(for: id, surfaceId: targetPanelId) + } + if let dir = panelDirectories[targetPanelId] { + currentDirectory = dir + } + gitBranch = panelGitBranches[targetPanelId] + pullRequest = panelPullRequests[targetPanelId] } - private func duplicateBrowserToRight(anchorTabId: TabID, inPane paneId: PaneID) { - guard let panelId = panelIdFromSurfaceId(anchorTabId), - let browser = browserPanel(for: panelId) else { return } - let targetIndex = insertionIndexToRight(of: anchorTabId, inPane: paneId) - guard let newPanel = newBrowserSurface( - inPane: paneId, - url: browser.currentURL, - focus: true, - preferredProfileID: browser.profileID - ) else { return } - _ = reorderSurface(panelId: newPanel.id, toIndex: targetIndex) + /// Reconcile focus/first-responder convergence. + /// Coalesce to the next main-queue turn so bonsplit selection/pane mutations settle first. + func scheduleFocusReconcile() { +#if DEBUG + if isDetachingCloseTransaction { + debugFocusReconcileScheduledDuringDetachCount += 1 + } +#endif + guard !focusReconcileScheduled else { return } + focusReconcileScheduled = true + DispatchQueue.main.async { [weak self] in + guard let self else { return } + self.focusReconcileScheduled = false + self.reconcileFocusState() + } } - private func promptRenamePanel(tabId: TabID) { - guard let panelId = panelIdFromSurfaceId(tabId), - let panel = panels[panelId] else { return } - - let alert = NSAlert() - alert.messageText = String(localized: "alert.renameTab.title", defaultValue: "Rename Tab") - alert.informativeText = String(localized: "alert.renameTab.message", defaultValue: "Enter a custom name for this tab.") - let currentTitle = panelCustomTitles[panelId] ?? panelTitles[panelId] ?? panel.displayTitle - let input = NSTextField(string: currentTitle) - input.placeholderString = String(localized: "alert.renameTab.placeholder", defaultValue: "Tab name") - input.frame = NSRect(x: 0, y: 0, width: 240, height: 22) - alert.accessoryView = input - alert.addButton(withTitle: String(localized: "alert.renameTab.rename", defaultValue: "Rename")) - alert.addButton(withTitle: String(localized: "alert.cancel", defaultValue: "Cancel")) - let alertWindow = alert.window - alertWindow.initialFirstResponder = input - DispatchQueue.main.async { - alertWindow.makeFirstResponder(input) - input.selectText(nil) + func beginEventDrivenLayoutFollowUp( + reason: String, + browserPanelId: UUID? = nil, + browserExitFocusPanelId: UUID? = nil, + terminalFocusPanelId: UUID? = nil, + includeGeometry: Bool = false + ) { + layoutFollowUpReason = reason + if let browserPanelId { + layoutFollowUpBrowserPanelId = browserPanelId } - let response = alert.runModal() - guard response == .alertFirstButtonReturn else { return } - setPanelCustomTitle(panelId: panelId, title: input.stringValue) - } + if let browserExitFocusPanelId { + layoutFollowUpBrowserExitFocusPanelId = browserExitFocusPanelId + } + if let terminalFocusPanelId { + layoutFollowUpTerminalFocusPanelId = terminalFocusPanelId + } + layoutFollowUpNeedsGeometryPass = layoutFollowUpNeedsGeometryPass || includeGeometry + layoutFollowUpStalledAttemptCount = 0 + // Invalidate any pending retry whose delay was computed from a stale stall count. + // Incrementing the version causes old closures to exit early; clearing the flag + // allows scheduleLayoutFollowUpAttempt() below to enqueue a fresh asyncAfter(0). + layoutFollowUpAttemptVersion &+= 1 + layoutFollowUpAttemptScheduled = false - private enum PanelMoveDestination { - case newWorkspaceInCurrentWindow - case selectedWorkspaceInNewWindow - case existingWorkspace(UUID) + if layoutFollowUpTimeoutWorkItem == nil { + installLayoutFollowUpObservers() + } + refreshLayoutFollowUpTimeout() + // Use async scheduling instead of a synchronous call here. beginEventDrivenLayoutFollowUp + // is often invoked from splitTabBar(_:didChangeGeometry:), which fires from inside + // SwiftUI's .onChange(of: geometry) during an active layout pass. Calling + // attemptEventDrivenLayoutFollowUp() synchronously in that context causes + // flushWorkspaceWindowLayouts() → displayIfNeeded() to be called re-entrantly, + // incrementing AppKit's per-window constraint-pass counter on every display cycle + // until it exceeds the limit and crashes with NSGenericException. + // scheduleLayoutFollowUpAttempt() defers via asyncAfter(0) so the flush always + // happens after the current layout pass completes. + scheduleLayoutFollowUpAttempt() } - private func promptMovePanel(tabId: TabID) { - guard let panelId = panelIdFromSurfaceId(tabId), - let app = AppDelegate.shared else { return } + func installLayoutFollowUpObservers() { + guard layoutFollowUpTimeoutWorkItem == nil else { return } - let currentWindowId = app.tabManagerFor(tabId: id).flatMap { app.windowId(for: $0) } - let workspaceTargets = app.workspaceMoveTargets( - excludingWorkspaceId: id, - referenceWindowId: currentWindowId - ) + let enqueueAttempt: () -> Void = { [weak self] in + self?.scheduleLayoutFollowUpAttempt() + } - var options: [(title: String, destination: PanelMoveDestination)] = [ - (String(localized: "alert.moveTab.newWorkspaceInCurrentWindow", defaultValue: "New Workspace in Current Window"), .newWorkspaceInCurrentWindow), - (String(localized: "alert.moveTab.selectedWorkspaceInNewWindow", defaultValue: "Selected Workspace in New Window"), .selectedWorkspaceInNewWindow), - ] - options.append(contentsOf: workspaceTargets.map { target in - (target.label, .existingWorkspace(target.workspaceId)) + layoutFollowUpObservers.append(NotificationCenter.default.addObserver( + forName: NSWindow.didUpdateNotification, + object: nil, + queue: .main + ) { _ in + enqueueAttempt() + }) + layoutFollowUpObservers.append(NotificationCenter.default.addObserver( + forName: .terminalSurfaceDidBecomeReady, + object: nil, + queue: .main + ) { _ in + enqueueAttempt() + }) + layoutFollowUpObservers.append(NotificationCenter.default.addObserver( + forName: .terminalSurfaceHostedViewDidMoveToWindow, + object: nil, + queue: .main + ) { _ in + enqueueAttempt() + }) + layoutFollowUpObservers.append(NotificationCenter.default.addObserver( + forName: .terminalPortalVisibilityDidChange, + object: nil, + queue: .main + ) { _ in + enqueueAttempt() + }) + layoutFollowUpObservers.append(NotificationCenter.default.addObserver( + forName: .browserPortalRegistryDidChange, + object: nil, + queue: .main + ) { _ in + enqueueAttempt() + }) + layoutFollowUpObservers.append(NotificationCenter.default.addObserver( + forName: .ghosttyDidBecomeFirstResponderSurface, + object: nil, + queue: .main + ) { _ in + enqueueAttempt() + }) + layoutFollowUpObservers.append(NotificationCenter.default.addObserver( + forName: .browserDidBecomeFirstResponderWebView, + object: nil, + queue: .main + ) { _ in + enqueueAttempt() }) + layoutFollowUpPanelsCancellable = $panels + .map { _ in () } + .sink { _ in + enqueueAttempt() + } + } - let alert = NSAlert() - alert.messageText = String(localized: "alert.moveTab.title", defaultValue: "Move Tab") - alert.informativeText = String(localized: "alert.moveTab.message", defaultValue: "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) + func refreshLayoutFollowUpTimeout() { + layoutFollowUpTimeoutWorkItem?.cancel() + let workItem = DispatchWorkItem { [weak self] in + self?.clearLayoutFollowUp() } - popup.selectItem(at: 0) - alert.accessoryView = popup - alert.addButton(withTitle: String(localized: "alert.moveTab.move", defaultValue: "Move")) - alert.addButton(withTitle: String(localized: "alert.cancel", defaultValue: "Cancel")) - - guard alert.runModal() == .alertFirstButtonReturn else { return } - let selectedIndex = max(0, min(popup.indexOfSelectedItem, options.count - 1)) - let destination = options[selectedIndex].destination - - let moved: Bool - switch destination { - case .newWorkspaceInCurrentWindow: - guard let manager = app.tabManagerFor(tabId: id) else { return } - let workspace = manager.addWorkspace(select: true) - moved = app.moveSurface( - panelId: panelId, - toWorkspace: workspace.id, - focus: true, - focusWindow: false - ) + layoutFollowUpTimeoutWorkItem = workItem + DispatchQueue.main.asyncAfter(deadline: .now() + 2.0, execute: workItem) + } - case .selectedWorkspaceInNewWindow: - let newWindowId = app.createMainWindow() - guard let destinationManager = app.tabManagerFor(windowId: newWindowId), - let destinationWorkspaceId = destinationManager.selectedTabId else { - return - } - moved = app.moveSurface( - panelId: panelId, - toWorkspace: destinationWorkspaceId, - focus: true, - focusWindow: true - ) - if !moved { - _ = app.closeMainWindow(windowId: newWindowId) - } + func clearLayoutFollowUp() { + layoutFollowUpTimeoutWorkItem?.cancel() + layoutFollowUpTimeoutWorkItem = nil + layoutFollowUpObservers.forEach { NotificationCenter.default.removeObserver($0) } + layoutFollowUpObservers.removeAll() + layoutFollowUpPanelsCancellable?.cancel() + layoutFollowUpPanelsCancellable = nil + layoutFollowUpReason = nil + layoutFollowUpTerminalFocusPanelId = nil + layoutFollowUpBrowserPanelId = nil + layoutFollowUpBrowserExitFocusPanelId = nil + layoutFollowUpNeedsGeometryPass = false + layoutFollowUpAttemptVersion &+= 1 + layoutFollowUpAttemptScheduled = false + layoutFollowUpStalledAttemptCount = 0 + } - case .existingWorkspace(let workspaceId): - moved = app.moveSurface( - panelId: panelId, - toWorkspace: workspaceId, - focus: true, - focusWindow: true - ) - } + func scheduleLayoutFollowUpAttempt() { + guard layoutFollowUpTimeoutWorkItem != nil else { return } + guard !layoutFollowUpAttemptScheduled else { return } - if !moved { - let failure = NSAlert() - failure.alertStyle = .warning - failure.messageText = String(localized: "alert.moveTab.failed.title", defaultValue: "Move Failed") - failure.informativeText = String(localized: "alert.moveTab.failed.message", defaultValue: "Programa could not move this tab to the selected destination.") - failure.addButton(withTitle: String(localized: "alert.ok", defaultValue: "OK")) - _ = failure.runModal() + layoutFollowUpAttemptScheduled = true + let delay = layoutFollowUpBackoffDelay() + let version = layoutFollowUpAttemptVersion + DispatchQueue.main.asyncAfter(deadline: .now() + delay) { [weak self] in + guard let self else { return } + guard self.layoutFollowUpAttemptVersion == version else { return } + self.layoutFollowUpAttemptScheduled = false + self.attemptEventDrivenLayoutFollowUp() } } - private func handleExternalTabDrop(_ request: BonsplitController.ExternalTabDropRequest) -> Bool { - guard let app = AppDelegate.shared else { return false } -#if DEBUG - let dropStart = ProcessInfo.processInfo.systemUptime -#endif - - let targetPane: PaneID - let targetIndex: Int? - let splitTarget: (orientation: SplitOrientation, insertFirst: Bool)? -#if DEBUG - let destinationLabel: String -#endif + func layoutFollowUpBackoffDelay() -> TimeInterval { + guard layoutFollowUpStalledAttemptCount > 0 else { return 0 } + let baseDelay: TimeInterval = 0.01 + let exponent = min(layoutFollowUpStalledAttemptCount - 1, 5) + return min(0.25, baseDelay * pow(2.0, Double(exponent))) + } - switch request.destination { - case .insert(let paneId, let index): - targetPane = paneId - targetIndex = index - splitTarget = nil -#if DEBUG - destinationLabel = "insert pane=\(paneId.id.uuidString.prefix(5)) index=\(index.map(String.init) ?? "nil")" -#endif - case .split(let paneId, let orientation, let insertFirst): - targetPane = paneId - targetIndex = nil - splitTarget = (orientation, insertFirst) -#if DEBUG - destinationLabel = "split pane=\(paneId.id.uuidString.prefix(5)) orientation=\(orientation.rawValue) insertFirst=\(insertFirst ? 1 : 0)" -#endif + func flushWorkspaceWindowLayouts() { + for window in NSApp.windows { + window.contentView?.layoutSubtreeIfNeeded() + window.contentView?.displayIfNeeded() } + } - #if DEBUG - dlog( - "split.externalDrop.begin ws=\(id.uuidString.prefix(5)) tab=\(request.tabId.uuid.uuidString.prefix(5)) " + - "sourcePane=\(request.sourcePaneId.id.uuidString.prefix(5)) destination=\(destinationLabel)" - ) - #endif - let moved = app.moveBonsplitTab( - tabId: request.tabId.uuid, - toWorkspace: id, - targetPane: targetPane, - targetIndex: targetIndex, - splitTarget: splitTarget, - focus: true, - focusWindow: true - ) -#if DEBUG - dlog( - "split.externalDrop.end ws=\(id.uuidString.prefix(5)) tab=\(request.tabId.uuid.uuidString.prefix(5)) " + - "moved=\(moved ? 1 : 0) elapsedMs=\(debugElapsedMs(since: dropStart))" - ) -#endif - return moved + func browserPortalAnchorReady(for browserPanel: BrowserPanel) -> Bool { + let anchorView = browserPanel.portalAnchorView + return + anchorView.window != nil && + anchorView.superview != nil && + anchorView.bounds.width > 1 && + anchorView.bounds.height > 1 } -} + func browserPortalReady(for browserPanel: BrowserPanel) -> Bool { + browserPortalAnchorReady(for: browserPanel) && + browserPanel.webView.window != nil && + browserPanel.webView.superview != nil && + BrowserWindowPortalRegistry.isWebView(browserPanel.webView, boundTo: browserPanel.portalAnchorView) + } -// MARK: - BonsplitDelegate + func browserSplitZoomExitFocusNeedsFollowUp(panelId: UUID) -> Bool { + guard let browserPanel = browserPanel(for: panelId), + let paneId = paneId(forPanelId: panelId), + let tabId = surfaceIdFromPanelId(panelId) else { + return false + } + let selectionConverged = + bonsplitController.focusedPaneId == paneId && + bonsplitController.selectedTab(inPane: paneId)?.id == tabId + return !selectionConverged || !browserPortalAnchorReady(for: browserPanel) + } -extension Workspace: BonsplitDelegate { - @MainActor - private func shouldCloseWorkspaceOnLastSurface(for tabId: TabID) -> Bool { - let manager = owningTabManager ?? AppDelegate.shared?.tabManagerFor(tabId: id) ?? AppDelegate.shared?.tabManager - guard panels.count <= 1, - panelIdFromSurfaceId(tabId) != nil, - let manager, - manager.tabs.contains(where: { $0.id == id }) else { + func terminalFocusNeedsFollowUp() -> Bool { + guard let panelId = layoutFollowUpTerminalFocusPanelId, + let terminalPanel = terminalPanel(for: panelId) else { return false } - return true + return focusedPanelId != panelId || !terminalPanel.hostedView.isSurfaceViewFirstResponder() } - @MainActor - private func confirmClosePanel(for tabId: TabID) async -> Bool { - let alert = NSAlert() + func browserPanelNeedsFollowUp() -> Bool { + guard let panelId = layoutFollowUpBrowserPanelId, + let browserPanel = browserPanel(for: panelId) else { + return false + } + return !browserPortalReady(for: browserPanel) + } - alert.messageText = String(localized: "dialog.closeTab.title", defaultValue: "Close tab?") + func attemptEventDrivenLayoutFollowUp() { + guard layoutFollowUpTimeoutWorkItem != nil, !isAttemptingLayoutFollowUp else { return } + isAttemptingLayoutFollowUp = true + defer { isAttemptingLayoutFollowUp = false } - let panelName: String? = { - guard let panelId = panelIdFromSurfaceId(tabId) else { return nil } - if let custom = panelCustomTitles[panelId], !custom.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty { - return custom - } - if let title = panelTitles[panelId], !title.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty { - return title - } - if let dir = panelDirectories[panelId], !dir.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty { - return (dir as NSString).lastPathComponent - } - return nil - }() + flushWorkspaceWindowLayouts() - if let panelName { - alert.informativeText = String(localized: "dialog.closeTab.messageNamed", defaultValue: "This will close \"\(panelName)\".") - } else { - alert.informativeText = String(localized: "dialog.closeTab.message", defaultValue: "This will close the current tab.") - } - alert.alertStyle = .warning - alert.addButton(withTitle: String(localized: "dialog.closeTab.close", defaultValue: "Close")) - alert.addButton(withTitle: String(localized: "dialog.closeTab.cancel", defaultValue: "Cancel")) + let geometryPendingBefore = layoutFollowUpNeedsGeometryPass + let terminalPortalPendingBefore = terminalPortalVisibilityNeedsFollowUp() + let browserVisibilityPendingBefore = browserPortalVisibilityNeedsFollowUp() + let terminalFocusPendingBefore = terminalFocusNeedsFollowUp() + let browserPanelPendingBefore = browserPanelNeedsFollowUp() + let browserExitPendingBefore = layoutFollowUpBrowserExitFocusPanelId != nil - if let closeButton = alert.buttons.first { - closeButton.keyEquivalent = "\r" - closeButton.keyEquivalentModifierMask = [] - alert.window.defaultButtonCell = closeButton.cell as? NSButtonCell - alert.window.initialFirstResponder = closeButton - } - if let cancelButton = alert.buttons.dropFirst().first { - cancelButton.keyEquivalent = "\u{1b}" + if layoutFollowUpNeedsGeometryPass { + layoutFollowUpNeedsGeometryPass = reconcileTerminalGeometryPass() } - // Prefer a sheet if we can find a window, otherwise fall back to modal. - if let window = NSApp.keyWindow ?? NSApp.mainWindow { - return await withCheckedContinuation { continuation in - alert.beginSheetModal(for: window) { response in - continuation.resume(returning: response == .alertFirstButtonReturn) + if let terminalFocusPanelId = layoutFollowUpTerminalFocusPanelId { + if let terminalPanel = terminalPanel(for: terminalFocusPanelId), + focusedPanelId == terminalFocusPanelId { + terminalPanel.hostedView.ensureFocus(for: id, surfaceId: terminalFocusPanelId) + if terminalPanel.hostedView.isSurfaceViewFirstResponder() { + layoutFollowUpTerminalFocusPanelId = nil } + } else if terminalPanel(for: terminalFocusPanelId) == nil { + layoutFollowUpTerminalFocusPanelId = nil } } - return alert.runModal() == .alertFirstButtonReturn - } + reconcileTerminalPortalVisibilityForCurrentRenderedLayout() + let terminalPortalPending = terminalPortalVisibilityNeedsFollowUp() - /// Apply the side-effects of selecting a tab (unfocus others, focus this panel, update state). - /// bonsplit doesn't always emit didSelectTab for programmatic selection paths (e.g. createTab). - private func applyTabSelection( - tabId: TabID, - inPane pane: PaneID, - reassertAppKitFocus: Bool = true, - focusIntent: PanelFocusIntent? = nil, - previousTerminalHostedView: GhosttySurfaceScrollView? = nil - ) { - pendingTabSelection = PendingTabSelectionRequest( - tabId: tabId, - pane: pane, - reassertAppKitFocus: reassertAppKitFocus, - focusIntent: focusIntent, - previousTerminalHostedView: previousTerminalHostedView - ) - guard !isApplyingTabSelection else { return } - isApplyingTabSelection = true - defer { - isApplyingTabSelection = false - pendingTabSelection = nil - } - - var iterations = 0 - while let request = pendingTabSelection { - pendingTabSelection = nil - iterations += 1 - if iterations > 8 { break } - applyTabSelectionNow( - tabId: request.tabId, - inPane: request.pane, - reassertAppKitFocus: request.reassertAppKitFocus, - focusIntent: request.focusIntent, - previousTerminalHostedView: request.previousTerminalHostedView - ) - } - } + let reason = layoutFollowUpReason ?? "workspace.layout" + reconcileBrowserPortalVisibilityForCurrentRenderedLayout(reason: reason) + let browserVisibilityPending = browserPortalVisibilityNeedsFollowUp() - /// Hide browser portals for tabs that are no longer selected in the given pane. - private func hideBrowserPortalsForDeselectedTabs(inPane pane: PaneID, selectedTabId: TabID) { - for tab in bonsplitController.tabs(inPane: pane) { - guard tab.id != selectedTabId else { continue } - guard let panelId = panelIdFromSurfaceId(tab.id), - let browserPanel = panels[panelId] as? BrowserPanel else { continue } - browserPanel.hideBrowserPortalView(source: "tabDeselected") + if let browserPanelId = layoutFollowUpBrowserPanelId { + if let browserPanel = browserPanel(for: browserPanelId) { + let anchorReady = browserPortalAnchorReady(for: browserPanel) + let wasReady = browserPortalReady(for: browserPanel) + if anchorReady && !wasReady { + BrowserWindowPortalRegistry.synchronizeForAnchor(browserPanel.portalAnchorView) + } + let isReady = browserPortalReady(for: browserPanel) + if isReady, + (!wasReady || BrowserWindowPortalRegistry.debugSnapshot(for: browserPanel.webView)?.containerHidden == true) { + BrowserWindowPortalRegistry.refresh( + webView: browserPanel.webView, + reason: reason + ) + } + if isReady { + layoutFollowUpBrowserPanelId = nil + } + } else { + layoutFollowUpBrowserPanelId = nil + } } - } - private func applyTabSelectionNow( - tabId: TabID, - inPane pane: PaneID, - reassertAppKitFocus: Bool, - focusIntent: PanelFocusIntent?, - previousTerminalHostedView: GhosttySurfaceScrollView? - ) { - let previousFocusedPanelId = focusedPanelId -#if DEBUG - let focusedPaneBefore = bonsplitController.focusedPaneId.map { String($0.id.uuidString.prefix(5)) } ?? "nil" - let selectedTabBefore = bonsplitController.focusedPaneId - .flatMap { bonsplitController.selectedTab(inPane: $0)?.id } - .map { String($0.uuid.uuidString.prefix(5)) } ?? "nil" - dlog( - "focus.split.apply.begin workspace=\(id.uuidString.prefix(5)) " + - "pane=\(pane.id.uuidString.prefix(5)) tab=\(tabId.uuid.uuidString.prefix(5)) " + - "focusedPane=\(focusedPaneBefore) selectedTab=\(selectedTabBefore) " + - "reassert=\(reassertAppKitFocus ? 1 : 0)" - ) -#endif - if bonsplitController.allPaneIds.contains(pane) { - if bonsplitController.focusedPaneId != pane { - bonsplitController.focusPane(pane) - } - if bonsplitController.tabs(inPane: pane).contains(where: { $0.id == tabId }), - bonsplitController.selectedTab(inPane: pane)?.id != tabId { - bonsplitController.selectTab(tabId) + if let browserExitFocusPanelId = layoutFollowUpBrowserExitFocusPanelId { + if browserSplitZoomExitFocusNeedsFollowUp(panelId: browserExitFocusPanelId) { + if browserPanel(for: browserExitFocusPanelId) != nil { + focusPanel(browserExitFocusPanelId) + scheduleFocusReconcile() + } else { + layoutFollowUpBrowserExitFocusPanelId = nil + } + } else { + layoutFollowUpBrowserExitFocusPanelId = nil } } - let focusedPane: PaneID - let selectedTabId: TabID - if let currentPane = bonsplitController.focusedPaneId, - let currentTabId = bonsplitController.selectedTab(inPane: currentPane)?.id { - focusedPane = currentPane - selectedTabId = currentTabId - } else if bonsplitController.tabs(inPane: pane).contains(where: { $0.id == tabId }) { - focusedPane = pane - selectedTabId = tabId - bonsplitController.focusPane(focusedPane) - bonsplitController.selectTab(selectedTabId) - } else { - return - } + let terminalFocusPending = terminalFocusNeedsFollowUp() + let browserPanelPending = browserPanelNeedsFollowUp() + let browserExitPending = layoutFollowUpBrowserExitFocusPanelId != nil + let needsMoreWork = + layoutFollowUpNeedsGeometryPass || + terminalPortalPending || + browserVisibilityPending || + terminalFocusPending || + browserPanelPending || + browserExitPending - // Focus the selected panel, but keep the previously focused terminal active while a - // newly created split terminal is still unattached. - guard let selectedPanelId = panelIdFromSurfaceId(selectedTabId) else { - return - } - let effectiveFocusedPanelId = effectiveSelectedPanelId(inPane: focusedPane) ?? selectedPanelId - guard let panel = panels[effectiveFocusedPanelId] else { + if !needsMoreWork { + clearLayoutFollowUp() return } - if debugStressPreloadSelectionDepth > 0 { - if let terminalPanel = panel as? TerminalPanel { - terminalPanel.requestViewReattach() - scheduleTerminalGeometryReconcile() - terminalPanel.surface.requestBackgroundSurfaceStartIfNeeded() - } - return - } + let didMakeProgress = + (geometryPendingBefore && !layoutFollowUpNeedsGeometryPass) || + (terminalPortalPendingBefore && !terminalPortalPending) || + (browserVisibilityPendingBefore && !browserVisibilityPending) || + (terminalFocusPendingBefore && !terminalFocusPending) || + (browserPanelPendingBefore && !browserPanelPending) || + (browserExitPendingBefore && !browserExitPending) - if shouldTreatCurrentEventAsExplicitFocusIntent() { - markExplicitFocusIntent(on: effectiveFocusedPanelId) + if didMakeProgress { + layoutFollowUpStalledAttemptCount = 0 + scheduleLayoutFollowUpAttempt() + } else { + layoutFollowUpStalledAttemptCount += 1 } - let activationIntent = focusIntent ?? panel.preferredFocusIntentForActivation() - panel.prepareFocusIntentForActivation(activationIntent) - let panelId = effectiveFocusedPanelId + } - syncPinnedStateForTab(selectedTabId, panelId: selectedPanelId) - syncUnreadBadgeStateForPanel(selectedPanelId) + /// Reconcile remaining terminal view geometries after split topology changes. + /// This keeps AppKit bounds and Ghostty surface sizes in sync in the next runloop turn. + func reconcileTerminalGeometryPass() -> Bool { + var needsFollowUpPass = false - // Unfocus all other panels - for (id, p) in panels where id != effectiveFocusedPanelId { - p.unfocus() + // Flush pending AppKit layout first so terminal-host bounds reflect latest split topology. + for window in NSApp.windows { + window.contentView?.layoutSubtreeIfNeeded() } - // Explicitly hide browser portals for deselected tabs in this pane. - // Bonsplit's keepAllAlive mode hides non-selected tabs via SwiftUI .opacity(0), - // but portal-hosted WKWebViews render at the window level in AppKit and are not - // affected by SwiftUI opacity. Without an explicit hide, the deselected browser's - // portal layer can remain visible above the newly selected tab. - hideBrowserPortalsForDeselectedTabs(inPane: focusedPane, selectedTabId: selectedTabId) - - if let focusWindow = activationWindow(for: panel) { - yieldForeignOwnedFocusIfNeeded( - in: focusWindow, - targetPanelId: panelId, - targetIntent: activationIntent - ) - } + for panel in panels.values { + guard let terminalPanel = panel as? TerminalPanel else { continue } + let hostedView = terminalPanel.hostedView + let hasUsableBounds = hostedView.bounds.width > 1 && hostedView.bounds.height > 1 + let hasSurface = terminalPanel.surface.surface != nil + let isAttached = hostedView.window != nil && hostedView.superview != nil - activatePanel( - panel, - focusIntent: activationIntent, - reassertAppKitFocus: reassertAppKitFocus - ) - let focusIntentAllowsBrowserOmnibarAutofocus = - shouldTreatCurrentEventAsExplicitFocusIntent() || - TerminalController.socketCommandAllowsInAppFocusMutations() - if let browserPanel = panel as? BrowserPanel, - shouldAllowBrowserOmnibarAutofocus(for: activationIntent), - 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) - } + // Split close/reparent churn can transiently detach a surviving terminal view. + // Force one SwiftUI representable update so the portal binding reattaches it. + if !isAttached || !hasUsableBounds || !hasSurface { + terminalPanel.requestViewReattach() + needsFollowUpPass = true } - } - // 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. - if reassertAppKitFocus, let terminalPanel = panel as? TerminalPanel { - if shouldMoveTerminalSurfaceFocus(for: activationIntent), - !terminalPanel.hostedView.isSurfaceViewFirstResponder() { -#if DEBUG - let previousExists = previousTerminalHostedView != nil ? 1 : 0 - dlog( - "focus.split.moveFocus workspace=\(id.uuidString.prefix(5)) " + - "panel=\(panelId.uuidString.prefix(5)) previousExists=\(previousExists) " + - "to=\(panelId.uuidString.prefix(5))" - ) -#endif - terminalPanel.hostedView.moveFocus(from: previousTerminalHostedView) + hostedView.reconcileGeometryNow() + // Re-check surface after reconcileGeometryNow() which can trigger AppKit + // layout and view lifecycle changes that free surfaces (#432). + if terminalPanel.surface.surface != nil { + terminalPanel.surface.forceRefresh() + } + if terminalPanel.surface.surface == nil, isAttached && hasUsableBounds { + terminalPanel.surface.requestBackgroundSurfaceStartIfNeeded() + needsFollowUpPass = true } -#if DEBUG - dlog( - "focus.split.ensureFocus workspace=\(id.uuidString.prefix(5)) " + - "panel=\(panelId.uuidString.prefix(5)) pane=\(focusedPane.id.uuidString.prefix(5)) " + - "tab=\(selectedTabId.uuid.uuidString.prefix(5)) intent=\(String(describing: activationIntent))" - ) -#endif - terminalPanel.hostedView.ensureFocus(for: id, surfaceId: panelId) - } - - if shouldRestoreFocusIntentAfterActivation(activationIntent) { - _ = panel.restoreFocusIntent(activationIntent) } - // Update current directory if this is a terminal - if let dir = panelDirectories[panelId] { - currentDirectory = dir - } - gitBranch = panelGitBranches[panelId] - pullRequest = panelPullRequests[panelId] + return needsFollowUpPass + } - // Post notification - NotificationCenter.default.post( - name: .ghosttyDidFocusSurface, - object: nil, - userInfo: [ - GhosttyNotificationKey.tabId: self.id, - GhosttyNotificationKey.surfaceId: panelId - ] - ) -#if DEBUG - let prevPanelShort = previousFocusedPanelId.map { String($0.uuidString.prefix(5)) } ?? "nil" - dlog( - "focus.split.apply.end workspace=\(id.uuidString.prefix(5)) " + - "panel=\(panelId.uuidString.prefix(5)) type=\(String(describing: type(of: panel))) " + - "focusedPane=\(focusedPane.id.uuidString.prefix(5)) selectedTab=\(selectedTabId.uuid.uuidString.prefix(5)) " + - "prevPanel=\(prevPanelShort)" + func scheduleTerminalGeometryReconcile() { + beginEventDrivenLayoutFollowUp( + reason: "workspace.geometry", + includeGeometry: true ) -#endif } - private func activatePanel( - _ panel: any Panel, - focusIntent: PanelFocusIntent, - reassertAppKitFocus: Bool - ) { - if let terminalPanel = panel as? TerminalPanel { - let shouldFocusTerminalSurface = shouldMoveTerminalSurfaceFocus(for: focusIntent) - terminalPanel.surface.setFocus(shouldFocusTerminalSurface) - terminalPanel.hostedView.setActive(true) - if reassertAppKitFocus && shouldFocusTerminalSurface { - terminalPanel.focus() + func renderedVisiblePanelIdsForCurrentLayout() -> Set { + let renderedPaneIds = bonsplitController.zoomedPaneId.map { [$0] } ?? bonsplitController.allPaneIds + var visiblePanelIds: Set = [] + + for paneId in renderedPaneIds { + let selectedTab = bonsplitController.selectedTab(inPane: paneId) ?? bonsplitController.tabs(inPane: paneId).first + guard let selectedTab, + let panelId = panelIdFromSurfaceId(selectedTab.id), + panels[panelId] != nil else { + continue } - return - } - - if let browserPanel = panel as? BrowserPanel { - guard shouldFocusBrowserWebView(for: focusIntent) else { return } - browserPanel.focus() - return + visiblePanelIds.insert(panelId) } - if reassertAppKitFocus { - panel.focus() + if let focusedPanelId, + panels[focusedPanelId] != nil, + let focusedPaneId = paneId(forPanelId: focusedPanelId), + renderedPaneIds.contains(where: { $0.id == focusedPaneId.id }) { + visiblePanelIds.insert(focusedPanelId) } - } - private func activationWindow(for panel: any Panel) -> NSWindow? { - if let terminalPanel = panel as? TerminalPanel { - return terminalPanel.hostedView.window ?? NSApp.keyWindow ?? NSApp.mainWindow - } - if let browserPanel = panel as? BrowserPanel { - return browserPanel.webView.window ?? browserPanel.portalAnchorView.window ?? NSApp.keyWindow ?? NSApp.mainWindow - } - return NSApp.keyWindow ?? NSApp.mainWindow + return visiblePanelIds } - private func yieldForeignOwnedFocusIfNeeded( - in window: NSWindow, - targetPanelId: UUID, - targetIntent: PanelFocusIntent - ) { - guard let firstResponder = window.firstResponder else { return } + @discardableResult + func reconcileTerminalPortalVisibilityForCurrentRenderedLayout() -> Bool { + let visiblePanelIds = renderedVisiblePanelIdsForCurrentLayout() + var didChange = false - for (panelId, panel) in panels where panelId != targetPanelId { - guard let ownedIntent = panel.ownedFocusIntent(for: firstResponder, in: window) else { continue } -#if DEBUG - dlog( - "focus.handoff.begin workspace=\(id.uuidString.prefix(5)) " + - "fromPanel=\(panelId.uuidString.prefix(5)) toPanel=\(targetPanelId.uuidString.prefix(5)) " + - "fromIntent=\(String(describing: ownedIntent)) toIntent=\(String(describing: targetIntent))" + for panel in panels.values { + guard let terminalPanel = panel as? TerminalPanel else { continue } + let shouldBeVisible = visiblePanelIds.contains(terminalPanel.id) + if terminalPanel.hostedView.debugPortalVisibleInUI != shouldBeVisible { + terminalPanel.hostedView.setVisibleInUI(shouldBeVisible) + didChange = true + } + let shouldBeActive = shouldBeVisible && focusedPanelId == terminalPanel.id + if terminalPanel.hostedView.debugPortalActive != shouldBeActive { + terminalPanel.hostedView.setActive(shouldBeActive) + didChange = true + } + TerminalWindowPortalRegistry.updateEntryVisibility( + for: terminalPanel.hostedView, + visibleInUI: shouldBeVisible ) -#endif - _ = panel.yieldFocusIntent(ownedIntent, in: window) - return } - } - private func shouldMoveTerminalSurfaceFocus(for intent: PanelFocusIntent) -> Bool { - switch intent { - case .terminal(.findField): - return false - default: - return true - } + return didChange } - private func shouldFocusBrowserWebView(for intent: PanelFocusIntent) -> Bool { - switch intent { - case .browser(.addressBar), .browser(.findField): - return false - default: - return true - } - } + func terminalPortalVisibilityNeedsFollowUp() -> Bool { + let visiblePanelIds = renderedVisiblePanelIdsForCurrentLayout() - private func shouldAllowBrowserOmnibarAutofocus(for intent: PanelFocusIntent) -> Bool { - switch intent { - case .browser(.webView), .panel: - return true - default: - return false - } - } + for panel in panels.values { + guard let terminalPanel = panel as? TerminalPanel else { continue } + let shouldBeVisible = visiblePanelIds.contains(terminalPanel.id) + let hostedView = terminalPanel.hostedView - private func shouldRestoreFocusIntentAfterActivation(_ intent: PanelFocusIntent) -> Bool { - switch intent { - case .browser(.addressBar), .browser(.findField), .terminal(.findField): - return true - case .panel, .browser(.webView), .terminal(.surface): - return false + if shouldBeVisible { + if hostedView.isHidden || hostedView.window == nil || hostedView.superview == nil { + return true + } + } else if !hostedView.isHidden { + return true + } } - } - - 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 + return false } - private func clearNonFocusSplitFocusReassert(generation: UInt64? = nil) { - guard let pending = pendingNonFocusSplitFocusReassert else { return } - if let generation, pending.generation != generation { return } - pendingNonFocusSplitFocusReassert = nil - } + @discardableResult + func reconcileBrowserPortalVisibilityForCurrentRenderedLayout(reason: String) -> Bool { + let visiblePanelIds = renderedVisiblePanelIdsForCurrentLayout() + var didChange = false - 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 + for panel in panels.values { + guard let browserPanel = panel as? BrowserPanel else { continue } + let shouldBeVisible = visiblePanelIds.contains(browserPanel.id) + let anchorView = browserPanel.portalAnchorView + let snapshot = BrowserWindowPortalRegistry.debugSnapshot(for: browserPanel.webView) + if shouldBeVisible { + if snapshot?.visibleInUI == false { + BrowserWindowPortalRegistry.updateEntryVisibility( + for: browserPanel.webView, + visibleInUI: true, + zPriority: 2 + ) + didChange = true + } + let anchorReady = browserPortalAnchorReady(for: browserPanel) + let portalReady = browserPortalReady(for: browserPanel) + if anchorReady && !portalReady { + BrowserWindowPortalRegistry.synchronizeForAnchor(anchorView) + if browserPortalReady(for: browserPanel) { + BrowserWindowPortalRegistry.refresh( + webView: browserPanel.webView, + reason: reason + ) + didChange = true + } + } else if anchorReady && snapshot?.containerHidden == true { + BrowserWindowPortalRegistry.refresh( + webView: browserPanel.webView, + reason: reason + ) + didChange = true + } + } else { + let portalNeedsHide = + snapshot?.visibleInUI == true || + snapshot?.containerHidden == false + if portalNeedsHide { + if snapshot?.visibleInUI == true { + BrowserWindowPortalRegistry.updateEntryVisibility( + for: browserPanel.webView, + visibleInUI: false, + zPriority: 0 + ) + } + BrowserWindowPortalRegistry.hide( + webView: browserPanel.webView, + source: reason + ) + didChange = true + } + } } - } - private func markExplicitFocusIntent(on panelId: UUID) { - guard let pending = pendingNonFocusSplitFocusReassert, - pending.splitPanelId == panelId else { - return - } - pendingNonFocusSplitFocusReassert = nil + return didChange } - func splitTabBar(_ controller: BonsplitController, shouldCloseTab tab: Bonsplit.Tab, inPane pane: PaneID) -> Bool { - func recordPostCloseSelection() { - let tabs = controller.tabs(inPane: pane) - guard let idx = tabs.firstIndex(where: { $0.id == tab.id }) else { - postCloseSelectTabId.removeValue(forKey: tab.id) - return - } - - let target: TabID? = { - if idx + 1 < tabs.count { return tabs[idx + 1].id } - if idx > 0 { return tabs[idx - 1].id } - return nil - }() + func browserPortalVisibilityNeedsFollowUp() -> Bool { + let visiblePanelIds = renderedVisiblePanelIdsForCurrentLayout() - if let target { - postCloseSelectTabId[tab.id] = target - } else { - postCloseSelectTabId.removeValue(forKey: tab.id) + for panel in panels.values { + guard let browserPanel = panel as? BrowserPanel else { continue } + guard visiblePanelIds.contains(browserPanel.id) else { continue } + let anchorView = browserPanel.portalAnchorView + let anchorReady = + anchorView.window != nil && + anchorView.superview != nil && + anchorView.bounds.width > 1 && + anchorView.bounds.height > 1 + if !anchorReady || + browserPanel.webView.window == nil || + browserPanel.webView.superview == nil || + !BrowserWindowPortalRegistry.isWebView(browserPanel.webView, boundTo: anchorView) { + return true } } - let explicitUserClose = explicitUserCloseTabIds.remove(tab.id) != nil + return false + } - if forceCloseTabIds.contains(tab.id) { - stageClosedBrowserRestoreSnapshotIfNeeded(for: tab, inPane: pane) - recordPostCloseSelection() - return true - } + func scheduleMovedTerminalRefresh(panelId: UUID) { + guard terminalPanel(for: panelId) != nil else { return } - if let panelId = panelIdFromSurfaceId(tab.id), - pinnedPanelIds.contains(panelId) { - clearStagedClosedBrowserRestoreSnapshot(for: tab.id) - NSSound.beep() - return false - } + // Force an NSViewRepresentable update after drag/move reparenting. This keeps + // portal host binding current when a pane auto-closes during tab moves. + terminalPanel(for: panelId)?.requestViewReattach() - if explicitUserClose && shouldCloseWorkspaceOnLastSurface(for: tab.id) { - clearStagedClosedBrowserRestoreSnapshot(for: tab.id) - owningTabManager?.closeWorkspaceWithConfirmation(self) - return false + 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 } + panel.hostedView.reconcileGeometryNow() + if panel.surface.surface != nil { + panel.surface.forceRefresh() + } + if panel.surface.surface == nil { + panel.surface.requestBackgroundSurfaceStartIfNeeded() + } + } } - // Check if the panel needs close confirmation - guard let panelId = panelIdFromSurfaceId(tab.id), - let terminalPanel = terminalPanel(for: panelId) else { - stageClosedBrowserRestoreSnapshotIfNeeded(for: tab, inPane: pane) - recordPostCloseSelection() - return true - } + // Run once immediately and once on the next turn so rapid split close/reparent + // sequences still get a post-layout redraw. + runRefreshPass(0) + runRefreshPass(0.03) + } - // If confirmation is required, Bonsplit will call into this delegate and we must return false. - // Show an app-level confirmation, then re-attempt the close with forceCloseTabIds to bypass - // this gating on the second pass. - if panelNeedsConfirmClose(panelId: panelId, fallbackNeedsConfirmClose: terminalPanel.needsConfirmClose()) { - clearStagedClosedBrowserRestoreSnapshot(for: tab.id) - if pendingCloseConfirmTabIds.contains(tab.id) { - return false + func closeTabs(_ tabIds: [TabID], skipPinned: Bool = true) { + for tabId in tabIds { + if skipPinned, + let panelId = panelIdFromSurfaceId(tabId), + pinnedPanelIds.contains(panelId) { + continue } + _ = bonsplitController.closeTab(tabId) + } + } - pendingCloseConfirmTabIds.insert(tab.id) - let tabId = tab.id - DispatchQueue.main.async { [weak self] in - guard let self else { return } - Task { @MainActor in - defer { self.pendingCloseConfirmTabIds.remove(tabId) } + func tabIdsToLeft(of anchorTabId: TabID, inPane paneId: PaneID) -> [TabID] { + let tabs = bonsplitController.tabs(inPane: paneId) + guard let index = tabs.firstIndex(where: { $0.id == anchorTabId }) else { return [] } + return Array(tabs.prefix(index).map(\.id)) + } + + func tabIdsToRight(of anchorTabId: TabID, inPane paneId: PaneID) -> [TabID] { + let tabs = bonsplitController.tabs(inPane: paneId) + guard let index = tabs.firstIndex(where: { $0.id == anchorTabId }), + index + 1 < tabs.count else { return [] } + return Array(tabs.suffix(from: index + 1).map(\.id)) + } - // If the tab disappeared while we were scheduling, do nothing. - guard self.panelIdFromSurfaceId(tabId) != nil else { return } + func tabIdsToCloseOthers(of anchorTabId: TabID, inPane paneId: PaneID) -> [TabID] { + bonsplitController.tabs(inPane: paneId) + .map(\.id) + .filter { $0 != anchorTabId } + } - let confirmed = await self.confirmClosePanel(for: tabId) - guard confirmed else { return } + func createTerminalToRight(of anchorTabId: TabID, inPane paneId: PaneID) { + let targetIndex = insertionIndexToRight(of: anchorTabId, inPane: paneId) + guard let newPanel = newTerminalSurface(inPane: paneId, focus: true) else { return } + _ = reorderSurface(panelId: newPanel.id, toIndex: targetIndex) + } - self.forceCloseTabIds.insert(tabId) - self.bonsplitController.closeTab(tabId) - } - } + func createBrowserToRight(of anchorTabId: TabID, inPane paneId: PaneID, url: URL? = nil) { + let targetIndex = insertionIndexToRight(of: anchorTabId, inPane: paneId) + let preferredProfileID = panelIdFromSurfaceId(anchorTabId).flatMap { browserPanel(for: $0)?.profileID } + guard let newPanel = newBrowserSurface( + inPane: paneId, + url: url, + focus: true, + preferredProfileID: preferredProfileID + ) else { return } + _ = reorderSurface(panelId: newPanel.id, toIndex: targetIndex) + } + + func duplicateBrowserToRight(anchorTabId: TabID, inPane paneId: PaneID) { + guard let panelId = panelIdFromSurfaceId(anchorTabId), + let browser = browserPanel(for: panelId) else { return } + let targetIndex = insertionIndexToRight(of: anchorTabId, inPane: paneId) + guard let newPanel = newBrowserSurface( + inPane: paneId, + url: browser.currentURL, + focus: true, + preferredProfileID: browser.profileID + ) else { return } + _ = reorderSurface(panelId: newPanel.id, toIndex: targetIndex) + } - return false + func promptRenamePanel(tabId: TabID) { + guard let panelId = panelIdFromSurfaceId(tabId), + let panel = panels[panelId] else { return } + + let alert = NSAlert() + alert.messageText = String(localized: "alert.renameTab.title", defaultValue: "Rename Tab") + alert.informativeText = String(localized: "alert.renameTab.message", defaultValue: "Enter a custom name for this tab.") + let currentTitle = panelCustomTitles[panelId] ?? panelTitles[panelId] ?? panel.displayTitle + let input = NSTextField(string: currentTitle) + input.placeholderString = String(localized: "alert.renameTab.placeholder", defaultValue: "Tab name") + input.frame = NSRect(x: 0, y: 0, width: 240, height: 22) + alert.accessoryView = input + alert.addButton(withTitle: String(localized: "alert.renameTab.rename", defaultValue: "Rename")) + alert.addButton(withTitle: String(localized: "alert.cancel", defaultValue: "Cancel")) + let alertWindow = alert.window + alertWindow.initialFirstResponder = input + DispatchQueue.main.async { + alertWindow.makeFirstResponder(input) + input.selectText(nil) } + let response = alert.runModal() + guard response == .alertFirstButtonReturn else { return } + setPanelCustomTitle(panelId: panelId, title: input.stringValue) + } - clearStagedClosedBrowserRestoreSnapshot(for: tab.id) - recordPostCloseSelection() - return true + enum PanelMoveDestination { + case newWorkspaceInCurrentWindow + case selectedWorkspaceInNewWindow + case existingWorkspace(UUID) } - func splitTabBar(_ controller: BonsplitController, didCloseTab tabId: TabID, fromPane pane: PaneID) { - forceCloseTabIds.remove(tabId) - let selectTabId = postCloseSelectTabId.removeValue(forKey: tabId) - let closedBrowserRestoreSnapshot = pendingClosedBrowserRestoreSnapshots.removeValue(forKey: tabId) - let isDetaching = detachingTabIds.remove(tabId) != nil || isDetachingCloseTransaction + func promptMovePanel(tabId: TabID) { + guard let panelId = panelIdFromSurfaceId(tabId), + let app = AppDelegate.shared else { return } - // Clean up our panel - guard let panelId = panelIdFromSurfaceId(tabId) else { - #if DEBUG - NSLog("[Workspace] didCloseTab: no panelId for tabId") - #endif - scheduleTerminalGeometryReconcile() - if !isDetaching { - scheduleFocusReconcile() - } - return - } + let currentWindowId = app.tabManagerFor(tabId: id).flatMap { app.windowId(for: $0) } + let workspaceTargets = app.workspaceMoveTargets( + excludingWorkspaceId: id, + referenceWindowId: currentWindowId + ) - #if DEBUG - NSLog("[Workspace] didCloseTab panelId=\(panelId) remainingPanels=\(panels.count - 1) remainingPanes=\(controller.allPaneIds.count)") - #endif + var options: [(title: String, destination: PanelMoveDestination)] = [ + (String(localized: "alert.moveTab.newWorkspaceInCurrentWindow", defaultValue: "New Workspace in Current Window"), .newWorkspaceInCurrentWindow), + (String(localized: "alert.moveTab.selectedWorkspaceInNewWindow", defaultValue: "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: "alert.moveTab.title", defaultValue: "Move Tab") + alert.informativeText = String(localized: "alert.moveTab.message", defaultValue: "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: "alert.moveTab.move", defaultValue: "Move")) + alert.addButton(withTitle: String(localized: "alert.cancel", defaultValue: "Cancel")) - let panel = panels[panelId] - let transferredRemoteCleanupConfiguration = transferredRemoteCleanupConfigurationsByPanelId.removeValue(forKey: panelId) + guard alert.runModal() == .alertFirstButtonReturn else { return } + let selectedIndex = max(0, min(popup.indexOfSelectedItem, options.count - 1)) + let destination = options[selectedIndex].destination - if isDetaching, let panel { - let browserPanel = panel as? BrowserPanel - let cachedTitle = panelTitles[panelId] - let transferFallbackTitle = cachedTitle ?? panel.displayTitle - pendingDetachedSurfaces[tabId] = DetachedSurfaceTransfer( + let moved: Bool + switch destination { + case .newWorkspaceInCurrentWindow: + guard let manager = app.tabManagerFor(tabId: id) else { return } + let workspace = manager.addWorkspace(select: true) + moved = app.moveSurface( panelId: panelId, - panel: panel, - 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], - ttyName: surfaceTTYNames[panelId], - cachedTitle: cachedTitle, - customTitle: panelCustomTitles[panelId], - manuallyUnread: manualUnreadPanelIds.contains(panelId), - isRemoteTerminal: activeRemoteTerminalSurfaceIds.contains(panelId), - remoteRelayPort: activeRemoteTerminalSurfaceIds.contains(panelId) - ? remoteConfiguration?.relayPort - : nil, - remoteCleanupConfiguration: transferredRemoteCleanupConfiguration + toWorkspace: workspace.id, + focus: true, + focusWindow: false ) - } else { - if let closedBrowserRestoreSnapshot { - onClosedBrowserPanel?(closedBrowserRestoreSnapshot) - } - panel?.close() - } - - panels.removeValue(forKey: panelId) - untrackRemoteTerminalSurface(panelId) - pendingRemoteTerminalChildExitSurfaceIds.remove(panelId) - surfaceIdToPanelId.removeValue(forKey: tabId) - removeSurfaceMetadata(panelId: panelId) - syncRemotePortScanTTYs() - recomputeListeningPorts() - clearRemoteConfigurationIfWorkspaceBecameLocal() - if !isDetaching, let transferredRemoteCleanupConfiguration { - Self.requestSSHControlMasterCleanupIfNeeded(configuration: transferredRemoteCleanupConfiguration) - } - // 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 { - scheduleTerminalGeometryReconcile() + case .selectedWorkspaceInNewWindow: + let newWindowId = app.createMainWindow() + guard let destinationManager = app.tabManagerFor(windowId: newWindowId), + let destinationWorkspaceId = destinationManager.selectedTabId else { return } - - let replacement = createReplacementTerminalPanel() - if let replacementTabId = surfaceIdFromPanelId(replacement.id), - let replacementPane = bonsplitController.allPaneIds.first { - bonsplitController.focusPane(replacementPane) - bonsplitController.selectTab(replacementTabId) - applyTabSelection(tabId: replacementTabId, inPane: replacementPane) + moved = app.moveSurface( + panelId: panelId, + toWorkspace: destinationWorkspaceId, + focus: true, + focusWindow: true + ) + if !moved { + _ = app.closeMainWindow(windowId: newWindowId) } - scheduleTerminalGeometryReconcile() - scheduleFocusReconcile() - return - } - - if let selectTabId, - bonsplitController.allPaneIds.contains(pane), - bonsplitController.tabs(inPane: pane).contains(where: { $0.id == selectTabId }), - bonsplitController.focusedPaneId == pane { - // Keep selection/focus convergence in the same close transaction to avoid a transient - // 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) - } - scheduleTerminalGeometryReconcile() - if !isDetaching { - scheduleFocusReconcile() + case .existingWorkspace(let workspaceId): + moved = app.moveSurface( + panelId: panelId, + toWorkspace: workspaceId, + focus: true, + focusWindow: true + ) } - } - - func splitTabBar(_ controller: BonsplitController, didSelectTab tab: Bonsplit.Tab, inPane pane: PaneID) { - applyTabSelection(tabId: tab.id, inPane: pane) - } - func splitTabBar(_ controller: BonsplitController, didMoveTab tab: Bonsplit.Tab, fromPane source: PaneID, toPane destination: PaneID) { -#if DEBUG - 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 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() - if !isDetachingCloseTransaction { - scheduleFocusReconcile() + if !moved { + let failure = NSAlert() + failure.alertStyle = .warning + failure.messageText = String(localized: "alert.moveTab.failed.title", defaultValue: "Move Failed") + failure.informativeText = String(localized: "alert.moveTab.failed.message", defaultValue: "Programa could not move this tab to the selected destination.") + failure.addButton(withTitle: String(localized: "alert.ok", defaultValue: "OK")) + _ = failure.runModal() } } - func splitTabBar(_ controller: BonsplitController, didFocusPane pane: PaneID) { - // When a pane is focused, focus its selected tab's panel - guard let tab = controller.selectedTab(inPane: pane) else { return } + func handleExternalTabDrop(_ request: BonsplitController.ExternalTabDropRequest) -> Bool { + guard let app = AppDelegate.shared else { return false } #if DEBUG - FocusLogStore.shared.append( - "Workspace.didFocusPane paneId=\(pane.id.uuidString) tabId=\(tab.id) focusedPane=\(controller.focusedPaneId?.id.uuidString ?? "nil")" - ) + let dropStart = ProcessInfo.processInfo.systemUptime #endif - applyTabSelection(tabId: tab.id, inPane: pane) - - // Apply window background for terminal - if let panelId = panelIdFromSurfaceId(tab.id), - let terminalPanel = panels[panelId] as? TerminalPanel { - terminalPanel.applyWindowBackgroundIfActive() - } - } - - /// Canonical per-panel metadata teardown shared by every close path. - /// The single-surface and pane-close paths previously hand-copied this - /// list and drifted (pane close leaked inheritance font points and never - /// cleared notifications). Aggregate recomputes (syncRemotePortScanTTYs, - /// recomputeListeningPorts) stay at the call sites. - private func removeSurfaceMetadata(panelId: UUID) { - 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) - manualUnreadMarkedAt.removeValue(forKey: panelId) - panelSubscriptions.removeValue(forKey: panelId) - panelShellActivityStates.removeValue(forKey: panelId) - surfaceTTYNames.removeValue(forKey: panelId) - surfaceListeningPorts.removeValue(forKey: panelId) - restoredTerminalScrollbackByPanelId.removeValue(forKey: panelId) - terminalInheritanceFontPointsByPanelId.removeValue(forKey: panelId) - if lastTerminalConfigInheritancePanelId == panelId { - lastTerminalConfigInheritancePanelId = nil - } - PortScanner.shared.unregisterPanel(workspaceId: id, panelId: panelId) - AppDelegate.shared?.notificationStore?.clearNotifications(forTabId: id, surfaceId: panelId) - } - - func splitTabBar(_ controller: BonsplitController, didClosePane paneId: PaneID) { - let closedPanelIds = pendingPaneClosePanelIds.removeValue(forKey: paneId.id) ?? [] - let shouldScheduleFocusReconcile = !isDetachingCloseTransaction - - if !closedPanelIds.isEmpty { - for panelId in closedPanelIds { - panels[panelId]?.close() - panels.removeValue(forKey: panelId) - untrackRemoteTerminalSurface(panelId) - pendingRemoteTerminalChildExitSurfaceIds.remove(panelId) - removeSurfaceMetadata(panelId: panelId) - } - - syncRemotePortScanTTYs() - let closedSet = Set(closedPanelIds) - surfaceIdToPanelId = surfaceIdToPanelId.filter { !closedSet.contains($0.value) } - recomputeListeningPorts() - clearRemoteConfigurationIfWorkspaceBecameLocal() - - if let focusedPane = bonsplitController.focusedPaneId, - let focusedTabId = bonsplitController.selectedTab(inPane: focusedPane)?.id { - applyTabSelection(tabId: focusedTabId, inPane: focusedPane) - } else if shouldScheduleFocusReconcile { - scheduleFocusReconcile() - } - } - - scheduleTerminalGeometryReconcile() - if shouldScheduleFocusReconcile { - scheduleFocusReconcile() - } - } - - func splitTabBar(_ controller: BonsplitController, shouldClosePane pane: PaneID) -> Bool { - // Check if any panel in this pane needs close confirmation - let tabs = controller.tabs(inPane: pane) - for tab in tabs { - if forceCloseTabIds.contains(tab.id) { continue } - if let panelId = panelIdFromSurfaceId(tab.id), - let terminalPanel = terminalPanel(for: panelId), - panelNeedsConfirmClose(panelId: panelId, fallbackNeedsConfirmClose: terminalPanel.needsConfirmClose()) { - pendingPaneClosePanelIds.removeValue(forKey: pane.id) - return false - } - } - pendingPaneClosePanelIds[pane.id] = tabs.compactMap { panelIdFromSurfaceId($0.id) } - return true - } - func splitTabBar(_ controller: BonsplitController, didSplitPane originalPane: PaneID, newPane: PaneID, orientation: SplitOrientation) { + let targetPane: PaneID + let targetIndex: Int? + let splitTarget: (orientation: SplitOrientation, insertFirst: Bool)? #if DEBUG - let panelKindForTab: (TabID) -> String = { tabId in - guard let panelId = self.panelIdFromSurfaceId(tabId), - let panel = self.panels[panelId] else { return "placeholder" } - if panel is TerminalPanel { return "terminal" } - if panel is BrowserPanel { return "browser" } - return String(describing: type(of: panel)) - } - let paneKindSummary: (PaneID) -> String = { paneId in - let tabs = controller.tabs(inPane: paneId) - guard !tabs.isEmpty else { return "-" } - return tabs.map { tab in - String(panelKindForTab(tab.id).prefix(1)) - }.joined(separator: ",") - } - let originalSelectedKind = controller.selectedTab(inPane: originalPane).map { panelKindForTab($0.id) } ?? "none" - let newSelectedKind = controller.selectedTab(inPane: newPane).map { panelKindForTab($0.id) } ?? "none" - dlog( - "split.didSplit original=\(originalPane.id.uuidString.prefix(5)) new=\(newPane.id.uuidString.prefix(5)) " + - "orientation=\(orientation) programmatic=\(isProgrammaticSplit ? 1 : 0) " + - "originalTabs=\(controller.tabs(inPane: originalPane).count) newTabs=\(controller.tabs(inPane: newPane).count) " + - "originalSelected=\(originalSelectedKind) newSelected=\(newSelectedKind) " + - "originalKinds=[\(paneKindSummary(originalPane))] newKinds=[\(paneKindSummary(newPane))]" - ) + let destinationLabel: String #endif - let rearmBrowserPortalHostReplacement: (PaneID, String) -> Void = { paneId, reason in - for tab in controller.tabs(inPane: paneId) { - guard let panelId = self.panelIdFromSurfaceId(tab.id), - let browserPanel = self.browserPanel(for: panelId) else { - continue - } - browserPanel.preparePortalHostReplacementForNextDistinctClaim( - inPane: paneId, - reason: reason - ) - } - } - rearmBrowserPortalHostReplacement(originalPane, "workspace.didSplit.original") - rearmBrowserPortalHostReplacement(newPane, "workspace.didSplit.new") - // Only auto-create a terminal if the split came from bonsplit UI. - // Programmatic splits via newTerminalSplit() set isProgrammaticSplit and handle their own panels. - guard !isProgrammaticSplit else { - normalizePinnedTabs(in: originalPane) - normalizePinnedTabs(in: newPane) - scheduleTerminalGeometryReconcile() - return - } - - // If the new pane already has a tab, this split moved an existing tab (drag-to-split). - // - // In the "drag the only tab to split edge" case, bonsplit inserts a placeholder "Empty" - // tab in the source pane to avoid leaving it tabless. In cmux, this is undesirable: - // it creates a pane with no real surfaces and leaves an "Empty" tab in the tab bar. - // - // Replace placeholder-only source panes with a real terminal surface, then drop the - // placeholder tabs so the UI stays consistent and pane lists don't contain empties. - if !controller.tabs(inPane: newPane).isEmpty { - let originalTabs = controller.tabs(inPane: originalPane) - let hasRealSurface = originalTabs.contains { panelIdFromSurfaceId($0.id) != nil } -#if DEBUG - dlog( - "split.didSplit.drag original=\(originalPane.id.uuidString.prefix(5)) " + - "new=\(newPane.id.uuidString.prefix(5)) originalTabs=\(originalTabs.count) " + - "newTabs=\(controller.tabs(inPane: newPane).count) hasRealSurface=\(hasRealSurface ? 1 : 0) " + - "originalKinds=[\(paneKindSummary(originalPane))] newKinds=[\(paneKindSummary(newPane))]" - ) -#endif - if !hasRealSurface { - let placeholderTabs = originalTabs.filter { panelIdFromSurfaceId($0.id) == nil } + switch request.destination { + case .insert(let paneId, let index): + targetPane = paneId + targetIndex = index + splitTarget = nil #if DEBUG - dlog( - "split.placeholderRepair pane=\(originalPane.id.uuidString.prefix(5)) " + - "action=reusePlaceholder placeholderCount=\(placeholderTabs.count)" - ) + destinationLabel = "insert pane=\(paneId.id.uuidString.prefix(5)) index=\(index.map(String.init) ?? "nil")" #endif - if let replacementTab = placeholderTabs.first { - // 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 = inheritedTerminalConfig(inPane: originalPane) - - let replacementPanel = TerminalPanel( - workspaceId: id, - context: GHOSTTY_SURFACE_CONTEXT_SPLIT, - configTemplate: inheritedConfig, - portOrdinal: portOrdinal - ) - configureTerminalPanel(replacementPanel) - panels[replacementPanel.id] = replacementPanel - panelTitles[replacementPanel.id] = replacementPanel.displayTitle - seedTerminalInheritanceFontPoints(panelId: replacementPanel.id, configTemplate: inheritedConfig) - surfaceIdToPanelId[replacementTab.id] = replacementPanel.id - - bonsplitController.updateTab( - replacementTab.id, - title: replacementPanel.displayTitle, - icon: .some(replacementPanel.displayIcon), - iconImageData: .some(nil), - kind: .some(SurfaceKind.terminal), - hasCustomTitle: false, - isDirty: replacementPanel.isDirty, - showsNotificationBadge: false, - isLoading: false, - isPinned: false - ) - - for extraPlaceholder in placeholderTabs.dropFirst() { - bonsplitController.closeTab(extraPlaceholder.id) - } - } else { + case .split(let paneId, let orientation, let insertFirst): + targetPane = paneId + targetIndex = nil + splitTarget = (orientation, insertFirst) #if DEBUG - dlog( - "split.placeholderRepair pane=\(originalPane.id.uuidString.prefix(5)) " + - "fallback=createTerminalAndDropPlaceholders" - ) + destinationLabel = "split pane=\(paneId.id.uuidString.prefix(5)) orientation=\(orientation.rawValue) insertFirst=\(insertFirst ? 1 : 0)" #endif - _ = newTerminalSurface(inPane: originalPane, focus: false) - for tab in controller.tabs(inPane: originalPane) { - if panelIdFromSurfaceId(tab.id) == nil { - bonsplitController.closeTab(tab.id) - } - } - } - } - normalizePinnedTabs(in: originalPane) - normalizePinnedTabs(in: newPane) - scheduleTerminalGeometryReconcile() - return } - // Mirror Cmd+D behavior: split buttons should always seed a terminal in the new pane. - // When the focused source is a browser, inherit terminal config from nearby terminals - // (or fall back to defaults) instead of leaving an empty selector pane. - let sourceTabId = controller.selectedTab(inPane: originalPane)?.id - let sourcePanelId = sourceTabId.flatMap { panelIdFromSurfaceId($0) } - -#if DEBUG + #if DEBUG dlog( - "split.didSplit.autoCreate pane=\(newPane.id.uuidString.prefix(5)) " + - "fromPane=\(originalPane.id.uuidString.prefix(5)) sourcePanel=\(sourcePanelId.map { String($0.uuidString.prefix(5)) } ?? "none")" - ) -#endif - - let inheritedConfig = inheritedTerminalConfig( - preferredPanelId: sourcePanelId, - inPane: originalPane + "split.externalDrop.begin ws=\(id.uuidString.prefix(5)) tab=\(request.tabId.uuid.uuidString.prefix(5)) " + + "sourcePane=\(request.sourcePaneId.id.uuidString.prefix(5)) destination=\(destinationLabel)" ) - - let newPanel = TerminalPanel( - workspaceId: id, - context: GHOSTTY_SURFACE_CONTEXT_SPLIT, - configTemplate: inheritedConfig, - portOrdinal: portOrdinal + #endif + let moved = app.moveBonsplitTab( + tabId: request.tabId.uuid, + toWorkspace: id, + targetPane: targetPane, + targetIndex: targetIndex, + splitTarget: splitTarget, + focus: true, + focusWindow: true ) - configureTerminalPanel(newPanel) - panels[newPanel.id] = newPanel - panelTitles[newPanel.id] = newPanel.displayTitle - seedTerminalInheritanceFontPoints(panelId: newPanel.id, configTemplate: inheritedConfig) - - guard let newTabId = bonsplitController.createTab( - title: newPanel.displayTitle, - icon: newPanel.displayIcon, - kind: SurfaceKind.terminal, - isDirty: newPanel.isDirty, - isPinned: false, - inPane: newPane - ) else { - panels.removeValue(forKey: newPanel.id) - panelTitles.removeValue(forKey: newPanel.id) - terminalInheritanceFontPointsByPanelId.removeValue(forKey: newPanel.id) - return - } - - surfaceIdToPanelId[newTabId] = newPanel.id - normalizePinnedTabs(in: newPane) #if DEBUG dlog( - "split.didSplit.autoCreate.done pane=\(newPane.id.uuidString.prefix(5)) " + - "panel=\(newPanel.id.uuidString.prefix(5))" + "split.externalDrop.end ws=\(id.uuidString.prefix(5)) tab=\(request.tabId.uuid.uuidString.prefix(5)) " + + "moved=\(moved ? 1 : 0) elapsedMs=\(debugElapsedMs(since: dropStart))" ) #endif - - // `createTab` selects the new tab but does not emit didSelectTab; schedule an explicit - // selection so our focus/unfocus logic runs after this delegate callback returns. - DispatchQueue.main.async { [weak self] in - guard let self else { return } - if self.bonsplitController.focusedPaneId == newPane { - self.bonsplitController.selectTab(newTabId) - } - self.scheduleTerminalGeometryReconcile() - self.scheduleFocusReconcile() - } - } - - func splitTabBar(_ controller: BonsplitController, didRequestNewTab kind: String, inPane pane: PaneID) { - switch kind { - case "terminal": - _ = newTerminalSurface(inPane: pane) - case "browser": - _ = newBrowserSurface(inPane: pane) - default: - _ = newTerminalSurface(inPane: pane) - } - } - - func splitTabBar(_ controller: BonsplitController, didRequestTabContextAction action: TabContextAction, for tab: Bonsplit.Tab, inPane pane: PaneID) { - switch action { - case .rename: - promptRenamePanel(tabId: tab.id) - case .clearName: - guard let panelId = panelIdFromSurfaceId(tab.id) else { return } - setPanelCustomTitle(panelId: panelId, title: nil) - case .closeToLeft: - closeTabs(tabIdsToLeft(of: tab.id, inPane: pane)) - case .closeToRight: - closeTabs(tabIdsToRight(of: tab.id, inPane: pane)) - case .closeOthers: - closeTabs(tabIdsToCloseOthers(of: tab.id, inPane: pane)) - case .move: - promptMovePanel(tabId: tab.id) - case .newTerminalToRight: - createTerminalToRight(of: tab.id, inPane: pane) - case .newBrowserToRight: - createBrowserToRight(of: tab.id, inPane: pane) - case .reload: - guard let panelId = panelIdFromSurfaceId(tab.id), - let browser = browserPanel(for: panelId) else { return } - browser.reload() - case .duplicate: - duplicateBrowserToRight(anchorTabId: tab.id, inPane: pane) - case .togglePin: - guard let panelId = panelIdFromSurfaceId(tab.id) else { return } - let shouldPin = !pinnedPanelIds.contains(panelId) - setPanelPinned(panelId: panelId, pinned: shouldPin) - case .markAsRead: - guard let panelId = panelIdFromSurfaceId(tab.id) else { return } - clearManualUnread(panelId: panelId) - case .markAsUnread: - guard let panelId = panelIdFromSurfaceId(tab.id) else { return } - markPanelUnread(panelId) - case .toggleZoom: - guard let panelId = panelIdFromSurfaceId(tab.id) else { return } - toggleSplitZoom(panelId: panelId) - @unknown default: - break - } - } - - func splitTabBar(_ controller: BonsplitController, didChangeGeometry snapshot: LayoutSnapshot) { - tmuxLayoutSnapshot = snapshot - scheduleTerminalGeometryReconcile() - if !isDetachingCloseTransaction { - scheduleFocusReconcile() - } + return moved } - // No post-close polling refresh loop: we rely on view invariants and Ghostty's wakeups. }