From 67333932278cbc93219f7a6cc5ce8cbf6283b063 Mon Sep 17 00:00:00 2001 From: arzafran Date: Thu, 9 Jul 2026 13:48:07 -0300 Subject: [PATCH 01/31] 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/31] 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 a4c45067207039851284a7bc24d2a5ae548b4b79 Mon Sep 17 00:00:00 2001 From: arzafran Date: Thu, 9 Jul 2026 13:52:18 -0300 Subject: [PATCH 03/31] refactor: extract shared FileWatcher, dedup config file-watch boilerplate Introduce Sources/FileWatcher.swift, a low-level primitive that wraps the open-fd / DispatchSource.makeFileSystemObjectSource / resume / cancel-close boilerplate that was duplicated 6 times: ProgramaConfigStore's local+global file/directory watchers, and ShortcutSettingsFileWatcher's file/directory watchers. Call-site retry/backoff/fallback policy is preserved exactly, including the drift between copies: - ProgramaConfigStore's local watcher only makes a single delayed (0.5s) existence check before permanently falling back to directory watching, while the global watcher actually recurses up to maxReattachAttempts (5) times with backoff before falling back. This drift (a leftover bug where the local reattach never recurses despite the shared constant) is preserved as-is and documented in a code comment. - ShortcutSettingsFileWatcher has no delayed retry at all; it reattaches synchronously and immediately on delete/rename. Preserved as-is. No behavior change intended beyond sharing the DispatchSource plumbing. Refs #100. --- GhosttyTabs.xcodeproj/project.pbxproj | 4 + Sources/FileWatcher.swift | 68 +++++++++ .../KeyboardShortcutSettingsFileStore.swift | 62 +++----- Sources/ProgramaConfig.swift | 134 +++++------------- 4 files changed, 129 insertions(+), 139 deletions(-) create mode 100644 Sources/FileWatcher.swift diff --git a/GhosttyTabs.xcodeproj/project.pbxproj b/GhosttyTabs.xcodeproj/project.pbxproj index 5ba0f619fc7..b29662e097b 100644 --- a/GhosttyTabs.xcodeproj/project.pbxproj +++ b/GhosttyTabs.xcodeproj/project.pbxproj @@ -86,6 +86,7 @@ A5001640 /* RemoteRelayZshBootstrap.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5001641 /* RemoteRelayZshBootstrap.swift */; }; A5001650 /* ProgramaConfig.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5001651 /* ProgramaConfig.swift */; }; A5001652 /* ProgramaConfigExecutor.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5001653 /* ProgramaConfigExecutor.swift */; }; + NRPA00001 /* FileWatcher.swift in Sources */ = {isa = PBXBuildFile; fileRef = NRPA00002 /* FileWatcher.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 */; }; @@ -309,6 +310,7 @@ A5001611 /* SessionPersistence.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SessionPersistence.swift; sourceTree = ""; }; A5001651 /* ProgramaConfig.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ProgramaConfig.swift; sourceTree = ""; }; A5001653 /* ProgramaConfigExecutor.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ProgramaConfigExecutor.swift; sourceTree = ""; }; + NRPA00002 /* FileWatcher.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FileWatcher.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 = ""; }; @@ -576,6 +578,7 @@ A5001641 /* RemoteRelayZshBootstrap.swift */, A5001651 /* ProgramaConfig.swift */, A5001653 /* ProgramaConfigExecutor.swift */, + NRPA00002 /* FileWatcher.swift */, A5001655 /* ProgramaDirectoryTrust.swift */, A5001661 /* SurfacePool.swift */, ); @@ -905,6 +908,7 @@ A5001640 /* RemoteRelayZshBootstrap.swift in Sources */, A5001650 /* ProgramaConfig.swift in Sources */, A5001652 /* ProgramaConfigExecutor.swift in Sources */, + NRPA00001 /* FileWatcher.swift in Sources */, A5001654 /* ProgramaDirectoryTrust.swift in Sources */, A5001660 /* SurfacePool.swift in Sources */, ); diff --git a/Sources/FileWatcher.swift b/Sources/FileWatcher.swift new file mode 100644 index 00000000000..46447c61218 --- /dev/null +++ b/Sources/FileWatcher.swift @@ -0,0 +1,68 @@ +import Foundation + +/// Low-level primitive for watching a single filesystem path (file or directory) via +/// `DispatchSource.makeFileSystemObjectSource`. +/// +/// This extracts the open-fd / create-source / resume / cancel-and-close boilerplate that +/// used to be duplicated across `ProgramaConfigStore`'s local + global config watchers and +/// `ShortcutSettingsFileWatcher`'s primary + fallback watchers. +/// +/// `FileWatcher` owns exactly one active `DispatchSourceFileSystemObject` at a time; starting +/// a new watch implicitly stops any previous one. It intentionally does NOT implement +/// retry/backoff or file-vs-directory fallback policy — callers own that, since it differs +/// (and has drifted) between call sites. See: +/// - `ProgramaConfigStore.startLocalFileWatcher` / `startGlobalFileWatcher` (programa.json watching) +/// - `ShortcutSettingsFileWatcher` in `ProgramaSettingsFileStore.swift` (settings.json watching) +final class FileWatcher { + private let queue: DispatchQueue + private var source: DispatchSourceFileSystemObject? + + init(queue: DispatchQueue) { + self.queue = queue + } + + deinit { + source?.cancel() + } + + /// Opens `path` for event-only access and starts watching it for `eventMask` events, + /// invoking `onEvent` with the fired event flags on `queue` each time. Stops any + /// previously active watch first. + /// + /// Returns `false` without starting anything if `path` could not be opened (e.g. it + /// doesn't exist yet) — callers use this to fall back to watching the containing + /// directory instead. + @discardableResult + func start( + path: String, + eventMask: DispatchSource.FileSystemEvent, + onEvent: @escaping (DispatchSource.FileSystemEvent) -> Void + ) -> Bool { + stop() + + let fd = open(path, O_EVTONLY) + guard fd >= 0 else { return false } + + let newSource = DispatchSource.makeFileSystemObjectSource( + fileDescriptor: fd, + eventMask: eventMask, + queue: queue + ) + newSource.setEventHandler { [weak self] in + guard let self, let activeSource = self.source else { return } + onEvent(activeSource.data) + } + newSource.setCancelHandler { + Darwin.close(fd) + } + source = newSource + newSource.resume() + return true + } + + /// Cancels the active watch, if any. Safe to call when nothing is active. + func stop() { + source?.cancel() + source = nil + } +} diff --git a/Sources/KeyboardShortcutSettingsFileStore.swift b/Sources/KeyboardShortcutSettingsFileStore.swift index 8a8859aafb6..b3bb3ca7c66 100644 --- a/Sources/KeyboardShortcutSettingsFileStore.swift +++ b/Sources/KeyboardShortcutSettingsFileStore.swift @@ -1547,29 +1547,30 @@ private enum JSONCParser { } } +// NOTE (drift, refs #100): unlike ProgramaConfigStore's local/global config watchers, this +// watcher has no delayed retry/backoff at all — on delete/rename it re-evaluates and +// reattaches (or falls back to directory watching) immediately, synchronously, on the watch +// queue. That immediate-reattach behavior is preserved as-is; only the raw DispatchSource +// open/create/resume/cancel plumbing is shared via `FileWatcher`. private final class ShortcutSettingsFileWatcher { private let path: String private let fileManager: FileManager private let onChange: () -> Void - private let watchQueue = DispatchQueue(label: "com.cmux.shortcut-settings-file-watch") - - private var source: DispatchSourceFileSystemObject? + private let watcher: FileWatcher init(path: String, fileManager: FileManager = .default, onChange: @escaping () -> Void) { self.path = path self.fileManager = fileManager self.onChange = onChange + self.watcher = FileWatcher(queue: DispatchQueue(label: "com.cmux.shortcut-settings-file-watch")) start() } func stop() { - source?.cancel() - source = nil + watcher.stop() } private func start() { - stop() - if fileManager.fileExists(atPath: path) { startFileWatcher() } else { @@ -1578,47 +1579,27 @@ private final class ShortcutSettingsFileWatcher { } private func startFileWatcher() { - let fd = open(path, O_EVTONLY) - guard fd >= 0 else { - startDirectoryWatcher() - return - } - - let source = DispatchSource.makeFileSystemObjectSource( - fileDescriptor: fd, - eventMask: [.write, .delete, .rename, .extend], - queue: watchQueue - ) - - source.setEventHandler { [weak self] in + let started = watcher.start( + path: path, + eventMask: [.write, .delete, .rename, .extend] + ) { [weak self] flags in guard let self else { return } - let flags = source.data if flags.contains(.delete) || flags.contains(.rename) { self.start() } self.onChange() } - - source.setCancelHandler { - close(fd) + if !started { + startDirectoryWatcher() } - - self.source = source - source.resume() } private func startDirectoryWatcher() { let directoryPath = (path as NSString).deletingLastPathComponent - let fd = open(directoryPath, O_EVTONLY) - guard fd >= 0 else { return } - - let source = DispatchSource.makeFileSystemObjectSource( - fileDescriptor: fd, - eventMask: [.write, .rename, .delete], - queue: watchQueue - ) - - source.setEventHandler { [weak self] in + watcher.start( + path: directoryPath, + eventMask: [.write, .rename, .delete] + ) { [weak self] _ in guard let self else { return } if self.fileManager.fileExists(atPath: self.path) { self.start() @@ -1626,12 +1607,5 @@ private final class ShortcutSettingsFileWatcher { self.onChange() } } - - source.setCancelHandler { - close(fd) - } - - self.source = source - source.resume() } } diff --git a/Sources/ProgramaConfig.swift b/Sources/ProgramaConfig.swift index 0046a8339a5..454727807e3 100644 --- a/Sources/ProgramaConfig.swift +++ b/Sources/ProgramaConfig.swift @@ -278,22 +278,22 @@ final class ProgramaConfigStore: ObservableObject { }() private var cancellables = Set() - private var localFileWatchSource: DispatchSourceFileSystemObject? - private var localFileDescriptor: Int32 = -1 - private var globalFileWatchSource: DispatchSourceFileSystemObject? - private var globalFileDescriptor: Int32 = -1 private let watchQueue = DispatchQueue(label: "com.cmux.config-file-watch") + private let localFileWatcher: FileWatcher + private let globalFileWatcher: FileWatcher private static let maxReattachAttempts = 5 private static let reattachDelay: TimeInterval = 0.5 init() { + localFileWatcher = FileWatcher(queue: watchQueue) + globalFileWatcher = FileWatcher(queue: watchQueue) startGlobalFileWatcher() } deinit { - localFileWatchSource?.cancel() - globalFileWatchSource?.cancel() + localFileWatcher.stop() + globalFileWatcher.stop() } // MARK: - Public API @@ -410,26 +410,24 @@ final class ProgramaConfigStore: ObservableObject { } // MARK: - File watching (local) + // + // NOTE (drift, refs #100): the local and global watchers below look symmetric but their + // reattach policy on delete/rename genuinely differs, and that difference is preserved + // here rather than "fixed" as part of the FileWatcher dedup: + // - Local: `scheduleLocalReattach` makes exactly ONE delayed existence check (0.5s) after + // a delete/rename, then permanently falls back to directory watching if the file is + // still missing — it does not recurse to retry further despite `maxReattachAttempts` + // being defined (a leftover from copy/pasting the global watcher). + // - Global: `scheduleGlobalReattach` actually recurses up to `maxReattachAttempts` times + // (2.5s total) before falling back to directory watching. private func startLocalFileWatcher() { guard let path = localConfigPath else { return } - let fd = open(path, O_EVTONLY) - guard fd >= 0 else { - // File doesn't exist yet — watch the directory instead - startLocalDirectoryWatcher() - return - } - localFileDescriptor = fd - - let source = DispatchSource.makeFileSystemObjectSource( - fileDescriptor: fd, - eventMask: [.write, .delete, .rename, .extend], - queue: watchQueue - ) - - source.setEventHandler { [weak self] in + let started = localFileWatcher.start( + path: path, + eventMask: [.write, .delete, .rename, .extend] + ) { [weak self] flags in guard let self else { return } - let flags = source.data if flags.contains(.delete) || flags.contains(.rename) { DispatchQueue.main.async { self.stopLocalFileWatcher() @@ -442,29 +440,19 @@ final class ProgramaConfigStore: ObservableObject { } } } - - source.setCancelHandler { - Darwin.close(fd) + if !started { + // File doesn't exist yet — watch the directory instead + startLocalDirectoryWatcher() } - - source.resume() - localFileWatchSource = source } private func startLocalDirectoryWatcher() { guard let path = localConfigPath else { return } let dirPath = (path as NSString).deletingLastPathComponent - let fd = open(dirPath, O_EVTONLY) - guard fd >= 0 else { return } - localFileDescriptor = fd - - let source = DispatchSource.makeFileSystemObjectSource( - fileDescriptor: fd, - eventMask: [.write, .link, .rename], - queue: watchQueue - ) - - source.setEventHandler { [weak self] in + localFileWatcher.start( + path: dirPath, + eventMask: [.write, .link, .rename] + ) { [weak self] _ in guard let self else { return } DispatchQueue.main.async { guard let configPath = self.localConfigPath, @@ -475,13 +463,6 @@ final class ProgramaConfigStore: ObservableObject { self.startLocalFileWatcher() } } - - source.setCancelHandler { - Darwin.close(fd) - } - - source.resume() - localFileWatchSource = source } private func scheduleLocalReattach(attempt: Int) { @@ -501,32 +482,17 @@ final class ProgramaConfigStore: ObservableObject { } private func stopLocalFileWatcher() { - if let source = localFileWatchSource { - source.cancel() - localFileWatchSource = nil - } - localFileDescriptor = -1 + localFileWatcher.stop() } // MARK: - File watching (global) private func startGlobalFileWatcher() { - let fd = open(globalConfigPath, O_EVTONLY) - guard fd >= 0 else { - startGlobalDirectoryWatcher() - return - } - globalFileDescriptor = fd - - let source = DispatchSource.makeFileSystemObjectSource( - fileDescriptor: fd, - eventMask: [.write, .delete, .rename, .extend], - queue: watchQueue - ) - - source.setEventHandler { [weak self] in + let started = globalFileWatcher.start( + path: globalConfigPath, + eventMask: [.write, .delete, .rename, .extend] + ) { [weak self] flags in guard let self else { return } - let flags = source.data if flags.contains(.delete) || flags.contains(.rename) { DispatchQueue.main.async { self.stopGlobalFileWatcher() @@ -539,13 +505,9 @@ final class ProgramaConfigStore: ObservableObject { } } } - - source.setCancelHandler { - Darwin.close(fd) + if !started { + startGlobalDirectoryWatcher() } - - source.resume() - globalFileWatchSource = source } private func scheduleGlobalReattach(attempt: Int) { @@ -572,17 +534,10 @@ final class ProgramaConfigStore: ObservableObject { if !fm.fileExists(atPath: dirPath) { try? fm.createDirectory(atPath: dirPath, withIntermediateDirectories: true) } - let fd = open(dirPath, O_EVTONLY) - guard fd >= 0 else { return } - globalFileDescriptor = fd - - let source = DispatchSource.makeFileSystemObjectSource( - fileDescriptor: fd, - eventMask: [.write, .link, .rename], - queue: watchQueue - ) - - source.setEventHandler { [weak self] in + globalFileWatcher.start( + path: dirPath, + eventMask: [.write, .link, .rename] + ) { [weak self] _ in guard let self else { return } DispatchQueue.main.async { guard FileManager.default.fileExists(atPath: self.globalConfigPath) else { return } @@ -591,21 +546,10 @@ final class ProgramaConfigStore: ObservableObject { self.startGlobalFileWatcher() } } - - source.setCancelHandler { - Darwin.close(fd) - } - - source.resume() - globalFileWatchSource = source } private func stopGlobalFileWatcher() { - if let source = globalFileWatchSource { - source.cancel() - globalFileWatchSource = nil - } - globalFileDescriptor = -1 + globalFileWatcher.stop() } } From 3a6620dc8a74f1e072ce5f101f21c192a1788b4e Mon Sep 17 00:00:00 2001 From: arzafran Date: Thu, 9 Jul 2026 13:52:24 -0300 Subject: [PATCH 04/31] 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 05/31] 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 3a0b294534e91a321896b5adac4ca7233ae3e197 Mon Sep 17 00:00:00 2001 From: arzafran Date: Thu, 9 Jul 2026 13:54:34 -0300 Subject: [PATCH 06/31] chore: rename KeyboardShortcutSettingsFileStore.swift to ProgramaSettingsFileStore.swift The type inside was already renamed to ProgramaSettingsFileStore (with a KeyboardShortcutSettingsFileStore typealias kept for existing call sites), but the file itself kept the old name. Rename the file to match; no source changes beyond the project.pbxproj path/name entries for the file's stable UUIDs. Refs #100. --- GhosttyTabs.xcodeproj/project.pbxproj | 8 ++++---- ...ngsFileStore.swift => ProgramaSettingsFileStore.swift} | 0 2 files changed, 4 insertions(+), 4 deletions(-) rename Sources/{KeyboardShortcutSettingsFileStore.swift => ProgramaSettingsFileStore.swift} (100%) diff --git a/GhosttyTabs.xcodeproj/project.pbxproj b/GhosttyTabs.xcodeproj/project.pbxproj index b29662e097b..0099d1f7d28 100644 --- a/GhosttyTabs.xcodeproj/project.pbxproj +++ b/GhosttyTabs.xcodeproj/project.pbxproj @@ -66,7 +66,7 @@ A5008373 /* BrowserFindJavaScript.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5008372 /* BrowserFindJavaScript.swift */; }; A50012F1 /* Backport.swift in Sources */ = {isa = PBXBuildFile; fileRef = A50012F0 /* Backport.swift */; }; A50012F3 /* KeyboardShortcutSettings.swift in Sources */ = {isa = PBXBuildFile; fileRef = A50012F2 /* KeyboardShortcutSettings.swift */; }; - A5F10011A1B2C3D4E5F60719 /* KeyboardShortcutSettingsFileStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5F10010A1B2C3D4E5F60719 /* KeyboardShortcutSettingsFileStore.swift */; }; + A5F10011A1B2C3D4E5F60719 /* ProgramaSettingsFileStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5F10010A1B2C3D4E5F60719 /* ProgramaSettingsFileStore.swift */; }; A50012F5 /* KeyboardLayout.swift in Sources */ = {isa = PBXBuildFile; fileRef = A50012F4 /* KeyboardLayout.swift */; }; A5001201 /* UpdateController.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5001211 /* UpdateController.swift */; }; A5001202 /* UpdateDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5001212 /* UpdateDelegate.swift */; }; @@ -291,7 +291,7 @@ A5008372 /* BrowserFindJavaScript.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Find/BrowserFindJavaScript.swift; sourceTree = ""; }; A50012F0 /* Backport.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Backport.swift; sourceTree = ""; }; A50012F2 /* KeyboardShortcutSettings.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = KeyboardShortcutSettings.swift; sourceTree = ""; }; - A5F10010A1B2C3D4E5F60719 /* KeyboardShortcutSettingsFileStore.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = KeyboardShortcutSettingsFileStore.swift; sourceTree = ""; }; + A5F10010A1B2C3D4E5F60719 /* ProgramaSettingsFileStore.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ProgramaSettingsFileStore.swift; sourceTree = ""; }; A50012F4 /* KeyboardLayout.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = KeyboardLayout.swift; sourceTree = ""; }; A5001211 /* UpdateController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Update/UpdateController.swift; sourceTree = ""; }; A5001212 /* UpdateDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Update/UpdateDelegate.swift; sourceTree = ""; }; @@ -510,7 +510,7 @@ B9000017A1B2C3D4E5F60719 /* WindowDragHandleView.swift */, A50012F0 /* Backport.swift */, A50012F2 /* KeyboardShortcutSettings.swift */, - A5F10010A1B2C3D4E5F60719 /* KeyboardShortcutSettingsFileStore.swift */, + A5F10010A1B2C3D4E5F60719 /* ProgramaSettingsFileStore.swift */, A50012F4 /* KeyboardLayout.swift */, A5001013 /* TabManager.swift */, A5FF0064 /* TabManager+SessionPersistence.swift */, @@ -841,7 +841,7 @@ B9000018A1B2C3D4E5F60719 /* WindowDragHandleView.swift in Sources */, A50012F1 /* Backport.swift in Sources */, A50012F3 /* KeyboardShortcutSettings.swift in Sources */, - A5F10011A1B2C3D4E5F60719 /* KeyboardShortcutSettingsFileStore.swift in Sources */, + A5F10011A1B2C3D4E5F60719 /* ProgramaSettingsFileStore.swift in Sources */, A50012F5 /* KeyboardLayout.swift in Sources */, A5001003 /* TabManager.swift in Sources */, A5FF0074 /* TabManager+SessionPersistence.swift in Sources */, diff --git a/Sources/KeyboardShortcutSettingsFileStore.swift b/Sources/ProgramaSettingsFileStore.swift similarity index 100% rename from Sources/KeyboardShortcutSettingsFileStore.swift rename to Sources/ProgramaSettingsFileStore.swift From dd1fe832d49a539436d55c2ae6a3b89b7f22a89a Mon Sep 17 00:00:00 2001 From: arzafran Date: Thu, 9 Jul 2026 13:56:10 -0300 Subject: [PATCH 07/31] 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 08/31] 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 09/31] 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 5303045d5e4f36b66bc08d20d4909f6e6b9e4f46 Mon Sep 17 00:00:00 2001 From: arzafran Date: Thu, 9 Jul 2026 13:58:52 -0300 Subject: [PATCH 10/31] refactor(browser): consolidate hosted-inspector dock detection into InspectorDock MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Extract the WKInspector string-sniffing and side-dock/divider geometry math that was independently reimplemented in BrowserPanel.swift, BrowserPanelView.swift (WebViewRepresentable.Coordinator.HostContainerView), and BrowserWindowPortal.swift (WindowBrowserHostView / WindowBrowserSlotView helpers) into one canonical Sources/Panels/InspectorDock.swift, and route all three through it. A real behavioral divergence is preserved explicitly: BrowserPanel's side-dock *detection* path required sibling width > 1, while the divider *hit-testing* paths in BrowserPanelView/BrowserWindowPortal intentionally omit that width check (siblings can be transiently zero-width mid-drag). This is now expressed as InspectorDock.isVisibleSiblingCandidate(_:requireMinWidth:) with the difference documented at the call sites instead of silently duplicated. AppDelegate.swift has its own copy of the responder-sniffing half (programaIsLikelyWebInspectorResponder) — left untouched since AppDelegate belongs to a different maintenance cluster. Also consolidates responderChainContains (previously separately implemented in BrowserPanel.swift and BrowserPanelView.swift) into InspectorDock, and folds three copies of relatedWebKitTransferSubviews' WKInspector-visibility check (BrowserPanelView.swift, BrowserWindowPortal.swift) onto the same InspectorDock primitives. Refs #91. Refs #103. --- GhosttyTabs.xcodeproj/project.pbxproj | 8 + Sources/BrowserWindowPortal.swift | 76 ++------ Sources/Panels/BrowserPanel.swift | 172 +++--------------- Sources/Panels/BrowserPanelView.swift | 107 ++--------- .../Panels/BrowserPopupWindowController.swift | 80 ++------ .../Panels/BrowserWebDialogPresenter.swift | 138 ++++++++++++++ Sources/Panels/InspectorDock.swift | 116 ++++++++++++ 7 files changed, 336 insertions(+), 361 deletions(-) create mode 100644 Sources/Panels/BrowserWebDialogPresenter.swift create mode 100644 Sources/Panels/InspectorDock.swift diff --git a/GhosttyTabs.xcodeproj/project.pbxproj b/GhosttyTabs.xcodeproj/project.pbxproj index 5ba0f619fc7..7437cc3d0dd 100644 --- a/GhosttyTabs.xcodeproj/project.pbxproj +++ b/GhosttyTabs.xcodeproj/project.pbxproj @@ -42,6 +42,8 @@ A5001400 /* Panel.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5001410 /* Panel.swift */; }; A5001401 /* TerminalPanel.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5001411 /* TerminalPanel.swift */; }; A5001402 /* BrowserPanel.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5001412 /* BrowserPanel.swift */; }; + NRBR0001 /* InspectorDock.swift in Sources */ = {isa = PBXBuildFile; fileRef = NRBR0002 /* InspectorDock.swift */; }; + NRBR0003 /* BrowserWebDialogPresenter.swift in Sources */ = {isa = PBXBuildFile; fileRef = NRBR0004 /* BrowserWebDialogPresenter.swift */; }; A5FF0013 /* BrowserDataImport.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5FF0003 /* BrowserDataImport.swift */; }; A500RG01 /* ReactGrab.swift in Sources */ = {isa = PBXBuildFile; fileRef = A500RG00 /* ReactGrab.swift */; }; A5001403 /* TerminalPanelView.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5001413 /* TerminalPanelView.swift */; }; @@ -267,6 +269,8 @@ A5001410 /* Panel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Panels/Panel.swift; sourceTree = ""; }; A5001411 /* TerminalPanel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Panels/TerminalPanel.swift; sourceTree = ""; }; A5001412 /* BrowserPanel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Panels/BrowserPanel.swift; sourceTree = ""; }; + NRBR0002 /* InspectorDock.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Panels/InspectorDock.swift; sourceTree = ""; }; + NRBR0004 /* BrowserWebDialogPresenter.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Panels/BrowserWebDialogPresenter.swift; sourceTree = ""; }; A5FF0003 /* BrowserDataImport.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Panels/BrowserDataImport.swift; sourceTree = ""; }; A500RG00 /* ReactGrab.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Panels/ReactGrab.swift; sourceTree = ""; }; A5001413 /* TerminalPanelView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Panels/TerminalPanelView.swift; sourceTree = ""; }; @@ -549,6 +553,8 @@ A5001410 /* Panel.swift */, A5001411 /* TerminalPanel.swift */, A5001412 /* BrowserPanel.swift */, + NRBR0002 /* InspectorDock.swift */, + NRBR0004 /* BrowserWebDialogPresenter.swift */, A5FF0003 /* BrowserDataImport.swift */, A500RG00 /* ReactGrab.swift */, A5001413 /* TerminalPanelView.swift */, @@ -878,6 +884,8 @@ A5001400 /* Panel.swift in Sources */, A5001401 /* TerminalPanel.swift in Sources */, A5001402 /* BrowserPanel.swift in Sources */, + NRBR0001 /* InspectorDock.swift in Sources */, + NRBR0003 /* BrowserWebDialogPresenter.swift in Sources */, A5FF0013 /* BrowserDataImport.swift in Sources */, A500RG01 /* ReactGrab.swift in Sources */, A5001403 /* TerminalPanelView.swift in Sources */, diff --git a/Sources/BrowserWindowPortal.swift b/Sources/BrowserWindowPortal.swift index 99f2037019b..e102d98fa0e 100644 --- a/Sources/BrowserWindowPortal.swift +++ b/Sources/BrowserWindowPortal.swift @@ -951,8 +951,8 @@ final class WindowBrowserHostView: NSView { } private func hostedInspectorDividerCandidate(in slot: WindowBrowserSlotView) -> HostedInspectorDividerHit? { - let inspectorCandidates = Self.visibleDescendants(in: slot) - .filter { Self.isVisibleHostedInspectorCandidate($0) && Self.isInspectorView($0) } + let inspectorCandidates = InspectorDock.visibleDescendants(in: slot) + .filter { InspectorDock.isVisibleCandidate($0) && InspectorDock.isInspectorView($0) } .sorted { lhs, rhs in let lhsFrame = slot.convert(lhs.bounds, from: lhs) let rhsFrame = slot.convert(rhs.bounds, from: rhs) @@ -987,9 +987,9 @@ final class WindowBrowserHostView: NSView { guard let containerView = inspectorView.superview else { break } let pageCandidates = containerView.subviews.compactMap { candidate -> (view: NSView, dockSide: HostedInspectorDockSide)? in - guard Self.isVisibleHostedInspectorSiblingCandidate(candidate) else { return nil } + guard InspectorDock.isVisibleSiblingCandidate(candidate, requireMinWidth: false) else { return nil } guard candidate !== inspectorView else { return nil } - guard Self.verticalOverlap(between: candidate.frame, and: inspectorView.frame) > 8 else { + guard InspectorDock.verticalOverlap(between: candidate.frame, and: inspectorView.frame) > 8 else { return nil } guard let dockSide = HostedInspectorDockSide.resolve( @@ -1035,13 +1035,13 @@ final class WindowBrowserHostView: NSView { private func hostedInspectorDividerCandidateScore(_ hit: HostedInspectorDividerHit) -> CGFloat { let pageFrame = hit.slotView.convert(hit.pageView.bounds, from: hit.pageView) let inspectorFrame = hit.slotView.convert(hit.inspectorView.bounds, from: hit.inspectorView) - let overlap = Self.verticalOverlap(between: pageFrame, and: inspectorFrame) + let overlap = InspectorDock.verticalOverlap(between: pageFrame, and: inspectorFrame) let coverageWidth = max(pageFrame.maxX, inspectorFrame.maxX) - min(pageFrame.minX, inspectorFrame.minX) return (overlap * 1_000) + coverageWidth + pageFrame.width } private func hostedInspectorPageCandidateScore(_ pageView: NSView, inspectorView: NSView) -> CGFloat { - let overlap = Self.verticalOverlap(between: pageView.frame, and: inspectorView.frame) + let overlap = InspectorDock.verticalOverlap(between: pageView.frame, and: inspectorView.frame) let coverageWidth = max(pageView.frame.maxX, inspectorView.frame.maxX) - min(pageView.frame.minX, inspectorView.frame.minX) return (overlap * 1_000) + coverageWidth + pageView.frame.width } @@ -1083,8 +1083,8 @@ final class WindowBrowserHostView: NSView { minimumInspectorWidth: Self.minimumHostedInspectorWidth, reason: reason ) - return !Self.rectApproximatelyEqual(oldPageFrame, hit.pageView.frame, epsilon: 0.5) || - !Self.rectApproximatelyEqual(oldInspectorFrame, hit.inspectorView.frame, epsilon: 0.5) + return !InspectorDock.rectApproximatelyEqual(oldPageFrame, hit.pageView.frame, epsilon: 0.5) || + !InspectorDock.rectApproximatelyEqual(oldInspectorFrame, hit.inspectorView.frame, epsilon: 0.5) } @discardableResult @@ -1107,8 +1107,8 @@ final class WindowBrowserHostView: NSView { let oldPageFrame = hit.pageView.frame let oldInspectorFrame = hit.inspectorView.frame - let pageChanged = !Self.rectApproximatelyEqual(pageFrame, oldPageFrame, epsilon: 0.5) - let inspectorChanged = !Self.rectApproximatelyEqual(inspectorFrame, oldInspectorFrame, epsilon: 0.5) + let pageChanged = !InspectorDock.rectApproximatelyEqual(pageFrame, oldPageFrame, epsilon: 0.5) + let inspectorChanged = !InspectorDock.rectApproximatelyEqual(inspectorFrame, oldInspectorFrame, epsilon: 0.5) guard pageChanged || inspectorChanged else { return (pageFrame, inspectorFrame) } @@ -1145,49 +1145,11 @@ final class WindowBrowserHostView: NSView { return (pageFrame, inspectorFrame) } - private static func verticalOverlap(between lhs: NSRect, and rhs: NSRect) -> CGFloat { - max(0, min(lhs.maxY, rhs.maxY) - max(lhs.minY, rhs.minY)) - } - - private static func rectApproximatelyEqual(_ lhs: NSRect, _ rhs: NSRect, epsilon: CGFloat = 0.01) -> Bool { - abs(lhs.origin.x - rhs.origin.x) <= epsilon && - abs(lhs.origin.y - rhs.origin.y) <= epsilon && - abs(lhs.size.width - rhs.size.width) <= epsilon && - abs(lhs.size.height - rhs.size.height) <= epsilon - } - private static func sizeApproximatelyEqual(_ lhs: NSSize, _ rhs: NSSize, epsilon: CGFloat = 0.01) -> Bool { abs(lhs.width - rhs.width) <= epsilon && abs(lhs.height - rhs.height) <= epsilon } - private static func visibleDescendants(in root: NSView) -> [NSView] { - var descendants: [NSView] = [] - var stack = Array(root.subviews.reversed()) - while let view = stack.popLast() { - descendants.append(view) - stack.append(contentsOf: view.subviews.reversed()) - } - return descendants - } - - private static func isInspectorView(_ view: NSView) -> Bool { - String(describing: type(of: view)).contains("WKInspector") - } - - private static func isVisibleHostedInspectorCandidate(_ view: NSView) -> Bool { - !view.isHidden && - view.alphaValue > 0 && - view.frame.width > 1 && - view.frame.height > 1 - } - - private static func isVisibleHostedInspectorSiblingCandidate(_ view: NSView) -> Bool { - !view.isHidden && - view.alphaValue > 0 && - view.frame.height > 1 - } - private static func collectSplitDividerRegions( in view: NSView, hostView: WindowBrowserHostView, @@ -2400,15 +2362,10 @@ final class WindowBrowserPortal: HostedViewPortalRegistry { private static func hasVisibleInspectorDescendant(in root: NSView) -> Bool { var stack: [NSView] = [root] while let current = stack.popLast() { - if current !== root { - let className = String(describing: type(of: current)) - if className.contains("WKInspector"), - !current.isHidden, - current.alphaValue > 0, - current.frame.width > 1, - current.frame.height > 1 { - return true - } + if current !== root, + InspectorDock.isInspectorView(current), + InspectorDock.isVisibleCandidate(current) { + return true } stack.append(contentsOf: current.subviews) } @@ -2468,7 +2425,7 @@ final class WindowBrowserPortal: HostedViewPortalRegistry { var count = 0 while let current = stack.popLast() { for subview in current.subviews { - if String(describing: type(of: subview)).contains("WKInspector") { + if InspectorDock.isInspectorView(subview) { count += 1 } stack.append(subview) @@ -2519,8 +2476,7 @@ final class WindowBrowserPortal: HostedViewPortalRegistry { if view === primaryWebView { continue } let className = String(describing: type(of: view)) guard className.contains("WK") else { continue } - if className.contains("WKInspector") && - (view.isHidden || view.alphaValue <= 0 || view.frame.width <= 1 || view.frame.height <= 1) { + if InspectorDock.isInspectorView(view) && !InspectorDock.isVisibleCandidate(view) { continue } append(view) diff --git a/Sources/Panels/BrowserPanel.swift b/Sources/Panels/BrowserPanel.swift index 74a5fc349c9..f749e6b25f2 100644 --- a/Sources/Panels/BrowserPanel.swift +++ b/Sources/Panels/BrowserPanel.swift @@ -3274,7 +3274,7 @@ final class BrowserPanel: Panel, ObservableObject { } } - if Self.responderChainContains(window.firstResponder, target: webView) { + if InspectorDock.responderChainContains(window.firstResponder, target: webView) { noteWebViewFocused() return } @@ -3293,7 +3293,7 @@ final class BrowserPanel: Panel, ObservableObject { guard let window = webView.window, !webView.isHiddenOrHasHiddenAncestor else { return false } - if Self.responderChainContains(window.firstResponder, target: webView) { + if InspectorDock.responderChainContains(window.firstResponder, target: webView) { // Prevent omnibar auto-focus from immediately stealing first responder back. suppressOmnibarAutofocus(for: 1.5) noteWebViewFocused() @@ -3308,7 +3308,7 @@ final class BrowserPanel: Panel, ObservableObject { DispatchQueue.main.async { [weak self, weak window, weak webView] in guard let self, let window, let webView else { return } guard webView.window === window else { return } - if !Self.responderChainContains(window.firstResponder, target: webView), + if !InspectorDock.responderChainContains(window.firstResponder, target: webView), window.makeFirstResponder(webView) { self.suppressOmnibarAutofocus(for: 1.5) self.noteWebViewFocused() @@ -3321,7 +3321,7 @@ final class BrowserPanel: Panel, ObservableObject { func unfocus() { invalidateSearchFocusRequests(reason: "panelUnfocus") guard let window = webView.window else { return } - if Self.responderChainContains(window.firstResponder, target: webView) { + if InspectorDock.responderChainContains(window.firstResponder, target: webView) { window.makeFirstResponder(nil) } } @@ -3893,14 +3893,7 @@ final class BrowserPanel: Panel, ObservableObject { guard let host = BrowserInsecureHTTPSettings.normalizeHost(url.host ?? "") else { return } let alert = insecureHTTPAlertFactory() - alert.alertStyle = .warning - alert.messageText = String(localized: "browser.error.insecure.title", defaultValue: "Connection isn\u{2019}t secure") - alert.informativeText = String(localized: "browser.error.insecure.message", defaultValue: "\(host) uses plain HTTP, so traffic can be read or modified on the network.\n\nOpen this URL in your default browser, or proceed in Programa.") - alert.addButton(withTitle: String(localized: "browser.openInDefaultBrowser", defaultValue: "Open in Default Browser")) - alert.addButton(withTitle: String(localized: "browser.proceedInPrograma", defaultValue: "Proceed in Programa")) - alert.addButton(withTitle: String(localized: "common.cancel", defaultValue: "Cancel")) - alert.showsSuppressionButton = true - alert.suppressionButton?.title = String(localized: "browser.alwaysAllowHost", defaultValue: "Always allow this host in Programa") + BrowserInsecureHTTPAlertBuilder.configure(alert, host: host) let handleResponse: (NSApplication.ModalResponse) -> Void = { [weak self, weak alert] response in self?.handleInsecureHTTPAlertResponse( @@ -4253,36 +4246,20 @@ extension BrowserPanel { webView.stopLoading() } - private static func windowContainsInspectorViews(_ root: NSView) -> Bool { - if String(describing: type(of: root)).contains("WKInspector") { - return true - } - for subview in root.subviews where windowContainsInspectorViews(subview) { - return true - } - return false - } - - private static func isDetachedInspectorWindow(_ window: NSWindow) -> Bool { - guard window.title.hasPrefix("Web Inspector") else { return false } - guard let contentView = window.contentView else { return false } - return windowContainsInspectorViews(contentView) - } - private func detachedDeveloperToolsWindows() -> [NSWindow] { let mainWindow = webView.window return NSApp.windows.filter { candidate in if let mainWindow, candidate === mainWindow { return false } - return Self.isDetachedInspectorWindow(candidate) + return InspectorDock.isDetachedInspectorWindow(candidate) } } private func hasAttachedDeveloperToolsLayout() -> Bool { guard let container = webView.superview else { return false } - return Self.visibleDescendants(in: container) - .contains { Self.isVisibleSideDockInspectorCandidate($0) && Self.isInspectorView($0) } + return InspectorDock.visibleDescendants(in: container) + .contains { InspectorDock.isVisibleCandidate($0) && InspectorDock.isInspectorView($0) } } private func setPreferredDeveloperToolsPresentation(_ next: DeveloperToolsPresentation) { @@ -4317,7 +4294,7 @@ extension BrowserPanel { guard let self, let window = notification.object as? NSWindow else { return } let isDetachedInspectorWindow = MainActor.assumeIsolated { - Self.isDetachedInspectorWindow(window) + InspectorDock.isDetachedInspectorWindow(window) } guard isDetachedInspectorWindow else { return } DispatchQueue.main.async { [weak self] in @@ -4346,7 +4323,7 @@ extension BrowserPanel { guard shouldDismissDetachedDeveloperToolsWindows() else { return } guard preferredDeveloperToolsVisible || isDeveloperToolsVisible(), let mainWindow = webView.window else { return } - for window in NSApp.windows where window !== mainWindow && Self.isDetachedInspectorWindow(window) { + for window in NSApp.windows where window !== mainWindow && InspectorDock.isDetachedInspectorWindow(window) { #if DEBUG dlog( "browser.devtools strayWindow.close panel=\(id.uuidString.prefix(5)) " + @@ -5132,7 +5109,7 @@ extension BrowserPanel { } if let window, - Self.responderChainContains(window.firstResponder, target: webView) { + InspectorDock.responderChainContains(window.firstResponder, target: webView) { return .browser(.webView) } @@ -5206,7 +5183,7 @@ extension BrowserPanel { return .browser(.findField) } - if Self.responderChainContains(responder, target: webView) { + if InspectorDock.responderChainContains(responder, target: webView) { return .browser(.webView) } @@ -5237,7 +5214,7 @@ extension BrowserPanel { #endif return yielded case .webView: - guard Self.responderChainContains(window.firstResponder, target: webView) else { return false } + guard InspectorDock.responderChainContains(window.firstResponder, target: webView) else { return false } return window.makeFirstResponder(nil) } } @@ -5571,7 +5548,7 @@ extension BrowserPanel { var count = 0 while let current = stack.popLast() { for subview in current.subviews { - if String(describing: type(of: subview)).contains("WKInspector") { + if InspectorDock.isInspectorView(subview) { count += 1 } stack.append(subview) @@ -5618,21 +5595,10 @@ private extension BrowserPanel { return true } - static func responderChainContains(_ start: NSResponder?, target: NSResponder) -> Bool { - var r = start - var hops = 0 - while let cur = r, hops < 64 { - if cur === target { return true } - r = cur.nextResponder - hops += 1 - } - return false - } - func hasSideDockedDeveloperToolsLayout() -> Bool { guard let container = webView.superview else { return false } - return Self.visibleDescendants(in: container) - .filter { Self.isVisibleSideDockInspectorCandidate($0) && Self.isInspectorView($0) } + return InspectorDock.visibleDescendants(in: container) + .filter { InspectorDock.isVisibleCandidate($0) && InspectorDock.isInspectorView($0) } .contains { inspectorCandidate in hasSideDockedInspectorSibling(startingAt: inspectorCandidate, root: container) } @@ -5644,13 +5610,13 @@ private extension BrowserPanel { while let inspectorView = current, inspectorView !== root { guard let containerView = inspectorView.superview else { break } let hasSideDockedSibling = containerView.subviews.contains { candidate in - guard Self.isVisibleSideDockSiblingCandidate(candidate) else { return false } + guard InspectorDock.isVisibleSiblingCandidate(candidate, requireMinWidth: true) else { return false } guard candidate !== inspectorView else { return false } let horizontallyAdjacent = candidate.frame.maxX <= inspectorView.frame.minX + 1 || candidate.frame.minX >= inspectorView.frame.maxX - 1 guard horizontallyAdjacent else { return false } - return Self.verticalOverlap(between: candidate.frame, and: inspectorView.frame) > 8 + return InspectorDock.verticalOverlap(between: candidate.frame, and: inspectorView.frame) > 8 } if hasSideDockedSibling { return true @@ -5661,38 +5627,6 @@ private extension BrowserPanel { return false } - - static func visibleDescendants(in root: NSView) -> [NSView] { - var descendants: [NSView] = [] - var stack = Array(root.subviews.reversed()) - while let view = stack.popLast() { - descendants.append(view) - stack.append(contentsOf: view.subviews.reversed()) - } - return descendants - } - - static func isInspectorView(_ view: NSView) -> Bool { - String(describing: type(of: view)).contains("WKInspector") - } - - static func isVisibleSideDockInspectorCandidate(_ view: NSView) -> Bool { - !view.isHidden && - view.alphaValue > 0 && - view.frame.width > 1 && - view.frame.height > 1 - } - - static func isVisibleSideDockSiblingCandidate(_ view: NSView) -> Bool { - !view.isHidden && - view.alphaValue > 0 && - view.frame.width > 1 && - view.frame.height > 1 - } - - static func verticalOverlap(between lhs: NSRect, and rhs: NSRect) -> CGFloat { - max(0, min(lhs.maxY, rhs.maxY) - max(lhs.minY, rhs.minY)) - } } extension BrowserPanel { @@ -6286,25 +6220,6 @@ private class BrowserUIDelegate: NSObject, WKUIDelegate { var requestNavigation: ((URLRequest, BrowserInsecureHTTPNavigationIntent) -> Void)? var openPopup: ((WKWebViewConfiguration, WKWindowFeatures) -> WKWebView?)? - private func javaScriptDialogTitle(for webView: WKWebView) -> String { - if let absolute = webView.url?.absoluteString, !absolute.isEmpty { - return String(localized: "browser.dialog.pageSaysAt", defaultValue: "The page at \(absolute) says:") - } - return String(localized: "browser.dialog.pageSays", defaultValue: "This page says:") - } - - private func presentDialog( - _ alert: NSAlert, - for webView: WKWebView, - completion: @escaping (NSApplication.ModalResponse) -> Void - ) { - if let window = webView.window { - alert.beginSheetModal(for: window, completionHandler: completion) - return - } - completion(alert.runModal()) - } - /// Called when the page requests a new window (window.open(), target=_blank, etc.). /// /// Returns a live popup WKWebView created with WebKit's supplied configuration @@ -6388,13 +6303,7 @@ private class BrowserUIDelegate: NSObject, WKUIDelegate { initiatedByFrame frame: WKFrameInfo, completionHandler: @escaping ([URL]?) -> Void ) { - let panel = NSOpenPanel() - panel.allowsMultipleSelection = parameters.allowsMultipleSelection - panel.canChooseDirectories = parameters.allowsDirectories - panel.canChooseFiles = true - panel.begin { result in - completionHandler(result == .OK ? panel.urls : nil) - } + BrowserJSDialogPresenter.presentOpenPanel(parameters: parameters, completionHandler: completionHandler) } func webView( @@ -6404,7 +6313,7 @@ private class BrowserUIDelegate: NSObject, WKUIDelegate { type: WKMediaCaptureType, decisionHandler: @escaping (WKPermissionDecision) -> Void ) { - decisionHandler(.prompt) + BrowserJSDialogPresenter.decideMediaCapturePermission(decisionHandler: decisionHandler) } func webView( @@ -6413,12 +6322,7 @@ private class BrowserUIDelegate: NSObject, WKUIDelegate { initiatedByFrame frame: WKFrameInfo, completionHandler: @escaping () -> Void ) { - let alert = NSAlert() - alert.alertStyle = .informational - alert.messageText = javaScriptDialogTitle(for: webView) - alert.informativeText = message - alert.addButton(withTitle: String(localized: "common.ok", defaultValue: "OK")) - presentDialog(alert, for: webView) { _ in completionHandler() } + BrowserJSDialogPresenter.presentAlert(message: message, webView: webView, completionHandler: completionHandler) } func webView( @@ -6427,15 +6331,7 @@ private class BrowserUIDelegate: NSObject, WKUIDelegate { initiatedByFrame frame: WKFrameInfo, completionHandler: @escaping (Bool) -> Void ) { - let alert = NSAlert() - alert.alertStyle = .informational - alert.messageText = javaScriptDialogTitle(for: webView) - alert.informativeText = message - alert.addButton(withTitle: String(localized: "common.ok", defaultValue: "OK")) - alert.addButton(withTitle: String(localized: "common.cancel", defaultValue: "Cancel")) - presentDialog(alert, for: webView) { response in - completionHandler(response == .alertFirstButtonReturn) - } + BrowserJSDialogPresenter.presentConfirm(message: message, webView: webView, completionHandler: completionHandler) } func webView( @@ -6445,24 +6341,12 @@ private class BrowserUIDelegate: NSObject, WKUIDelegate { initiatedByFrame frame: WKFrameInfo, completionHandler: @escaping (String?) -> Void ) { - let alert = NSAlert() - alert.alertStyle = .informational - alert.messageText = javaScriptDialogTitle(for: webView) - alert.informativeText = prompt - alert.addButton(withTitle: String(localized: "common.ok", defaultValue: "OK")) - alert.addButton(withTitle: String(localized: "common.cancel", defaultValue: "Cancel")) - - let field = NSTextField(frame: NSRect(x: 0, y: 0, width: 320, height: 24)) - field.stringValue = defaultText ?? "" - alert.accessoryView = field - - presentDialog(alert, for: webView) { response in - if response == .alertFirstButtonReturn { - completionHandler(field.stringValue) - } else { - completionHandler(nil) - } - } + BrowserJSDialogPresenter.presentTextInput( + prompt: prompt, + defaultText: defaultText, + webView: webView, + completionHandler: completionHandler + ) } } diff --git a/Sources/Panels/BrowserPanelView.swift b/Sources/Panels/BrowserPanelView.swift index 46aeeaa45e1..69564a764a6 100644 --- a/Sources/Panels/BrowserPanelView.swift +++ b/Sources/Panels/BrowserPanelView.swift @@ -4747,8 +4747,8 @@ struct WebViewRepresentable: NSViewRepresentable { let nextFrames = (page: hit.pageView.frame, inspector: hit.inspectorView.frame) if let lastLoggedHostedInspectorFrames, - Self.rectApproximatelyEqual(lastLoggedHostedInspectorFrames.page, nextFrames.page), - Self.rectApproximatelyEqual(lastLoggedHostedInspectorFrames.inspector, nextFrames.inspector) { + InspectorDock.rectApproximatelyEqual(lastLoggedHostedInspectorFrames.page, nextFrames.page), + InspectorDock.rectApproximatelyEqual(lastLoggedHostedInspectorFrames.inspector, nextFrames.inspector) { return } @@ -4757,13 +4757,6 @@ struct WebViewRepresentable: NSViewRepresentable { } #endif - private static func rectApproximatelyEqual(_ lhs: NSRect, _ rhs: NSRect, epsilon: CGFloat = 0.5) -> Bool { - abs(lhs.origin.x - rhs.origin.x) <= epsilon && - abs(lhs.origin.y - rhs.origin.y) <= epsilon && - abs(lhs.width - rhs.width) <= epsilon && - abs(lhs.height - rhs.height) <= epsilon - } - private static func sizeApproximatelyEqual(_ lhs: NSSize, _ rhs: NSSize, epsilon: CGFloat = 0.5) -> Bool { abs(lhs.width - rhs.width) <= epsilon && abs(lhs.height - rhs.height) <= epsilon @@ -5612,8 +5605,8 @@ struct WebViewRepresentable: NSViewRepresentable { return preferredHit } - let inspectorCandidates = Self.visibleDescendants(in: root) - .filter { Self.isVisibleHostedInspectorCandidate($0) && Self.isInspectorView($0) } + let inspectorCandidates = InspectorDock.visibleDescendants(in: root) + .filter { InspectorDock.isVisibleCandidate($0) && InspectorDock.isInspectorView($0) } .sorted { lhs, rhs in let lhsFrame = root.convert(lhs.bounds, from: lhs) let rhsFrame = root.convert(rhs.bounds, from: rhs) @@ -5642,7 +5635,7 @@ struct WebViewRepresentable: NSViewRepresentable { let inspectorLeaf = hostedInspectorFrontendWebView, pageLeaf.isDescendant(of: root), inspectorLeaf.isDescendant(of: root), - Self.isVisibleHostedInspectorCandidate(inspectorLeaf) else { + InspectorDock.isVisibleCandidate(inspectorLeaf) else { return nil } return hostedInspectorDividerCandidate( @@ -5670,8 +5663,8 @@ struct WebViewRepresentable: NSViewRepresentable { continue } guard pageView !== inspectorView, - Self.isVisibleHostedInspectorSiblingCandidate(pageView), - Self.verticalOverlap(between: pageView.frame, and: inspectorView.frame) > 8, + InspectorDock.isVisibleSiblingCandidate(pageView, requireMinWidth: false), + InspectorDock.verticalOverlap(between: pageView.frame, and: inspectorView.frame) > 8, let dockSide = HostedInspectorDockSide.resolve( pageFrame: pageView.frame, inspectorFrame: inspectorView.frame @@ -5709,9 +5702,9 @@ struct WebViewRepresentable: NSViewRepresentable { guard let containerView = inspectorView.superview else { break } let pageCandidates = containerView.subviews.compactMap { candidate -> (view: NSView, dockSide: HostedInspectorDockSide)? in - guard Self.isVisibleHostedInspectorSiblingCandidate(candidate) else { return nil } + guard InspectorDock.isVisibleSiblingCandidate(candidate, requireMinWidth: false) else { return nil } guard candidate !== inspectorView else { return nil } - guard Self.verticalOverlap(between: candidate.frame, and: inspectorView.frame) > 8 else { + guard InspectorDock.verticalOverlap(between: candidate.frame, and: inspectorView.frame) > 8 else { return nil } guard let dockSide = HostedInspectorDockSide.resolve( @@ -5744,13 +5737,13 @@ struct WebViewRepresentable: NSViewRepresentable { private func hostedInspectorDividerCandidateScore(_ hit: HostedInspectorDividerHit) -> CGFloat { let pageFrame = convert(hit.pageView.bounds, from: hit.pageView) let inspectorFrame = convert(hit.inspectorView.bounds, from: hit.inspectorView) - let overlap = Self.verticalOverlap(between: pageFrame, and: inspectorFrame) + let overlap = InspectorDock.verticalOverlap(between: pageFrame, and: inspectorFrame) let coverageWidth = max(pageFrame.maxX, inspectorFrame.maxX) - min(pageFrame.minX, inspectorFrame.minX) return (overlap * 1_000) + coverageWidth + pageFrame.width } private func hostedInspectorPageCandidateScore(_ pageView: NSView, inspectorView: NSView) -> CGFloat { - let overlap = Self.verticalOverlap(between: pageView.frame, and: inspectorView.frame) + let overlap = InspectorDock.verticalOverlap(between: pageView.frame, and: inspectorView.frame) let coverageWidth = max(pageView.frame.maxX, inspectorView.frame.maxX) - min(pageView.frame.minX, inspectorView.frame.minX) return (overlap * 1_000) + coverageWidth + pageView.frame.width } @@ -5857,8 +5850,8 @@ struct WebViewRepresentable: NSViewRepresentable { let oldPageFrame = hit.pageView.frame let oldInspectorFrame = hit.inspectorView.frame - let pageChanged = !Self.rectApproximatelyEqual(pageFrame, oldPageFrame, epsilon: 0.5) - let inspectorChanged = !Self.rectApproximatelyEqual(inspectorFrame, oldInspectorFrame, epsilon: 0.5) + let pageChanged = !InspectorDock.rectApproximatelyEqual(pageFrame, oldPageFrame, epsilon: 0.5) + let inspectorChanged = !InspectorDock.rectApproximatelyEqual(inspectorFrame, oldInspectorFrame, epsilon: 0.5) guard pageChanged || inspectorChanged else { return (pageFrame, inspectorFrame) } @@ -5900,16 +5893,6 @@ struct WebViewRepresentable: NSViewRepresentable { return (pageFrame, inspectorFrame) } - private static func visibleDescendants(in root: NSView) -> [NSView] { - var descendants: [NSView] = [] - var stack = Array(root.subviews.reversed()) - while let view = stack.popLast() { - descendants.append(view) - stack.append(contentsOf: view.subviews.reversed()) - } - return descendants - } - private static func directChild(of container: NSView, containing descendant: NSView) -> NSView? { var current: NSView? = descendant var directChild: NSView? @@ -5920,27 +5903,6 @@ struct WebViewRepresentable: NSViewRepresentable { guard current === container else { return nil } return directChild } - - fileprivate static func isInspectorView(_ view: NSView) -> Bool { - String(describing: type(of: view)).contains("WKInspector") - } - - fileprivate static func isVisibleHostedInspectorCandidate(_ view: NSView) -> Bool { - !view.isHidden && - view.alphaValue > 0 && - view.frame.width > 1 && - view.frame.height > 1 - } - - private static func isVisibleHostedInspectorSiblingCandidate(_ view: NSView) -> Bool { - !view.isHidden && - view.alphaValue > 0 && - view.frame.height > 1 - } - - private static func verticalOverlap(between lhs: NSRect, and rhs: NSRect) -> CGFloat { - max(0, min(lhs.maxY, rhs.maxY) - max(lhs.minY, rhs.minY)) - } } #if DEBUG @@ -5980,42 +5942,12 @@ struct WebViewRepresentable: NSViewRepresentable { } #endif - private static func responderChainContains(_ start: NSResponder?, target: NSResponder) -> Bool { - var r = start - var hops = 0 - while let cur = r, hops < 64 { - if cur === target { return true } - r = cur.nextResponder - hops += 1 - } - return false - } - - private static func isLikelyInspectorResponder(_ 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 - } - private static func firstResponderResignState( _ responder: NSResponder?, webView: WKWebView ) -> (needsResign: Bool, flags: String) { - let inWebViewChain = responderChainContains(responder, target: webView) - let inspectorResponder = isLikelyInspectorResponder(responder) + let inWebViewChain = InspectorDock.responderChainContains(responder, target: webView) + let inspectorResponder = InspectorDock.isLikelyInspectorResponder(responder) let needsResign = inWebViewChain || inspectorResponder return ( needsResign: needsResign, @@ -6100,8 +6032,7 @@ struct WebViewRepresentable: NSViewRepresentable { if view === primaryWebView { continue } let className = String(describing: type(of: view)) guard className.contains("WK") else { continue } - if className.contains("WKInspector") && - (view.isHidden || view.alphaValue <= 0 || view.frame.width <= 1 || view.frame.height <= 1) { + if InspectorDock.isInspectorView(view) && !InspectorDock.isVisibleCandidate(view) { continue } append(view) @@ -6671,7 +6602,7 @@ struct WebViewRepresentable: NSViewRepresentable { #endif return } - if isPanelFocused && responderChainContains(window.firstResponder, target: webView) { + if isPanelFocused && InspectorDock.responderChainContains(window.firstResponder, target: webView) { panel.noteWebViewFocused() } if shouldFocusWebView { @@ -6684,7 +6615,7 @@ struct WebViewRepresentable: NSViewRepresentable { #endif return } - if responderChainContains(window.firstResponder, target: webView) { + if InspectorDock.responderChainContains(window.firstResponder, target: webView) { #if DEBUG dlog( "browser.focus.content.apply panel=\(panel.id.uuidString.prefix(5)) " + @@ -6703,7 +6634,7 @@ struct WebViewRepresentable: NSViewRepresentable { "action=focus result=\(result ? 1 : 0) fr=\(responderDescription(window.firstResponder))" ) #endif - } else if !isPanelFocused && responderChainContains(window.firstResponder, target: webView) { + } else if !isPanelFocused && InspectorDock.responderChainContains(window.firstResponder, target: webView) { // Only force-resign WebView focus when this panel itself is not focused. // If the panel is focused but the omnibar-focus state is briefly stale, aggressively // clearing first responder here can undo programmatic webview focus (socket tests). diff --git a/Sources/Panels/BrowserPopupWindowController.swift b/Sources/Panels/BrowserPopupWindowController.swift index 3bba5d56d04..51b2f990b64 100644 --- a/Sources/Panels/BrowserPopupWindowController.swift +++ b/Sources/Panels/BrowserPopupWindowController.swift @@ -358,15 +358,7 @@ final class BrowserPopupWindowController: NSObject, NSWindowDelegate { return } - let alert = NSAlert() - alert.alertStyle = .warning - alert.messageText = String(localized: "browser.error.insecure.title", defaultValue: "Connection isn\u{2019}t secure") - alert.informativeText = String(localized: "browser.error.insecure.message", defaultValue: "\(host) uses plain HTTP, so traffic can be read or modified on the network.\n\nOpen this URL in your default browser, or proceed in Programa.") - alert.addButton(withTitle: String(localized: "browser.openInDefaultBrowser", defaultValue: "Open in Default Browser")) - alert.addButton(withTitle: String(localized: "browser.proceedInPrograma", defaultValue: "Proceed in Programa")) - alert.addButton(withTitle: String(localized: "common.cancel", defaultValue: "Cancel")) - alert.showsSuppressionButton = true - alert.suppressionButton?.title = String(localized: "browser.alwaysAllowHost", defaultValue: "Always allow this host in Programa") + let alert = BrowserInsecureHTTPAlertBuilder.makeAlert(host: host) let handleResponse: (NSApplication.ModalResponse) -> Void = { [weak alert] response in if browserShouldPersistInsecureHTTPAllowlistSelection( @@ -443,37 +435,13 @@ private class PopupUIDelegate: NSObject, WKUIDelegate { // MARK: - JS Dialogs (parity with main browser) - private func javaScriptDialogTitle(for webView: WKWebView) -> String { - if let absolute = webView.url?.absoluteString, !absolute.isEmpty { - return String(localized: "browser.dialog.pageSaysAt", defaultValue: "The page at \(absolute) says:") - } - return String(localized: "browser.dialog.pageSays", defaultValue: "This page says:") - } - - private func presentDialog( - _ alert: NSAlert, - for webView: WKWebView, - completion: @escaping (NSApplication.ModalResponse) -> Void - ) { - if let window = webView.window { - alert.beginSheetModal(for: window, completionHandler: completion) - return - } - completion(alert.runModal()) - } - func webView( _ webView: WKWebView, runJavaScriptAlertPanelWithMessage message: String, initiatedByFrame frame: WKFrameInfo, completionHandler: @escaping () -> Void ) { - let alert = NSAlert() - alert.alertStyle = .informational - alert.messageText = javaScriptDialogTitle(for: webView) - alert.informativeText = message - alert.addButton(withTitle: String(localized: "common.ok", defaultValue: "OK")) - presentDialog(alert, for: webView) { _ in completionHandler() } + BrowserJSDialogPresenter.presentAlert(message: message, webView: webView, completionHandler: completionHandler) } func webView( @@ -482,15 +450,7 @@ private class PopupUIDelegate: NSObject, WKUIDelegate { initiatedByFrame frame: WKFrameInfo, completionHandler: @escaping (Bool) -> Void ) { - let alert = NSAlert() - alert.alertStyle = .informational - alert.messageText = javaScriptDialogTitle(for: webView) - alert.informativeText = message - alert.addButton(withTitle: String(localized: "common.ok", defaultValue: "OK")) - alert.addButton(withTitle: String(localized: "common.cancel", defaultValue: "Cancel")) - presentDialog(alert, for: webView) { response in - completionHandler(response == .alertFirstButtonReturn) - } + BrowserJSDialogPresenter.presentConfirm(message: message, webView: webView, completionHandler: completionHandler) } func webView( @@ -500,24 +460,12 @@ private class PopupUIDelegate: NSObject, WKUIDelegate { initiatedByFrame frame: WKFrameInfo, completionHandler: @escaping (String?) -> Void ) { - let alert = NSAlert() - alert.alertStyle = .informational - alert.messageText = javaScriptDialogTitle(for: webView) - alert.informativeText = prompt - alert.addButton(withTitle: String(localized: "common.ok", defaultValue: "OK")) - alert.addButton(withTitle: String(localized: "common.cancel", defaultValue: "Cancel")) - - let field = NSTextField(frame: NSRect(x: 0, y: 0, width: 320, height: 24)) - field.stringValue = defaultText ?? "" - alert.accessoryView = field - - presentDialog(alert, for: webView) { response in - if response == .alertFirstButtonReturn { - completionHandler(field.stringValue) - } else { - completionHandler(nil) - } - } + BrowserJSDialogPresenter.presentTextInput( + prompt: prompt, + defaultText: defaultText, + webView: webView, + completionHandler: completionHandler + ) } func webView( @@ -526,13 +474,7 @@ private class PopupUIDelegate: NSObject, WKUIDelegate { initiatedByFrame frame: WKFrameInfo, completionHandler: @escaping ([URL]?) -> Void ) { - let panel = NSOpenPanel() - panel.allowsMultipleSelection = parameters.allowsMultipleSelection - panel.canChooseDirectories = parameters.allowsDirectories - panel.canChooseFiles = true - panel.begin { result in - completionHandler(result == .OK ? panel.urls : nil) - } + BrowserJSDialogPresenter.presentOpenPanel(parameters: parameters, completionHandler: completionHandler) } func webView( @@ -542,7 +484,7 @@ private class PopupUIDelegate: NSObject, WKUIDelegate { type: WKMediaCaptureType, decisionHandler: @escaping (WKPermissionDecision) -> Void ) { - decisionHandler(.prompt) + BrowserJSDialogPresenter.decideMediaCapturePermission(decisionHandler: decisionHandler) } } diff --git a/Sources/Panels/BrowserWebDialogPresenter.swift b/Sources/Panels/BrowserWebDialogPresenter.swift new file mode 100644 index 00000000000..f9ec5874c84 --- /dev/null +++ b/Sources/Panels/BrowserWebDialogPresenter.swift @@ -0,0 +1,138 @@ +import AppKit +import WebKit + +/// Canonical implementations of the `WKUIDelegate` JS-dialog / file-picker / media-permission +/// behaviors that were byte-for-byte duplicated between the main browser's `BrowserUIDelegate` +/// (`BrowserPanel.swift`) and the popup window's `PopupUIDelegate` +/// (`BrowserPopupWindowController.swift`, marked "parity with main browser" in its doc comment). +/// +/// `WKUIDelegate` methods are `@objc optional`, so the Objective-C runtime resolves them via +/// `respondsToSelector:` on the conforming class itself — a protocol-extension default +/// implementation would silently never be called. Each conforming class therefore keeps its own +/// thin `@objc`-visible method, forwarding the body to these shared statics so there is exactly +/// one implementation of the actual behavior. +enum BrowserJSDialogPresenter { + static func title(for webView: WKWebView) -> String { + if let absolute = webView.url?.absoluteString, !absolute.isEmpty { + return String(localized: "browser.dialog.pageSaysAt", defaultValue: "The page at \(absolute) says:") + } + return String(localized: "browser.dialog.pageSays", defaultValue: "This page says:") + } + + static func present( + _ alert: NSAlert, + for webView: WKWebView, + completion: @escaping (NSApplication.ModalResponse) -> Void + ) { + if let window = webView.window { + alert.beginSheetModal(for: window, completionHandler: completion) + return + } + completion(alert.runModal()) + } + + static func presentAlert( + message: String, + webView: WKWebView, + completionHandler: @escaping () -> Void + ) { + let alert = NSAlert() + alert.alertStyle = .informational + alert.messageText = title(for: webView) + alert.informativeText = message + alert.addButton(withTitle: String(localized: "common.ok", defaultValue: "OK")) + present(alert, for: webView) { _ in completionHandler() } + } + + static func presentConfirm( + message: String, + webView: WKWebView, + completionHandler: @escaping (Bool) -> Void + ) { + let alert = NSAlert() + alert.alertStyle = .informational + alert.messageText = title(for: webView) + alert.informativeText = message + alert.addButton(withTitle: String(localized: "common.ok", defaultValue: "OK")) + alert.addButton(withTitle: String(localized: "common.cancel", defaultValue: "Cancel")) + present(alert, for: webView) { response in + completionHandler(response == .alertFirstButtonReturn) + } + } + + static func presentTextInput( + prompt: String, + defaultText: String?, + webView: WKWebView, + completionHandler: @escaping (String?) -> Void + ) { + let alert = NSAlert() + alert.alertStyle = .informational + alert.messageText = title(for: webView) + alert.informativeText = prompt + alert.addButton(withTitle: String(localized: "common.ok", defaultValue: "OK")) + alert.addButton(withTitle: String(localized: "common.cancel", defaultValue: "Cancel")) + + let field = NSTextField(frame: NSRect(x: 0, y: 0, width: 320, height: 24)) + field.stringValue = defaultText ?? "" + alert.accessoryView = field + + present(alert, for: webView) { response in + if response == .alertFirstButtonReturn { + completionHandler(field.stringValue) + } else { + completionHandler(nil) + } + } + } + + /// Handle `` elements by presenting the native file picker. + static func presentOpenPanel( + parameters: WKOpenPanelParameters, + completionHandler: @escaping ([URL]?) -> Void + ) { + let panel = NSOpenPanel() + panel.allowsMultipleSelection = parameters.allowsMultipleSelection + panel.canChooseDirectories = parameters.allowsDirectories + panel.canChooseFiles = true + panel.begin { result in + completionHandler(result == .OK ? panel.urls : nil) + } + } + + /// Always prompts rather than silently granting/denying — the system permission prompt + /// itself is the actual gate. + static func decideMediaCapturePermission(decisionHandler: @escaping (WKPermissionDecision) -> Void) { + decisionHandler(.prompt) + } +} + +/// Canonical construction of the insecure-HTTP (plain-`http://`) warning alert shown by both the +/// main browser (`BrowserPanel.presentInsecureHTTPAlert`) and popup windows +/// (`BrowserPopupWindowController.presentInsecureHTTPAlert`, "parity with main browser"). +/// +/// Only the alert's *construction* is shared here — the two call sites diverge in what happens +/// after a button is chosen (the main browser persists typed-navigation/bypass-host state across +/// tabs; the popup only opens externally or proceeds inline), so that response handling stays +/// local to each call site. +enum BrowserInsecureHTTPAlertBuilder { + /// Configures `alert` in place (rather than constructing and returning a new `NSAlert`) so the + /// main browser can keep routing alert creation through its `#if DEBUG`-overridable + /// `insecureHTTPAlertFactory` test hook while still sharing this construction logic. + static func configure(_ alert: NSAlert, host: String) { + alert.alertStyle = .warning + alert.messageText = String(localized: "browser.error.insecure.title", defaultValue: "Connection isn\u{2019}t secure") + alert.informativeText = String(localized: "browser.error.insecure.message", defaultValue: "\(host) uses plain HTTP, so traffic can be read or modified on the network.\n\nOpen this URL in your default browser, or proceed in Programa.") + alert.addButton(withTitle: String(localized: "browser.openInDefaultBrowser", defaultValue: "Open in Default Browser")) + alert.addButton(withTitle: String(localized: "browser.proceedInPrograma", defaultValue: "Proceed in Programa")) + alert.addButton(withTitle: String(localized: "common.cancel", defaultValue: "Cancel")) + alert.showsSuppressionButton = true + alert.suppressionButton?.title = String(localized: "browser.alwaysAllowHost", defaultValue: "Always allow this host in Programa") + } + + static func makeAlert(host: String) -> NSAlert { + let alert = NSAlert() + configure(alert, host: host) + return alert + } +} diff --git a/Sources/Panels/InspectorDock.swift b/Sources/Panels/InspectorDock.swift new file mode 100644 index 00000000000..0f47c0c2b20 --- /dev/null +++ b/Sources/Panels/InspectorDock.swift @@ -0,0 +1,116 @@ +import AppKit + +/// Canonical helpers for detecting and reasoning about WebKit's hosted Web Inspector +/// (the attached "WKInspector*" view hierarchy) inside the browser view stack. +/// +/// Prior to this type, the same WKInspector string-sniffing and side-dock geometry math was +/// independently reimplemented in `BrowserPanel.swift`, `BrowserPanelView.swift` +/// (`WebViewRepresentable.Coordinator.HostContainerView`), and `BrowserWindowPortal.swift` +/// (`WindowBrowserHostView`). This consolidates the parts that were byte-for-byte identical +/// across those call sites. `AppDelegate.swift` has its own copy of the responder-sniffing half +/// (`programaIsLikelyWebInspectorResponder`) that is intentionally left alone here since +/// AppDelegate belongs to a different maintenance cluster. +enum InspectorDock { + /// WebKit's hosted inspector views are private API with type names like + /// `WKInspectorWKWebView` / `WKInspectorViewController` — detected by substring since there is + /// no public type to check against. + static func isInspectorView(_ view: NSView) -> Bool { + String(describing: type(of: view)).contains("WKInspector") + } + + /// Depth-first flattening of every descendant of `root` (not including `root` itself). + static func visibleDescendants(in root: NSView) -> [NSView] { + var descendants: [NSView] = [] + var stack = Array(root.subviews.reversed()) + while let view = stack.popLast() { + descendants.append(view) + stack.append(contentsOf: view.subviews.reversed()) + } + return descendants + } + + static func verticalOverlap(between lhs: NSRect, and rhs: NSRect) -> CGFloat { + max(0, min(lhs.maxY, rhs.maxY) - max(lhs.minY, rhs.minY)) + } + + /// A view large enough on both axes to plausibly be a docked inspector panel (as opposed to a + /// collapsed/zero-size view mid-layout-pass). + static func isVisibleCandidate(_ view: NSView) -> Bool { + !view.isHidden && + view.alphaValue > 0 && + view.frame.width > 1 && + view.frame.height > 1 + } + + /// A sibling of a candidate inspector view, large enough vertically to plausibly sit beside it. + /// + /// `requireMinWidth` preserves a real behavioral divergence between call sites: the + /// `BrowserPanel` side-dock *detection* path requires `width > 1` (it only cares about fully + /// laid-out siblings), while the `BrowserPanelView` / `BrowserWindowPortal` divider *hit-testing* + /// paths intentionally omit the width check (a page sibling can be transiently zero-width while + /// the user is mid-drag on the divider). Pass `true` for detection, `false` for divider hit-testing. + static func isVisibleSiblingCandidate(_ view: NSView, requireMinWidth: Bool) -> Bool { + guard !view.isHidden, view.alphaValue > 0, view.frame.height > 1 else { return false } + return requireMinWidth ? view.frame.width > 1 : true + } + + static func rectApproximatelyEqual(_ lhs: NSRect, _ rhs: NSRect, epsilon: CGFloat = 0.01) -> Bool { + abs(lhs.origin.x - rhs.origin.x) <= epsilon && + abs(lhs.origin.y - rhs.origin.y) <= epsilon && + abs(lhs.size.width - rhs.size.width) <= epsilon && + abs(lhs.size.height - rhs.size.height) <= epsilon + } + + /// Walks the responder chain from `start` looking for `target`, bounded to guard against an + /// accidental cycle. + static func responderChainContains(_ start: NSResponder?, target: NSResponder, maxHops: Int = 64) -> Bool { + var responder = start + var hops = 0 + while let current = responder, hops < maxHops { + if current === target { return true } + responder = current.nextResponder + hops += 1 + } + return false + } + + /// True if `responder` is itself a hosted-inspector view, or is nested inside one by walking up + /// the `superview` chain. + static func isLikelyInspectorResponder(_ responder: NSResponder?) -> Bool { + guard let responder else { return false } + if String(describing: type(of: responder)).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 isInspectorView(current) { + return true + } + node = current.superview + hops += 1 + } + return false + } + + /// True if `root` (or any descendant) is a hosted inspector view — used to identify a detached + /// Web Inspector window by inspecting its content view tree. + static func windowContainsInspectorViews(_ root: NSView) -> Bool { + if isInspectorView(root) { + return true + } + for subview in root.subviews where windowContainsInspectorViews(subview) { + return true + } + return false + } + + /// True if `window` is a detached (undocked) Web Inspector window: titled "Web Inspector…" and + /// hosting an inspector view somewhere in its content view tree. + static func isDetachedInspectorWindow(_ window: NSWindow) -> Bool { + guard window.title.hasPrefix("Web Inspector") else { return false } + guard let contentView = window.contentView else { return false } + return windowContainsInspectorViews(contentView) + } +} From fde4223b16060405a8df935723e90a1818d06993 Mon Sep 17 00:00:00 2001 From: arzafran Date: Thu, 9 Jul 2026 13:59:23 -0300 Subject: [PATCH 11/31] 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 12/31] 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 13/31] 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 14/31] 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 15/31] 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 16/31] 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 17/31] 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 5d4b33f050f0448c6695a52fd1b29202df047c79 Mon Sep 17 00:00:00 2001 From: arzafran Date: Thu, 9 Jul 2026 14:07:56 -0300 Subject: [PATCH 18/31] refactor(tabmanager): extract DEBUG UI-test harness into TabManager+UITestHarness.swift Pure move of the ~1,340-line DEBUG-only UI-test harness (workspace/terminal-panel readiness waiters, focus-shortcut/split-close-right/child-exit-split/child-exit-keyboard test setup, and the vsync IOSurface timeline capture helper it drives) out of the TabManager class body into its own extension file, following the same pattern as AppDelegate+UITestHarnesses.swift. Same API, still #if DEBUG-gated. Access widening (fileprivate/private -> internal), each required only for cross-file visibility from the new extension file: - didSetupSplitCloseRightUITest, didSetupUITestFocusShortcuts, didSetupChildExitSplitUITest, didSetupChildExitKeyboardUITest, uiTestCancellables: stored properties, so they must stay on the TabManager class body (Swift extensions cannot add stored properties) but are now read/written from the new file. - setupUITestFocusShortcutsIfNeeded/setupSplitCloseRightUITestIfNeeded/ setupChildExitSplitUITestIfNeeded/setupChildExitKeyboardUITestIfNeeded: called from TabManager.init() in TabManager.swift. - VsyncIOSurfaceTimelineState / programaVsyncIOSurfaceTimelineCallback: file-scoped helpers used only by the split-close-right visual capture harness; moved alongside it. Registered the new file in project.pbxproj (PBXFileReference + PBXBuildFile + group + Sources phase), following the TabManager+GitMetadataPolling.swift wiring pattern. Refs #90. --- GhosttyTabs.xcodeproj/project.pbxproj | 4 + Sources/TabManager+UITestHarness.swift | 1366 ++++++++++++++++++++++++ Sources/TabManager.swift | 1358 +---------------------- 3 files changed, 1377 insertions(+), 1351 deletions(-) create mode 100644 Sources/TabManager+UITestHarness.swift diff --git a/GhosttyTabs.xcodeproj/project.pbxproj b/GhosttyTabs.xcodeproj/project.pbxproj index 5ba0f619fc7..edf7178707c 100644 --- a/GhosttyTabs.xcodeproj/project.pbxproj +++ b/GhosttyTabs.xcodeproj/project.pbxproj @@ -18,6 +18,7 @@ A5FF0073 /* TabManager+Splits.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5FF0063 /* TabManager+Splits.swift */; }; A5FF0072 /* TabManager+Browser.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5FF0062 /* TabManager+Browser.swift */; }; A5FF0071 /* TabManager+GitMetadataPolling.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5FF0061 /* TabManager+GitMetadataPolling.swift */; }; + NRTM00000002 /* TabManager+UITestHarness.swift in Sources */ = {isa = PBXBuildFile; fileRef = NRTM00000001 /* TabManager+UITestHarness.swift */; }; A5001004 /* GhosttyConfig.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5001014 /* GhosttyConfig.swift */; }; A5001005 /* GhosttyTerminalView.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5001015 /* GhosttyTerminalView.swift */; }; A5FF0051 /* GhosttyTerminalView+Keyboard.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5FF0041 /* GhosttyTerminalView+Keyboard.swift */; }; @@ -242,6 +243,7 @@ A5FF0063 /* TabManager+Splits.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "TabManager+Splits.swift"; sourceTree = ""; }; A5FF0062 /* TabManager+Browser.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "TabManager+Browser.swift"; sourceTree = ""; }; A5FF0061 /* TabManager+GitMetadataPolling.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "TabManager+GitMetadataPolling.swift"; sourceTree = ""; }; + NRTM00000001 /* TabManager+UITestHarness.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "TabManager+UITestHarness.swift"; sourceTree = ""; }; A5001014 /* GhosttyConfig.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GhosttyConfig.swift; sourceTree = ""; }; A5001015 /* GhosttyTerminalView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GhosttyTerminalView.swift; sourceTree = ""; }; A5FF0041 /* GhosttyTerminalView+Keyboard.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "GhosttyTerminalView+Keyboard.swift"; sourceTree = ""; }; @@ -515,6 +517,7 @@ A5FF0063 /* TabManager+Splits.swift */, A5FF0062 /* TabManager+Browser.swift */, A5FF0061 /* TabManager+GitMetadataPolling.swift */, + NRTM00000001 /* TabManager+UITestHarness.swift */, A5001511 /* UITestRecorder.swift */, A5001416 /* Workspace.swift */, A5FF0002 /* WorkspaceRemoteSession.swift */, @@ -845,6 +848,7 @@ A5FF0073 /* TabManager+Splits.swift in Sources */, A5FF0072 /* TabManager+Browser.swift in Sources */, A5FF0071 /* TabManager+GitMetadataPolling.swift in Sources */, + NRTM00000002 /* TabManager+UITestHarness.swift in Sources */, A5001501 /* UITestRecorder.swift in Sources */, A5001406 /* Workspace.swift in Sources */, A5FF0012 /* WorkspaceRemoteSession.swift in Sources */, diff --git a/Sources/TabManager+UITestHarness.swift b/Sources/TabManager+UITestHarness.swift new file mode 100644 index 00000000000..55ad2eb3bb5 --- /dev/null +++ b/Sources/TabManager+UITestHarness.swift @@ -0,0 +1,1366 @@ +import AppKit +import SwiftUI +import Foundation +import Bonsplit +import CoreVideo +import Combine + +// MARK: - DEBUG UI Test Harness +// +// Test-only setup helpers, gated behind PROGRAMA_UI_TEST_* environment variables, that +// drive deterministic UI-test scenarios (focus shortcuts, split-close-right visual capture, +// child-process-exit split/keyboard scenarios) from within programaUITests. Never compiled +// into Release builds. + +#if DEBUG +// Sample the actual IOSurface-backed terminal layer at vsync cadence so UI tests can reliably +// catch a single compositor-frame blank flash and any transient compositor scaling (stretched text). +// +// This is DEBUG-only and used only for UI tests; no polling or display-link loops exist in normal app runtime. +fileprivate final class VsyncIOSurfaceTimelineState { + struct Target { + let label: String + let sample: @MainActor () -> GhosttySurfaceScrollView.DebugFrameSample? + } + + let frameCount: Int + let closeFrame: Int + let lock = NSLock() + + var framesWritten = 0 + var inFlight = false + var finished = false + + var scheduledActions: [(frame: Int, action: () -> Void)] = [] + var nextActionIndex: Int = 0 + + var targets: [Target] = [] + + // Results + var firstBlank: (label: String, frame: Int)? + var firstSizeMismatch: (label: String, frame: Int, ios: String, expected: String)? + var trace: [String] = [] + + var link: CVDisplayLink? + var continuation: CheckedContinuation? + + init(frameCount: Int, closeFrame: Int) { + self.frameCount = frameCount + self.closeFrame = closeFrame + } + + func tryBeginCapture() -> Bool { + lock.lock() + defer { lock.unlock() } + if finished { return false } + if inFlight { return false } + inFlight = true + return true + } + + func endCapture() { + lock.lock() + inFlight = false + lock.unlock() + } + + func finish() { + lock.lock() + if finished { + lock.unlock() + return + } + finished = true + let cont = continuation + continuation = nil + lock.unlock() + cont?.resume() + } +} + +fileprivate func programaVsyncIOSurfaceTimelineCallback( + _ displayLink: CVDisplayLink, + _ inNow: UnsafePointer, + _ inOutputTime: UnsafePointer, + _ flagsIn: CVOptionFlags, + _ flagsOut: UnsafeMutablePointer, + _ ctx: UnsafeMutableRawPointer? +) -> CVReturn { + guard let ctx else { return kCVReturnSuccess } + let st = Unmanaged.fromOpaque(ctx).takeUnretainedValue() + if !st.tryBeginCapture() { return kCVReturnSuccess } + + // Sample on the main thread synchronously so we don't "miss" a single compositor frame. + // (The previous Task/@MainActor hop could be delayed long enough to skip the blank frame.) + DispatchQueue.main.sync { + defer { st.endCapture() } + guard st.framesWritten < st.frameCount else { return } + + while st.nextActionIndex < st.scheduledActions.count { + let next = st.scheduledActions[st.nextActionIndex] + if next.frame != st.framesWritten { break } + st.nextActionIndex += 1 + next.action() + } + + for t in st.targets { + guard let s = t.sample() else { continue } + + let iosW = s.iosurfaceWidthPx + let iosH = s.iosurfaceHeightPx + let expW = s.expectedWidthPx + let expH = s.expectedHeightPx + let gravity = s.layerContentsGravity + let hasDimensions = iosW > 0 && iosH > 0 && expW > 0 && expH > 0 + let dw = hasDimensions ? abs(iosW - expW) : 0 + let dh = hasDimensions ? abs(iosH - expH) : 0 + let hasSizeMismatch = hasDimensions && (dw > 2 || dh > 2) + let stretchRisk = (gravity == CALayerContentsGravity.resize.rawValue) + + // Ignore setup/warmup frames before the close action. We only care about + // regressions that happen at/after the close mutation. + if st.firstBlank == nil, st.framesWritten >= st.closeFrame, s.isProbablyBlank { + st.firstBlank = (label: t.label, frame: st.framesWritten) + } + + if st.firstSizeMismatch == nil, + st.framesWritten >= st.closeFrame, + stretchRisk, + hasSizeMismatch { + st.firstSizeMismatch = ( + label: t.label, + frame: st.framesWritten, + ios: "\(iosW)x\(iosH)", + expected: "\(expW)x\(expH)" + ) + } + + if st.trace.count < 200 { + st.trace.append("\(st.framesWritten):\(t.label):blank=\(s.isProbablyBlank ? 1 : 0):ios=\(iosW)x\(iosH):exp=\(expW)x\(expH):gravity=\(gravity):key=\(s.layerContentsKey)") + } + } + + st.framesWritten += 1 + } + + // Stop/resume outside the main-thread sync block to avoid reentrancy issues. + if st.framesWritten >= st.frameCount, let link = st.link { + CVDisplayLinkStop(link) + st.finish() + Unmanaged.fromOpaque(ctx).release() + } + + return kCVReturnSuccess +} +#endif + +extension TabManager { +#if DEBUG + @MainActor + private func waitForWorkspacePanelsCondition( + tab: Workspace, + timeoutSeconds: TimeInterval, + condition: @escaping (Workspace) -> Bool + ) async -> Bool { + guard !condition(tab) else { return true } + + return await withCheckedContinuation { (cont: CheckedContinuation) in + var resolved = false + var cancellable: AnyCancellable? + + func finish(_ value: Bool) { + guard !resolved else { return } + resolved = true + cancellable?.cancel() + cont.resume(returning: value) + } + + func evaluate() { + if condition(tab) { + finish(true) + } + } + + cancellable = tab.$panels + .map { _ in () } + .sink { _ in evaluate() } + + DispatchQueue.main.asyncAfter(deadline: .now() + timeoutSeconds) { + Task { @MainActor in + finish(condition(tab)) + } + } + evaluate() + } + } + + @MainActor + private func waitForTerminalPanelCondition( + tab: Workspace, + panelId: UUID, + timeoutSeconds: TimeInterval, + condition: @escaping (TerminalPanel) -> Bool + ) async -> Bool { + if let panel = tab.terminalPanel(for: panelId), condition(panel) { + return true + } + + return await withCheckedContinuation { (cont: CheckedContinuation) in + var resolved = false + var panelsCancellable: AnyCancellable? + var readyObserver: NSObjectProtocol? + var hostedViewObserver: NSObjectProtocol? + + @MainActor + func finish(_ value: Bool) { + guard !resolved else { return } + resolved = true + panelsCancellable?.cancel() + if let readyObserver { + NotificationCenter.default.removeObserver(readyObserver) + } + if let hostedViewObserver { + NotificationCenter.default.removeObserver(hostedViewObserver) + } + cont.resume(returning: value) + } + + @MainActor + func evaluate() { + guard let panel = tab.terminalPanel(for: panelId) else { + finish(false) + return + } + panel.surface.requestBackgroundSurfaceStartIfNeeded() + if condition(panel) { + finish(true) + } + } + + panelsCancellable = tab.$panels + .map { _ in () } + .sink { _ in + Task { @MainActor in + evaluate() + } + } + readyObserver = NotificationCenter.default.addObserver( + forName: .terminalSurfaceDidBecomeReady, + object: nil, + queue: .main + ) { note in + guard let readySurfaceId = note.userInfo?["surfaceId"] as? UUID, + readySurfaceId == panelId else { return } + Task { @MainActor in + evaluate() + } + } + hostedViewObserver = NotificationCenter.default.addObserver( + forName: .terminalSurfaceHostedViewDidMoveToWindow, + object: nil, + queue: .main + ) { note in + guard let hostedSurfaceId = note.userInfo?["surfaceId"] as? UUID, + hostedSurfaceId == panelId else { return } + Task { @MainActor in + evaluate() + } + } + + DispatchQueue.main.asyncAfter(deadline: .now() + timeoutSeconds) { + Task { @MainActor in + if let panel = tab.terminalPanel(for: panelId) { + finish(condition(panel)) + } else { + finish(false) + } + } + } + evaluate() + } + } + + @MainActor + private func waitForTerminalPanelReadyForUITest( + tab: Workspace, + panelId: UUID, + timeoutSeconds: TimeInterval = 6.0 + ) async -> (attached: Bool, hasSurface: Bool, firstResponder: Bool) { + var attached = false + var hasSurface = false + var firstResponder = false + + let _ = await waitForTerminalPanelCondition( + tab: tab, + panelId: panelId, + timeoutSeconds: timeoutSeconds + ) { panel in + panel.surface.requestBackgroundSurfaceStartIfNeeded() + attached = panel.hostedView.window != nil + hasSurface = panel.surface.surface != nil + firstResponder = panel.hostedView.isSurfaceViewFirstResponder() + return attached && hasSurface + } + + return (attached, hasSurface, firstResponder) + } + + // Widened from `private` to `internal`: called from TabManager.init() in TabManager.swift. + func setupUITestFocusShortcutsIfNeeded() { + guard !didSetupUITestFocusShortcuts else { return } + didSetupUITestFocusShortcuts = true + + let env = ProcessInfo.processInfo.environment + guard env["PROGRAMA_UI_TEST_FOCUS_SHORTCUTS"] == "1" else { return } + + // UI tests can't record arrow keys via the shortcut recorder. Use letter-based shortcuts + // so tests can reliably drive pane navigation without mouse clicks. + KeyboardShortcutSettings.setShortcut( + StoredShortcut(key: "h", command: true, shift: false, option: false, control: true), + for: .focusLeft + ) + KeyboardShortcutSettings.setShortcut( + StoredShortcut(key: "l", command: true, shift: false, option: false, control: true), + for: .focusRight + ) + KeyboardShortcutSettings.setShortcut( + StoredShortcut(key: "k", command: true, shift: false, option: false, control: true), + for: .focusUp + ) + KeyboardShortcutSettings.setShortcut( + StoredShortcut(key: "j", command: true, shift: false, option: false, control: true), + for: .focusDown + ) + } + + // Widened from `private` to `internal`: called from TabManager.init() in TabManager.swift. + func setupSplitCloseRightUITestIfNeeded() { + guard !didSetupSplitCloseRightUITest else { return } + didSetupSplitCloseRightUITest = true + + let env = ProcessInfo.processInfo.environment + guard env["PROGRAMA_UI_TEST_SPLIT_CLOSE_RIGHT_SETUP"] == "1" else { return } + guard let path = env["PROGRAMA_UI_TEST_SPLIT_CLOSE_RIGHT_PATH"], !path.isEmpty else { return } + let visualMode = env["PROGRAMA_UI_TEST_SPLIT_CLOSE_RIGHT_VISUAL"] == "1" + let shotsDir = (env["PROGRAMA_UI_TEST_SPLIT_CLOSE_RIGHT_SHOTS_DIR"] ?? "").trimmingCharacters(in: .whitespacesAndNewlines) + let visualIterations = Int((env["PROGRAMA_UI_TEST_SPLIT_CLOSE_RIGHT_ITERATIONS"] ?? "20").trimmingCharacters(in: .whitespacesAndNewlines)) ?? 20 + let burstFrames = Int((env["PROGRAMA_UI_TEST_SPLIT_CLOSE_RIGHT_BURST_FRAMES"] ?? "6").trimmingCharacters(in: .whitespacesAndNewlines)) ?? 6 + let closeDelayMs = Int((env["PROGRAMA_UI_TEST_SPLIT_CLOSE_RIGHT_CLOSE_DELAY_MS"] ?? "70").trimmingCharacters(in: .whitespacesAndNewlines)) ?? 70 + let pattern = (env["PROGRAMA_UI_TEST_SPLIT_CLOSE_RIGHT_PATTERN"] ?? "close_right") + .trimmingCharacters(in: .whitespacesAndNewlines) + + DispatchQueue.main.asyncAfter(deadline: .now() + 0.2) { [weak self] in + guard let self else { return } + Task { @MainActor [weak self] in + guard let self else { return } + guard let tab = self.selectedWorkspace else { + self.writeSplitCloseRightTestData(["setupError": "Missing selected workspace"], at: path) + return + } + + guard let topLeftPanelId = tab.focusedPanelId else { + self.writeSplitCloseRightTestData(["setupError": "Missing initial focused panel"], at: path) + return + } + let initialTerminalReadiness = await self.waitForTerminalPanelReadyForUITest( + tab: tab, + panelId: topLeftPanelId + ) + + guard initialTerminalReadiness.attached, + initialTerminalReadiness.hasSurface, + let terminal = tab.terminalPanel(for: topLeftPanelId) else { + self.writeSplitCloseRightTestData([ + "preTerminalAttached": initialTerminalReadiness.attached ? "1" : "0", + "preTerminalSurfaceNil": initialTerminalReadiness.hasSurface ? "0" : "1", + "setupError": "Initial terminal not ready (not attached or surface nil)" + ], at: path) + return + } + + self.writeSplitCloseRightTestData([ + "preTerminalAttached": "1", + "preTerminalSurfaceNil": terminal.surface.surface == nil ? "1" : "0" + ], at: path) + + if visualMode { + // Visual repro mode: repeat the split/close sequence many times and write + // screenshots to `shotsDir`. This avoids relying on XCUITest to click hover-only + // close buttons, while still exercising the "close unfocused right tabs" path. + self.writeSplitCloseRightTestData([ + "visualMode": "1", + "visualIterations": String(visualIterations), + "visualDone": "0" + ], at: path) + + await self.runSplitCloseRightVisualRepro( + tab: tab, + topLeftPanelId: topLeftPanelId, + path: path, + shotsDir: shotsDir, + iterations: max(1, min(visualIterations, 60)), + burstFrames: max(0, min(burstFrames, 80)), + closeDelayMs: max(0, min(closeDelayMs, 500)), + pattern: pattern + ) + + self.writeSplitCloseRightTestData(["visualDone": "1"], at: path) + return + } + + // Layout goal: 2x2 grid (2 top, 2 bottom), then close both right panels. + // Order matters: split down first, then split right in each row (matches UI shortcut repro). + guard let bottomLeft = tab.newTerminalSplit(from: topLeftPanelId, orientation: .vertical) else { + self.writeSplitCloseRightTestData(["setupError": "Failed to create bottom-left split"], at: path) + return + } + guard let bottomRight = tab.newTerminalSplit(from: bottomLeft.id, orientation: .horizontal) else { + self.writeSplitCloseRightTestData(["setupError": "Failed to create bottom-right split"], at: path) + return + } + tab.focusPanel(topLeftPanelId) + guard let topRight = tab.newTerminalSplit(from: topLeftPanelId, orientation: .horizontal) else { + self.writeSplitCloseRightTestData(["setupError": "Failed to create top-right split"], at: path) + return + } + + self.writeSplitCloseRightTestData([ + "tabId": tab.id.uuidString, + "topLeftPanelId": topLeftPanelId.uuidString, + "bottomLeftPanelId": bottomLeft.id.uuidString, + "topRightPanelId": topRight.id.uuidString, + "bottomRightPanelId": bottomRight.id.uuidString, + "createdPaneCount": String(tab.bonsplitController.allPaneIds.count), + "createdPanelCount": String(tab.panels.count) + ], at: path) + + DebugUIEventCounters.resetEmptyPanelAppearCount() + + // Close the two right panes via the same path as Cmd+W. + tab.focusPanel(topRight.id) + tab.closePanel(topRight.id, force: true) + tab.focusPanel(bottomRight.id) + tab.closePanel(bottomRight.id, force: true) + + + // Capture final state after Bonsplit/AppKit/Ghostty geometry reconciliation. + // We avoid sleep-based timing and converge over a few main-actor turns. + @MainActor func collectSplitCloseRightState() -> (data: [String: String], settled: Bool) { + let paneIds = tab.bonsplitController.allPaneIds + let bonsplitTabCount = tab.bonsplitController.allTabIds.count + let panelCount = tab.panels.count + + var missingSelectedTabCount = 0 + var missingPanelMappingCount = 0 + var selectedTerminalCount = 0 + var selectedTerminalAttachedCount = 0 + var selectedTerminalZeroSizeCount = 0 + var selectedTerminalSurfaceNilCount = 0 + + for paneId in paneIds { + guard let selected = tab.bonsplitController.selectedTab(inPane: paneId) else { + missingSelectedTabCount += 1 + continue + } + guard let panel = tab.panel(for: selected.id) else { + missingPanelMappingCount += 1 + continue + } + if let terminal = panel as? TerminalPanel { + selectedTerminalCount += 1 + if terminal.hostedView.window != nil { + selectedTerminalAttachedCount += 1 + } + let size = terminal.hostedView.bounds.size + if size.width < 5 || size.height < 5 { + selectedTerminalZeroSizeCount += 1 + } + if terminal.surface.surface == nil { + selectedTerminalSurfaceNilCount += 1 + } + } + } + + let settled = + paneIds.count == 2 && + missingSelectedTabCount == 0 && + missingPanelMappingCount == 0 && + DebugUIEventCounters.emptyPanelAppearCount == 0 && + selectedTerminalCount == 2 && + selectedTerminalAttachedCount == 2 && + selectedTerminalZeroSizeCount == 0 && + selectedTerminalSurfaceNilCount == 0 + + return ( + data: [ + "finalPaneCount": String(paneIds.count), + "finalBonsplitTabCount": String(bonsplitTabCount), + "finalPanelCount": String(panelCount), + "missingSelectedTabCount": String(missingSelectedTabCount), + "missingPanelMappingCount": String(missingPanelMappingCount), + "emptyPanelAppearCount": String(DebugUIEventCounters.emptyPanelAppearCount), + "selectedTerminalCount": String(selectedTerminalCount), + "selectedTerminalAttachedCount": String(selectedTerminalAttachedCount), + "selectedTerminalZeroSizeCount": String(selectedTerminalZeroSizeCount), + "selectedTerminalSurfaceNilCount": String(selectedTerminalSurfaceNilCount), + ], + settled: settled + ) + } + @MainActor func reconcileVisibleTerminalGeometry() { + NSApp.windows.forEach { window in + window.contentView?.layoutSubtreeIfNeeded() + window.contentView?.displayIfNeeded() + } + for paneId in tab.bonsplitController.allPaneIds { + guard let selected = tab.bonsplitController.selectedTab(inPane: paneId), + let terminal = tab.panel(for: selected.id) as? TerminalPanel else { + continue + } + terminal.hostedView.reconcileGeometryNow() + terminal.surface.forceRefresh() + } + } + + var finalState = collectSplitCloseRightState() + for attempt in 1...8 { + reconcileVisibleTerminalGeometry() + await Task.yield() + finalState = collectSplitCloseRightState() + var payload = finalState.data + payload["finalAttempt"] = String(attempt) + self.writeSplitCloseRightTestData(payload, at: path) + if finalState.settled { + break + } + } + } + } + } + + @MainActor + private func runSplitCloseRightVisualRepro( + tab: Workspace, + topLeftPanelId: UUID, + path: String, + shotsDir: String, + iterations: Int, + burstFrames: Int, + closeDelayMs: Int, + pattern: String + ) async { + _ = shotsDir // legacy: screenshots removed in favor of IOSurface sampling + + func sendText(_ panelId: UUID, _ text: String) { + guard let tp = tab.terminalPanel(for: panelId) else { return } + tp.surface.sendText(text) + } + + // Sample a very top strip so the probe remains valid even after vertical expand/collapse. + // We pin marker text to row 1 before each close sequence. + let sampleCrop = CGRect(x: 0.04, y: 0.01, width: 0.92, height: 0.08) + + for i in 1...iterations { + // Reset to a single pane: close everything except the top-left panel. + tab.focusPanel(topLeftPanelId) + let toClose = Array(tab.panels.keys).filter { $0 != topLeftPanelId } + for pid in toClose { + tab.closePanel(pid, force: true) + } + + // Create the repro layout. Most patterns use a 2x2 grid, but keep a single-split + // variant for the exact "close right in a horizontal pair" user report. + let topLeftId = topLeftPanelId + let topRight: TerminalPanel + var bottomLeft: TerminalPanel? + var bottomRight: TerminalPanel? + + switch pattern { + case "close_right_single": + guard let tr = tab.newTerminalSplit(from: topLeftId, orientation: .horizontal) else { + writeSplitCloseRightTestData(["setupError": "Failed to split right from top-left (iteration \(i))"], at: path) + return + } + topRight = tr + case "close_right_lrtd", "close_right_lrtd_bottom_first", "close_right_bottom_first", "close_right_lrtd_unfocused": + // User repro: split left/right first, then split top/down in each column. + guard let tr = tab.newTerminalSplit(from: topLeftId, orientation: .horizontal) else { + writeSplitCloseRightTestData(["setupError": "Failed to split right from top-left (iteration \(i))"], at: path) + return + } + guard let bl = tab.newTerminalSplit(from: topLeftId, orientation: .vertical) else { + writeSplitCloseRightTestData(["setupError": "Failed to split down from left (iteration \(i))"], at: path) + return + } + guard let br = tab.newTerminalSplit(from: tr.id, orientation: .vertical) else { + writeSplitCloseRightTestData(["setupError": "Failed to split down from right (iteration \(i))"], at: path) + return + } + topRight = tr + bottomLeft = bl + bottomRight = br + default: + // Default: split top/down first, then split left/right in each row. + guard let bl = tab.newTerminalSplit(from: topLeftId, orientation: .vertical) else { + writeSplitCloseRightTestData(["setupError": "Failed to split down from top-left (iteration \(i))"], at: path) + return + } + guard let br = tab.newTerminalSplit(from: bl.id, orientation: .horizontal) else { + writeSplitCloseRightTestData(["setupError": "Failed to split right from bottom-left (iteration \(i))"], at: path) + return + } + guard let tr = tab.newTerminalSplit(from: topLeftId, orientation: .horizontal) else { + writeSplitCloseRightTestData(["setupError": "Failed to split right from top-left (iteration \(i))"], at: path) + return + } + topRight = tr + bottomLeft = bl + bottomRight = br + } + + // Let newly created surfaces attach before priming content, so sampled panes have + // stable non-blank text before the close timeline begins. + try? await Task.sleep(nanoseconds: 180_000_000) + + // Fill left panes with visible content. + sendText(topLeftId, "printf '\\033[2J\\033[H'; for i in {1..200}; do echo PROGRAMA_SPLIT_TOPLEFT_\(i); done; printf '\\033[HPROGRAMA_MARKER_TOPLEFT\\n'\r") + sendText(topRight.id, "printf '\\033[2J\\033[H'; for i in {1..200}; do echo PROGRAMA_SPLIT_TOPRIGHT_\(i); done; printf '\\033[HPROGRAMA_MARKER_TOPRIGHT\\n'\r") + if let bottomLeft { + sendText(bottomLeft.id, "printf '\\033[2J\\033[H'; for i in {1..200}; do echo PROGRAMA_SPLIT_BOTTOMLEFT_\(i); done; printf '\\033[HPROGRAMA_MARKER_BOTTOMLEFT\\n'\r") + } + if let bottomRight { + sendText(bottomRight.id, "printf '\\033[2J\\033[H'; for i in {1..200}; do echo PROGRAMA_SPLIT_BOTTOMRIGHT_\(i); done; printf '\\033[HPROGRAMA_MARKER_BOTTOMRIGHT\\n'\r") + } + // Give shell output a moment to paint before we start the close timeline. + try? await Task.sleep(nanoseconds: 180_000_000) + + let desiredFrames = max(16, min(burstFrames, 60)) + let closeFrame = min(6, max(1, desiredFrames / 4)) + let delayFrames = max(0, Int((Double(max(0, closeDelayMs)) / 16.6667).rounded(.up))) + let secondCloseFrame = min(desiredFrames - 1, closeFrame + delayFrames) + + var closeOrder = "" + let actions: [(frame: Int, action: () -> Void)] = { + switch pattern { + case "close_right_single": + closeOrder = "TR_ONLY" + return [ + (frame: closeFrame, action: { + tab.focusPanel(topRight.id) + tab.closePanel(topRight.id, force: true) + }), + ] + case "close_bottom": + guard let bottomRight, let bottomLeft else { return [] } + closeOrder = "BR_THEN_BL" + return [ + (frame: closeFrame, action: { + tab.focusPanel(bottomRight.id) + tab.closePanel(bottomRight.id, force: true) + }), + (frame: secondCloseFrame, action: { + tab.focusPanel(bottomLeft.id) + tab.closePanel(bottomLeft.id, force: true) + }), + ] + case "close_right_lrtd_bottom_first", "close_right_bottom_first": + guard let bottomRight else { return [] } + closeOrder = "BR_THEN_TR" + return [ + (frame: closeFrame, action: { + tab.focusPanel(bottomRight.id) + tab.closePanel(bottomRight.id, force: true) + }), + (frame: secondCloseFrame, action: { + tab.focusPanel(topRight.id) + tab.closePanel(topRight.id, force: true) + }), + ] + case "close_right_lrtd_unfocused": + guard let bottomRight else { return [] } + closeOrder = "TR_THEN_BR_UNFOCUSED" + return [ + (frame: closeFrame, action: { + tab.closePanel(topRight.id, force: true) + }), + (frame: secondCloseFrame, action: { + tab.closePanel(bottomRight.id, force: true) + }), + ] + default: + guard let bottomRight else { return [] } + closeOrder = "TR_THEN_BR" + return [ + (frame: closeFrame, action: { + tab.focusPanel(topRight.id) + tab.closePanel(topRight.id, force: true) + }), + (frame: secondCloseFrame, action: { + tab.focusPanel(bottomRight.id) + tab.closePanel(bottomRight.id, force: true) + }), + ] + } + }() + + let targets: [(label: String, view: GhosttySurfaceScrollView)] = { + switch pattern { + case "close_right_single": + return [ + ("TL", tab.terminalPanel(for: topLeftId)!.surface.hostedView), + ] + case "close_bottom": + return [ + ("TL", tab.terminalPanel(for: topLeftId)!.surface.hostedView), + ("TR", topRight.surface.hostedView), + ] + case "close_right_lrtd_bottom_first", "close_right_bottom_first": + return [ + ("TR", topRight.surface.hostedView), + ("TL", tab.terminalPanel(for: topLeftId)!.surface.hostedView), + ] + default: + guard let bottomLeft else { return [] } + return [ + ("TL", tab.terminalPanel(for: topLeftId)!.surface.hostedView), + ("BL", bottomLeft.surface.hostedView), + ] + } + }() + + let result = await captureVsyncIOSurfaceTimeline( + frameCount: desiredFrames, + closeFrame: closeFrame, + crop: sampleCrop, + targets: targets, + actions: actions + ) + + let paneStateTrace: String = { + tab.bonsplitController.allPaneIds.map { paneId in + let tabs = tab.bonsplitController.tabs(inPane: paneId) + let selected = tab.bonsplitController.selectedTab(inPane: paneId) + let selectedId = selected.map { String(describing: $0.id) } ?? "nil" + let selectedPanelId = selected.flatMap { tab.panelIdFromSurfaceId($0.id) } + let selectedPanelLive: String = { + guard let selected else { return "0" } + return tab.panel(for: selected.id) != nil ? "1" : "0" + }() + let mappedCount = tabs.filter { tab.panelIdFromSurfaceId($0.id) != nil }.count + let selectedPanel = selectedPanelId?.uuidString.prefix(8) ?? "nil" + return "pane=\(paneId.id.uuidString.prefix(8)):tabs=\(tabs.count):mapped=\(mappedCount):selected=\(selectedId.prefix(8)):selectedPanel=\(selectedPanel):selectedLive=\(selectedPanelLive)" + }.joined(separator: ";") + }() + + writeSplitCloseRightTestData([ + "pattern": pattern, + "iteration": String(i), + "closeDelayMs": String(closeDelayMs), + "closeDelayFrames": String(delayFrames), + "closeOrder": closeOrder, + "timelineFrameCount": String(desiredFrames), + "timelineCloseFrame": String(closeFrame), + "timelineSecondCloseFrame": String(secondCloseFrame), + "timelineFirstBlank": result.firstBlank.map { "\($0.label)@\($0.frame)" } ?? "", + "timelineFirstSizeMismatch": result.firstSizeMismatch.map { "\($0.label)@\($0.frame):ios=\($0.ios):exp=\($0.expected)" } ?? "", + "timelineTrace": result.trace.joined(separator: "|"), + "timelinePaneState": paneStateTrace, + "visualLastIteration": String(i), + ], at: path) + + if let firstBlank = result.firstBlank { + writeSplitCloseRightTestData([ + "blankFrameSeen": "1", + "blankObservedIteration": String(i), + "blankObservedAt": "\(firstBlank.label)@\(firstBlank.frame)" + ], at: path) + return + } + + if let firstMismatch = result.firstSizeMismatch { + writeSplitCloseRightTestData([ + "sizeMismatchSeen": "1", + "sizeMismatchObservedIteration": String(i), + "sizeMismatchObservedAt": "\(firstMismatch.label)@\(firstMismatch.frame):ios=\(firstMismatch.ios):exp=\(firstMismatch.expected)" + ], at: path) + return + } + } + } + + @MainActor + private func captureVsyncIOSurfaceTimeline( + frameCount: Int, + closeFrame: Int, + crop: CGRect, + targets: [(label: String, view: GhosttySurfaceScrollView)], + actions: [(frame: Int, action: () -> Void)] = [] + ) async -> (firstBlank: (label: String, frame: Int)?, firstSizeMismatch: (label: String, frame: Int, ios: String, expected: String)?, trace: [String]) { + guard frameCount > 0 else { return (nil, nil, []) } + + let st = VsyncIOSurfaceTimelineState(frameCount: frameCount, closeFrame: closeFrame) + st.scheduledActions = actions.sorted(by: { $0.frame < $1.frame }) + st.nextActionIndex = 0 + st.targets = targets.map { t in + VsyncIOSurfaceTimelineState.Target(label: t.label, sample: { @MainActor in + t.view.debugSampleIOSurface(normalizedCrop: crop) + }) + } + + let unmanaged = Unmanaged.passRetained(st) + let ctx = unmanaged.toOpaque() + + await withCheckedContinuation { (cont: CheckedContinuation) in + st.continuation = cont + var link: CVDisplayLink? + CVDisplayLinkCreateWithActiveCGDisplays(&link) + guard let link else { + st.finish() + Unmanaged.fromOpaque(ctx).release() + return + } + st.link = link + + CVDisplayLinkSetOutputCallback(link, programaVsyncIOSurfaceTimelineCallback, ctx) + CVDisplayLinkStart(link) + } + + return (st.firstBlank, st.firstSizeMismatch, st.trace) + } + + private func writeSplitCloseRightTestData(_ updates: [String: String], at path: String) { + var payload = loadSplitCloseRightTestData(at: path) + for (key, value) in updates { + payload[key] = value + } + guard let data = try? JSONSerialization.data(withJSONObject: payload) else { return } + try? data.write(to: URL(fileURLWithPath: path), options: .atomic) + } + + private func loadSplitCloseRightTestData(at path: String) -> [String: String] { + guard let data = try? Data(contentsOf: URL(fileURLWithPath: path)), + let object = try? JSONSerialization.jsonObject(with: data) as? [String: String] else { + return [:] + } + return object + } + + // Widened from `private` to `internal`: called from TabManager.init() in TabManager.swift. + func setupChildExitSplitUITestIfNeeded() { + guard !didSetupChildExitSplitUITest else { return } + didSetupChildExitSplitUITest = true + + let env = ProcessInfo.processInfo.environment + guard env["PROGRAMA_UI_TEST_CHILD_EXIT_SPLIT_SETUP"] == "1" else { return } + guard let path = env["PROGRAMA_UI_TEST_CHILD_EXIT_SPLIT_PATH"], !path.isEmpty else { return } + let requestedIterations = Int(env["PROGRAMA_UI_TEST_CHILD_EXIT_SPLIT_ITERATIONS"] ?? "1") ?? 1 + let iterations = max(1, min(requestedIterations, 20)) + + func write(_ updates: [String: String]) { + var payload: [String: String] = { + guard let data = try? Data(contentsOf: URL(fileURLWithPath: path)), + let obj = try? JSONSerialization.jsonObject(with: data) as? [String: String] else { + return [:] + } + return obj + }() + for (k, v) in updates { payload[k] = v } + guard let out = try? JSONSerialization.data(withJSONObject: payload) else { return } + try? out.write(to: URL(fileURLWithPath: path), options: .atomic) + } + + Task { @MainActor [weak self] in + guard let self else { return } + // Small delay so the initial window/panel has completed first layout. + try? await Task.sleep(nanoseconds: 200_000_000) + + guard let tab = self.selectedWorkspace else { + write(["setupError": "Missing selected workspace", "done": "1"]) + return + } + write([ + "requestedIterations": String(requestedIterations), + "iterations": String(iterations), + "workspaceCountBefore": String(self.tabs.count), + "panelCountBefore": String(tab.panels.count), + "done": "0", + ]) + + var completedIterations = 0 + var timedOut = false + var closedWorkspace = false + + for i in 1...iterations { + guard self.tabs.contains(where: { $0.id == tab.id }) else { + closedWorkspace = true + break + } + + guard let leftPanelId = tab.focusedPanelId ?? tab.panels.keys.first else { + write(["setupError": "Missing focused panel before iteration \(i)", "done": "1"]) + return + } + + // Start each iteration from a deterministic 1x1 workspace. + if tab.panels.count > 1 { + for panelId in tab.panels.keys where panelId != leftPanelId { + tab.closePanel(panelId, force: true) + } + let collapsed = await self.waitForWorkspacePanelsCondition( + tab: tab, + timeoutSeconds: 2.0 + ) { workspace in + workspace.panels.count == 1 + } + if !collapsed { + write(["setupError": "Timed out collapsing workspace before iteration \(i)", "done": "1"]) + return + } + } + + guard let rightPanel = tab.newTerminalSplit(from: leftPanelId, orientation: .horizontal) else { + write(["setupError": "Failed to create right split at iteration \(i)", "done": "1"]) + return + } + + write([ + "iteration": String(i), + "leftPanelId": leftPanelId.uuidString, + "rightPanelId": rightPanel.id.uuidString, + ]) + + tab.focusPanel(rightPanel.id) + // Wait for the split terminal surface to be attached before sending exit. + // Without this, very early writes can be dropped during initial surface creation. + _ = await self.waitForTerminalPanelCondition( + tab: tab, + panelId: rightPanel.id, + timeoutSeconds: 2.0 + ) { panel in + panel.hostedView.window != nil && panel.surface.surface != nil + } + // Use an explicit shell exit command for deterministic child-exit behavior across + // startup timing variance; this still exercises the same SHOW_CHILD_EXITED path. + rightPanel.surface.sendText("exit\r") + + // Wait for the right panel to close. + let closed = await withCheckedContinuation { (cont: CheckedContinuation) in + var cancellable: AnyCancellable? + var resolved = false + + func finish(_ value: Bool) { + guard !resolved else { return } + resolved = true + cancellable?.cancel() + cont.resume(returning: value) + } + + cancellable = tab.$panels + .map { $0.count } + .removeDuplicates() + .sink { count in + if count == 1 { + finish(true) + } + } + + DispatchQueue.main.asyncAfter(deadline: .now() + 8.0) { + finish(false) + } + } + + if !closed { + timedOut = true + write(["timedOutIteration": String(i)]) + break + } + + if !self.tabs.contains(where: { $0.id == tab.id }) { + closedWorkspace = true + write(["closedWorkspaceIteration": String(i)]) + break + } + + completedIterations = i + } + + let workspaceStillOpen = self.tabs.contains(where: { $0.id == tab.id }) + let effectiveClosedWorkspace = closedWorkspace || !workspaceStillOpen + + write([ + "workspaceCountAfter": String(self.tabs.count), + "panelCountAfter": String(tab.panels.count), + "workspaceStillOpen": workspaceStillOpen ? "1" : "0", + "closedWorkspace": effectiveClosedWorkspace ? "1" : "0", + "timedOut": timedOut ? "1" : "0", + "completedIterations": String(completedIterations), + "done": "1", + ]) + } + } + + // Widened from `private` to `internal`: called from TabManager.init() in TabManager.swift. + func setupChildExitKeyboardUITestIfNeeded() { + guard !didSetupChildExitKeyboardUITest else { return } + didSetupChildExitKeyboardUITest = true + + let env = ProcessInfo.processInfo.environment + guard env["PROGRAMA_UI_TEST_CHILD_EXIT_KEYBOARD_SETUP"] == "1" else { return } + guard let path = env["PROGRAMA_UI_TEST_CHILD_EXIT_KEYBOARD_PATH"], !path.isEmpty else { return } + let autoTrigger = env["PROGRAMA_UI_TEST_CHILD_EXIT_KEYBOARD_AUTO_TRIGGER"] == "1" + let strictKeyOnly = env["PROGRAMA_UI_TEST_CHILD_EXIT_KEYBOARD_STRICT"] == "1" + let triggerMode = (env["PROGRAMA_UI_TEST_CHILD_EXIT_KEYBOARD_TRIGGER_MODE"] ?? "shell_input") + .trimmingCharacters(in: .whitespacesAndNewlines) + let useEarlyCtrlShiftTrigger = triggerMode == "early_ctrl_shift_d" + let useEarlyCtrlDTrigger = triggerMode == "early_ctrl_d" + let useEarlyTrigger = useEarlyCtrlShiftTrigger || useEarlyCtrlDTrigger + let triggerUsesShift = triggerMode == "ctrl_shift_d" || useEarlyCtrlShiftTrigger + let layout = (env["PROGRAMA_UI_TEST_CHILD_EXIT_KEYBOARD_LAYOUT"] ?? "lr") + .trimmingCharacters(in: .whitespacesAndNewlines) + let expectedPanelsAfter = max( + 1, + Int((env["PROGRAMA_UI_TEST_CHILD_EXIT_KEYBOARD_EXPECTED_PANELS_AFTER"] ?? "1") + .trimmingCharacters(in: .whitespacesAndNewlines) + ) ?? 1 + ) + + func write(_ updates: [String: String]) { + var payload: [String: String] = { + guard let data = try? Data(contentsOf: URL(fileURLWithPath: path)), + let obj = try? JSONSerialization.jsonObject(with: data) as? [String: String] else { + return [:] + } + return obj + }() + for (k, v) in updates { payload[k] = v } + guard let out = try? JSONSerialization.data(withJSONObject: payload) else { return } + try? out.write(to: URL(fileURLWithPath: path), options: .atomic) + } + + Task { @MainActor [weak self] in + guard let self else { return } + try? await Task.sleep(nanoseconds: 200_000_000) + + guard let tab = self.selectedWorkspace else { + write(["setupError": "Missing selected workspace", "done": "1"]) + return + } + guard let leftPanelId = tab.focusedPanelId else { + write(["setupError": "Missing initial focused panel", "done": "1"]) + return + } + guard let rightPanel = tab.newTerminalSplit(from: leftPanelId, orientation: .horizontal) else { + write(["setupError": "Failed to create right split", "done": "1"]) + return + } + + var bottomLeftPanelId = "" + let topRightPanelId = rightPanel.id.uuidString + var bottomRightPanelId = "" + var exitPanelId = rightPanel.id + + if layout == "lr_left_vertical" { + guard let bottomLeft = tab.newTerminalSplit(from: leftPanelId, orientation: .vertical) else { + write(["setupError": "Failed to create bottom-left split", "done": "1"]) + return + } + bottomLeftPanelId = bottomLeft.id.uuidString + } else if layout == "lrtd_close_right_then_exit_top_left" { + guard let bottomLeft = tab.newTerminalSplit(from: leftPanelId, orientation: .vertical) else { + write(["setupError": "Failed to create bottom-left split", "done": "1"]) + return + } + guard let bottomRight = tab.newTerminalSplit(from: rightPanel.id, orientation: .vertical) else { + write(["setupError": "Failed to create bottom-right split", "done": "1"]) + return + } + bottomLeftPanelId = bottomLeft.id.uuidString + bottomRightPanelId = bottomRight.id.uuidString + + // Repro flow: with a 2x2 (left/right then top/down), close both right panes, + // then trigger Ctrl+D in top-left. + tab.focusPanel(rightPanel.id) + tab.closePanel(rightPanel.id, force: true) + tab.focusPanel(bottomRight.id) + tab.closePanel(bottomRight.id, force: true) + exitPanelId = leftPanelId + + let collapsed = await self.waitForWorkspacePanelsCondition( + tab: tab, + timeoutSeconds: 2.0 + ) { workspace in + workspace.panels.count == 2 + } + if !collapsed { + write([ + "setupError": "Expected 2 panels after closing right column, got \(tab.panels.count)", + "done": "1", + ]) + return + } + } else if layout == "tdlr_close_bottom_then_exit_top_left" { + // Alternate repro flow: + // 1) split top/down + // 2) split left/right for each row (2x2) + // 3) close both bottom panes + // 4) trigger Ctrl+D in top-left + guard let bottomLeft = tab.newTerminalSplit(from: leftPanelId, orientation: .vertical) else { + write(["setupError": "Failed to create bottom-left split", "done": "1"]) + return + } + guard let topRight = tab.newTerminalSplit(from: leftPanelId, orientation: .horizontal) else { + write(["setupError": "Failed to create top-right split", "done": "1"]) + return + } + guard let bottomRight = tab.newTerminalSplit(from: bottomLeft.id, orientation: .horizontal) else { + write(["setupError": "Failed to create bottom-right split", "done": "1"]) + return + } + bottomLeftPanelId = bottomLeft.id.uuidString + bottomRightPanelId = bottomRight.id.uuidString + + // Close every pane except the top row; do it one-by-one and wait for model convergence. + let keepPanels: Set = [leftPanelId, topRight.id] + for panelId in Array(tab.panels.keys) where !keepPanels.contains(panelId) { + tab.focusPanel(panelId) + tab.closePanel(panelId, force: true) + let closed = await self.waitForWorkspacePanelsCondition( + tab: tab, + timeoutSeconds: 1.0 + ) { workspace in + workspace.panels[panelId] == nil + } + if !closed { + write([ + "setupError": "Failed to close bottom pane \(panelId.uuidString)", + "done": "1", + ]) + return + } + } + exitPanelId = leftPanelId + + let collapsed = await self.waitForWorkspacePanelsCondition( + tab: tab, + timeoutSeconds: 2.0 + ) { workspace in + workspace.panels.count == 2 + } + if !collapsed { + write([ + "setupError": "Expected 2 panels after closing bottom row, got \(tab.panels.count)", + "done": "1", + ]) + return + } + } + + tab.focusPanel(exitPanelId) + // Keep child-exit keyboard tests deterministic across user shell configs. + // `exec cat` exits on a single Ctrl+D and avoids ignore-eof shell settings. + if let exitPanel = tab.terminalPanel(for: exitPanelId) { + exitPanel.sendText("exec cat\r") + } + + var exitPanelAttachedBeforeCtrlD = false + var exitPanelHasSurfaceBeforeCtrlD = false + if !useEarlyTrigger { + let readiness = await self.waitForTerminalPanelReadyForUITest( + tab: tab, + panelId: exitPanelId + ) + exitPanelAttachedBeforeCtrlD = readiness.attached + exitPanelHasSurfaceBeforeCtrlD = readiness.hasSurface + if !(readiness.attached && readiness.hasSurface) { + write([ + "exitPanelAttachedBeforeCtrlD": readiness.attached ? "1" : "0", + "exitPanelHasSurfaceBeforeCtrlD": readiness.hasSurface ? "1" : "0", + "setupError": "Exit panel not ready for Ctrl+D (not attached or surface nil)", + "done": "1", + ]) + return + } + self.ensureFocusedTerminalFirstResponder() + } else if let exitPanel = tab.terminalPanel(for: exitPanelId) { + exitPanelAttachedBeforeCtrlD = exitPanel.hostedView.window != nil + exitPanelHasSurfaceBeforeCtrlD = exitPanel.surface.surface != nil + } + + let focusedPanelBefore = tab.focusedPanelId?.uuidString ?? "" + let firstResponderPanelBefore = tab.panels.compactMap { (panelId, panel) -> UUID? in + guard let terminal = panel as? TerminalPanel else { return nil } + return terminal.hostedView.isSurfaceViewFirstResponder() ? panelId : nil + }.first?.uuidString ?? "" + + write([ + "workspaceId": tab.id.uuidString, + "leftPanelId": leftPanelId.uuidString, + "rightPanelId": rightPanel.id.uuidString, + "topRightPanelId": topRightPanelId, + "bottomLeftPanelId": bottomLeftPanelId, + "bottomRightPanelId": bottomRightPanelId, + "exitPanelId": exitPanelId.uuidString, + "panelCountBeforeCtrlD": String(tab.panels.count), + "layout": layout, + "expectedPanelsAfter": String(expectedPanelsAfter), + "focusedPanelBefore": focusedPanelBefore, + "firstResponderPanelBefore": firstResponderPanelBefore, + "exitPanelAttachedBeforeCtrlD": exitPanelAttachedBeforeCtrlD ? "1" : "0", + "exitPanelHasSurfaceBeforeCtrlD": exitPanelHasSurfaceBeforeCtrlD ? "1" : "0", + "ready": "1", + "done": "0", + ]) + + var finished = false + var timeoutWork: DispatchWorkItem? + + @MainActor + func finish(_ updates: [String: String]) { + guard !finished else { return } + finished = true + timeoutWork?.cancel() + write(updates.merging(["done": "1"], uniquingKeysWith: { _, new in new })) + self.uiTestCancellables.removeAll() + } + + tab.$panels + .map { $0.count } + .removeDuplicates() + .sink { [weak self, weak tab] count in + Task { @MainActor in + guard let self, let tab else { return } + if count == expectedPanelsAfter { + // Require the post-exit state to be stable for a short window so + // we catch "close looked correct, then workspace vanished" races. + try? await Task.sleep(nanoseconds: 1_200_000_000) + guard tab.panels.count == expectedPanelsAfter else { return } + + let firstResponderPanelAfter = tab.panels.compactMap { (panelId, panel) -> UUID? in + guard let terminal = panel as? TerminalPanel else { return nil } + return terminal.hostedView.isSurfaceViewFirstResponder() ? panelId : nil + }.first?.uuidString ?? "" + + finish([ + "workspaceCountAfter": String(self.tabs.count), + "panelCountAfter": String(tab.panels.count), + "closedWorkspace": self.tabs.contains(where: { $0.id == tab.id }) ? "0" : "1", + "focusedPanelAfter": tab.focusedPanelId?.uuidString ?? "", + "firstResponderPanelAfter": firstResponderPanelAfter, + ]) + } + } + } + .store(in: &uiTestCancellables) + + $tabs + .map { $0.contains(where: { $0.id == tab.id }) } + .removeDuplicates() + .sink { alive in + Task { @MainActor in + if !alive { + finish([ + "workspaceCountAfter": "0", + "panelCountAfter": "0", + "closedWorkspace": "1", + ]) + } + } + } + .store(in: &uiTestCancellables) + + let work = DispatchWorkItem { + finish([ + "workspaceCountAfter": String(self.tabs.count), + "panelCountAfter": String(tab.panels.count), + "closedWorkspace": self.tabs.contains(where: { $0.id == tab.id }) ? "0" : "1", + "timedOut": "1", + ]) + } + timeoutWork = work + DispatchQueue.main.asyncAfter(deadline: .now() + 8.0, execute: work) + + if autoTrigger { + Task { @MainActor [weak tab] in + guard let tab else { return } + write(["autoTriggerStarted": "1"]) + + if triggerMode == "runtime_close_callback" { + write(["autoTriggerMode": "runtime_close_callback"]) + self.closePanelAfterChildExited(tabId: tab.id, surfaceId: exitPanelId) + return + } + + let triggerModifiers: NSEvent.ModifierFlags = triggerUsesShift + ? [.control, .shift] + : [.control] + let shouldWaitForSurface = !useEarlyTrigger + + var attachedBeforeTrigger = false + var hasSurfaceBeforeTrigger = false + if shouldWaitForSurface { + let ready = await self.waitForTerminalPanelCondition( + tab: tab, + panelId: exitPanelId, + timeoutSeconds: 5.0 + ) { panel in + attachedBeforeTrigger = panel.hostedView.window != nil + hasSurfaceBeforeTrigger = panel.surface.surface != nil + return attachedBeforeTrigger && hasSurfaceBeforeTrigger + } + if !ready, + tab.terminalPanel(for: exitPanelId) == nil { + write(["autoTriggerError": "missingExitPanelBeforeTrigger"]) + return + } + } else if let panel = tab.terminalPanel(for: exitPanelId) { + attachedBeforeTrigger = panel.hostedView.window != nil + hasSurfaceBeforeTrigger = panel.surface.surface != nil + } + write([ + "exitPanelAttachedBeforeTrigger": attachedBeforeTrigger ? "1" : "0", + "exitPanelHasSurfaceBeforeTrigger": hasSurfaceBeforeTrigger ? "1" : "0", + ]) + if shouldWaitForSurface && !(attachedBeforeTrigger && hasSurfaceBeforeTrigger) { + write(["autoTriggerError": "exitPanelNotReadyBeforeTrigger"]) + return + } + + guard let panel = tab.terminalPanel(for: exitPanelId) else { + write(["autoTriggerError": "missingExitPanelAtTrigger"]) + return + } + // Exercise the real key path (ghostty_surface_key for Ctrl+D). + if panel.hostedView.sendSyntheticCtrlDForUITest(modifierFlags: triggerModifiers) { + write(["autoTriggerSentCtrlDKey1": "1"]) + } else { + write([ + "autoTriggerCtrlDKeyUnavailable": "1", + "autoTriggerError": "ctrlDKeyUnavailable", + ]) + return + } + + // In strict mode, never mask routing bugs with fallback writes. + if strictKeyOnly { + let strictModeLabel: String = { + if useEarlyCtrlShiftTrigger { return "strict_early_ctrl_shift_d" } + if useEarlyCtrlDTrigger { return "strict_early_ctrl_d" } + if triggerUsesShift { return "strict_ctrl_shift_d" } + return "strict_ctrl_d" + }() + write(["autoTriggerMode": strictModeLabel]) + return + } + + // Non-strict mode keeps one additional Ctrl+D retry for startup timing variance. + try? await Task.sleep(nanoseconds: 450_000_000) + if tab.panels[exitPanelId] != nil, + panel.hostedView.sendSyntheticCtrlDForUITest(modifierFlags: triggerModifiers) { + write(["autoTriggerSentCtrlDKey2": "1"]) + } + } + } + } + } +#endif +} diff --git a/Sources/TabManager.swift b/Sources/TabManager.swift index cae341e10c8..b77382076df 100644 --- a/Sources/TabManager.swift +++ b/Sources/TabManager.swift @@ -502,148 +502,6 @@ struct RecentlyClosedBrowserStack { } } -#if DEBUG -// Sample the actual IOSurface-backed terminal layer at vsync cadence so UI tests can reliably -// catch a single compositor-frame blank flash and any transient compositor scaling (stretched text). -// -// This is DEBUG-only and used only for UI tests; no polling or display-link loops exist in normal app runtime. -fileprivate final class VsyncIOSurfaceTimelineState { - struct Target { - let label: String - let sample: @MainActor () -> GhosttySurfaceScrollView.DebugFrameSample? - } - - let frameCount: Int - let closeFrame: Int - let lock = NSLock() - - var framesWritten = 0 - var inFlight = false - var finished = false - - var scheduledActions: [(frame: Int, action: () -> Void)] = [] - var nextActionIndex: Int = 0 - - var targets: [Target] = [] - - // Results - var firstBlank: (label: String, frame: Int)? - var firstSizeMismatch: (label: String, frame: Int, ios: String, expected: String)? - var trace: [String] = [] - - var link: CVDisplayLink? - var continuation: CheckedContinuation? - - init(frameCount: Int, closeFrame: Int) { - self.frameCount = frameCount - self.closeFrame = closeFrame - } - - func tryBeginCapture() -> Bool { - lock.lock() - defer { lock.unlock() } - if finished { return false } - if inFlight { return false } - inFlight = true - return true - } - - func endCapture() { - lock.lock() - inFlight = false - lock.unlock() - } - - func finish() { - lock.lock() - if finished { - lock.unlock() - return - } - finished = true - let cont = continuation - continuation = nil - lock.unlock() - cont?.resume() - } -} - -fileprivate func programaVsyncIOSurfaceTimelineCallback( - _ displayLink: CVDisplayLink, - _ inNow: UnsafePointer, - _ inOutputTime: UnsafePointer, - _ flagsIn: CVOptionFlags, - _ flagsOut: UnsafeMutablePointer, - _ ctx: UnsafeMutableRawPointer? -) -> CVReturn { - guard let ctx else { return kCVReturnSuccess } - let st = Unmanaged.fromOpaque(ctx).takeUnretainedValue() - if !st.tryBeginCapture() { return kCVReturnSuccess } - - // Sample on the main thread synchronously so we don't "miss" a single compositor frame. - // (The previous Task/@MainActor hop could be delayed long enough to skip the blank frame.) - DispatchQueue.main.sync { - defer { st.endCapture() } - guard st.framesWritten < st.frameCount else { return } - - while st.nextActionIndex < st.scheduledActions.count { - let next = st.scheduledActions[st.nextActionIndex] - if next.frame != st.framesWritten { break } - st.nextActionIndex += 1 - next.action() - } - - for t in st.targets { - guard let s = t.sample() else { continue } - - let iosW = s.iosurfaceWidthPx - let iosH = s.iosurfaceHeightPx - let expW = s.expectedWidthPx - let expH = s.expectedHeightPx - let gravity = s.layerContentsGravity - let hasDimensions = iosW > 0 && iosH > 0 && expW > 0 && expH > 0 - let dw = hasDimensions ? abs(iosW - expW) : 0 - let dh = hasDimensions ? abs(iosH - expH) : 0 - let hasSizeMismatch = hasDimensions && (dw > 2 || dh > 2) - let stretchRisk = (gravity == CALayerContentsGravity.resize.rawValue) - - // Ignore setup/warmup frames before the close action. We only care about - // regressions that happen at/after the close mutation. - if st.firstBlank == nil, st.framesWritten >= st.closeFrame, s.isProbablyBlank { - st.firstBlank = (label: t.label, frame: st.framesWritten) - } - - if st.firstSizeMismatch == nil, - st.framesWritten >= st.closeFrame, - stretchRisk, - hasSizeMismatch { - st.firstSizeMismatch = ( - label: t.label, - frame: st.framesWritten, - ios: "\(iosW)x\(iosH)", - expected: "\(expW)x\(expH)" - ) - } - - if st.trace.count < 200 { - st.trace.append("\(st.framesWritten):\(t.label):blank=\(s.isProbablyBlank ? 1 : 0):ios=\(iosW)x\(iosH):exp=\(expW)x\(expH):gravity=\(gravity):key=\(s.layerContentsKey)") - } - } - - st.framesWritten += 1 - } - - // Stop/resume outside the main-thread sync block to avoid reentrancy issues. - if st.framesWritten >= st.frameCount, let link = st.link { - CVDisplayLinkStop(link) - st.finish() - Unmanaged.fromOpaque(ctx).release() - } - - return kCVReturnSuccess -} -#endif - @MainActor class TabManager: ObservableObject { private enum WorkspacePullRequestSnapshot: Equatable { @@ -829,11 +687,13 @@ class TabManager: ObservableObject { #endif #if DEBUG - private var didSetupSplitCloseRightUITest = false - private var didSetupUITestFocusShortcuts = false - private var didSetupChildExitSplitUITest = false - private var didSetupChildExitKeyboardUITest = false - private var uiTestCancellables = Set() + // Widened from `private` to `internal` (default access) so TabManager+UITestHarness.swift, + // which owns the setup functions that mutate this state, can see it across files. + var didSetupSplitCloseRightUITest = false + var didSetupUITestFocusShortcuts = false + var didSetupChildExitSplitUITest = false + var didSetupChildExitKeyboardUITest = false + var uiTestCancellables = Set() #endif init(initialWorkingDirectory: String? = nil) { @@ -3731,1210 +3591,6 @@ class TabManager: ObservableObject { terminalPanel(tabId: tabId, panelId: surfaceId)?.surface } -#if DEBUG - @MainActor - private func waitForWorkspacePanelsCondition( - tab: Workspace, - timeoutSeconds: TimeInterval, - condition: @escaping (Workspace) -> Bool - ) async -> Bool { - guard !condition(tab) else { return true } - - return await withCheckedContinuation { (cont: CheckedContinuation) in - var resolved = false - var cancellable: AnyCancellable? - - func finish(_ value: Bool) { - guard !resolved else { return } - resolved = true - cancellable?.cancel() - cont.resume(returning: value) - } - - func evaluate() { - if condition(tab) { - finish(true) - } - } - - cancellable = tab.$panels - .map { _ in () } - .sink { _ in evaluate() } - - DispatchQueue.main.asyncAfter(deadline: .now() + timeoutSeconds) { - Task { @MainActor in - finish(condition(tab)) - } - } - evaluate() - } - } - - @MainActor - private func waitForTerminalPanelCondition( - tab: Workspace, - panelId: UUID, - timeoutSeconds: TimeInterval, - condition: @escaping (TerminalPanel) -> Bool - ) async -> Bool { - if let panel = tab.terminalPanel(for: panelId), condition(panel) { - return true - } - - return await withCheckedContinuation { (cont: CheckedContinuation) in - var resolved = false - var panelsCancellable: AnyCancellable? - var readyObserver: NSObjectProtocol? - var hostedViewObserver: NSObjectProtocol? - - @MainActor - func finish(_ value: Bool) { - guard !resolved else { return } - resolved = true - panelsCancellable?.cancel() - if let readyObserver { - NotificationCenter.default.removeObserver(readyObserver) - } - if let hostedViewObserver { - NotificationCenter.default.removeObserver(hostedViewObserver) - } - cont.resume(returning: value) - } - - @MainActor - func evaluate() { - guard let panel = tab.terminalPanel(for: panelId) else { - finish(false) - return - } - panel.surface.requestBackgroundSurfaceStartIfNeeded() - if condition(panel) { - finish(true) - } - } - - panelsCancellable = tab.$panels - .map { _ in () } - .sink { _ in - Task { @MainActor in - evaluate() - } - } - readyObserver = NotificationCenter.default.addObserver( - forName: .terminalSurfaceDidBecomeReady, - object: nil, - queue: .main - ) { note in - guard let readySurfaceId = note.userInfo?["surfaceId"] as? UUID, - readySurfaceId == panelId else { return } - Task { @MainActor in - evaluate() - } - } - hostedViewObserver = NotificationCenter.default.addObserver( - forName: .terminalSurfaceHostedViewDidMoveToWindow, - object: nil, - queue: .main - ) { note in - guard let hostedSurfaceId = note.userInfo?["surfaceId"] as? UUID, - hostedSurfaceId == panelId else { return } - Task { @MainActor in - evaluate() - } - } - - DispatchQueue.main.asyncAfter(deadline: .now() + timeoutSeconds) { - Task { @MainActor in - if let panel = tab.terminalPanel(for: panelId) { - finish(condition(panel)) - } else { - finish(false) - } - } - } - evaluate() - } - } - - @MainActor - private func waitForTerminalPanelReadyForUITest( - tab: Workspace, - panelId: UUID, - timeoutSeconds: TimeInterval = 6.0 - ) async -> (attached: Bool, hasSurface: Bool, firstResponder: Bool) { - var attached = false - var hasSurface = false - var firstResponder = false - - let _ = await waitForTerminalPanelCondition( - tab: tab, - panelId: panelId, - timeoutSeconds: timeoutSeconds - ) { panel in - panel.surface.requestBackgroundSurfaceStartIfNeeded() - attached = panel.hostedView.window != nil - hasSurface = panel.surface.surface != nil - firstResponder = panel.hostedView.isSurfaceViewFirstResponder() - return attached && hasSurface - } - - return (attached, hasSurface, firstResponder) - } - - private func setupUITestFocusShortcutsIfNeeded() { - guard !didSetupUITestFocusShortcuts else { return } - didSetupUITestFocusShortcuts = true - - let env = ProcessInfo.processInfo.environment - guard env["PROGRAMA_UI_TEST_FOCUS_SHORTCUTS"] == "1" else { return } - - // UI tests can't record arrow keys via the shortcut recorder. Use letter-based shortcuts - // so tests can reliably drive pane navigation without mouse clicks. - KeyboardShortcutSettings.setShortcut( - StoredShortcut(key: "h", command: true, shift: false, option: false, control: true), - for: .focusLeft - ) - KeyboardShortcutSettings.setShortcut( - StoredShortcut(key: "l", command: true, shift: false, option: false, control: true), - for: .focusRight - ) - KeyboardShortcutSettings.setShortcut( - StoredShortcut(key: "k", command: true, shift: false, option: false, control: true), - for: .focusUp - ) - KeyboardShortcutSettings.setShortcut( - StoredShortcut(key: "j", command: true, shift: false, option: false, control: true), - for: .focusDown - ) - } - - private func setupSplitCloseRightUITestIfNeeded() { - guard !didSetupSplitCloseRightUITest else { return } - didSetupSplitCloseRightUITest = true - - let env = ProcessInfo.processInfo.environment - guard env["PROGRAMA_UI_TEST_SPLIT_CLOSE_RIGHT_SETUP"] == "1" else { return } - guard let path = env["PROGRAMA_UI_TEST_SPLIT_CLOSE_RIGHT_PATH"], !path.isEmpty else { return } - let visualMode = env["PROGRAMA_UI_TEST_SPLIT_CLOSE_RIGHT_VISUAL"] == "1" - let shotsDir = (env["PROGRAMA_UI_TEST_SPLIT_CLOSE_RIGHT_SHOTS_DIR"] ?? "").trimmingCharacters(in: .whitespacesAndNewlines) - let visualIterations = Int((env["PROGRAMA_UI_TEST_SPLIT_CLOSE_RIGHT_ITERATIONS"] ?? "20").trimmingCharacters(in: .whitespacesAndNewlines)) ?? 20 - let burstFrames = Int((env["PROGRAMA_UI_TEST_SPLIT_CLOSE_RIGHT_BURST_FRAMES"] ?? "6").trimmingCharacters(in: .whitespacesAndNewlines)) ?? 6 - let closeDelayMs = Int((env["PROGRAMA_UI_TEST_SPLIT_CLOSE_RIGHT_CLOSE_DELAY_MS"] ?? "70").trimmingCharacters(in: .whitespacesAndNewlines)) ?? 70 - let pattern = (env["PROGRAMA_UI_TEST_SPLIT_CLOSE_RIGHT_PATTERN"] ?? "close_right") - .trimmingCharacters(in: .whitespacesAndNewlines) - - DispatchQueue.main.asyncAfter(deadline: .now() + 0.2) { [weak self] in - guard let self else { return } - Task { @MainActor [weak self] in - guard let self else { return } - guard let tab = self.selectedWorkspace else { - self.writeSplitCloseRightTestData(["setupError": "Missing selected workspace"], at: path) - return - } - - guard let topLeftPanelId = tab.focusedPanelId else { - self.writeSplitCloseRightTestData(["setupError": "Missing initial focused panel"], at: path) - return - } - let initialTerminalReadiness = await self.waitForTerminalPanelReadyForUITest( - tab: tab, - panelId: topLeftPanelId - ) - - guard initialTerminalReadiness.attached, - initialTerminalReadiness.hasSurface, - let terminal = tab.terminalPanel(for: topLeftPanelId) else { - self.writeSplitCloseRightTestData([ - "preTerminalAttached": initialTerminalReadiness.attached ? "1" : "0", - "preTerminalSurfaceNil": initialTerminalReadiness.hasSurface ? "0" : "1", - "setupError": "Initial terminal not ready (not attached or surface nil)" - ], at: path) - return - } - - self.writeSplitCloseRightTestData([ - "preTerminalAttached": "1", - "preTerminalSurfaceNil": terminal.surface.surface == nil ? "1" : "0" - ], at: path) - - if visualMode { - // Visual repro mode: repeat the split/close sequence many times and write - // screenshots to `shotsDir`. This avoids relying on XCUITest to click hover-only - // close buttons, while still exercising the "close unfocused right tabs" path. - self.writeSplitCloseRightTestData([ - "visualMode": "1", - "visualIterations": String(visualIterations), - "visualDone": "0" - ], at: path) - - await self.runSplitCloseRightVisualRepro( - tab: tab, - topLeftPanelId: topLeftPanelId, - path: path, - shotsDir: shotsDir, - iterations: max(1, min(visualIterations, 60)), - burstFrames: max(0, min(burstFrames, 80)), - closeDelayMs: max(0, min(closeDelayMs, 500)), - pattern: pattern - ) - - self.writeSplitCloseRightTestData(["visualDone": "1"], at: path) - return - } - - // Layout goal: 2x2 grid (2 top, 2 bottom), then close both right panels. - // Order matters: split down first, then split right in each row (matches UI shortcut repro). - guard let bottomLeft = tab.newTerminalSplit(from: topLeftPanelId, orientation: .vertical) else { - self.writeSplitCloseRightTestData(["setupError": "Failed to create bottom-left split"], at: path) - return - } - guard let bottomRight = tab.newTerminalSplit(from: bottomLeft.id, orientation: .horizontal) else { - self.writeSplitCloseRightTestData(["setupError": "Failed to create bottom-right split"], at: path) - return - } - tab.focusPanel(topLeftPanelId) - guard let topRight = tab.newTerminalSplit(from: topLeftPanelId, orientation: .horizontal) else { - self.writeSplitCloseRightTestData(["setupError": "Failed to create top-right split"], at: path) - return - } - - self.writeSplitCloseRightTestData([ - "tabId": tab.id.uuidString, - "topLeftPanelId": topLeftPanelId.uuidString, - "bottomLeftPanelId": bottomLeft.id.uuidString, - "topRightPanelId": topRight.id.uuidString, - "bottomRightPanelId": bottomRight.id.uuidString, - "createdPaneCount": String(tab.bonsplitController.allPaneIds.count), - "createdPanelCount": String(tab.panels.count) - ], at: path) - - DebugUIEventCounters.resetEmptyPanelAppearCount() - - // Close the two right panes via the same path as Cmd+W. - tab.focusPanel(topRight.id) - tab.closePanel(topRight.id, force: true) - tab.focusPanel(bottomRight.id) - tab.closePanel(bottomRight.id, force: true) - - - // Capture final state after Bonsplit/AppKit/Ghostty geometry reconciliation. - // We avoid sleep-based timing and converge over a few main-actor turns. - @MainActor func collectSplitCloseRightState() -> (data: [String: String], settled: Bool) { - let paneIds = tab.bonsplitController.allPaneIds - let bonsplitTabCount = tab.bonsplitController.allTabIds.count - let panelCount = tab.panels.count - - var missingSelectedTabCount = 0 - var missingPanelMappingCount = 0 - var selectedTerminalCount = 0 - var selectedTerminalAttachedCount = 0 - var selectedTerminalZeroSizeCount = 0 - var selectedTerminalSurfaceNilCount = 0 - - for paneId in paneIds { - guard let selected = tab.bonsplitController.selectedTab(inPane: paneId) else { - missingSelectedTabCount += 1 - continue - } - guard let panel = tab.panel(for: selected.id) else { - missingPanelMappingCount += 1 - continue - } - if let terminal = panel as? TerminalPanel { - selectedTerminalCount += 1 - if terminal.hostedView.window != nil { - selectedTerminalAttachedCount += 1 - } - let size = terminal.hostedView.bounds.size - if size.width < 5 || size.height < 5 { - selectedTerminalZeroSizeCount += 1 - } - if terminal.surface.surface == nil { - selectedTerminalSurfaceNilCount += 1 - } - } - } - - let settled = - paneIds.count == 2 && - missingSelectedTabCount == 0 && - missingPanelMappingCount == 0 && - DebugUIEventCounters.emptyPanelAppearCount == 0 && - selectedTerminalCount == 2 && - selectedTerminalAttachedCount == 2 && - selectedTerminalZeroSizeCount == 0 && - selectedTerminalSurfaceNilCount == 0 - - return ( - data: [ - "finalPaneCount": String(paneIds.count), - "finalBonsplitTabCount": String(bonsplitTabCount), - "finalPanelCount": String(panelCount), - "missingSelectedTabCount": String(missingSelectedTabCount), - "missingPanelMappingCount": String(missingPanelMappingCount), - "emptyPanelAppearCount": String(DebugUIEventCounters.emptyPanelAppearCount), - "selectedTerminalCount": String(selectedTerminalCount), - "selectedTerminalAttachedCount": String(selectedTerminalAttachedCount), - "selectedTerminalZeroSizeCount": String(selectedTerminalZeroSizeCount), - "selectedTerminalSurfaceNilCount": String(selectedTerminalSurfaceNilCount), - ], - settled: settled - ) - } - @MainActor func reconcileVisibleTerminalGeometry() { - NSApp.windows.forEach { window in - window.contentView?.layoutSubtreeIfNeeded() - window.contentView?.displayIfNeeded() - } - for paneId in tab.bonsplitController.allPaneIds { - guard let selected = tab.bonsplitController.selectedTab(inPane: paneId), - let terminal = tab.panel(for: selected.id) as? TerminalPanel else { - continue - } - terminal.hostedView.reconcileGeometryNow() - terminal.surface.forceRefresh() - } - } - - var finalState = collectSplitCloseRightState() - for attempt in 1...8 { - reconcileVisibleTerminalGeometry() - await Task.yield() - finalState = collectSplitCloseRightState() - var payload = finalState.data - payload["finalAttempt"] = String(attempt) - self.writeSplitCloseRightTestData(payload, at: path) - if finalState.settled { - break - } - } - } - } - } - - @MainActor - private func runSplitCloseRightVisualRepro( - tab: Workspace, - topLeftPanelId: UUID, - path: String, - shotsDir: String, - iterations: Int, - burstFrames: Int, - closeDelayMs: Int, - pattern: String - ) async { - _ = shotsDir // legacy: screenshots removed in favor of IOSurface sampling - - func sendText(_ panelId: UUID, _ text: String) { - guard let tp = tab.terminalPanel(for: panelId) else { return } - tp.surface.sendText(text) - } - - // Sample a very top strip so the probe remains valid even after vertical expand/collapse. - // We pin marker text to row 1 before each close sequence. - let sampleCrop = CGRect(x: 0.04, y: 0.01, width: 0.92, height: 0.08) - - for i in 1...iterations { - // Reset to a single pane: close everything except the top-left panel. - tab.focusPanel(topLeftPanelId) - let toClose = Array(tab.panels.keys).filter { $0 != topLeftPanelId } - for pid in toClose { - tab.closePanel(pid, force: true) - } - - // Create the repro layout. Most patterns use a 2x2 grid, but keep a single-split - // variant for the exact "close right in a horizontal pair" user report. - let topLeftId = topLeftPanelId - let topRight: TerminalPanel - var bottomLeft: TerminalPanel? - var bottomRight: TerminalPanel? - - switch pattern { - case "close_right_single": - guard let tr = tab.newTerminalSplit(from: topLeftId, orientation: .horizontal) else { - writeSplitCloseRightTestData(["setupError": "Failed to split right from top-left (iteration \(i))"], at: path) - return - } - topRight = tr - case "close_right_lrtd", "close_right_lrtd_bottom_first", "close_right_bottom_first", "close_right_lrtd_unfocused": - // User repro: split left/right first, then split top/down in each column. - guard let tr = tab.newTerminalSplit(from: topLeftId, orientation: .horizontal) else { - writeSplitCloseRightTestData(["setupError": "Failed to split right from top-left (iteration \(i))"], at: path) - return - } - guard let bl = tab.newTerminalSplit(from: topLeftId, orientation: .vertical) else { - writeSplitCloseRightTestData(["setupError": "Failed to split down from left (iteration \(i))"], at: path) - return - } - guard let br = tab.newTerminalSplit(from: tr.id, orientation: .vertical) else { - writeSplitCloseRightTestData(["setupError": "Failed to split down from right (iteration \(i))"], at: path) - return - } - topRight = tr - bottomLeft = bl - bottomRight = br - default: - // Default: split top/down first, then split left/right in each row. - guard let bl = tab.newTerminalSplit(from: topLeftId, orientation: .vertical) else { - writeSplitCloseRightTestData(["setupError": "Failed to split down from top-left (iteration \(i))"], at: path) - return - } - guard let br = tab.newTerminalSplit(from: bl.id, orientation: .horizontal) else { - writeSplitCloseRightTestData(["setupError": "Failed to split right from bottom-left (iteration \(i))"], at: path) - return - } - guard let tr = tab.newTerminalSplit(from: topLeftId, orientation: .horizontal) else { - writeSplitCloseRightTestData(["setupError": "Failed to split right from top-left (iteration \(i))"], at: path) - return - } - topRight = tr - bottomLeft = bl - bottomRight = br - } - - // Let newly created surfaces attach before priming content, so sampled panes have - // stable non-blank text before the close timeline begins. - try? await Task.sleep(nanoseconds: 180_000_000) - - // Fill left panes with visible content. - sendText(topLeftId, "printf '\\033[2J\\033[H'; for i in {1..200}; do echo PROGRAMA_SPLIT_TOPLEFT_\(i); done; printf '\\033[HPROGRAMA_MARKER_TOPLEFT\\n'\r") - sendText(topRight.id, "printf '\\033[2J\\033[H'; for i in {1..200}; do echo PROGRAMA_SPLIT_TOPRIGHT_\(i); done; printf '\\033[HPROGRAMA_MARKER_TOPRIGHT\\n'\r") - if let bottomLeft { - sendText(bottomLeft.id, "printf '\\033[2J\\033[H'; for i in {1..200}; do echo PROGRAMA_SPLIT_BOTTOMLEFT_\(i); done; printf '\\033[HPROGRAMA_MARKER_BOTTOMLEFT\\n'\r") - } - if let bottomRight { - sendText(bottomRight.id, "printf '\\033[2J\\033[H'; for i in {1..200}; do echo PROGRAMA_SPLIT_BOTTOMRIGHT_\(i); done; printf '\\033[HPROGRAMA_MARKER_BOTTOMRIGHT\\n'\r") - } - // Give shell output a moment to paint before we start the close timeline. - try? await Task.sleep(nanoseconds: 180_000_000) - - let desiredFrames = max(16, min(burstFrames, 60)) - let closeFrame = min(6, max(1, desiredFrames / 4)) - let delayFrames = max(0, Int((Double(max(0, closeDelayMs)) / 16.6667).rounded(.up))) - let secondCloseFrame = min(desiredFrames - 1, closeFrame + delayFrames) - - var closeOrder = "" - let actions: [(frame: Int, action: () -> Void)] = { - switch pattern { - case "close_right_single": - closeOrder = "TR_ONLY" - return [ - (frame: closeFrame, action: { - tab.focusPanel(topRight.id) - tab.closePanel(topRight.id, force: true) - }), - ] - case "close_bottom": - guard let bottomRight, let bottomLeft else { return [] } - closeOrder = "BR_THEN_BL" - return [ - (frame: closeFrame, action: { - tab.focusPanel(bottomRight.id) - tab.closePanel(bottomRight.id, force: true) - }), - (frame: secondCloseFrame, action: { - tab.focusPanel(bottomLeft.id) - tab.closePanel(bottomLeft.id, force: true) - }), - ] - case "close_right_lrtd_bottom_first", "close_right_bottom_first": - guard let bottomRight else { return [] } - closeOrder = "BR_THEN_TR" - return [ - (frame: closeFrame, action: { - tab.focusPanel(bottomRight.id) - tab.closePanel(bottomRight.id, force: true) - }), - (frame: secondCloseFrame, action: { - tab.focusPanel(topRight.id) - tab.closePanel(topRight.id, force: true) - }), - ] - case "close_right_lrtd_unfocused": - guard let bottomRight else { return [] } - closeOrder = "TR_THEN_BR_UNFOCUSED" - return [ - (frame: closeFrame, action: { - tab.closePanel(topRight.id, force: true) - }), - (frame: secondCloseFrame, action: { - tab.closePanel(bottomRight.id, force: true) - }), - ] - default: - guard let bottomRight else { return [] } - closeOrder = "TR_THEN_BR" - return [ - (frame: closeFrame, action: { - tab.focusPanel(topRight.id) - tab.closePanel(topRight.id, force: true) - }), - (frame: secondCloseFrame, action: { - tab.focusPanel(bottomRight.id) - tab.closePanel(bottomRight.id, force: true) - }), - ] - } - }() - - let targets: [(label: String, view: GhosttySurfaceScrollView)] = { - switch pattern { - case "close_right_single": - return [ - ("TL", tab.terminalPanel(for: topLeftId)!.surface.hostedView), - ] - case "close_bottom": - return [ - ("TL", tab.terminalPanel(for: topLeftId)!.surface.hostedView), - ("TR", topRight.surface.hostedView), - ] - case "close_right_lrtd_bottom_first", "close_right_bottom_first": - return [ - ("TR", topRight.surface.hostedView), - ("TL", tab.terminalPanel(for: topLeftId)!.surface.hostedView), - ] - default: - guard let bottomLeft else { return [] } - return [ - ("TL", tab.terminalPanel(for: topLeftId)!.surface.hostedView), - ("BL", bottomLeft.surface.hostedView), - ] - } - }() - - let result = await captureVsyncIOSurfaceTimeline( - frameCount: desiredFrames, - closeFrame: closeFrame, - crop: sampleCrop, - targets: targets, - actions: actions - ) - - let paneStateTrace: String = { - tab.bonsplitController.allPaneIds.map { paneId in - let tabs = tab.bonsplitController.tabs(inPane: paneId) - let selected = tab.bonsplitController.selectedTab(inPane: paneId) - let selectedId = selected.map { String(describing: $0.id) } ?? "nil" - let selectedPanelId = selected.flatMap { tab.panelIdFromSurfaceId($0.id) } - let selectedPanelLive: String = { - guard let selected else { return "0" } - return tab.panel(for: selected.id) != nil ? "1" : "0" - }() - let mappedCount = tabs.filter { tab.panelIdFromSurfaceId($0.id) != nil }.count - let selectedPanel = selectedPanelId?.uuidString.prefix(8) ?? "nil" - return "pane=\(paneId.id.uuidString.prefix(8)):tabs=\(tabs.count):mapped=\(mappedCount):selected=\(selectedId.prefix(8)):selectedPanel=\(selectedPanel):selectedLive=\(selectedPanelLive)" - }.joined(separator: ";") - }() - - writeSplitCloseRightTestData([ - "pattern": pattern, - "iteration": String(i), - "closeDelayMs": String(closeDelayMs), - "closeDelayFrames": String(delayFrames), - "closeOrder": closeOrder, - "timelineFrameCount": String(desiredFrames), - "timelineCloseFrame": String(closeFrame), - "timelineSecondCloseFrame": String(secondCloseFrame), - "timelineFirstBlank": result.firstBlank.map { "\($0.label)@\($0.frame)" } ?? "", - "timelineFirstSizeMismatch": result.firstSizeMismatch.map { "\($0.label)@\($0.frame):ios=\($0.ios):exp=\($0.expected)" } ?? "", - "timelineTrace": result.trace.joined(separator: "|"), - "timelinePaneState": paneStateTrace, - "visualLastIteration": String(i), - ], at: path) - - if let firstBlank = result.firstBlank { - writeSplitCloseRightTestData([ - "blankFrameSeen": "1", - "blankObservedIteration": String(i), - "blankObservedAt": "\(firstBlank.label)@\(firstBlank.frame)" - ], at: path) - return - } - - if let firstMismatch = result.firstSizeMismatch { - writeSplitCloseRightTestData([ - "sizeMismatchSeen": "1", - "sizeMismatchObservedIteration": String(i), - "sizeMismatchObservedAt": "\(firstMismatch.label)@\(firstMismatch.frame):ios=\(firstMismatch.ios):exp=\(firstMismatch.expected)" - ], at: path) - return - } - } - } - - @MainActor - private func captureVsyncIOSurfaceTimeline( - frameCount: Int, - closeFrame: Int, - crop: CGRect, - targets: [(label: String, view: GhosttySurfaceScrollView)], - actions: [(frame: Int, action: () -> Void)] = [] - ) async -> (firstBlank: (label: String, frame: Int)?, firstSizeMismatch: (label: String, frame: Int, ios: String, expected: String)?, trace: [String]) { - guard frameCount > 0 else { return (nil, nil, []) } - - let st = VsyncIOSurfaceTimelineState(frameCount: frameCount, closeFrame: closeFrame) - st.scheduledActions = actions.sorted(by: { $0.frame < $1.frame }) - st.nextActionIndex = 0 - st.targets = targets.map { t in - VsyncIOSurfaceTimelineState.Target(label: t.label, sample: { @MainActor in - t.view.debugSampleIOSurface(normalizedCrop: crop) - }) - } - - let unmanaged = Unmanaged.passRetained(st) - let ctx = unmanaged.toOpaque() - - await withCheckedContinuation { (cont: CheckedContinuation) in - st.continuation = cont - var link: CVDisplayLink? - CVDisplayLinkCreateWithActiveCGDisplays(&link) - guard let link else { - st.finish() - Unmanaged.fromOpaque(ctx).release() - return - } - st.link = link - - CVDisplayLinkSetOutputCallback(link, programaVsyncIOSurfaceTimelineCallback, ctx) - CVDisplayLinkStart(link) - } - - return (st.firstBlank, st.firstSizeMismatch, st.trace) - } - - private func writeSplitCloseRightTestData(_ updates: [String: String], at path: String) { - var payload = loadSplitCloseRightTestData(at: path) - for (key, value) in updates { - payload[key] = value - } - guard let data = try? JSONSerialization.data(withJSONObject: payload) else { return } - try? data.write(to: URL(fileURLWithPath: path), options: .atomic) - } - - private func loadSplitCloseRightTestData(at path: String) -> [String: String] { - guard let data = try? Data(contentsOf: URL(fileURLWithPath: path)), - let object = try? JSONSerialization.jsonObject(with: data) as? [String: String] else { - return [:] - } - return object - } - - private func setupChildExitSplitUITestIfNeeded() { - guard !didSetupChildExitSplitUITest else { return } - didSetupChildExitSplitUITest = true - - let env = ProcessInfo.processInfo.environment - guard env["PROGRAMA_UI_TEST_CHILD_EXIT_SPLIT_SETUP"] == "1" else { return } - guard let path = env["PROGRAMA_UI_TEST_CHILD_EXIT_SPLIT_PATH"], !path.isEmpty else { return } - let requestedIterations = Int(env["PROGRAMA_UI_TEST_CHILD_EXIT_SPLIT_ITERATIONS"] ?? "1") ?? 1 - let iterations = max(1, min(requestedIterations, 20)) - - func write(_ updates: [String: String]) { - var payload: [String: String] = { - guard let data = try? Data(contentsOf: URL(fileURLWithPath: path)), - let obj = try? JSONSerialization.jsonObject(with: data) as? [String: String] else { - return [:] - } - return obj - }() - for (k, v) in updates { payload[k] = v } - guard let out = try? JSONSerialization.data(withJSONObject: payload) else { return } - try? out.write(to: URL(fileURLWithPath: path), options: .atomic) - } - - Task { @MainActor [weak self] in - guard let self else { return } - // Small delay so the initial window/panel has completed first layout. - try? await Task.sleep(nanoseconds: 200_000_000) - - guard let tab = self.selectedWorkspace else { - write(["setupError": "Missing selected workspace", "done": "1"]) - return - } - write([ - "requestedIterations": String(requestedIterations), - "iterations": String(iterations), - "workspaceCountBefore": String(self.tabs.count), - "panelCountBefore": String(tab.panels.count), - "done": "0", - ]) - - var completedIterations = 0 - var timedOut = false - var closedWorkspace = false - - for i in 1...iterations { - guard self.tabs.contains(where: { $0.id == tab.id }) else { - closedWorkspace = true - break - } - - guard let leftPanelId = tab.focusedPanelId ?? tab.panels.keys.first else { - write(["setupError": "Missing focused panel before iteration \(i)", "done": "1"]) - return - } - - // Start each iteration from a deterministic 1x1 workspace. - if tab.panels.count > 1 { - for panelId in tab.panels.keys where panelId != leftPanelId { - tab.closePanel(panelId, force: true) - } - let collapsed = await self.waitForWorkspacePanelsCondition( - tab: tab, - timeoutSeconds: 2.0 - ) { workspace in - workspace.panels.count == 1 - } - if !collapsed { - write(["setupError": "Timed out collapsing workspace before iteration \(i)", "done": "1"]) - return - } - } - - guard let rightPanel = tab.newTerminalSplit(from: leftPanelId, orientation: .horizontal) else { - write(["setupError": "Failed to create right split at iteration \(i)", "done": "1"]) - return - } - - write([ - "iteration": String(i), - "leftPanelId": leftPanelId.uuidString, - "rightPanelId": rightPanel.id.uuidString, - ]) - - tab.focusPanel(rightPanel.id) - // Wait for the split terminal surface to be attached before sending exit. - // Without this, very early writes can be dropped during initial surface creation. - _ = await self.waitForTerminalPanelCondition( - tab: tab, - panelId: rightPanel.id, - timeoutSeconds: 2.0 - ) { panel in - panel.hostedView.window != nil && panel.surface.surface != nil - } - // Use an explicit shell exit command for deterministic child-exit behavior across - // startup timing variance; this still exercises the same SHOW_CHILD_EXITED path. - rightPanel.surface.sendText("exit\r") - - // Wait for the right panel to close. - let closed = await withCheckedContinuation { (cont: CheckedContinuation) in - var cancellable: AnyCancellable? - var resolved = false - - func finish(_ value: Bool) { - guard !resolved else { return } - resolved = true - cancellable?.cancel() - cont.resume(returning: value) - } - - cancellable = tab.$panels - .map { $0.count } - .removeDuplicates() - .sink { count in - if count == 1 { - finish(true) - } - } - - DispatchQueue.main.asyncAfter(deadline: .now() + 8.0) { - finish(false) - } - } - - if !closed { - timedOut = true - write(["timedOutIteration": String(i)]) - break - } - - if !self.tabs.contains(where: { $0.id == tab.id }) { - closedWorkspace = true - write(["closedWorkspaceIteration": String(i)]) - break - } - - completedIterations = i - } - - let workspaceStillOpen = self.tabs.contains(where: { $0.id == tab.id }) - let effectiveClosedWorkspace = closedWorkspace || !workspaceStillOpen - - write([ - "workspaceCountAfter": String(self.tabs.count), - "panelCountAfter": String(tab.panels.count), - "workspaceStillOpen": workspaceStillOpen ? "1" : "0", - "closedWorkspace": effectiveClosedWorkspace ? "1" : "0", - "timedOut": timedOut ? "1" : "0", - "completedIterations": String(completedIterations), - "done": "1", - ]) - } - } - - private func setupChildExitKeyboardUITestIfNeeded() { - guard !didSetupChildExitKeyboardUITest else { return } - didSetupChildExitKeyboardUITest = true - - let env = ProcessInfo.processInfo.environment - guard env["PROGRAMA_UI_TEST_CHILD_EXIT_KEYBOARD_SETUP"] == "1" else { return } - guard let path = env["PROGRAMA_UI_TEST_CHILD_EXIT_KEYBOARD_PATH"], !path.isEmpty else { return } - let autoTrigger = env["PROGRAMA_UI_TEST_CHILD_EXIT_KEYBOARD_AUTO_TRIGGER"] == "1" - let strictKeyOnly = env["PROGRAMA_UI_TEST_CHILD_EXIT_KEYBOARD_STRICT"] == "1" - let triggerMode = (env["PROGRAMA_UI_TEST_CHILD_EXIT_KEYBOARD_TRIGGER_MODE"] ?? "shell_input") - .trimmingCharacters(in: .whitespacesAndNewlines) - let useEarlyCtrlShiftTrigger = triggerMode == "early_ctrl_shift_d" - let useEarlyCtrlDTrigger = triggerMode == "early_ctrl_d" - let useEarlyTrigger = useEarlyCtrlShiftTrigger || useEarlyCtrlDTrigger - let triggerUsesShift = triggerMode == "ctrl_shift_d" || useEarlyCtrlShiftTrigger - let layout = (env["PROGRAMA_UI_TEST_CHILD_EXIT_KEYBOARD_LAYOUT"] ?? "lr") - .trimmingCharacters(in: .whitespacesAndNewlines) - let expectedPanelsAfter = max( - 1, - Int((env["PROGRAMA_UI_TEST_CHILD_EXIT_KEYBOARD_EXPECTED_PANELS_AFTER"] ?? "1") - .trimmingCharacters(in: .whitespacesAndNewlines) - ) ?? 1 - ) - - func write(_ updates: [String: String]) { - var payload: [String: String] = { - guard let data = try? Data(contentsOf: URL(fileURLWithPath: path)), - let obj = try? JSONSerialization.jsonObject(with: data) as? [String: String] else { - return [:] - } - return obj - }() - for (k, v) in updates { payload[k] = v } - guard let out = try? JSONSerialization.data(withJSONObject: payload) else { return } - try? out.write(to: URL(fileURLWithPath: path), options: .atomic) - } - - Task { @MainActor [weak self] in - guard let self else { return } - try? await Task.sleep(nanoseconds: 200_000_000) - - guard let tab = self.selectedWorkspace else { - write(["setupError": "Missing selected workspace", "done": "1"]) - return - } - guard let leftPanelId = tab.focusedPanelId else { - write(["setupError": "Missing initial focused panel", "done": "1"]) - return - } - guard let rightPanel = tab.newTerminalSplit(from: leftPanelId, orientation: .horizontal) else { - write(["setupError": "Failed to create right split", "done": "1"]) - return - } - - var bottomLeftPanelId = "" - let topRightPanelId = rightPanel.id.uuidString - var bottomRightPanelId = "" - var exitPanelId = rightPanel.id - - if layout == "lr_left_vertical" { - guard let bottomLeft = tab.newTerminalSplit(from: leftPanelId, orientation: .vertical) else { - write(["setupError": "Failed to create bottom-left split", "done": "1"]) - return - } - bottomLeftPanelId = bottomLeft.id.uuidString - } else if layout == "lrtd_close_right_then_exit_top_left" { - guard let bottomLeft = tab.newTerminalSplit(from: leftPanelId, orientation: .vertical) else { - write(["setupError": "Failed to create bottom-left split", "done": "1"]) - return - } - guard let bottomRight = tab.newTerminalSplit(from: rightPanel.id, orientation: .vertical) else { - write(["setupError": "Failed to create bottom-right split", "done": "1"]) - return - } - bottomLeftPanelId = bottomLeft.id.uuidString - bottomRightPanelId = bottomRight.id.uuidString - - // Repro flow: with a 2x2 (left/right then top/down), close both right panes, - // then trigger Ctrl+D in top-left. - tab.focusPanel(rightPanel.id) - tab.closePanel(rightPanel.id, force: true) - tab.focusPanel(bottomRight.id) - tab.closePanel(bottomRight.id, force: true) - exitPanelId = leftPanelId - - let collapsed = await self.waitForWorkspacePanelsCondition( - tab: tab, - timeoutSeconds: 2.0 - ) { workspace in - workspace.panels.count == 2 - } - if !collapsed { - write([ - "setupError": "Expected 2 panels after closing right column, got \(tab.panels.count)", - "done": "1", - ]) - return - } - } else if layout == "tdlr_close_bottom_then_exit_top_left" { - // Alternate repro flow: - // 1) split top/down - // 2) split left/right for each row (2x2) - // 3) close both bottom panes - // 4) trigger Ctrl+D in top-left - guard let bottomLeft = tab.newTerminalSplit(from: leftPanelId, orientation: .vertical) else { - write(["setupError": "Failed to create bottom-left split", "done": "1"]) - return - } - guard let topRight = tab.newTerminalSplit(from: leftPanelId, orientation: .horizontal) else { - write(["setupError": "Failed to create top-right split", "done": "1"]) - return - } - guard let bottomRight = tab.newTerminalSplit(from: bottomLeft.id, orientation: .horizontal) else { - write(["setupError": "Failed to create bottom-right split", "done": "1"]) - return - } - bottomLeftPanelId = bottomLeft.id.uuidString - bottomRightPanelId = bottomRight.id.uuidString - - // Close every pane except the top row; do it one-by-one and wait for model convergence. - let keepPanels: Set = [leftPanelId, topRight.id] - for panelId in Array(tab.panels.keys) where !keepPanels.contains(panelId) { - tab.focusPanel(panelId) - tab.closePanel(panelId, force: true) - let closed = await self.waitForWorkspacePanelsCondition( - tab: tab, - timeoutSeconds: 1.0 - ) { workspace in - workspace.panels[panelId] == nil - } - if !closed { - write([ - "setupError": "Failed to close bottom pane \(panelId.uuidString)", - "done": "1", - ]) - return - } - } - exitPanelId = leftPanelId - - let collapsed = await self.waitForWorkspacePanelsCondition( - tab: tab, - timeoutSeconds: 2.0 - ) { workspace in - workspace.panels.count == 2 - } - if !collapsed { - write([ - "setupError": "Expected 2 panels after closing bottom row, got \(tab.panels.count)", - "done": "1", - ]) - return - } - } - - tab.focusPanel(exitPanelId) - // Keep child-exit keyboard tests deterministic across user shell configs. - // `exec cat` exits on a single Ctrl+D and avoids ignore-eof shell settings. - if let exitPanel = tab.terminalPanel(for: exitPanelId) { - exitPanel.sendText("exec cat\r") - } - - var exitPanelAttachedBeforeCtrlD = false - var exitPanelHasSurfaceBeforeCtrlD = false - if !useEarlyTrigger { - let readiness = await self.waitForTerminalPanelReadyForUITest( - tab: tab, - panelId: exitPanelId - ) - exitPanelAttachedBeforeCtrlD = readiness.attached - exitPanelHasSurfaceBeforeCtrlD = readiness.hasSurface - if !(readiness.attached && readiness.hasSurface) { - write([ - "exitPanelAttachedBeforeCtrlD": readiness.attached ? "1" : "0", - "exitPanelHasSurfaceBeforeCtrlD": readiness.hasSurface ? "1" : "0", - "setupError": "Exit panel not ready for Ctrl+D (not attached or surface nil)", - "done": "1", - ]) - return - } - self.ensureFocusedTerminalFirstResponder() - } else if let exitPanel = tab.terminalPanel(for: exitPanelId) { - exitPanelAttachedBeforeCtrlD = exitPanel.hostedView.window != nil - exitPanelHasSurfaceBeforeCtrlD = exitPanel.surface.surface != nil - } - - let focusedPanelBefore = tab.focusedPanelId?.uuidString ?? "" - let firstResponderPanelBefore = tab.panels.compactMap { (panelId, panel) -> UUID? in - guard let terminal = panel as? TerminalPanel else { return nil } - return terminal.hostedView.isSurfaceViewFirstResponder() ? panelId : nil - }.first?.uuidString ?? "" - - write([ - "workspaceId": tab.id.uuidString, - "leftPanelId": leftPanelId.uuidString, - "rightPanelId": rightPanel.id.uuidString, - "topRightPanelId": topRightPanelId, - "bottomLeftPanelId": bottomLeftPanelId, - "bottomRightPanelId": bottomRightPanelId, - "exitPanelId": exitPanelId.uuidString, - "panelCountBeforeCtrlD": String(tab.panels.count), - "layout": layout, - "expectedPanelsAfter": String(expectedPanelsAfter), - "focusedPanelBefore": focusedPanelBefore, - "firstResponderPanelBefore": firstResponderPanelBefore, - "exitPanelAttachedBeforeCtrlD": exitPanelAttachedBeforeCtrlD ? "1" : "0", - "exitPanelHasSurfaceBeforeCtrlD": exitPanelHasSurfaceBeforeCtrlD ? "1" : "0", - "ready": "1", - "done": "0", - ]) - - var finished = false - var timeoutWork: DispatchWorkItem? - - @MainActor - func finish(_ updates: [String: String]) { - guard !finished else { return } - finished = true - timeoutWork?.cancel() - write(updates.merging(["done": "1"], uniquingKeysWith: { _, new in new })) - self.uiTestCancellables.removeAll() - } - - tab.$panels - .map { $0.count } - .removeDuplicates() - .sink { [weak self, weak tab] count in - Task { @MainActor in - guard let self, let tab else { return } - if count == expectedPanelsAfter { - // Require the post-exit state to be stable for a short window so - // we catch "close looked correct, then workspace vanished" races. - try? await Task.sleep(nanoseconds: 1_200_000_000) - guard tab.panels.count == expectedPanelsAfter else { return } - - let firstResponderPanelAfter = tab.panels.compactMap { (panelId, panel) -> UUID? in - guard let terminal = panel as? TerminalPanel else { return nil } - return terminal.hostedView.isSurfaceViewFirstResponder() ? panelId : nil - }.first?.uuidString ?? "" - - finish([ - "workspaceCountAfter": String(self.tabs.count), - "panelCountAfter": String(tab.panels.count), - "closedWorkspace": self.tabs.contains(where: { $0.id == tab.id }) ? "0" : "1", - "focusedPanelAfter": tab.focusedPanelId?.uuidString ?? "", - "firstResponderPanelAfter": firstResponderPanelAfter, - ]) - } - } - } - .store(in: &uiTestCancellables) - - $tabs - .map { $0.contains(where: { $0.id == tab.id }) } - .removeDuplicates() - .sink { alive in - Task { @MainActor in - if !alive { - finish([ - "workspaceCountAfter": "0", - "panelCountAfter": "0", - "closedWorkspace": "1", - ]) - } - } - } - .store(in: &uiTestCancellables) - - let work = DispatchWorkItem { - finish([ - "workspaceCountAfter": String(self.tabs.count), - "panelCountAfter": String(tab.panels.count), - "closedWorkspace": self.tabs.contains(where: { $0.id == tab.id }) ? "0" : "1", - "timedOut": "1", - ]) - } - timeoutWork = work - DispatchQueue.main.asyncAfter(deadline: .now() + 8.0, execute: work) - - if autoTrigger { - Task { @MainActor [weak tab] in - guard let tab else { return } - write(["autoTriggerStarted": "1"]) - - if triggerMode == "runtime_close_callback" { - write(["autoTriggerMode": "runtime_close_callback"]) - self.closePanelAfterChildExited(tabId: tab.id, surfaceId: exitPanelId) - return - } - - let triggerModifiers: NSEvent.ModifierFlags = triggerUsesShift - ? [.control, .shift] - : [.control] - let shouldWaitForSurface = !useEarlyTrigger - - var attachedBeforeTrigger = false - var hasSurfaceBeforeTrigger = false - if shouldWaitForSurface { - let ready = await self.waitForTerminalPanelCondition( - tab: tab, - panelId: exitPanelId, - timeoutSeconds: 5.0 - ) { panel in - attachedBeforeTrigger = panel.hostedView.window != nil - hasSurfaceBeforeTrigger = panel.surface.surface != nil - return attachedBeforeTrigger && hasSurfaceBeforeTrigger - } - if !ready, - tab.terminalPanel(for: exitPanelId) == nil { - write(["autoTriggerError": "missingExitPanelBeforeTrigger"]) - return - } - } else if let panel = tab.terminalPanel(for: exitPanelId) { - attachedBeforeTrigger = panel.hostedView.window != nil - hasSurfaceBeforeTrigger = panel.surface.surface != nil - } - write([ - "exitPanelAttachedBeforeTrigger": attachedBeforeTrigger ? "1" : "0", - "exitPanelHasSurfaceBeforeTrigger": hasSurfaceBeforeTrigger ? "1" : "0", - ]) - if shouldWaitForSurface && !(attachedBeforeTrigger && hasSurfaceBeforeTrigger) { - write(["autoTriggerError": "exitPanelNotReadyBeforeTrigger"]) - return - } - - guard let panel = tab.terminalPanel(for: exitPanelId) else { - write(["autoTriggerError": "missingExitPanelAtTrigger"]) - return - } - // Exercise the real key path (ghostty_surface_key for Ctrl+D). - if panel.hostedView.sendSyntheticCtrlDForUITest(modifierFlags: triggerModifiers) { - write(["autoTriggerSentCtrlDKey1": "1"]) - } else { - write([ - "autoTriggerCtrlDKeyUnavailable": "1", - "autoTriggerError": "ctrlDKeyUnavailable", - ]) - return - } - - // In strict mode, never mask routing bugs with fallback writes. - if strictKeyOnly { - let strictModeLabel: String = { - if useEarlyCtrlShiftTrigger { return "strict_early_ctrl_shift_d" } - if useEarlyCtrlDTrigger { return "strict_early_ctrl_d" } - if triggerUsesShift { return "strict_ctrl_shift_d" } - return "strict_ctrl_d" - }() - write(["autoTriggerMode": strictModeLabel]) - return - } - - // Non-strict mode keeps one additional Ctrl+D retry for startup timing variance. - try? await Task.sleep(nanoseconds: 450_000_000) - if tab.panels[exitPanelId] != nil, - panel.hostedView.sendSyntheticCtrlDForUITest(modifierFlags: triggerModifiers) { - write(["autoTriggerSentCtrlDKey2": "1"]) - } - } - } - } - } -#endif } // MARK: - Direction Types for Backwards Compatibility From 55635480862a6989378cac36536e8ae6ed497156 Mon Sep 17 00:00:00 2001 From: arzafran Date: Thu, 9 Jul 2026 14:08:29 -0300 Subject: [PATCH 19/31] refactor(browser): split BrowserPanel.swift along concern seams MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Move-only file reorganization (#99). Extracts self-contained top-level types out of the ~6.5k-line BrowserPanel.swift god object into dedicated files, one concern each: - BrowserProfileStore.swift: BrowserProfileDefinition + BrowserProfileStore (profiles concern) - BrowserHistoryStore.swift: normalizedBrowserHistoryNamespace(bundleIdentifier:) + BrowserHistoryStore (history concern) - BrowserDownloadDelegate.swift: BrowserDownloadDelegate (downloads concern) - BrowserPanelWebDelegates.swift: browserNavigationShouldOpenInNewTab / browserNavigationShouldCreatePopup / browserNavigationShouldFallbackNilTargetToNewTab + BrowserNavigationDelegate + BrowserUIDelegate (navigation delegates concern) - BrowserUserProxySettings.swift: BrowserUserProxySettings (remote proxy concern) - IMECompositionMessageHandler.swift: IMECompositionMessageHandler (JS injection/IME concern) BrowserPanel.swift: 6517 -> 4677 lines. Access-level widening required for cross-file visibility (all move-only, no behavior change): - BrowserInsecureHTTPNavigationIntent: private -> internal (used by BrowserNavigationDelegate/BrowserUIDelegate, now in a different file) - BrowserNavigationDelegate, BrowserUIDelegate: private class -> class (instantiated from BrowserPanel in BrowserPanel.swift) - IMECompositionMessageHandler: private final class -> final class (instantiated from BrowserPanel in BrowserPanel.swift) The stale "// MARK: - Browser Data Import" comment that sat directly above BrowserUserProxySettings (mislabeled leftover, unrelated to the actual Browser Data Import feature which lives in BrowserDataImport.swift) was corrected to "// MARK: - Remote/User Proxy Settings" when moved. Not attempted here (left in BrowserPanel.swift, ~4.7k lines remaining): the BrowserPanel class body itself (profiles/history/JS-injection/remote-proxy call sites, plus the automation surface) is still one large class — pulling its methods into extensions in other files would require widening many individual stored-property access levels and was judged too risky to do without dedicated design time. BrowserPanelView.swift's WebViewRepresentable and BrowserWindowPortal.swift's remaining seams are also unaddressed. Refs #99. --- GhosttyTabs.xcodeproj/project.pbxproj | 24 + Sources/Panels/BrowserDownloadDelegate.swift | 140 ++ Sources/Panels/BrowserHistoryStore.swift | 801 ++++++++ Sources/Panels/BrowserPanel.swift | 1732 +---------------- Sources/Panels/BrowserPanelWebDelegates.swift | 541 +++++ Sources/Panels/BrowserProfileStore.swift | 206 ++ Sources/Panels/BrowserUserProxySettings.swift | 34 + .../Panels/IMECompositionMessageHandler.swift | 19 + 8 files changed, 1769 insertions(+), 1728 deletions(-) create mode 100644 Sources/Panels/BrowserDownloadDelegate.swift create mode 100644 Sources/Panels/BrowserHistoryStore.swift create mode 100644 Sources/Panels/BrowserPanelWebDelegates.swift create mode 100644 Sources/Panels/BrowserProfileStore.swift create mode 100644 Sources/Panels/BrowserUserProxySettings.swift create mode 100644 Sources/Panels/IMECompositionMessageHandler.swift diff --git a/GhosttyTabs.xcodeproj/project.pbxproj b/GhosttyTabs.xcodeproj/project.pbxproj index 7437cc3d0dd..d027a7709af 100644 --- a/GhosttyTabs.xcodeproj/project.pbxproj +++ b/GhosttyTabs.xcodeproj/project.pbxproj @@ -44,6 +44,12 @@ A5001402 /* BrowserPanel.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5001412 /* BrowserPanel.swift */; }; NRBR0001 /* InspectorDock.swift in Sources */ = {isa = PBXBuildFile; fileRef = NRBR0002 /* InspectorDock.swift */; }; NRBR0003 /* BrowserWebDialogPresenter.swift in Sources */ = {isa = PBXBuildFile; fileRef = NRBR0004 /* BrowserWebDialogPresenter.swift */; }; + NRBR0005 /* BrowserProfileStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = NRBR0006 /* BrowserProfileStore.swift */; }; + NRBR0007 /* BrowserHistoryStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = NRBR0008 /* BrowserHistoryStore.swift */; }; + NRBR0009 /* BrowserDownloadDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = NRBR0010 /* BrowserDownloadDelegate.swift */; }; + NRBR0011 /* BrowserPanelWebDelegates.swift in Sources */ = {isa = PBXBuildFile; fileRef = NRBR0012 /* BrowserPanelWebDelegates.swift */; }; + NRBR0013 /* BrowserUserProxySettings.swift in Sources */ = {isa = PBXBuildFile; fileRef = NRBR0014 /* BrowserUserProxySettings.swift */; }; + NRBR0015 /* IMECompositionMessageHandler.swift in Sources */ = {isa = PBXBuildFile; fileRef = NRBR0016 /* IMECompositionMessageHandler.swift */; }; A5FF0013 /* BrowserDataImport.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5FF0003 /* BrowserDataImport.swift */; }; A500RG01 /* ReactGrab.swift in Sources */ = {isa = PBXBuildFile; fileRef = A500RG00 /* ReactGrab.swift */; }; A5001403 /* TerminalPanelView.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5001413 /* TerminalPanelView.swift */; }; @@ -271,6 +277,12 @@ A5001412 /* BrowserPanel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Panels/BrowserPanel.swift; sourceTree = ""; }; NRBR0002 /* InspectorDock.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Panels/InspectorDock.swift; sourceTree = ""; }; NRBR0004 /* BrowserWebDialogPresenter.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Panels/BrowserWebDialogPresenter.swift; sourceTree = ""; }; + NRBR0006 /* BrowserProfileStore.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Panels/BrowserProfileStore.swift; sourceTree = ""; }; + NRBR0008 /* BrowserHistoryStore.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Panels/BrowserHistoryStore.swift; sourceTree = ""; }; + NRBR0010 /* BrowserDownloadDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Panels/BrowserDownloadDelegate.swift; sourceTree = ""; }; + NRBR0012 /* BrowserPanelWebDelegates.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Panels/BrowserPanelWebDelegates.swift; sourceTree = ""; }; + NRBR0014 /* BrowserUserProxySettings.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Panels/BrowserUserProxySettings.swift; sourceTree = ""; }; + NRBR0016 /* IMECompositionMessageHandler.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Panels/IMECompositionMessageHandler.swift; sourceTree = ""; }; A5FF0003 /* BrowserDataImport.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Panels/BrowserDataImport.swift; sourceTree = ""; }; A500RG00 /* ReactGrab.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Panels/ReactGrab.swift; sourceTree = ""; }; A5001413 /* TerminalPanelView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Panels/TerminalPanelView.swift; sourceTree = ""; }; @@ -555,6 +567,12 @@ A5001412 /* BrowserPanel.swift */, NRBR0002 /* InspectorDock.swift */, NRBR0004 /* BrowserWebDialogPresenter.swift */, + NRBR0006 /* BrowserProfileStore.swift */, + NRBR0008 /* BrowserHistoryStore.swift */, + NRBR0010 /* BrowserDownloadDelegate.swift */, + NRBR0012 /* BrowserPanelWebDelegates.swift */, + NRBR0014 /* BrowserUserProxySettings.swift */, + NRBR0016 /* IMECompositionMessageHandler.swift */, A5FF0003 /* BrowserDataImport.swift */, A500RG00 /* ReactGrab.swift */, A5001413 /* TerminalPanelView.swift */, @@ -886,6 +904,12 @@ A5001402 /* BrowserPanel.swift in Sources */, NRBR0001 /* InspectorDock.swift in Sources */, NRBR0003 /* BrowserWebDialogPresenter.swift in Sources */, + NRBR0005 /* BrowserProfileStore.swift in Sources */, + NRBR0007 /* BrowserHistoryStore.swift in Sources */, + NRBR0009 /* BrowserDownloadDelegate.swift in Sources */, + NRBR0011 /* BrowserPanelWebDelegates.swift in Sources */, + NRBR0013 /* BrowserUserProxySettings.swift in Sources */, + NRBR0015 /* IMECompositionMessageHandler.swift in Sources */, A5FF0013 /* BrowserDataImport.swift in Sources */, A500RG01 /* ReactGrab.swift in Sources */, A5001403 /* TerminalPanelView.swift in Sources */, diff --git a/Sources/Panels/BrowserDownloadDelegate.swift b/Sources/Panels/BrowserDownloadDelegate.swift new file mode 100644 index 00000000000..5b3d520fe6c --- /dev/null +++ b/Sources/Panels/BrowserDownloadDelegate.swift @@ -0,0 +1,140 @@ +import AppKit +import Bonsplit +import WebKit + +// MARK: - Download Delegate + +/// Handles WKDownload lifecycle by saving to a temp file synchronously (no UI +/// during WebKit callbacks), then showing NSSavePanel after the download finishes. +class BrowserDownloadDelegate: NSObject, WKDownloadDelegate { + private struct DownloadState { + let tempURL: URL + let suggestedFilename: String + } + + /// Tracks active downloads keyed by WKDownload identity. + private var activeDownloads: [ObjectIdentifier: DownloadState] = [:] + private let activeDownloadsLock = NSLock() + var onDownloadStarted: ((String) -> Void)? + var onDownloadReadyToSave: (() -> Void)? + var onDownloadFailed: ((Error) -> Void)? + + private static let tempDir: URL = { + let dir = FileManager.default.temporaryDirectory.appendingPathComponent("cmux-downloads", isDirectory: true) + try? FileManager.default.createDirectory(at: dir, withIntermediateDirectories: true) + return dir + }() + + private static func sanitizedFilename(_ raw: String, fallbackURL: URL?) -> String { + let trimmed = raw.trimmingCharacters(in: .whitespacesAndNewlines) + let candidate = (trimmed as NSString).lastPathComponent + let fromURL = fallbackURL?.lastPathComponent ?? "" + let base = candidate.isEmpty ? fromURL : candidate + let replaced = base.replacingOccurrences(of: ":", with: "-") + let safe = replaced.trimmingCharacters(in: .whitespacesAndNewlines) + return safe.isEmpty ? "download" : safe + } + + /// Resolves a non-colliding destination in ~/Downloads, appending " 2", " 3", … like Safari. + static func uniqueDownloadsURL(for filename: String) -> URL { + let fileManager = FileManager.default + let downloads = fileManager.urls(for: .downloadsDirectory, in: .userDomainMask).first + ?? fileManager.homeDirectoryForCurrentUser.appendingPathComponent("Downloads", isDirectory: true) + try? fileManager.createDirectory(at: downloads, withIntermediateDirectories: true) + + var candidate = downloads.appendingPathComponent(filename, isDirectory: false) + guard fileManager.fileExists(atPath: candidate.path) else { return candidate } + + let base = (filename as NSString).deletingPathExtension + let ext = (filename as NSString).pathExtension + var counter = 2 + repeat { + let name = ext.isEmpty ? "\(base) \(counter)" : "\(base) \(counter).\(ext)" + candidate = downloads.appendingPathComponent(name, isDirectory: false) + counter += 1 + } while fileManager.fileExists(atPath: candidate.path) + return candidate + } + + private func storeState(_ state: DownloadState, for download: WKDownload) { + activeDownloadsLock.lock() + activeDownloads[ObjectIdentifier(download)] = state + activeDownloadsLock.unlock() + } + + private func removeState(for download: WKDownload) -> DownloadState? { + activeDownloadsLock.lock() + let state = activeDownloads.removeValue(forKey: ObjectIdentifier(download)) + activeDownloadsLock.unlock() + return state + } + + private func notifyOnMain(_ action: @escaping () -> Void) { + if Thread.isMainThread { + action() + } else { + DispatchQueue.main.async(execute: action) + } + } + + func download( + _ download: WKDownload, + decideDestinationUsing response: URLResponse, + suggestedFilename: String, + completionHandler: @escaping (URL?) -> Void + ) { + // Save to a temp file — return synchronously so WebKit is never blocked. + let safeFilename = Self.sanitizedFilename(suggestedFilename, fallbackURL: response.url) + let tempFilename = "\(UUID().uuidString)-\(safeFilename)" + let destURL = Self.tempDir.appendingPathComponent(tempFilename, isDirectory: false) + try? FileManager.default.removeItem(at: destURL) + storeState(DownloadState(tempURL: destURL, suggestedFilename: safeFilename), for: download) + notifyOnMain { [weak self] in + self?.onDownloadStarted?(safeFilename) + } + #if DEBUG + dlog("download.decideDestination file=\(safeFilename)") + #endif + NSLog("BrowserPanel download: temp path=%@", destURL.path) + completionHandler(destURL) + } + + func downloadDidFinish(_ download: WKDownload) { + guard let info = removeState(for: download) else { + #if DEBUG + dlog("download.finished missing-state") + #endif + return + } + #if DEBUG + dlog("download.finished file=\(info.suggestedFilename)") + #endif + NSLog("BrowserPanel download finished: %@", info.suggestedFilename) + + // #9: auto-save to ~/Downloads (Safari-style) instead of prompting with a save panel. + DispatchQueue.main.async { + self.onDownloadReadyToSave?() + let destURL = Self.uniqueDownloadsURL(for: info.suggestedFilename) + do { + try FileManager.default.moveItem(at: info.tempURL, to: destURL) + NSLog("BrowserPanel download saved: %@", destURL.path) + } catch { + NSLog("BrowserPanel download move failed: %@", error.localizedDescription) + try? FileManager.default.removeItem(at: info.tempURL) + } + } + } + + func download(_ download: WKDownload, didFailWithError error: Error, resumeData: Data?) { + if let info = removeState(for: download) { + try? FileManager.default.removeItem(at: info.tempURL) + } + notifyOnMain { [weak self] in + self?.onDownloadFailed?(error) + } + #if DEBUG + dlog("download.failed error=\(error.localizedDescription)") + #endif + NSLog("BrowserPanel download failed: %@", error.localizedDescription) + } +} diff --git a/Sources/Panels/BrowserHistoryStore.swift b/Sources/Panels/BrowserHistoryStore.swift new file mode 100644 index 00000000000..d6054b2d968 --- /dev/null +++ b/Sources/Panels/BrowserHistoryStore.swift @@ -0,0 +1,801 @@ +import Foundation +import Combine + +func normalizedBrowserHistoryNamespace(bundleIdentifier: String) -> String { + if bundleIdentifier.hasPrefix("com.darkroom.programa.debug.") { + return "com.darkroom.programa.debug" + } + if bundleIdentifier.hasPrefix("com.darkroom.programa.staging.") { + return "com.darkroom.programa.staging" + } + return bundleIdentifier +} + +@MainActor +final class BrowserHistoryStore: ObservableObject { + static let shared = BrowserHistoryStore() + + struct Entry: Codable, Identifiable, Hashable { + let id: UUID + var url: String + var title: String? + var lastVisited: Date + var visitCount: Int + var typedCount: Int + var lastTypedAt: Date? + + private enum CodingKeys: String, CodingKey { + case id + case url + case title + case lastVisited + case visitCount + case typedCount + case lastTypedAt + } + + init( + id: UUID, + url: String, + title: String?, + lastVisited: Date, + visitCount: Int, + typedCount: Int = 0, + lastTypedAt: Date? = nil + ) { + self.id = id + self.url = url + self.title = title + self.lastVisited = lastVisited + self.visitCount = visitCount + self.typedCount = typedCount + self.lastTypedAt = lastTypedAt + } + + init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + id = try container.decode(UUID.self, forKey: .id) + url = try container.decode(String.self, forKey: .url) + title = try container.decodeIfPresent(String.self, forKey: .title) + lastVisited = try container.decode(Date.self, forKey: .lastVisited) + visitCount = try container.decode(Int.self, forKey: .visitCount) + typedCount = try container.decodeIfPresent(Int.self, forKey: .typedCount) ?? 0 + lastTypedAt = try container.decodeIfPresent(Date.self, forKey: .lastTypedAt) + } + } + + @Published private(set) var entries: [Entry] = [] + + private let fileURL: URL? + private var didLoad: Bool = false + private var saveTask: Task? + private let maxEntries: Int = 5000 + private let saveDebounceNanoseconds: UInt64 = 120_000_000 + + private struct SuggestionCandidate { + let entry: Entry + let urlLower: String + let urlSansSchemeLower: String + let hostLower: String + let pathAndQueryLower: String + let titleLower: String + } + + private struct ScoredSuggestion { + let entry: Entry + let score: Double + } + + init(fileURL: URL? = nil) { + // Avoid calling @MainActor-isolated static methods from default argument context. + self.fileURL = fileURL ?? BrowserHistoryStore.defaultHistoryFileURL() + } + + func loadIfNeeded() { + guard !didLoad else { return } + didLoad = true + guard let fileURL else { return } + migrateLegacyTaggedHistoryFileIfNeeded(to: fileURL) + + // Load synchronously on first access so the first omnibar query can use + // persisted history immediately (important for deterministic UI behavior). + let data: Data + do { + data = try Data(contentsOf: fileURL) + } catch { + return + } + + let decoded: [Entry] + do { + decoded = try JSONDecoder().decode([Entry].self, from: data) + } catch { + return + } + + // Most-recent first. + entries = decoded.sorted(by: { $0.lastVisited > $1.lastVisited }) + + // Remove entries with invalid hosts (no TLD), e.g. "https://news." + let beforeCount = entries.count + entries.removeAll { entry in + guard let url = URL(string: entry.url), + let host = url.host?.lowercased() else { return false } + let trimmed = host.hasSuffix(".") ? String(host.dropLast()) : host + return !trimmed.contains(".") + } + if entries.count != beforeCount { + scheduleSave() + } + } + + func recordVisit(url: URL?, title: String?) { + loadIfNeeded() + + guard let url else { return } + guard let scheme = url.scheme?.lowercased(), + scheme == "http" || scheme == "https" else { return } + // Skip URLs whose host lacks a TLD (e.g. "https://news."). + if let host = url.host?.lowercased() { + let trimmed = host.hasSuffix(".") ? String(host.dropLast()) : host + if !trimmed.contains(".") { return } + } + + let urlString = url.absoluteString + guard urlString != "about:blank" else { return } + let normalizedKey = normalizedHistoryKey(url: url) + + if let idx = entries.firstIndex(where: { + if $0.url == urlString { return true } + return normalizedHistoryKey(urlString: $0.url) == normalizedKey + }) { + entries[idx].lastVisited = Date() + entries[idx].visitCount += 1 + // Prefer non-empty titles, but don't clobber an existing title with empty/whitespace. + if let title, !title.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty { + entries[idx].title = title + } + } else { + entries.insert(Entry( + id: UUID(), + url: urlString, + title: title?.trimmingCharacters(in: .whitespacesAndNewlines), + lastVisited: Date(), + visitCount: 1 + ), at: 0) + } + + // Keep most-recent first and bound size. + entries.sort(by: { $0.lastVisited > $1.lastVisited }) + if entries.count > maxEntries { + entries.removeLast(entries.count - maxEntries) + } + + scheduleSave() + } + + func recordTypedNavigation(url: URL?) { + loadIfNeeded() + + guard let url else { return } + guard let scheme = url.scheme?.lowercased(), + scheme == "http" || scheme == "https" else { return } + // Skip URLs whose host lacks a TLD (e.g. "https://news."). + if let host = url.host?.lowercased() { + let trimmed = host.hasSuffix(".") ? String(host.dropLast()) : host + if !trimmed.contains(".") { return } + } + + let urlString = url.absoluteString + guard urlString != "about:blank" else { return } + + let now = Date() + let normalizedKey = normalizedHistoryKey(url: url) + if let idx = entries.firstIndex(where: { + if $0.url == urlString { return true } + return normalizedHistoryKey(urlString: $0.url) == normalizedKey + }) { + entries[idx].typedCount += 1 + entries[idx].lastTypedAt = now + entries[idx].lastVisited = now + } else { + entries.insert(Entry( + id: UUID(), + url: urlString, + title: nil, + lastVisited: now, + visitCount: 1, + typedCount: 1, + lastTypedAt: now + ), at: 0) + } + + entries.sort(by: { $0.lastVisited > $1.lastVisited }) + if entries.count > maxEntries { + entries.removeLast(entries.count - maxEntries) + } + + scheduleSave() + } + + func suggestions(for input: String, limit: Int = 10) -> [Entry] { + loadIfNeeded() + guard limit > 0 else { return [] } + + let q = input.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() + guard !q.isEmpty else { return [] } + let queryTokens = tokenizeSuggestionQuery(q) + let now = Date() + + let matched = entries.compactMap { entry -> ScoredSuggestion? in + let candidate = makeSuggestionCandidate(entry: entry) + guard let score = suggestionScore(candidate: candidate, query: q, queryTokens: queryTokens, now: now) else { + return nil + } + return ScoredSuggestion(entry: entry, score: score) + } + .sorted { lhs, rhs in + if lhs.score != rhs.score { return lhs.score > rhs.score } + if lhs.entry.lastVisited != rhs.entry.lastVisited { return lhs.entry.lastVisited > rhs.entry.lastVisited } + if lhs.entry.visitCount != rhs.entry.visitCount { return lhs.entry.visitCount > rhs.entry.visitCount } + return lhs.entry.url < rhs.entry.url + } + + if matched.count <= limit { return matched.map(\.entry) } + return Array(matched.prefix(limit).map(\.entry)) + } + + func recentSuggestions(limit: Int = 10) -> [Entry] { + loadIfNeeded() + guard limit > 0 else { return [] } + + let ranked = entries.sorted { lhs, rhs in + if lhs.typedCount != rhs.typedCount { return lhs.typedCount > rhs.typedCount } + let lhsTypedDate = lhs.lastTypedAt ?? .distantPast + let rhsTypedDate = rhs.lastTypedAt ?? .distantPast + if lhsTypedDate != rhsTypedDate { return lhsTypedDate > rhsTypedDate } + if lhs.lastVisited != rhs.lastVisited { return lhs.lastVisited > rhs.lastVisited } + if lhs.visitCount != rhs.visitCount { return lhs.visitCount > rhs.visitCount } + return lhs.url < rhs.url + } + + if ranked.count <= limit { return ranked } + return Array(ranked.prefix(limit)) + } + + @discardableResult + func mergeImportedEntries(_ importedEntries: [Entry]) -> Int { + loadIfNeeded() + guard !importedEntries.isEmpty else { return 0 } + + var mergedCount = 0 + for imported in importedEntries { + guard let parsedURL = URL(string: imported.url), + let scheme = parsedURL.scheme?.lowercased(), + scheme == "http" || scheme == "https" else { + continue + } + + if let host = parsedURL.host?.lowercased() { + let trimmed = host.hasSuffix(".") ? String(host.dropLast()) : host + if !trimmed.contains(".") { continue } + } + + let urlString = parsedURL.absoluteString + guard urlString != "about:blank" else { continue } + let normalizedKey = normalizedHistoryKey(url: parsedURL) + + let importedTitle = imported.title?.trimmingCharacters(in: .whitespacesAndNewlines) + let importedLastVisited = imported.lastVisited + let importedVisitCount = max(1, imported.visitCount) + let importedTypedCount = max(0, imported.typedCount) + let importedLastTypedAt = imported.lastTypedAt + + if let idx = entries.firstIndex(where: { + if $0.url == urlString { return true } + guard let normalizedKey else { return false } + return normalizedHistoryKey(urlString: $0.url) == normalizedKey + }) { + var didMutate = false + if importedLastVisited > entries[idx].lastVisited { + entries[idx].lastVisited = importedLastVisited + didMutate = true + } + if importedVisitCount > entries[idx].visitCount { + entries[idx].visitCount = importedVisitCount + didMutate = true + } + if importedTypedCount > entries[idx].typedCount { + entries[idx].typedCount = importedTypedCount + didMutate = true + } + if let importedLastTypedAt { + if let existingLastTypedAt = entries[idx].lastTypedAt { + if importedLastTypedAt > existingLastTypedAt { + entries[idx].lastTypedAt = importedLastTypedAt + didMutate = true + } + } else { + entries[idx].lastTypedAt = importedLastTypedAt + didMutate = true + } + } + + let existingTitle = entries[idx].title?.trimmingCharacters(in: .whitespacesAndNewlines) ?? "" + let incomingTitle = importedTitle ?? "" + if !incomingTitle.isEmpty, + (existingTitle.isEmpty || importedLastVisited >= entries[idx].lastVisited) { + if entries[idx].title != incomingTitle { + entries[idx].title = incomingTitle + didMutate = true + } + } + + if didMutate { + mergedCount += 1 + } + } else { + entries.append(Entry( + id: UUID(), + url: urlString, + title: importedTitle, + lastVisited: importedLastVisited, + visitCount: importedVisitCount, + typedCount: importedTypedCount, + lastTypedAt: importedLastTypedAt + )) + mergedCount += 1 + } + } + + guard mergedCount > 0 else { return 0 } + entries.sort(by: { $0.lastVisited > $1.lastVisited }) + if entries.count > maxEntries { + entries.removeLast(entries.count - maxEntries) + } + scheduleSave() + return mergedCount + } + + func clearHistory() { + loadIfNeeded() + saveTask?.cancel() + saveTask = nil + entries = [] + guard let fileURL else { return } + try? FileManager.default.removeItem(at: fileURL) + } + + @discardableResult + func removeHistoryEntry(urlString: String) -> Bool { + loadIfNeeded() + let normalized = normalizedHistoryKey(urlString: urlString) + let originalCount = entries.count + entries.removeAll { entry in + if entry.url == urlString { return true } + guard let normalized else { return false } + return normalizedHistoryKey(urlString: entry.url) == normalized + } + let didRemove = entries.count != originalCount + if didRemove { + scheduleSave() + } + return didRemove + } + + func flushPendingSaves() { + loadIfNeeded() + saveTask?.cancel() + saveTask = nil + guard let fileURL else { return } + try? Self.persistSnapshot(entries, to: fileURL) + } + + private func scheduleSave() { + guard let fileURL else { return } + + saveTask?.cancel() + let snapshot = entries + let debounceNanoseconds = saveDebounceNanoseconds + + saveTask = Task.detached(priority: .utility) { + do { + try await Task.sleep(nanoseconds: debounceNanoseconds) // debounce + } catch { + return + } + if Task.isCancelled { return } + + do { + try Self.persistSnapshot(snapshot, to: fileURL) + } catch { + return + } + } + } + + private func migrateLegacyTaggedHistoryFileIfNeeded(to targetURL: URL) { + let fm = FileManager.default + guard !fm.fileExists(atPath: targetURL.path) else { return } + guard let legacyURL = Self.legacyTaggedHistoryFileURL(), + legacyURL != targetURL, + fm.fileExists(atPath: legacyURL.path) else { + return + } + + do { + let dir = targetURL.deletingLastPathComponent() + try fm.createDirectory(at: dir, withIntermediateDirectories: true, attributes: nil) + try fm.copyItem(at: legacyURL, to: targetURL) + } catch { + return + } + } + + private func makeSuggestionCandidate(entry: Entry) -> SuggestionCandidate { + let urlLower = entry.url.lowercased() + let urlSansSchemeLower = stripHTTPSSchemePrefix(urlLower) + let components = URLComponents(string: entry.url) + let hostLower = components?.host?.lowercased() ?? "" + let path = (components?.percentEncodedPath ?? components?.path ?? "").lowercased() + let query = (components?.percentEncodedQuery ?? components?.query ?? "").lowercased() + let pathAndQueryLower: String + if query.isEmpty { + pathAndQueryLower = path + } else { + pathAndQueryLower = "\(path)?\(query)" + } + let titleLower = (entry.title ?? "").trimmingCharacters(in: .whitespacesAndNewlines).lowercased() + return SuggestionCandidate( + entry: entry, + urlLower: urlLower, + urlSansSchemeLower: urlSansSchemeLower, + hostLower: hostLower, + pathAndQueryLower: pathAndQueryLower, + titleLower: titleLower + ) + } + + private func suggestionScore( + candidate: SuggestionCandidate, + query: String, + queryTokens: [String], + now: Date + ) -> Double? { + let queryIncludesScheme = query.hasPrefix("http://") || query.hasPrefix("https://") + let urlMatchValue = queryIncludesScheme ? candidate.urlLower : candidate.urlSansSchemeLower + let isSingleCharacterQuery = query.count == 1 + if isSingleCharacterQuery { + let hasSingleCharStrongMatch = + candidate.hostLower.hasPrefix(query) || + candidate.titleLower.hasPrefix(query) || + urlMatchValue.hasPrefix(query) + guard hasSingleCharStrongMatch else { return nil } + } + + let queryMatches = + urlMatchValue.contains(query) || + candidate.hostLower.contains(query) || + candidate.pathAndQueryLower.contains(query) || + candidate.titleLower.contains(query) + + let tokenMatches = !queryTokens.isEmpty && queryTokens.allSatisfy { token in + candidate.urlSansSchemeLower.contains(token) || + candidate.hostLower.contains(token) || + candidate.pathAndQueryLower.contains(token) || + candidate.titleLower.contains(token) + } + + guard queryMatches || tokenMatches else { return nil } + + var score = 0.0 + + if urlMatchValue == query { score += 1200 } + if candidate.hostLower == query { score += 980 } + if candidate.hostLower.hasPrefix(query) { score += 680 } + if urlMatchValue.hasPrefix(query) { score += 560 } + if candidate.titleLower.hasPrefix(query) { score += 420 } + if candidate.pathAndQueryLower.hasPrefix(query) { score += 300 } + + if candidate.hostLower.contains(query) { score += 210 } + if candidate.pathAndQueryLower.contains(query) { score += 165 } + if candidate.titleLower.contains(query) { score += 145 } + + for token in queryTokens { + if candidate.hostLower == token { score += 260 } + else if candidate.hostLower.hasPrefix(token) { score += 170 } + else if candidate.hostLower.contains(token) { score += 110 } + + if candidate.pathAndQueryLower.hasPrefix(token) { score += 80 } + else if candidate.pathAndQueryLower.contains(token) { score += 52 } + + if candidate.titleLower.hasPrefix(token) { score += 74 } + else if candidate.titleLower.contains(token) { score += 48 } + } + + // Blend recency and repeat visits so history feels closer to browser frecency. + let ageHours = max(0, now.timeIntervalSince(candidate.entry.lastVisited) / 3600) + let recencyScore = max(0, 110 - (ageHours / 3)) + let frequencyScore = min(120, log1p(Double(max(1, candidate.entry.visitCount))) * 38) + let typedFrequencyScore = min(190, log1p(Double(max(0, candidate.entry.typedCount))) * 80) + let typedRecencyScore: Double + if let lastTypedAt = candidate.entry.lastTypedAt { + let typedAgeHours = max(0, now.timeIntervalSince(lastTypedAt) / 3600) + typedRecencyScore = max(0, 85 - (typedAgeHours / 4)) + } else { + typedRecencyScore = 0 + } + score += recencyScore + frequencyScore + typedFrequencyScore + typedRecencyScore + + return score + } + + private func stripHTTPSSchemePrefix(_ value: String) -> String { + if value.hasPrefix("https://") { + return String(value.dropFirst("https://".count)) + } + if value.hasPrefix("http://") { + return String(value.dropFirst("http://".count)) + } + return value + } + + private func normalizedHistoryKey(url: URL) -> String? { + guard var components = URLComponents(url: url, resolvingAgainstBaseURL: true) else { return nil } + return normalizedHistoryKey(components: &components) + } + + private func normalizedHistoryKey(urlString: String) -> String? { + guard var components = URLComponents(string: urlString) else { return nil } + return normalizedHistoryKey(components: &components) + } + + private func normalizedHistoryKey(components: inout URLComponents) -> String? { + guard let scheme = components.scheme?.lowercased(), + scheme == "http" || scheme == "https", + var host = components.host?.lowercased() else { + return nil + } + + if host.hasPrefix("www.") { + host.removeFirst(4) + } + + if (scheme == "http" && components.port == 80) || + (scheme == "https" && components.port == 443) { + components.port = nil + } + + let portPart: String + if let port = components.port { + portPart = ":\(port)" + } else { + portPart = "" + } + + var path = components.percentEncodedPath + if path.isEmpty { path = "/" } + while path.count > 1, path.hasSuffix("/") { + path.removeLast() + } + + let queryPart: String + if let query = components.percentEncodedQuery, !query.isEmpty { + queryPart = "?\(query.lowercased())" + } else { + queryPart = "" + } + + return "\(scheme)://\(host)\(portPart)\(path)\(queryPart)" + } + + private func tokenizeSuggestionQuery(_ query: String) -> [String] { + var tokens: [String] = [] + var seen = Set() + let separators = CharacterSet.whitespacesAndNewlines.union(.punctuationCharacters).union(.symbols) + for raw in query.components(separatedBy: separators) { + let token = raw.trimmingCharacters(in: .whitespacesAndNewlines) + guard !token.isEmpty else { continue } + guard !seen.contains(token) else { continue } + seen.insert(token) + tokens.append(token) + } + return tokens + } + + nonisolated private static func defaultHistoryFileURL() -> URL? { + let fm = FileManager.default + guard let appSupport = fm.urls(for: .applicationSupportDirectory, in: .userDomainMask).first else { + return nil + } + let bundleId = Bundle.main.bundleIdentifier ?? "com.darkroom.programa" + let namespace = normalizedBrowserHistoryNamespace(bundleIdentifier: bundleId) + let dir = appSupport.appendingPathComponent(namespace, isDirectory: true) + return dir.appendingPathComponent("browser_history.json", isDirectory: false) + } + + nonisolated private static func legacyTaggedHistoryFileURL() -> URL? { + guard let bundleId = Bundle.main.bundleIdentifier else { return nil } + let namespace = normalizedBrowserHistoryNamespace(bundleIdentifier: bundleId) + guard namespace != bundleId else { return nil } + let fm = FileManager.default + guard let appSupport = fm.urls(for: .applicationSupportDirectory, in: .userDomainMask).first else { + return nil + } + let dir = appSupport.appendingPathComponent(bundleId, isDirectory: true) + return dir.appendingPathComponent("browser_history.json", isDirectory: false) + } + + nonisolated private static func persistSnapshot(_ snapshot: [Entry], to fileURL: URL) throws { + let dir = fileURL.deletingLastPathComponent() + try FileManager.default.createDirectory(at: dir, withIntermediateDirectories: true, attributes: nil) + + let encoder = JSONEncoder() + encoder.outputFormatting = [.withoutEscapingSlashes] + let data = try encoder.encode(snapshot) + try data.write(to: fileURL, options: [.atomic]) + } + + nonisolated static func defaultHistoryFileURLForCurrentBundle() -> URL? { + defaultHistoryFileURL() + } + + nonisolated static func normalizedBrowserHistoryNamespaceForBundleIdentifier(_ bundleIdentifier: String) -> String { + normalizedBrowserHistoryNamespace(bundleIdentifier: bundleIdentifier) + } +} + +actor BrowserSearchSuggestionService { + static let shared = BrowserSearchSuggestionService() + + func suggestions(engine: BrowserSearchEngine, query: String) async -> [String] { + let trimmed = query.trimmingCharacters(in: .whitespacesAndNewlines) + guard !trimmed.isEmpty else { return [] } + + // Deterministic UI-test hook for validating remote suggestion rendering + // without relying on external network behavior. + let forced = ProcessInfo.processInfo.environment["PROGRAMA_UI_TEST_REMOTE_SUGGESTIONS_JSON"] + ?? UserDefaults.standard.string(forKey: "PROGRAMA_UI_TEST_REMOTE_SUGGESTIONS_JSON") + if let forced, + let data = forced.data(using: .utf8), + let parsed = try? JSONSerialization.jsonObject(with: data) as? [Any] { + return parsed.compactMap { item in + guard let s = item as? String else { return nil } + let value = s.trimmingCharacters(in: .whitespacesAndNewlines) + return value.isEmpty ? nil : value + } + } + + // Google's endpoint can intermittently throttle/block app-style traffic. + // Query fallbacks in parallel so we can show predictions quickly. + if engine == .google { + return await fetchRemoteSuggestionsWithGoogleFallbacks(query: trimmed) + } + + return await fetchRemoteSuggestions(engine: engine, query: trimmed) + } + + private func fetchRemoteSuggestionsWithGoogleFallbacks(query: String) async -> [String] { + await withTaskGroup(of: [String].self, returning: [String].self) { group in + group.addTask { + await self.fetchRemoteSuggestions(engine: .google, query: query) + } + group.addTask { + await self.fetchRemoteSuggestions(engine: .duckduckgo, query: query) + } + group.addTask { + await self.fetchRemoteSuggestions(engine: .bing, query: query) + } + + while let result = await group.next() { + if !result.isEmpty { + group.cancelAll() + return result + } + } + + return [] + } + } + + private func fetchRemoteSuggestions(engine: BrowserSearchEngine, query: String) async -> [String] { + let url: URL? + switch engine { + case .google: + var c = URLComponents(string: "https://suggestqueries.google.com/complete/search") + c?.queryItems = [ + URLQueryItem(name: "client", value: "firefox"), + URLQueryItem(name: "q", value: query), + ] + url = c?.url + case .duckduckgo: + var c = URLComponents(string: "https://duckduckgo.com/ac/") + c?.queryItems = [ + URLQueryItem(name: "q", value: query), + URLQueryItem(name: "type", value: "list"), + ] + url = c?.url + case .bing: + var c = URLComponents(string: "https://www.bing.com/osjson.aspx") + c?.queryItems = [ + URLQueryItem(name: "query", value: query), + ] + url = c?.url + case .kagi: + var c = URLComponents(string: "https://kagi.com/api/autosuggest") + c?.queryItems = [ + URLQueryItem(name: "q", value: query), + ] + url = c?.url + case .startpage: + var c = URLComponents(string: "https://www.startpage.com/osuggestions") + c?.queryItems = [ + URLQueryItem(name: "q", value: query), + ] + url = c?.url + } + + guard let url else { return [] } + + var req = URLRequest(url: url) + req.timeoutInterval = 0.65 + req.cachePolicy = .returnCacheDataElseLoad + req.setValue(BrowserUserAgentSettings.safariUserAgent, forHTTPHeaderField: "User-Agent") + req.setValue("en-US,en;q=0.9", forHTTPHeaderField: "Accept-Language") + + let data: Data + let response: URLResponse + do { + (data, response) = try await URLSession.shared.data(for: req) + } catch { + return [] + } + + guard let http = response as? HTTPURLResponse, + (200..<300).contains(http.statusCode) else { + return [] + } + + switch engine { + case .google, .bing, .kagi, .startpage: + return parseOSJSON(data: data) + case .duckduckgo: + return parseDuckDuckGo(data: data) + } + } + + private func parseOSJSON(data: Data) -> [String] { + // Format: [query, [suggestions...], ...] + guard let root = try? JSONSerialization.jsonObject(with: data) as? [Any], + root.count >= 2, + let list = root[1] as? [Any] else { + return [] + } + var out: [String] = [] + out.reserveCapacity(list.count) + for item in list { + guard let s = item as? String else { continue } + let trimmed = s.trimmingCharacters(in: .whitespacesAndNewlines) + guard !trimmed.isEmpty else { continue } + out.append(trimmed) + } + return out + } + + private func parseDuckDuckGo(data: Data) -> [String] { + // Format: [{phrase:"..."}, ...] + guard let root = try? JSONSerialization.jsonObject(with: data) as? [Any] else { + return [] + } + var out: [String] = [] + out.reserveCapacity(root.count) + for item in root { + guard let dict = item as? [String: Any], + let phrase = dict["phrase"] as? String else { continue } + let trimmed = phrase.trimmingCharacters(in: .whitespacesAndNewlines) + guard !trimmed.isEmpty else { continue } + out.append(trimmed) + } + return out + } +} diff --git a/Sources/Panels/BrowserPanel.swift b/Sources/Panels/BrowserPanel.swift index f749e6b25f2..f69c7e24dd3 100644 --- a/Sources/Panels/BrowserPanel.swift +++ b/Sources/Panels/BrowserPanel.swift @@ -274,209 +274,6 @@ enum BrowserImportHintSettings { } } -struct BrowserProfileDefinition: Codable, Hashable, Identifiable, Sendable { - let id: UUID - var displayName: String - let createdAt: Date - let isBuiltInDefault: Bool - - var slug: String { - if isBuiltInDefault { - return "default" - } - - let normalized = displayName - .trimmingCharacters(in: .whitespacesAndNewlines) - .lowercased() - .replacingOccurrences(of: "[^a-z0-9]+", with: "-", options: .regularExpression) - .trimmingCharacters(in: CharacterSet(charactersIn: "-")) - return normalized.isEmpty ? id.uuidString.lowercased() : normalized - } -} - -@MainActor -final class BrowserProfileStore: ObservableObject { - static let shared = BrowserProfileStore() - - private static let profilesDefaultsKey = "browserProfiles.v1" - private static let lastUsedProfileDefaultsKey = "browserProfiles.lastUsed" - private static let builtInDefaultProfileID = UUID(uuidString: "52B43C05-4A1D-45D3-8FD5-9EF94952E445")! - - @Published private(set) var profiles: [BrowserProfileDefinition] = [] - @Published private(set) var lastUsedProfileID: UUID = builtInDefaultProfileID - - private let defaults: UserDefaults - private var dataStores: [UUID: WKWebsiteDataStore] = [:] - private var historyStores: [UUID: BrowserHistoryStore] = [:] - - init(defaults: UserDefaults = .standard) { - self.defaults = defaults - load() - } - - var builtInDefaultProfileID: UUID { - Self.builtInDefaultProfileID - } - - var effectiveLastUsedProfileID: UUID { - profileDefinition(id: lastUsedProfileID) != nil ? lastUsedProfileID : Self.builtInDefaultProfileID - } - - func profileDefinition(id: UUID) -> BrowserProfileDefinition? { - profiles.first(where: { $0.id == id }) - } - - func displayName(for id: UUID) -> String { - profileDefinition(id: id)?.displayName - ?? String(localized: "browser.profile.default", defaultValue: "Default") - } - - func createProfile(named rawName: String) -> BrowserProfileDefinition? { - let name = rawName.trimmingCharacters(in: .whitespacesAndNewlines) - guard !name.isEmpty else { return nil } - let profile = BrowserProfileDefinition( - id: UUID(), - displayName: name, - createdAt: Date(), - isBuiltInDefault: false - ) - profiles.append(profile) - profiles.sort { - if $0.isBuiltInDefault != $1.isBuiltInDefault { - return $0.isBuiltInDefault && !$1.isBuiltInDefault - } - return $0.displayName.localizedCaseInsensitiveCompare($1.displayName) == .orderedAscending - } - persist() - noteUsed(profile.id) - return profile - } - - func renameProfile(id: UUID, to rawName: String) -> Bool { - let name = rawName.trimmingCharacters(in: .whitespacesAndNewlines) - guard !name.isEmpty, - let index = profiles.firstIndex(where: { $0.id == id }), - !profiles[index].isBuiltInDefault else { - return false - } - profiles[index].displayName = name - profiles.sort { - if $0.isBuiltInDefault != $1.isBuiltInDefault { - return $0.isBuiltInDefault && !$1.isBuiltInDefault - } - return $0.displayName.localizedCaseInsensitiveCompare($1.displayName) == .orderedAscending - } - persist() - return true - } - - func canRenameProfile(id: UUID) -> Bool { - guard let profile = profileDefinition(id: id) else { return false } - return !profile.isBuiltInDefault - } - - func noteUsed(_ id: UUID) { - guard profileDefinition(id: id) != nil else { return } - if lastUsedProfileID != id { - lastUsedProfileID = id - defaults.set(id.uuidString, forKey: Self.lastUsedProfileDefaultsKey) - } - } - - func websiteDataStore(for profileID: UUID) -> WKWebsiteDataStore { - if profileID == Self.builtInDefaultProfileID { - return .default() - } - if let existing = dataStores[profileID] { - return existing - } - let store = WKWebsiteDataStore(forIdentifier: profileID) - dataStores[profileID] = store - return store - } - - func historyStore(for profileID: UUID) -> BrowserHistoryStore { - if profileID == Self.builtInDefaultProfileID { - return .shared - } - if let existing = historyStores[profileID] { - return existing - } - let store = BrowserHistoryStore(fileURL: historyFileURL(for: profileID)) - historyStores[profileID] = store - return store - } - - func historyFileURL(for profileID: UUID) -> URL? { - if profileID == Self.builtInDefaultProfileID { - return BrowserHistoryStore.defaultHistoryFileURLForCurrentBundle() - } - - let fm = FileManager.default - guard let appSupport = fm.urls(for: .applicationSupportDirectory, in: .userDomainMask).first else { - return nil - } - let bundleId = Bundle.main.bundleIdentifier ?? "com.darkroom.programa" - let namespace = BrowserHistoryStore.normalizedBrowserHistoryNamespaceForBundleIdentifier(bundleId) - let profilesDir = appSupport - .appendingPathComponent(namespace, isDirectory: true) - .appendingPathComponent("browser_profiles", isDirectory: true) - .appendingPathComponent(profileID.uuidString.lowercased(), isDirectory: true) - return profilesDir.appendingPathComponent("browser_history.json", isDirectory: false) - } - - func flushPendingSaves() { - BrowserHistoryStore.shared.flushPendingSaves() - for store in historyStores.values { - store.flushPendingSaves() - } - } - - private func load() { - let builtInDefaultProfile = BrowserProfileDefinition( - id: Self.builtInDefaultProfileID, - displayName: String(localized: "browser.profile.default", defaultValue: "Default"), - createdAt: Date(timeIntervalSince1970: 0), - isBuiltInDefault: true - ) - - if let data = defaults.data(forKey: Self.profilesDefaultsKey), - let decoded = try? JSONDecoder().decode([BrowserProfileDefinition].self, from: data), - !decoded.isEmpty { - var resolvedProfiles = decoded.filter { $0.id != Self.builtInDefaultProfileID } - resolvedProfiles.append(builtInDefaultProfile) - profiles = sortedProfiles(resolvedProfiles) - } else { - profiles = [builtInDefaultProfile] - persist() - } - - if let rawLastUsed = defaults.string(forKey: Self.lastUsedProfileDefaultsKey), - let parsed = UUID(uuidString: rawLastUsed), - profileDefinition(id: parsed) != nil { - lastUsedProfileID = parsed - } else { - lastUsedProfileID = Self.builtInDefaultProfileID - defaults.set(lastUsedProfileID.uuidString, forKey: Self.lastUsedProfileDefaultsKey) - } - } - - private func persist() { - let encoder = JSONEncoder() - guard let data = try? encoder.encode(profiles) else { return } - defaults.set(data, forKey: Self.profilesDefaultsKey) - } - - private func sortedProfiles(_ profiles: [BrowserProfileDefinition]) -> [BrowserProfileDefinition] { - profiles.sorted { - if $0.isBuiltInDefault != $1.isBuiltInDefault { - return $0.isBuiltInDefault && !$1.isBuiltInDefault - } - return $0.displayName.localizedCaseInsensitiveCompare($1.displayName) == .orderedAscending - } - } -} - enum BrowserLinkOpenSettings { static let openTerminalLinksInProgramaBrowserKey = "browserOpenTerminalLinksInProgramaBrowser" static let defaultOpenTerminalLinksInProgramaBrowser: Bool = true @@ -827,808 +624,12 @@ enum BrowserUserAgentSettings { static let safariUserAgent = "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/26.2 Safari/605.1.15" } -func normalizedBrowserHistoryNamespace(bundleIdentifier: String) -> String { - if bundleIdentifier.hasPrefix("com.darkroom.programa.debug.") { - return "com.darkroom.programa.debug" - } - if bundleIdentifier.hasPrefix("com.darkroom.programa.staging.") { - return "com.darkroom.programa.staging" - } - return bundleIdentifier -} - -@MainActor -final class BrowserHistoryStore: ObservableObject { - static let shared = BrowserHistoryStore() - - struct Entry: Codable, Identifiable, Hashable { - let id: UUID - var url: String - var title: String? - var lastVisited: Date - var visitCount: Int - var typedCount: Int - var lastTypedAt: Date? - - private enum CodingKeys: String, CodingKey { - case id - case url - case title - case lastVisited - case visitCount - case typedCount - case lastTypedAt - } - - init( - id: UUID, - url: String, - title: String?, - lastVisited: Date, - visitCount: Int, - typedCount: Int = 0, - lastTypedAt: Date? = nil - ) { - self.id = id - self.url = url - self.title = title - self.lastVisited = lastVisited - self.visitCount = visitCount - self.typedCount = typedCount - self.lastTypedAt = lastTypedAt - } - - init(from decoder: Decoder) throws { - let container = try decoder.container(keyedBy: CodingKeys.self) - id = try container.decode(UUID.self, forKey: .id) - url = try container.decode(String.self, forKey: .url) - title = try container.decodeIfPresent(String.self, forKey: .title) - lastVisited = try container.decode(Date.self, forKey: .lastVisited) - visitCount = try container.decode(Int.self, forKey: .visitCount) - typedCount = try container.decodeIfPresent(Int.self, forKey: .typedCount) ?? 0 - lastTypedAt = try container.decodeIfPresent(Date.self, forKey: .lastTypedAt) - } - } - - @Published private(set) var entries: [Entry] = [] - - private let fileURL: URL? - private var didLoad: Bool = false - private var saveTask: Task? - private let maxEntries: Int = 5000 - private let saveDebounceNanoseconds: UInt64 = 120_000_000 - - private struct SuggestionCandidate { - let entry: Entry - let urlLower: String - let urlSansSchemeLower: String - let hostLower: String - let pathAndQueryLower: String - let titleLower: String - } - - private struct ScoredSuggestion { - let entry: Entry - let score: Double - } - - init(fileURL: URL? = nil) { - // Avoid calling @MainActor-isolated static methods from default argument context. - self.fileURL = fileURL ?? BrowserHistoryStore.defaultHistoryFileURL() - } - - func loadIfNeeded() { - guard !didLoad else { return } - didLoad = true - guard let fileURL else { return } - migrateLegacyTaggedHistoryFileIfNeeded(to: fileURL) - - // Load synchronously on first access so the first omnibar query can use - // persisted history immediately (important for deterministic UI behavior). - let data: Data - do { - data = try Data(contentsOf: fileURL) - } catch { - return - } - - let decoded: [Entry] - do { - decoded = try JSONDecoder().decode([Entry].self, from: data) - } catch { - return - } - - // Most-recent first. - entries = decoded.sorted(by: { $0.lastVisited > $1.lastVisited }) - - // Remove entries with invalid hosts (no TLD), e.g. "https://news." - let beforeCount = entries.count - entries.removeAll { entry in - guard let url = URL(string: entry.url), - let host = url.host?.lowercased() else { return false } - let trimmed = host.hasSuffix(".") ? String(host.dropLast()) : host - return !trimmed.contains(".") - } - if entries.count != beforeCount { - scheduleSave() - } - } - - func recordVisit(url: URL?, title: String?) { - loadIfNeeded() - - guard let url else { return } - guard let scheme = url.scheme?.lowercased(), - scheme == "http" || scheme == "https" else { return } - // Skip URLs whose host lacks a TLD (e.g. "https://news."). - if let host = url.host?.lowercased() { - let trimmed = host.hasSuffix(".") ? String(host.dropLast()) : host - if !trimmed.contains(".") { return } - } - - let urlString = url.absoluteString - guard urlString != "about:blank" else { return } - let normalizedKey = normalizedHistoryKey(url: url) - - if let idx = entries.firstIndex(where: { - if $0.url == urlString { return true } - return normalizedHistoryKey(urlString: $0.url) == normalizedKey - }) { - entries[idx].lastVisited = Date() - entries[idx].visitCount += 1 - // Prefer non-empty titles, but don't clobber an existing title with empty/whitespace. - if let title, !title.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty { - entries[idx].title = title - } - } else { - entries.insert(Entry( - id: UUID(), - url: urlString, - title: title?.trimmingCharacters(in: .whitespacesAndNewlines), - lastVisited: Date(), - visitCount: 1 - ), at: 0) - } - - // Keep most-recent first and bound size. - entries.sort(by: { $0.lastVisited > $1.lastVisited }) - if entries.count > maxEntries { - entries.removeLast(entries.count - maxEntries) - } - - scheduleSave() - } - - func recordTypedNavigation(url: URL?) { - loadIfNeeded() - - guard let url else { return } - guard let scheme = url.scheme?.lowercased(), - scheme == "http" || scheme == "https" else { return } - // Skip URLs whose host lacks a TLD (e.g. "https://news."). - if let host = url.host?.lowercased() { - let trimmed = host.hasSuffix(".") ? String(host.dropLast()) : host - if !trimmed.contains(".") { return } - } - - let urlString = url.absoluteString - guard urlString != "about:blank" else { return } - - let now = Date() - let normalizedKey = normalizedHistoryKey(url: url) - if let idx = entries.firstIndex(where: { - if $0.url == urlString { return true } - return normalizedHistoryKey(urlString: $0.url) == normalizedKey - }) { - entries[idx].typedCount += 1 - entries[idx].lastTypedAt = now - entries[idx].lastVisited = now - } else { - entries.insert(Entry( - id: UUID(), - url: urlString, - title: nil, - lastVisited: now, - visitCount: 1, - typedCount: 1, - lastTypedAt: now - ), at: 0) - } - - entries.sort(by: { $0.lastVisited > $1.lastVisited }) - if entries.count > maxEntries { - entries.removeLast(entries.count - maxEntries) - } - - scheduleSave() - } - - func suggestions(for input: String, limit: Int = 10) -> [Entry] { - loadIfNeeded() - guard limit > 0 else { return [] } - - let q = input.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() - guard !q.isEmpty else { return [] } - let queryTokens = tokenizeSuggestionQuery(q) - let now = Date() - - let matched = entries.compactMap { entry -> ScoredSuggestion? in - let candidate = makeSuggestionCandidate(entry: entry) - guard let score = suggestionScore(candidate: candidate, query: q, queryTokens: queryTokens, now: now) else { - return nil - } - return ScoredSuggestion(entry: entry, score: score) - } - .sorted { lhs, rhs in - if lhs.score != rhs.score { return lhs.score > rhs.score } - if lhs.entry.lastVisited != rhs.entry.lastVisited { return lhs.entry.lastVisited > rhs.entry.lastVisited } - if lhs.entry.visitCount != rhs.entry.visitCount { return lhs.entry.visitCount > rhs.entry.visitCount } - return lhs.entry.url < rhs.entry.url - } - - if matched.count <= limit { return matched.map(\.entry) } - return Array(matched.prefix(limit).map(\.entry)) - } - - func recentSuggestions(limit: Int = 10) -> [Entry] { - loadIfNeeded() - guard limit > 0 else { return [] } - - let ranked = entries.sorted { lhs, rhs in - if lhs.typedCount != rhs.typedCount { return lhs.typedCount > rhs.typedCount } - let lhsTypedDate = lhs.lastTypedAt ?? .distantPast - let rhsTypedDate = rhs.lastTypedAt ?? .distantPast - if lhsTypedDate != rhsTypedDate { return lhsTypedDate > rhsTypedDate } - if lhs.lastVisited != rhs.lastVisited { return lhs.lastVisited > rhs.lastVisited } - if lhs.visitCount != rhs.visitCount { return lhs.visitCount > rhs.visitCount } - return lhs.url < rhs.url - } - - if ranked.count <= limit { return ranked } - return Array(ranked.prefix(limit)) - } - - @discardableResult - func mergeImportedEntries(_ importedEntries: [Entry]) -> Int { - loadIfNeeded() - guard !importedEntries.isEmpty else { return 0 } - - var mergedCount = 0 - for imported in importedEntries { - guard let parsedURL = URL(string: imported.url), - let scheme = parsedURL.scheme?.lowercased(), - scheme == "http" || scheme == "https" else { - continue - } - - if let host = parsedURL.host?.lowercased() { - let trimmed = host.hasSuffix(".") ? String(host.dropLast()) : host - if !trimmed.contains(".") { continue } - } - - let urlString = parsedURL.absoluteString - guard urlString != "about:blank" else { continue } - let normalizedKey = normalizedHistoryKey(url: parsedURL) - - let importedTitle = imported.title?.trimmingCharacters(in: .whitespacesAndNewlines) - let importedLastVisited = imported.lastVisited - let importedVisitCount = max(1, imported.visitCount) - let importedTypedCount = max(0, imported.typedCount) - let importedLastTypedAt = imported.lastTypedAt - - if let idx = entries.firstIndex(where: { - if $0.url == urlString { return true } - guard let normalizedKey else { return false } - return normalizedHistoryKey(urlString: $0.url) == normalizedKey - }) { - var didMutate = false - if importedLastVisited > entries[idx].lastVisited { - entries[idx].lastVisited = importedLastVisited - didMutate = true - } - if importedVisitCount > entries[idx].visitCount { - entries[idx].visitCount = importedVisitCount - didMutate = true - } - if importedTypedCount > entries[idx].typedCount { - entries[idx].typedCount = importedTypedCount - didMutate = true - } - if let importedLastTypedAt { - if let existingLastTypedAt = entries[idx].lastTypedAt { - if importedLastTypedAt > existingLastTypedAt { - entries[idx].lastTypedAt = importedLastTypedAt - didMutate = true - } - } else { - entries[idx].lastTypedAt = importedLastTypedAt - didMutate = true - } - } - - let existingTitle = entries[idx].title?.trimmingCharacters(in: .whitespacesAndNewlines) ?? "" - let incomingTitle = importedTitle ?? "" - if !incomingTitle.isEmpty, - (existingTitle.isEmpty || importedLastVisited >= entries[idx].lastVisited) { - if entries[idx].title != incomingTitle { - entries[idx].title = incomingTitle - didMutate = true - } - } - - if didMutate { - mergedCount += 1 - } - } else { - entries.append(Entry( - id: UUID(), - url: urlString, - title: importedTitle, - lastVisited: importedLastVisited, - visitCount: importedVisitCount, - typedCount: importedTypedCount, - lastTypedAt: importedLastTypedAt - )) - mergedCount += 1 - } - } - - guard mergedCount > 0 else { return 0 } - entries.sort(by: { $0.lastVisited > $1.lastVisited }) - if entries.count > maxEntries { - entries.removeLast(entries.count - maxEntries) - } - scheduleSave() - return mergedCount - } - - func clearHistory() { - loadIfNeeded() - saveTask?.cancel() - saveTask = nil - entries = [] - guard let fileURL else { return } - try? FileManager.default.removeItem(at: fileURL) - } - - @discardableResult - func removeHistoryEntry(urlString: String) -> Bool { - loadIfNeeded() - let normalized = normalizedHistoryKey(urlString: urlString) - let originalCount = entries.count - entries.removeAll { entry in - if entry.url == urlString { return true } - guard let normalized else { return false } - return normalizedHistoryKey(urlString: entry.url) == normalized - } - let didRemove = entries.count != originalCount - if didRemove { - scheduleSave() - } - return didRemove - } - - func flushPendingSaves() { - loadIfNeeded() - saveTask?.cancel() - saveTask = nil - guard let fileURL else { return } - try? Self.persistSnapshot(entries, to: fileURL) - } - - private func scheduleSave() { - guard let fileURL else { return } - - saveTask?.cancel() - let snapshot = entries - let debounceNanoseconds = saveDebounceNanoseconds - - saveTask = Task.detached(priority: .utility) { - do { - try await Task.sleep(nanoseconds: debounceNanoseconds) // debounce - } catch { - return - } - if Task.isCancelled { return } - - do { - try Self.persistSnapshot(snapshot, to: fileURL) - } catch { - return - } - } - } - - private func migrateLegacyTaggedHistoryFileIfNeeded(to targetURL: URL) { - let fm = FileManager.default - guard !fm.fileExists(atPath: targetURL.path) else { return } - guard let legacyURL = Self.legacyTaggedHistoryFileURL(), - legacyURL != targetURL, - fm.fileExists(atPath: legacyURL.path) else { - return - } - - do { - let dir = targetURL.deletingLastPathComponent() - try fm.createDirectory(at: dir, withIntermediateDirectories: true, attributes: nil) - try fm.copyItem(at: legacyURL, to: targetURL) - } catch { - return - } - } - - private func makeSuggestionCandidate(entry: Entry) -> SuggestionCandidate { - let urlLower = entry.url.lowercased() - let urlSansSchemeLower = stripHTTPSSchemePrefix(urlLower) - let components = URLComponents(string: entry.url) - let hostLower = components?.host?.lowercased() ?? "" - let path = (components?.percentEncodedPath ?? components?.path ?? "").lowercased() - let query = (components?.percentEncodedQuery ?? components?.query ?? "").lowercased() - let pathAndQueryLower: String - if query.isEmpty { - pathAndQueryLower = path - } else { - pathAndQueryLower = "\(path)?\(query)" - } - let titleLower = (entry.title ?? "").trimmingCharacters(in: .whitespacesAndNewlines).lowercased() - return SuggestionCandidate( - entry: entry, - urlLower: urlLower, - urlSansSchemeLower: urlSansSchemeLower, - hostLower: hostLower, - pathAndQueryLower: pathAndQueryLower, - titleLower: titleLower - ) - } - - private func suggestionScore( - candidate: SuggestionCandidate, - query: String, - queryTokens: [String], - now: Date - ) -> Double? { - let queryIncludesScheme = query.hasPrefix("http://") || query.hasPrefix("https://") - let urlMatchValue = queryIncludesScheme ? candidate.urlLower : candidate.urlSansSchemeLower - let isSingleCharacterQuery = query.count == 1 - if isSingleCharacterQuery { - let hasSingleCharStrongMatch = - candidate.hostLower.hasPrefix(query) || - candidate.titleLower.hasPrefix(query) || - urlMatchValue.hasPrefix(query) - guard hasSingleCharStrongMatch else { return nil } - } - - let queryMatches = - urlMatchValue.contains(query) || - candidate.hostLower.contains(query) || - candidate.pathAndQueryLower.contains(query) || - candidate.titleLower.contains(query) - - let tokenMatches = !queryTokens.isEmpty && queryTokens.allSatisfy { token in - candidate.urlSansSchemeLower.contains(token) || - candidate.hostLower.contains(token) || - candidate.pathAndQueryLower.contains(token) || - candidate.titleLower.contains(token) - } - - guard queryMatches || tokenMatches else { return nil } - - var score = 0.0 - - if urlMatchValue == query { score += 1200 } - if candidate.hostLower == query { score += 980 } - if candidate.hostLower.hasPrefix(query) { score += 680 } - if urlMatchValue.hasPrefix(query) { score += 560 } - if candidate.titleLower.hasPrefix(query) { score += 420 } - if candidate.pathAndQueryLower.hasPrefix(query) { score += 300 } - - if candidate.hostLower.contains(query) { score += 210 } - if candidate.pathAndQueryLower.contains(query) { score += 165 } - if candidate.titleLower.contains(query) { score += 145 } - - for token in queryTokens { - if candidate.hostLower == token { score += 260 } - else if candidate.hostLower.hasPrefix(token) { score += 170 } - else if candidate.hostLower.contains(token) { score += 110 } - - if candidate.pathAndQueryLower.hasPrefix(token) { score += 80 } - else if candidate.pathAndQueryLower.contains(token) { score += 52 } - - if candidate.titleLower.hasPrefix(token) { score += 74 } - else if candidate.titleLower.contains(token) { score += 48 } - } - - // Blend recency and repeat visits so history feels closer to browser frecency. - let ageHours = max(0, now.timeIntervalSince(candidate.entry.lastVisited) / 3600) - let recencyScore = max(0, 110 - (ageHours / 3)) - let frequencyScore = min(120, log1p(Double(max(1, candidate.entry.visitCount))) * 38) - let typedFrequencyScore = min(190, log1p(Double(max(0, candidate.entry.typedCount))) * 80) - let typedRecencyScore: Double - if let lastTypedAt = candidate.entry.lastTypedAt { - let typedAgeHours = max(0, now.timeIntervalSince(lastTypedAt) / 3600) - typedRecencyScore = max(0, 85 - (typedAgeHours / 4)) - } else { - typedRecencyScore = 0 - } - score += recencyScore + frequencyScore + typedFrequencyScore + typedRecencyScore - - return score - } - - private func stripHTTPSSchemePrefix(_ value: String) -> String { - if value.hasPrefix("https://") { - return String(value.dropFirst("https://".count)) - } - if value.hasPrefix("http://") { - return String(value.dropFirst("http://".count)) - } - return value - } - - private func normalizedHistoryKey(url: URL) -> String? { - guard var components = URLComponents(url: url, resolvingAgainstBaseURL: true) else { return nil } - return normalizedHistoryKey(components: &components) - } - - private func normalizedHistoryKey(urlString: String) -> String? { - guard var components = URLComponents(string: urlString) else { return nil } - return normalizedHistoryKey(components: &components) - } - - private func normalizedHistoryKey(components: inout URLComponents) -> String? { - guard let scheme = components.scheme?.lowercased(), - scheme == "http" || scheme == "https", - var host = components.host?.lowercased() else { - return nil - } - - if host.hasPrefix("www.") { - host.removeFirst(4) - } - - if (scheme == "http" && components.port == 80) || - (scheme == "https" && components.port == 443) { - components.port = nil - } - - let portPart: String - if let port = components.port { - portPart = ":\(port)" - } else { - portPart = "" - } - - var path = components.percentEncodedPath - if path.isEmpty { path = "/" } - while path.count > 1, path.hasSuffix("/") { - path.removeLast() - } - - let queryPart: String - if let query = components.percentEncodedQuery, !query.isEmpty { - queryPart = "?\(query.lowercased())" - } else { - queryPart = "" - } - - return "\(scheme)://\(host)\(portPart)\(path)\(queryPart)" - } - - private func tokenizeSuggestionQuery(_ query: String) -> [String] { - var tokens: [String] = [] - var seen = Set() - let separators = CharacterSet.whitespacesAndNewlines.union(.punctuationCharacters).union(.symbols) - for raw in query.components(separatedBy: separators) { - let token = raw.trimmingCharacters(in: .whitespacesAndNewlines) - guard !token.isEmpty else { continue } - guard !seen.contains(token) else { continue } - seen.insert(token) - tokens.append(token) - } - return tokens - } - - nonisolated private static func defaultHistoryFileURL() -> URL? { - let fm = FileManager.default - guard let appSupport = fm.urls(for: .applicationSupportDirectory, in: .userDomainMask).first else { - return nil - } - let bundleId = Bundle.main.bundleIdentifier ?? "com.darkroom.programa" - let namespace = normalizedBrowserHistoryNamespace(bundleIdentifier: bundleId) - let dir = appSupport.appendingPathComponent(namespace, isDirectory: true) - return dir.appendingPathComponent("browser_history.json", isDirectory: false) - } - - nonisolated private static func legacyTaggedHistoryFileURL() -> URL? { - guard let bundleId = Bundle.main.bundleIdentifier else { return nil } - let namespace = normalizedBrowserHistoryNamespace(bundleIdentifier: bundleId) - guard namespace != bundleId else { return nil } - let fm = FileManager.default - guard let appSupport = fm.urls(for: .applicationSupportDirectory, in: .userDomainMask).first else { - return nil - } - let dir = appSupport.appendingPathComponent(bundleId, isDirectory: true) - return dir.appendingPathComponent("browser_history.json", isDirectory: false) - } - - nonisolated private static func persistSnapshot(_ snapshot: [Entry], to fileURL: URL) throws { - let dir = fileURL.deletingLastPathComponent() - try FileManager.default.createDirectory(at: dir, withIntermediateDirectories: true, attributes: nil) - - let encoder = JSONEncoder() - encoder.outputFormatting = [.withoutEscapingSlashes] - let data = try encoder.encode(snapshot) - try data.write(to: fileURL, options: [.atomic]) - } - - nonisolated static func defaultHistoryFileURLForCurrentBundle() -> URL? { - defaultHistoryFileURL() - } - - nonisolated static func normalizedBrowserHistoryNamespaceForBundleIdentifier(_ bundleIdentifier: String) -> String { - normalizedBrowserHistoryNamespace(bundleIdentifier: bundleIdentifier) - } -} - -actor BrowserSearchSuggestionService { - static let shared = BrowserSearchSuggestionService() - - func suggestions(engine: BrowserSearchEngine, query: String) async -> [String] { - let trimmed = query.trimmingCharacters(in: .whitespacesAndNewlines) - guard !trimmed.isEmpty else { return [] } - - // Deterministic UI-test hook for validating remote suggestion rendering - // without relying on external network behavior. - let forced = ProcessInfo.processInfo.environment["PROGRAMA_UI_TEST_REMOTE_SUGGESTIONS_JSON"] - ?? UserDefaults.standard.string(forKey: "PROGRAMA_UI_TEST_REMOTE_SUGGESTIONS_JSON") - if let forced, - let data = forced.data(using: .utf8), - let parsed = try? JSONSerialization.jsonObject(with: data) as? [Any] { - return parsed.compactMap { item in - guard let s = item as? String else { return nil } - let value = s.trimmingCharacters(in: .whitespacesAndNewlines) - return value.isEmpty ? nil : value - } - } - - // Google's endpoint can intermittently throttle/block app-style traffic. - // Query fallbacks in parallel so we can show predictions quickly. - if engine == .google { - return await fetchRemoteSuggestionsWithGoogleFallbacks(query: trimmed) - } - - return await fetchRemoteSuggestions(engine: engine, query: trimmed) - } - - private func fetchRemoteSuggestionsWithGoogleFallbacks(query: String) async -> [String] { - await withTaskGroup(of: [String].self, returning: [String].self) { group in - group.addTask { - await self.fetchRemoteSuggestions(engine: .google, query: query) - } - group.addTask { - await self.fetchRemoteSuggestions(engine: .duckduckgo, query: query) - } - group.addTask { - await self.fetchRemoteSuggestions(engine: .bing, query: query) - } - - while let result = await group.next() { - if !result.isEmpty { - group.cancelAll() - return result - } - } - - return [] - } - } - - private func fetchRemoteSuggestions(engine: BrowserSearchEngine, query: String) async -> [String] { - let url: URL? - switch engine { - case .google: - var c = URLComponents(string: "https://suggestqueries.google.com/complete/search") - c?.queryItems = [ - URLQueryItem(name: "client", value: "firefox"), - URLQueryItem(name: "q", value: query), - ] - url = c?.url - case .duckduckgo: - var c = URLComponents(string: "https://duckduckgo.com/ac/") - c?.queryItems = [ - URLQueryItem(name: "q", value: query), - URLQueryItem(name: "type", value: "list"), - ] - url = c?.url - case .bing: - var c = URLComponents(string: "https://www.bing.com/osjson.aspx") - c?.queryItems = [ - URLQueryItem(name: "query", value: query), - ] - url = c?.url - case .kagi: - var c = URLComponents(string: "https://kagi.com/api/autosuggest") - c?.queryItems = [ - URLQueryItem(name: "q", value: query), - ] - url = c?.url - case .startpage: - var c = URLComponents(string: "https://www.startpage.com/osuggestions") - c?.queryItems = [ - URLQueryItem(name: "q", value: query), - ] - url = c?.url - } - - guard let url else { return [] } - - var req = URLRequest(url: url) - req.timeoutInterval = 0.65 - req.cachePolicy = .returnCacheDataElseLoad - req.setValue(BrowserUserAgentSettings.safariUserAgent, forHTTPHeaderField: "User-Agent") - req.setValue("en-US,en;q=0.9", forHTTPHeaderField: "Accept-Language") - - let data: Data - let response: URLResponse - do { - (data, response) = try await URLSession.shared.data(for: req) - } catch { - return [] - } - - guard let http = response as? HTTPURLResponse, - (200..<300).contains(http.statusCode) else { - return [] - } - - switch engine { - case .google, .bing, .kagi, .startpage: - return parseOSJSON(data: data) - case .duckduckgo: - return parseDuckDuckGo(data: data) - } - } - - private func parseOSJSON(data: Data) -> [String] { - // Format: [query, [suggestions...], ...] - guard let root = try? JSONSerialization.jsonObject(with: data) as? [Any], - root.count >= 2, - let list = root[1] as? [Any] else { - return [] - } - var out: [String] = [] - out.reserveCapacity(list.count) - for item in list { - guard let s = item as? String else { continue } - let trimmed = s.trimmingCharacters(in: .whitespacesAndNewlines) - guard !trimmed.isEmpty else { continue } - out.append(trimmed) - } - return out - } - - private func parseDuckDuckGo(data: Data) -> [String] { - // Format: [{phrase:"..."}, ...] - guard let root = try? JSONSerialization.jsonObject(with: data) as? [Any] else { - return [] - } - var out: [String] = [] - out.reserveCapacity(root.count) - for item in root { - guard let dict = item as? [String: Any], - let phrase = dict["phrase"] as? String else { continue } - let trimmed = phrase.trimmingCharacters(in: .whitespacesAndNewlines) - guard !trimmed.isEmpty else { continue } - out.append(trimmed) - } - return out - } -} - /// BrowserPanel provides a WKWebView-based browser panel. /// All browser panels share a WKProcessPool for cookie sharing. -private enum BrowserInsecureHTTPNavigationIntent { +/// +/// Widened from `private` to `internal` (file-reorg for #99): also referenced by +/// `BrowserNavigationDelegate`/`BrowserUIDelegate` in BrowserPanelWebDelegates.swift. +enum BrowserInsecureHTTPNavigationIntent { case currentTab case newTab } @@ -5674,728 +4675,3 @@ private extension NSObject { fn(self, selector) } } - -// MARK: - Download Delegate - -/// Handles WKDownload lifecycle by saving to a temp file synchronously (no UI -/// during WebKit callbacks), then showing NSSavePanel after the download finishes. -class BrowserDownloadDelegate: NSObject, WKDownloadDelegate { - private struct DownloadState { - let tempURL: URL - let suggestedFilename: String - } - - /// Tracks active downloads keyed by WKDownload identity. - private var activeDownloads: [ObjectIdentifier: DownloadState] = [:] - private let activeDownloadsLock = NSLock() - var onDownloadStarted: ((String) -> Void)? - var onDownloadReadyToSave: (() -> Void)? - var onDownloadFailed: ((Error) -> Void)? - - private static let tempDir: URL = { - let dir = FileManager.default.temporaryDirectory.appendingPathComponent("cmux-downloads", isDirectory: true) - try? FileManager.default.createDirectory(at: dir, withIntermediateDirectories: true) - return dir - }() - - private static func sanitizedFilename(_ raw: String, fallbackURL: URL?) -> String { - let trimmed = raw.trimmingCharacters(in: .whitespacesAndNewlines) - let candidate = (trimmed as NSString).lastPathComponent - let fromURL = fallbackURL?.lastPathComponent ?? "" - let base = candidate.isEmpty ? fromURL : candidate - let replaced = base.replacingOccurrences(of: ":", with: "-") - let safe = replaced.trimmingCharacters(in: .whitespacesAndNewlines) - return safe.isEmpty ? "download" : safe - } - - /// Resolves a non-colliding destination in ~/Downloads, appending " 2", " 3", … like Safari. - static func uniqueDownloadsURL(for filename: String) -> URL { - let fileManager = FileManager.default - let downloads = fileManager.urls(for: .downloadsDirectory, in: .userDomainMask).first - ?? fileManager.homeDirectoryForCurrentUser.appendingPathComponent("Downloads", isDirectory: true) - try? fileManager.createDirectory(at: downloads, withIntermediateDirectories: true) - - var candidate = downloads.appendingPathComponent(filename, isDirectory: false) - guard fileManager.fileExists(atPath: candidate.path) else { return candidate } - - let base = (filename as NSString).deletingPathExtension - let ext = (filename as NSString).pathExtension - var counter = 2 - repeat { - let name = ext.isEmpty ? "\(base) \(counter)" : "\(base) \(counter).\(ext)" - candidate = downloads.appendingPathComponent(name, isDirectory: false) - counter += 1 - } while fileManager.fileExists(atPath: candidate.path) - return candidate - } - - private func storeState(_ state: DownloadState, for download: WKDownload) { - activeDownloadsLock.lock() - activeDownloads[ObjectIdentifier(download)] = state - activeDownloadsLock.unlock() - } - - private func removeState(for download: WKDownload) -> DownloadState? { - activeDownloadsLock.lock() - let state = activeDownloads.removeValue(forKey: ObjectIdentifier(download)) - activeDownloadsLock.unlock() - return state - } - - private func notifyOnMain(_ action: @escaping () -> Void) { - if Thread.isMainThread { - action() - } else { - DispatchQueue.main.async(execute: action) - } - } - - func download( - _ download: WKDownload, - decideDestinationUsing response: URLResponse, - suggestedFilename: String, - completionHandler: @escaping (URL?) -> Void - ) { - // Save to a temp file — return synchronously so WebKit is never blocked. - let safeFilename = Self.sanitizedFilename(suggestedFilename, fallbackURL: response.url) - let tempFilename = "\(UUID().uuidString)-\(safeFilename)" - let destURL = Self.tempDir.appendingPathComponent(tempFilename, isDirectory: false) - try? FileManager.default.removeItem(at: destURL) - storeState(DownloadState(tempURL: destURL, suggestedFilename: safeFilename), for: download) - notifyOnMain { [weak self] in - self?.onDownloadStarted?(safeFilename) - } - #if DEBUG - dlog("download.decideDestination file=\(safeFilename)") - #endif - NSLog("BrowserPanel download: temp path=%@", destURL.path) - completionHandler(destURL) - } - - func downloadDidFinish(_ download: WKDownload) { - guard let info = removeState(for: download) else { - #if DEBUG - dlog("download.finished missing-state") - #endif - return - } - #if DEBUG - dlog("download.finished file=\(info.suggestedFilename)") - #endif - NSLog("BrowserPanel download finished: %@", info.suggestedFilename) - - // #9: auto-save to ~/Downloads (Safari-style) instead of prompting with a save panel. - DispatchQueue.main.async { - self.onDownloadReadyToSave?() - let destURL = Self.uniqueDownloadsURL(for: info.suggestedFilename) - do { - try FileManager.default.moveItem(at: info.tempURL, to: destURL) - NSLog("BrowserPanel download saved: %@", destURL.path) - } catch { - NSLog("BrowserPanel download move failed: %@", error.localizedDescription) - try? FileManager.default.removeItem(at: info.tempURL) - } - } - } - - func download(_ download: WKDownload, didFailWithError error: Error, resumeData: Data?) { - if let info = removeState(for: download) { - try? FileManager.default.removeItem(at: info.tempURL) - } - notifyOnMain { [weak self] in - self?.onDownloadFailed?(error) - } - #if DEBUG - dlog("download.failed error=\(error.localizedDescription)") - #endif - NSLog("BrowserPanel download failed: %@", error.localizedDescription) - } -} - -// MARK: - Navigation Delegate - -func browserNavigationShouldOpenInNewTab( - navigationType: WKNavigationType, - modifierFlags: NSEvent.ModifierFlags, - buttonNumber: Int, - hasRecentMiddleClickIntent: Bool = false, - currentEventType: NSEvent.EventType? = NSApp.currentEvent?.type, - currentEventButtonNumber: Int? = NSApp.currentEvent?.buttonNumber -) -> Bool { - guard navigationType == .linkActivated || navigationType == .other else { - return false - } - - if modifierFlags.contains(.command) { - return true - } - if buttonNumber == 2 { - return true - } - // In some WebKit paths, middle-click arrives as buttonNumber=4. - // Recover intent when we just observed a local middle-click. - if buttonNumber == 4, hasRecentMiddleClickIntent { - return true - } - - // WebKit can omit buttonNumber for middle-click link activations. - if let currentEventType, - (currentEventType == .otherMouseDown || currentEventType == .otherMouseUp), - currentEventButtonNumber == 2 { - return true - } - return false -} - -func browserNavigationShouldCreatePopup( - navigationType: WKNavigationType, - modifierFlags: NSEvent.ModifierFlags, - buttonNumber: Int, - hasRecentMiddleClickIntent: Bool = false, - currentEventType: NSEvent.EventType? = NSApp.currentEvent?.type, - currentEventButtonNumber: Int? = NSApp.currentEvent?.buttonNumber -) -> Bool { - let isUserNewTab = browserNavigationShouldOpenInNewTab( - navigationType: navigationType, - modifierFlags: modifierFlags, - buttonNumber: buttonNumber, - hasRecentMiddleClickIntent: hasRecentMiddleClickIntent, - currentEventType: currentEventType, - currentEventButtonNumber: currentEventButtonNumber - ) - return navigationType == .other && !isUserNewTab -} - -func browserNavigationShouldFallbackNilTargetToNewTab( - navigationType: WKNavigationType -) -> Bool { - // Scripted popups rely on WKUIDelegate.createWebViewWith returning a live - // web view so window.opener/postMessage remain intact across OAuth flows. - navigationType != .other -} - -private class BrowserNavigationDelegate: NSObject, WKNavigationDelegate { - var didFinish: ((WKWebView) -> Void)? - var didFailNavigation: ((WKWebView, String) -> Void)? - var didTerminateWebContentProcess: ((WKWebView) -> Void)? - var openInNewTab: ((URL) -> Void)? - var shouldBlockInsecureHTTPNavigation: ((URL) -> Bool)? - var handleBlockedInsecureHTTPNavigation: ((URLRequest, BrowserInsecureHTTPNavigationIntent) -> Void)? - /// Direct reference to the download delegate — must be set synchronously in didBecome callbacks. - var downloadDelegate: WKDownloadDelegate? - /// The URL of the last navigation that was attempted. Used to preserve the omnibar URL - /// when a provisional navigation fails (e.g. connection refused on localhost:3000). - var lastAttemptedURL: URL? - - func webView(_ webView: WKWebView, didStartProvisionalNavigation navigation: WKNavigation!) { - lastAttemptedURL = webView.url - } - - func webView(_ webView: WKWebView, didFinish navigation: WKNavigation!) { - didFinish?(webView) - } - - func webView(_ webView: WKWebView, didFail navigation: WKNavigation!, withError error: Error) { - NSLog("BrowserPanel navigation failed: %@", error.localizedDescription) - // Treat committed-navigation failures the same as provisional ones so - // stale favicon/title state from the prior page gets cleared. - let failedURL = webView.url?.absoluteString ?? "" - didFailNavigation?(webView, failedURL) - } - - func webView(_ webView: WKWebView, didFailProvisionalNavigation navigation: WKNavigation!, withError error: Error) { - let nsError = error as NSError - NSLog("BrowserPanel provisional navigation failed: %@", error.localizedDescription) - - // Cancelled navigations (e.g. rapid typing) are not real errors. - if nsError.domain == NSURLErrorDomain, nsError.code == NSURLErrorCancelled { - return - } - - // "Frame load interrupted" (WebKitErrorDomain code 102) fires when a - // navigation response is converted into a download via .download policy. - // This is expected and should not show an error page. - if nsError.domain == "WebKitErrorDomain", nsError.code == 102 { - return - } - - let failedURL = nsError.userInfo[NSURLErrorFailingURLStringErrorKey] as? String - ?? lastAttemptedURL?.absoluteString - ?? "" - didFailNavigation?(webView, failedURL) - loadErrorPage(in: webView, failedURL: failedURL, error: nsError) - } - - func webView( - _ webView: WKWebView, - didReceive challenge: URLAuthenticationChallenge, - completionHandler: @escaping (URLSession.AuthChallengeDisposition, URLCredential?) -> Void - ) { - // WKWebView rejects all authentication challenges by default when this - // delegate method is not implemented (.rejectProtectionSpace). This - // breaks TLS client-certificate flows such as Microsoft Entra ID - // Conditional Access, which verifies device compliance via a client - // certificate stored in the system keychain by MDM enrollment. - // - // By returning .performDefaultHandling the system's standard URL-loading - // behaviour takes over: the keychain is searched for matching client - // identities, MDM-installed root CAs are trusted, and any configured SSO - // extensions (e.g. Microsoft Enterprise SSO) can intercept the challenge. - completionHandler(.performDefaultHandling, nil) - } - - func webViewWebContentProcessDidTerminate(_ webView: WKWebView) { -#if DEBUG - dlog("browser.webcontent.terminated panel=\(String(describing: self))") -#endif - didTerminateWebContentProcess?(webView) - } - - private func loadErrorPage(in webView: WKWebView, failedURL: String, error: NSError) { - let title: String - let message: String - - switch (error.domain, error.code) { - case (NSURLErrorDomain, NSURLErrorCannotConnectToHost), - (NSURLErrorDomain, NSURLErrorCannotFindHost), - (NSURLErrorDomain, NSURLErrorTimedOut): - title = String(localized: "browser.error.cantReach.title", defaultValue: "Can\u{2019}t reach this page") - if failedURL.isEmpty { - message = String(localized: "browser.error.cantReach.messageSite", defaultValue: "The site refused to connect. Check that a server is running on this address.") - } else { - message = String(localized: "browser.error.cantReach.messageURL", defaultValue: "\(failedURL) refused to connect. Check that a server is running on this address.") - } - case (NSURLErrorDomain, NSURLErrorNotConnectedToInternet), - (NSURLErrorDomain, NSURLErrorNetworkConnectionLost): - title = String(localized: "browser.error.noInternet", defaultValue: "No internet connection") - message = String(localized: "browser.error.checkNetwork", defaultValue: "Check your network connection and try again.") - case (NSURLErrorDomain, NSURLErrorSecureConnectionFailed), - (NSURLErrorDomain, NSURLErrorServerCertificateUntrusted), - (NSURLErrorDomain, NSURLErrorServerCertificateHasUnknownRoot), - (NSURLErrorDomain, NSURLErrorServerCertificateHasBadDate), - (NSURLErrorDomain, NSURLErrorServerCertificateNotYetValid): - title = String(localized: "browser.error.insecure.title", defaultValue: "Connection isn\u{2019}t secure") - message = String(localized: "browser.error.invalidCertificate", defaultValue: "The certificate for this site is invalid.") - default: - title = String(localized: "browser.error.cantOpen.title", defaultValue: "Can\u{2019}t open this page") - message = error.localizedDescription - } - - let escapeHTML: (String) -> String = { value in - value - .replacingOccurrences(of: "&", with: "&") - .replacingOccurrences(of: "<", with: "<") - .replacingOccurrences(of: ">", with: ">") - .replacingOccurrences(of: "\"", with: """) - } - - let escapedTitle = escapeHTML(title) - let escapedMessage = escapeHTML(message) - let escapedURL = escapeHTML(failedURL) - let escapedReloadLabel = escapeHTML(String(localized: "browser.error.reload", defaultValue: "Reload")) - - let html = """ - - - - - - - - -
-

\(escapedTitle)

-

\(escapedMessage)

-
\(escapedURL)
- -
- - - """ - webView.loadHTMLString(html, baseURL: URL(string: failedURL)) - } - - func webView( - _ webView: WKWebView, - decidePolicyFor navigationAction: WKNavigationAction, - decisionHandler: @escaping (WKNavigationActionPolicy) -> Void - ) { - let hasRecentMiddleClickIntent = ProgramaWebView.hasRecentMiddleClickIntent(for: webView) - let shouldOpenInNewTab = browserNavigationShouldOpenInNewTab( - navigationType: navigationAction.navigationType, - modifierFlags: navigationAction.modifierFlags, - buttonNumber: navigationAction.buttonNumber, - hasRecentMiddleClickIntent: hasRecentMiddleClickIntent - ) -#if DEBUG - let currentEventType = NSApp.currentEvent.map { String(describing: $0.type) } ?? "nil" - let currentEventButton = NSApp.currentEvent.map { String($0.buttonNumber) } ?? "nil" - let navType = String(describing: navigationAction.navigationType) - dlog( - "browser.nav.decidePolicy navType=\(navType) button=\(navigationAction.buttonNumber) " + - "mods=\(navigationAction.modifierFlags.rawValue) targetNil=\(navigationAction.targetFrame == nil ? 1 : 0) " + - "eventType=\(currentEventType) eventButton=\(currentEventButton) " + - "recentMiddleIntent=\(hasRecentMiddleClickIntent ? 1 : 0) " + - "openInNewTab=\(shouldOpenInNewTab ? 1 : 0)" - ) -#endif - - if let url = navigationAction.request.url, - navigationAction.targetFrame?.isMainFrame != false, - shouldBlockInsecureHTTPNavigation?(url) == true { - let intent: BrowserInsecureHTTPNavigationIntent - if shouldOpenInNewTab || navigationAction.targetFrame == nil { - intent = .newTab - } else { - intent = .currentTab - } -#if DEBUG - dlog( - "browser.nav.decidePolicy.action kind=blockedInsecure intent=\(intent == .newTab ? "newTab" : "currentTab") " + - "url=\(url.absoluteString)" - ) -#endif - handleBlockedInsecureHTTPNavigation?(navigationAction.request, intent) - decisionHandler(.cancel) - return - } - - // WebKit cannot open app-specific deeplinks (discord://, slack://, zoommtg://, etc.). - // Hand these off to macOS so the owning app can handle them. - if let url = navigationAction.request.url, - navigationAction.targetFrame?.isMainFrame != false, - browserShouldOpenURLExternally(url) { - let opened = NSWorkspace.shared.open(url) - if !opened { - NSLog("BrowserPanel external navigation failed to open URL: %@", url.absoluteString) - } - #if DEBUG - dlog("browser.navigation.external source=navDelegate opened=\(opened ? 1 : 0) url=\(url.absoluteString)") - #endif - decisionHandler(.cancel) - return - } - - // Cmd+click and middle-click on regular links should always open in a new tab. - if shouldOpenInNewTab, - let url = navigationAction.request.url { -#if DEBUG - dlog("browser.nav.decidePolicy.action kind=openInNewTab url=\(url.absoluteString)") -#endif - openInNewTab?(url) - decisionHandler(.cancel) - return - } - - // target=_blank link navigations should open in a new tab. - // Scripted popups (navigationType == .other) are handled in - // WKUIDelegate.createWebViewWith so OAuth opener linkage survives. - if navigationAction.targetFrame == nil, - browserNavigationShouldFallbackNilTargetToNewTab( - navigationType: navigationAction.navigationType - ), - let url = navigationAction.request.url { -#if DEBUG - dlog("browser.nav.decidePolicy.action kind=openInNewTabFromNilTarget url=\(url.absoluteString)") -#endif - openInNewTab?(url) - decisionHandler(.cancel) - return - } - -#if DEBUG - let targetURL = navigationAction.request.url?.absoluteString ?? "nil" - dlog("browser.nav.decidePolicy.action kind=allow url=\(targetURL)") -#endif - decisionHandler(.allow) - } - - func webView( - _ webView: WKWebView, - decidePolicyFor navigationResponse: WKNavigationResponse, - decisionHandler: @escaping (WKNavigationResponsePolicy) -> Void - ) { - if !navigationResponse.isForMainFrame { - // Subframe responses normally render inline, but a Content-Disposition: - // attachment header signals an explicit download intent (e.g. a Gmail - // attachment inside an iframe). Check before allowing so those reach - // the download delegate instead of being silently dropped. - if let response = navigationResponse.response as? HTTPURLResponse, - let cd = response.value(forHTTPHeaderField: "Content-Disposition"), - cd.lowercased().hasPrefix("attachment") { - decisionHandler(.download) - return - } - decisionHandler(.allow) - return - } - - let mime = navigationResponse.response.mimeType ?? "unknown" - let canShow = navigationResponse.canShowMIMEType - let responseURL = navigationResponse.response.url?.absoluteString ?? "nil" - - // Only classify HTTP(S) top-level responses as downloads. - if let scheme = navigationResponse.response.url?.scheme?.lowercased(), - scheme != "http", scheme != "https" { - decisionHandler(.allow) - return - } - - NSLog("BrowserPanel navigationResponse: url=%@ mime=%@ canShow=%d isMainFrame=%d", - responseURL, mime, canShow ? 1 : 0, - navigationResponse.isForMainFrame ? 1 : 0) - - // Check if this response should be treated as a download. - // Criteria: explicit Content-Disposition: attachment, or a MIME type - // that WebKit cannot render inline. - if let response = navigationResponse.response as? HTTPURLResponse { - let contentDisposition = response.value(forHTTPHeaderField: "Content-Disposition") ?? "" - if contentDisposition.lowercased().hasPrefix("attachment") { - NSLog("BrowserPanel download: content-disposition=attachment mime=%@ url=%@", mime, responseURL) - #if DEBUG - dlog("download.policy=download reason=content-disposition mime=\(mime)") - #endif - decisionHandler(.download) - return - } - } - - if !canShow { - NSLog("BrowserPanel download: cannotShowMIME mime=%@ url=%@", mime, responseURL) - #if DEBUG - dlog("download.policy=download reason=cannotShowMIME mime=\(mime)") - #endif - decisionHandler(.download) - return - } - - decisionHandler(.allow) - } - - func webView(_ webView: WKWebView, navigationAction: WKNavigationAction, didBecome download: WKDownload) { - #if DEBUG - dlog("download.didBecome source=navigationAction") - #endif - NSLog("BrowserPanel download didBecome from navigationAction") - download.delegate = downloadDelegate - } - - func webView(_ webView: WKWebView, navigationResponse: WKNavigationResponse, didBecome download: WKDownload) { - #if DEBUG - dlog("download.didBecome source=navigationResponse") - #endif - NSLog("BrowserPanel download didBecome from navigationResponse") - download.delegate = downloadDelegate - } -} - -// MARK: - UI Delegate - -private class BrowserUIDelegate: NSObject, WKUIDelegate { - var openInNewTab: ((URL) -> Void)? - var requestNavigation: ((URLRequest, BrowserInsecureHTTPNavigationIntent) -> Void)? - var openPopup: ((WKWebViewConfiguration, WKWindowFeatures) -> WKWebView?)? - - /// Called when the page requests a new window (window.open(), target=_blank, etc.). - /// - /// Returns a live popup WKWebView created with WebKit's supplied configuration - /// to preserve popup browsing-context semantics (window.opener, postMessage). - /// Falls back to new-tab behavior only if popup creation is unavailable. - func webView( - _ webView: WKWebView, - createWebViewWith configuration: WKWebViewConfiguration, - for navigationAction: WKNavigationAction, - windowFeatures: WKWindowFeatures - ) -> WKWebView? { -#if DEBUG - let currentEventType = NSApp.currentEvent.map { String(describing: $0.type) } ?? "nil" - let currentEventButton = NSApp.currentEvent.map { String($0.buttonNumber) } ?? "nil" - let navType = String(describing: navigationAction.navigationType) - dlog( - "browser.nav.createWebView navType=\(navType) button=\(navigationAction.buttonNumber) " + - "mods=\(navigationAction.modifierFlags.rawValue) targetNil=\(navigationAction.targetFrame == nil ? 1 : 0) " + - "eventType=\(currentEventType) eventButton=\(currentEventButton)" - ) -#endif - // External URL schemes → hand off to macOS, don't create a popup - if let url = navigationAction.request.url, - browserShouldOpenURLExternally(url) { - let opened = NSWorkspace.shared.open(url) - if !opened { - NSLog("BrowserPanel external navigation failed to open URL: %@", url.absoluteString) - } - #if DEBUG - dlog("browser.navigation.external source=uiDelegate opened=\(opened ? 1 : 0) url=\(url.absoluteString)") - #endif - return nil - } - - // Classifier: only scripted requests (window.open()) get popup windows. - // User-initiated actions (link clicks, context menu "Open Link in New Tab", - // Cmd+click, middle-click) fall through to existing new-tab behavior. - // - // WebKit sometimes delivers .other for Cmd+click / middle-click, so we - // reuse browserNavigationShouldOpenInNewTab to recover user intent before - // treating .other as a scripted popup. - let isScriptedPopup = browserNavigationShouldCreatePopup( - navigationType: navigationAction.navigationType, - modifierFlags: navigationAction.modifierFlags, - buttonNumber: navigationAction.buttonNumber, - hasRecentMiddleClickIntent: ProgramaWebView.hasRecentMiddleClickIntent(for: webView) - ) - - if isScriptedPopup, let popupWebView = openPopup?(configuration, windowFeatures) { -#if DEBUG - dlog("browser.nav.createWebView.action kind=popup") -#endif - return popupWebView - } - - // Fallback: open in new tab (no opener linkage) - if let url = navigationAction.request.url { - if let requestNavigation { - let intent: BrowserInsecureHTTPNavigationIntent = .newTab -#if DEBUG - dlog( - "browser.nav.createWebView.action kind=requestNavigation intent=newTab " + - "url=\(url.absoluteString)" - ) -#endif - requestNavigation(navigationAction.request, intent) - } else { -#if DEBUG - dlog("browser.nav.createWebView.action kind=openInNewTab url=\(url.absoluteString)") -#endif - openInNewTab?(url) - } - } - return nil - } - - /// Handle elements by presenting the native file picker. - func webView( - _ webView: WKWebView, - runOpenPanelWith parameters: WKOpenPanelParameters, - initiatedByFrame frame: WKFrameInfo, - completionHandler: @escaping ([URL]?) -> Void - ) { - BrowserJSDialogPresenter.presentOpenPanel(parameters: parameters, completionHandler: completionHandler) - } - - func webView( - _ webView: WKWebView, - requestMediaCapturePermissionFor origin: WKSecurityOrigin, - initiatedByFrame frame: WKFrameInfo, - type: WKMediaCaptureType, - decisionHandler: @escaping (WKPermissionDecision) -> Void - ) { - BrowserJSDialogPresenter.decideMediaCapturePermission(decisionHandler: decisionHandler) - } - - func webView( - _ webView: WKWebView, - runJavaScriptAlertPanelWithMessage message: String, - initiatedByFrame frame: WKFrameInfo, - completionHandler: @escaping () -> Void - ) { - BrowserJSDialogPresenter.presentAlert(message: message, webView: webView, completionHandler: completionHandler) - } - - func webView( - _ webView: WKWebView, - runJavaScriptConfirmPanelWithMessage message: String, - initiatedByFrame frame: WKFrameInfo, - completionHandler: @escaping (Bool) -> Void - ) { - BrowserJSDialogPresenter.presentConfirm(message: message, webView: webView, completionHandler: completionHandler) - } - - func webView( - _ webView: WKWebView, - runJavaScriptTextInputPanelWithPrompt prompt: String, - defaultText: String?, - initiatedByFrame frame: WKFrameInfo, - completionHandler: @escaping (String?) -> Void - ) { - BrowserJSDialogPresenter.presentTextInput( - prompt: prompt, - defaultText: defaultText, - webView: webView, - completionHandler: completionHandler - ) - } -} - -// MARK: - Browser Data Import - -enum BrowserUserProxySettings { - static let hostKey = "browserUserProxyHost" - static let portKey = "browserUserProxyPort" - static let typeKey = "browserUserProxyType" - - enum ProxyType: String { - case socks5 - case httpConnect - } - - struct Descriptor { - let host: String - let port: Int - let proxyType: ProxyType - } - - /// Returns the user-configured proxy descriptor, or nil when no valid proxy is set. - static func descriptor(defaults: UserDefaults = .standard) -> Descriptor? { - guard let host = defaults.string(forKey: hostKey) else { return nil } - let trimmedHost = host.trimmingCharacters(in: .whitespacesAndNewlines) - guard !trimmedHost.isEmpty else { return nil } - // Use object(forKey:) check so that port == 0 stored as a real value is still caught. - guard defaults.object(forKey: portKey) != nil else { return nil } - let port = defaults.integer(forKey: portKey) - guard port >= 1 && port <= 65535 else { return nil } - let typeRaw = defaults.string(forKey: typeKey) ?? ProxyType.socks5.rawValue - guard let proxyType = ProxyType(rawValue: typeRaw) else { return nil } - return Descriptor(host: trimmedHost, port: port, proxyType: proxyType) - } -} - -/// Receives `compositionstart`/`compositionend` bridge messages from the web view's -/// JS IME tracking script and forwards them to the native `ProgramaWebView`. -private final class IMECompositionMessageHandler: NSObject, WKScriptMessageHandler { - private let onCompositionStateChanged: (Bool) -> Void - - init(onCompositionStateChanged: @escaping (Bool) -> Void) { - self.onCompositionStateChanged = onCompositionStateChanged - super.init() - } - - func userContentController(_ userContentController: WKUserContentController, didReceive message: WKScriptMessage) { - guard let body = message.body as? [String: Any], - let composing = body["composing"] as? Bool else { return } - onCompositionStateChanged(composing) - } -} diff --git a/Sources/Panels/BrowserPanelWebDelegates.swift b/Sources/Panels/BrowserPanelWebDelegates.swift new file mode 100644 index 00000000000..d05db924ed0 --- /dev/null +++ b/Sources/Panels/BrowserPanelWebDelegates.swift @@ -0,0 +1,541 @@ +import AppKit +import Bonsplit +import WebKit + +// MARK: - Navigation Delegate + +func browserNavigationShouldOpenInNewTab( + navigationType: WKNavigationType, + modifierFlags: NSEvent.ModifierFlags, + buttonNumber: Int, + hasRecentMiddleClickIntent: Bool = false, + currentEventType: NSEvent.EventType? = NSApp.currentEvent?.type, + currentEventButtonNumber: Int? = NSApp.currentEvent?.buttonNumber +) -> Bool { + guard navigationType == .linkActivated || navigationType == .other else { + return false + } + + if modifierFlags.contains(.command) { + return true + } + if buttonNumber == 2 { + return true + } + // In some WebKit paths, middle-click arrives as buttonNumber=4. + // Recover intent when we just observed a local middle-click. + if buttonNumber == 4, hasRecentMiddleClickIntent { + return true + } + + // WebKit can omit buttonNumber for middle-click link activations. + if let currentEventType, + (currentEventType == .otherMouseDown || currentEventType == .otherMouseUp), + currentEventButtonNumber == 2 { + return true + } + return false +} + +func browserNavigationShouldCreatePopup( + navigationType: WKNavigationType, + modifierFlags: NSEvent.ModifierFlags, + buttonNumber: Int, + hasRecentMiddleClickIntent: Bool = false, + currentEventType: NSEvent.EventType? = NSApp.currentEvent?.type, + currentEventButtonNumber: Int? = NSApp.currentEvent?.buttonNumber +) -> Bool { + let isUserNewTab = browserNavigationShouldOpenInNewTab( + navigationType: navigationType, + modifierFlags: modifierFlags, + buttonNumber: buttonNumber, + hasRecentMiddleClickIntent: hasRecentMiddleClickIntent, + currentEventType: currentEventType, + currentEventButtonNumber: currentEventButtonNumber + ) + return navigationType == .other && !isUserNewTab +} + +func browserNavigationShouldFallbackNilTargetToNewTab( + navigationType: WKNavigationType +) -> Bool { + // Scripted popups rely on WKUIDelegate.createWebViewWith returning a live + // web view so window.opener/postMessage remain intact across OAuth flows. + navigationType != .other +} + +class BrowserNavigationDelegate: NSObject, WKNavigationDelegate { + var didFinish: ((WKWebView) -> Void)? + var didFailNavigation: ((WKWebView, String) -> Void)? + var didTerminateWebContentProcess: ((WKWebView) -> Void)? + var openInNewTab: ((URL) -> Void)? + var shouldBlockInsecureHTTPNavigation: ((URL) -> Bool)? + var handleBlockedInsecureHTTPNavigation: ((URLRequest, BrowserInsecureHTTPNavigationIntent) -> Void)? + /// Direct reference to the download delegate — must be set synchronously in didBecome callbacks. + var downloadDelegate: WKDownloadDelegate? + /// The URL of the last navigation that was attempted. Used to preserve the omnibar URL + /// when a provisional navigation fails (e.g. connection refused on localhost:3000). + var lastAttemptedURL: URL? + + func webView(_ webView: WKWebView, didStartProvisionalNavigation navigation: WKNavigation!) { + lastAttemptedURL = webView.url + } + + func webView(_ webView: WKWebView, didFinish navigation: WKNavigation!) { + didFinish?(webView) + } + + func webView(_ webView: WKWebView, didFail navigation: WKNavigation!, withError error: Error) { + NSLog("BrowserPanel navigation failed: %@", error.localizedDescription) + // Treat committed-navigation failures the same as provisional ones so + // stale favicon/title state from the prior page gets cleared. + let failedURL = webView.url?.absoluteString ?? "" + didFailNavigation?(webView, failedURL) + } + + func webView(_ webView: WKWebView, didFailProvisionalNavigation navigation: WKNavigation!, withError error: Error) { + let nsError = error as NSError + NSLog("BrowserPanel provisional navigation failed: %@", error.localizedDescription) + + // Cancelled navigations (e.g. rapid typing) are not real errors. + if nsError.domain == NSURLErrorDomain, nsError.code == NSURLErrorCancelled { + return + } + + // "Frame load interrupted" (WebKitErrorDomain code 102) fires when a + // navigation response is converted into a download via .download policy. + // This is expected and should not show an error page. + if nsError.domain == "WebKitErrorDomain", nsError.code == 102 { + return + } + + let failedURL = nsError.userInfo[NSURLErrorFailingURLStringErrorKey] as? String + ?? lastAttemptedURL?.absoluteString + ?? "" + didFailNavigation?(webView, failedURL) + loadErrorPage(in: webView, failedURL: failedURL, error: nsError) + } + + func webView( + _ webView: WKWebView, + didReceive challenge: URLAuthenticationChallenge, + completionHandler: @escaping (URLSession.AuthChallengeDisposition, URLCredential?) -> Void + ) { + // WKWebView rejects all authentication challenges by default when this + // delegate method is not implemented (.rejectProtectionSpace). This + // breaks TLS client-certificate flows such as Microsoft Entra ID + // Conditional Access, which verifies device compliance via a client + // certificate stored in the system keychain by MDM enrollment. + // + // By returning .performDefaultHandling the system's standard URL-loading + // behaviour takes over: the keychain is searched for matching client + // identities, MDM-installed root CAs are trusted, and any configured SSO + // extensions (e.g. Microsoft Enterprise SSO) can intercept the challenge. + completionHandler(.performDefaultHandling, nil) + } + + func webViewWebContentProcessDidTerminate(_ webView: WKWebView) { +#if DEBUG + dlog("browser.webcontent.terminated panel=\(String(describing: self))") +#endif + didTerminateWebContentProcess?(webView) + } + + private func loadErrorPage(in webView: WKWebView, failedURL: String, error: NSError) { + let title: String + let message: String + + switch (error.domain, error.code) { + case (NSURLErrorDomain, NSURLErrorCannotConnectToHost), + (NSURLErrorDomain, NSURLErrorCannotFindHost), + (NSURLErrorDomain, NSURLErrorTimedOut): + title = String(localized: "browser.error.cantReach.title", defaultValue: "Can\u{2019}t reach this page") + if failedURL.isEmpty { + message = String(localized: "browser.error.cantReach.messageSite", defaultValue: "The site refused to connect. Check that a server is running on this address.") + } else { + message = String(localized: "browser.error.cantReach.messageURL", defaultValue: "\(failedURL) refused to connect. Check that a server is running on this address.") + } + case (NSURLErrorDomain, NSURLErrorNotConnectedToInternet), + (NSURLErrorDomain, NSURLErrorNetworkConnectionLost): + title = String(localized: "browser.error.noInternet", defaultValue: "No internet connection") + message = String(localized: "browser.error.checkNetwork", defaultValue: "Check your network connection and try again.") + case (NSURLErrorDomain, NSURLErrorSecureConnectionFailed), + (NSURLErrorDomain, NSURLErrorServerCertificateUntrusted), + (NSURLErrorDomain, NSURLErrorServerCertificateHasUnknownRoot), + (NSURLErrorDomain, NSURLErrorServerCertificateHasBadDate), + (NSURLErrorDomain, NSURLErrorServerCertificateNotYetValid): + title = String(localized: "browser.error.insecure.title", defaultValue: "Connection isn\u{2019}t secure") + message = String(localized: "browser.error.invalidCertificate", defaultValue: "The certificate for this site is invalid.") + default: + title = String(localized: "browser.error.cantOpen.title", defaultValue: "Can\u{2019}t open this page") + message = error.localizedDescription + } + + let escapeHTML: (String) -> String = { value in + value + .replacingOccurrences(of: "&", with: "&") + .replacingOccurrences(of: "<", with: "<") + .replacingOccurrences(of: ">", with: ">") + .replacingOccurrences(of: "\"", with: """) + } + + let escapedTitle = escapeHTML(title) + let escapedMessage = escapeHTML(message) + let escapedURL = escapeHTML(failedURL) + let escapedReloadLabel = escapeHTML(String(localized: "browser.error.reload", defaultValue: "Reload")) + + let html = """ + + + + + + + + +
+

\(escapedTitle)

+

\(escapedMessage)

+
\(escapedURL)
+ +
+ + + """ + webView.loadHTMLString(html, baseURL: URL(string: failedURL)) + } + + func webView( + _ webView: WKWebView, + decidePolicyFor navigationAction: WKNavigationAction, + decisionHandler: @escaping (WKNavigationActionPolicy) -> Void + ) { + let hasRecentMiddleClickIntent = ProgramaWebView.hasRecentMiddleClickIntent(for: webView) + let shouldOpenInNewTab = browserNavigationShouldOpenInNewTab( + navigationType: navigationAction.navigationType, + modifierFlags: navigationAction.modifierFlags, + buttonNumber: navigationAction.buttonNumber, + hasRecentMiddleClickIntent: hasRecentMiddleClickIntent + ) +#if DEBUG + let currentEventType = NSApp.currentEvent.map { String(describing: $0.type) } ?? "nil" + let currentEventButton = NSApp.currentEvent.map { String($0.buttonNumber) } ?? "nil" + let navType = String(describing: navigationAction.navigationType) + dlog( + "browser.nav.decidePolicy navType=\(navType) button=\(navigationAction.buttonNumber) " + + "mods=\(navigationAction.modifierFlags.rawValue) targetNil=\(navigationAction.targetFrame == nil ? 1 : 0) " + + "eventType=\(currentEventType) eventButton=\(currentEventButton) " + + "recentMiddleIntent=\(hasRecentMiddleClickIntent ? 1 : 0) " + + "openInNewTab=\(shouldOpenInNewTab ? 1 : 0)" + ) +#endif + + if let url = navigationAction.request.url, + navigationAction.targetFrame?.isMainFrame != false, + shouldBlockInsecureHTTPNavigation?(url) == true { + let intent: BrowserInsecureHTTPNavigationIntent + if shouldOpenInNewTab || navigationAction.targetFrame == nil { + intent = .newTab + } else { + intent = .currentTab + } +#if DEBUG + dlog( + "browser.nav.decidePolicy.action kind=blockedInsecure intent=\(intent == .newTab ? "newTab" : "currentTab") " + + "url=\(url.absoluteString)" + ) +#endif + handleBlockedInsecureHTTPNavigation?(navigationAction.request, intent) + decisionHandler(.cancel) + return + } + + // WebKit cannot open app-specific deeplinks (discord://, slack://, zoommtg://, etc.). + // Hand these off to macOS so the owning app can handle them. + if let url = navigationAction.request.url, + navigationAction.targetFrame?.isMainFrame != false, + browserShouldOpenURLExternally(url) { + let opened = NSWorkspace.shared.open(url) + if !opened { + NSLog("BrowserPanel external navigation failed to open URL: %@", url.absoluteString) + } + #if DEBUG + dlog("browser.navigation.external source=navDelegate opened=\(opened ? 1 : 0) url=\(url.absoluteString)") + #endif + decisionHandler(.cancel) + return + } + + // Cmd+click and middle-click on regular links should always open in a new tab. + if shouldOpenInNewTab, + let url = navigationAction.request.url { +#if DEBUG + dlog("browser.nav.decidePolicy.action kind=openInNewTab url=\(url.absoluteString)") +#endif + openInNewTab?(url) + decisionHandler(.cancel) + return + } + + // target=_blank link navigations should open in a new tab. + // Scripted popups (navigationType == .other) are handled in + // WKUIDelegate.createWebViewWith so OAuth opener linkage survives. + if navigationAction.targetFrame == nil, + browserNavigationShouldFallbackNilTargetToNewTab( + navigationType: navigationAction.navigationType + ), + let url = navigationAction.request.url { +#if DEBUG + dlog("browser.nav.decidePolicy.action kind=openInNewTabFromNilTarget url=\(url.absoluteString)") +#endif + openInNewTab?(url) + decisionHandler(.cancel) + return + } + +#if DEBUG + let targetURL = navigationAction.request.url?.absoluteString ?? "nil" + dlog("browser.nav.decidePolicy.action kind=allow url=\(targetURL)") +#endif + decisionHandler(.allow) + } + + func webView( + _ webView: WKWebView, + decidePolicyFor navigationResponse: WKNavigationResponse, + decisionHandler: @escaping (WKNavigationResponsePolicy) -> Void + ) { + if !navigationResponse.isForMainFrame { + // Subframe responses normally render inline, but a Content-Disposition: + // attachment header signals an explicit download intent (e.g. a Gmail + // attachment inside an iframe). Check before allowing so those reach + // the download delegate instead of being silently dropped. + if let response = navigationResponse.response as? HTTPURLResponse, + let cd = response.value(forHTTPHeaderField: "Content-Disposition"), + cd.lowercased().hasPrefix("attachment") { + decisionHandler(.download) + return + } + decisionHandler(.allow) + return + } + + let mime = navigationResponse.response.mimeType ?? "unknown" + let canShow = navigationResponse.canShowMIMEType + let responseURL = navigationResponse.response.url?.absoluteString ?? "nil" + + // Only classify HTTP(S) top-level responses as downloads. + if let scheme = navigationResponse.response.url?.scheme?.lowercased(), + scheme != "http", scheme != "https" { + decisionHandler(.allow) + return + } + + NSLog("BrowserPanel navigationResponse: url=%@ mime=%@ canShow=%d isMainFrame=%d", + responseURL, mime, canShow ? 1 : 0, + navigationResponse.isForMainFrame ? 1 : 0) + + // Check if this response should be treated as a download. + // Criteria: explicit Content-Disposition: attachment, or a MIME type + // that WebKit cannot render inline. + if let response = navigationResponse.response as? HTTPURLResponse { + let contentDisposition = response.value(forHTTPHeaderField: "Content-Disposition") ?? "" + if contentDisposition.lowercased().hasPrefix("attachment") { + NSLog("BrowserPanel download: content-disposition=attachment mime=%@ url=%@", mime, responseURL) + #if DEBUG + dlog("download.policy=download reason=content-disposition mime=\(mime)") + #endif + decisionHandler(.download) + return + } + } + + if !canShow { + NSLog("BrowserPanel download: cannotShowMIME mime=%@ url=%@", mime, responseURL) + #if DEBUG + dlog("download.policy=download reason=cannotShowMIME mime=\(mime)") + #endif + decisionHandler(.download) + return + } + + decisionHandler(.allow) + } + + func webView(_ webView: WKWebView, navigationAction: WKNavigationAction, didBecome download: WKDownload) { + #if DEBUG + dlog("download.didBecome source=navigationAction") + #endif + NSLog("BrowserPanel download didBecome from navigationAction") + download.delegate = downloadDelegate + } + + func webView(_ webView: WKWebView, navigationResponse: WKNavigationResponse, didBecome download: WKDownload) { + #if DEBUG + dlog("download.didBecome source=navigationResponse") + #endif + NSLog("BrowserPanel download didBecome from navigationResponse") + download.delegate = downloadDelegate + } +} + +// MARK: - UI Delegate + +class BrowserUIDelegate: NSObject, WKUIDelegate { + var openInNewTab: ((URL) -> Void)? + var requestNavigation: ((URLRequest, BrowserInsecureHTTPNavigationIntent) -> Void)? + var openPopup: ((WKWebViewConfiguration, WKWindowFeatures) -> WKWebView?)? + + /// Called when the page requests a new window (window.open(), target=_blank, etc.). + /// + /// Returns a live popup WKWebView created with WebKit's supplied configuration + /// to preserve popup browsing-context semantics (window.opener, postMessage). + /// Falls back to new-tab behavior only if popup creation is unavailable. + func webView( + _ webView: WKWebView, + createWebViewWith configuration: WKWebViewConfiguration, + for navigationAction: WKNavigationAction, + windowFeatures: WKWindowFeatures + ) -> WKWebView? { +#if DEBUG + let currentEventType = NSApp.currentEvent.map { String(describing: $0.type) } ?? "nil" + let currentEventButton = NSApp.currentEvent.map { String($0.buttonNumber) } ?? "nil" + let navType = String(describing: navigationAction.navigationType) + dlog( + "browser.nav.createWebView navType=\(navType) button=\(navigationAction.buttonNumber) " + + "mods=\(navigationAction.modifierFlags.rawValue) targetNil=\(navigationAction.targetFrame == nil ? 1 : 0) " + + "eventType=\(currentEventType) eventButton=\(currentEventButton)" + ) +#endif + // External URL schemes → hand off to macOS, don't create a popup + if let url = navigationAction.request.url, + browserShouldOpenURLExternally(url) { + let opened = NSWorkspace.shared.open(url) + if !opened { + NSLog("BrowserPanel external navigation failed to open URL: %@", url.absoluteString) + } + #if DEBUG + dlog("browser.navigation.external source=uiDelegate opened=\(opened ? 1 : 0) url=\(url.absoluteString)") + #endif + return nil + } + + // Classifier: only scripted requests (window.open()) get popup windows. + // User-initiated actions (link clicks, context menu "Open Link in New Tab", + // Cmd+click, middle-click) fall through to existing new-tab behavior. + // + // WebKit sometimes delivers .other for Cmd+click / middle-click, so we + // reuse browserNavigationShouldOpenInNewTab to recover user intent before + // treating .other as a scripted popup. + let isScriptedPopup = browserNavigationShouldCreatePopup( + navigationType: navigationAction.navigationType, + modifierFlags: navigationAction.modifierFlags, + buttonNumber: navigationAction.buttonNumber, + hasRecentMiddleClickIntent: ProgramaWebView.hasRecentMiddleClickIntent(for: webView) + ) + + if isScriptedPopup, let popupWebView = openPopup?(configuration, windowFeatures) { +#if DEBUG + dlog("browser.nav.createWebView.action kind=popup") +#endif + return popupWebView + } + + // Fallback: open in new tab (no opener linkage) + if let url = navigationAction.request.url { + if let requestNavigation { + let intent: BrowserInsecureHTTPNavigationIntent = .newTab +#if DEBUG + dlog( + "browser.nav.createWebView.action kind=requestNavigation intent=newTab " + + "url=\(url.absoluteString)" + ) +#endif + requestNavigation(navigationAction.request, intent) + } else { +#if DEBUG + dlog("browser.nav.createWebView.action kind=openInNewTab url=\(url.absoluteString)") +#endif + openInNewTab?(url) + } + } + return nil + } + + /// Handle elements by presenting the native file picker. + func webView( + _ webView: WKWebView, + runOpenPanelWith parameters: WKOpenPanelParameters, + initiatedByFrame frame: WKFrameInfo, + completionHandler: @escaping ([URL]?) -> Void + ) { + BrowserJSDialogPresenter.presentOpenPanel(parameters: parameters, completionHandler: completionHandler) + } + + func webView( + _ webView: WKWebView, + requestMediaCapturePermissionFor origin: WKSecurityOrigin, + initiatedByFrame frame: WKFrameInfo, + type: WKMediaCaptureType, + decisionHandler: @escaping (WKPermissionDecision) -> Void + ) { + BrowserJSDialogPresenter.decideMediaCapturePermission(decisionHandler: decisionHandler) + } + + func webView( + _ webView: WKWebView, + runJavaScriptAlertPanelWithMessage message: String, + initiatedByFrame frame: WKFrameInfo, + completionHandler: @escaping () -> Void + ) { + BrowserJSDialogPresenter.presentAlert(message: message, webView: webView, completionHandler: completionHandler) + } + + func webView( + _ webView: WKWebView, + runJavaScriptConfirmPanelWithMessage message: String, + initiatedByFrame frame: WKFrameInfo, + completionHandler: @escaping (Bool) -> Void + ) { + BrowserJSDialogPresenter.presentConfirm(message: message, webView: webView, completionHandler: completionHandler) + } + + func webView( + _ webView: WKWebView, + runJavaScriptTextInputPanelWithPrompt prompt: String, + defaultText: String?, + initiatedByFrame frame: WKFrameInfo, + completionHandler: @escaping (String?) -> Void + ) { + BrowserJSDialogPresenter.presentTextInput( + prompt: prompt, + defaultText: defaultText, + webView: webView, + completionHandler: completionHandler + ) + } +} diff --git a/Sources/Panels/BrowserProfileStore.swift b/Sources/Panels/BrowserProfileStore.swift new file mode 100644 index 00000000000..c43b70e53fe --- /dev/null +++ b/Sources/Panels/BrowserProfileStore.swift @@ -0,0 +1,206 @@ +import Foundation +import Combine +import WebKit + +struct BrowserProfileDefinition: Codable, Hashable, Identifiable, Sendable { + let id: UUID + var displayName: String + let createdAt: Date + let isBuiltInDefault: Bool + + var slug: String { + if isBuiltInDefault { + return "default" + } + + let normalized = displayName + .trimmingCharacters(in: .whitespacesAndNewlines) + .lowercased() + .replacingOccurrences(of: "[^a-z0-9]+", with: "-", options: .regularExpression) + .trimmingCharacters(in: CharacterSet(charactersIn: "-")) + return normalized.isEmpty ? id.uuidString.lowercased() : normalized + } +} + +@MainActor +final class BrowserProfileStore: ObservableObject { + static let shared = BrowserProfileStore() + + private static let profilesDefaultsKey = "browserProfiles.v1" + private static let lastUsedProfileDefaultsKey = "browserProfiles.lastUsed" + private static let builtInDefaultProfileID = UUID(uuidString: "52B43C05-4A1D-45D3-8FD5-9EF94952E445")! + + @Published private(set) var profiles: [BrowserProfileDefinition] = [] + @Published private(set) var lastUsedProfileID: UUID = builtInDefaultProfileID + + private let defaults: UserDefaults + private var dataStores: [UUID: WKWebsiteDataStore] = [:] + private var historyStores: [UUID: BrowserHistoryStore] = [:] + + init(defaults: UserDefaults = .standard) { + self.defaults = defaults + load() + } + + var builtInDefaultProfileID: UUID { + Self.builtInDefaultProfileID + } + + var effectiveLastUsedProfileID: UUID { + profileDefinition(id: lastUsedProfileID) != nil ? lastUsedProfileID : Self.builtInDefaultProfileID + } + + func profileDefinition(id: UUID) -> BrowserProfileDefinition? { + profiles.first(where: { $0.id == id }) + } + + func displayName(for id: UUID) -> String { + profileDefinition(id: id)?.displayName + ?? String(localized: "browser.profile.default", defaultValue: "Default") + } + + func createProfile(named rawName: String) -> BrowserProfileDefinition? { + let name = rawName.trimmingCharacters(in: .whitespacesAndNewlines) + guard !name.isEmpty else { return nil } + let profile = BrowserProfileDefinition( + id: UUID(), + displayName: name, + createdAt: Date(), + isBuiltInDefault: false + ) + profiles.append(profile) + profiles.sort { + if $0.isBuiltInDefault != $1.isBuiltInDefault { + return $0.isBuiltInDefault && !$1.isBuiltInDefault + } + return $0.displayName.localizedCaseInsensitiveCompare($1.displayName) == .orderedAscending + } + persist() + noteUsed(profile.id) + return profile + } + + func renameProfile(id: UUID, to rawName: String) -> Bool { + let name = rawName.trimmingCharacters(in: .whitespacesAndNewlines) + guard !name.isEmpty, + let index = profiles.firstIndex(where: { $0.id == id }), + !profiles[index].isBuiltInDefault else { + return false + } + profiles[index].displayName = name + profiles.sort { + if $0.isBuiltInDefault != $1.isBuiltInDefault { + return $0.isBuiltInDefault && !$1.isBuiltInDefault + } + return $0.displayName.localizedCaseInsensitiveCompare($1.displayName) == .orderedAscending + } + persist() + return true + } + + func canRenameProfile(id: UUID) -> Bool { + guard let profile = profileDefinition(id: id) else { return false } + return !profile.isBuiltInDefault + } + + func noteUsed(_ id: UUID) { + guard profileDefinition(id: id) != nil else { return } + if lastUsedProfileID != id { + lastUsedProfileID = id + defaults.set(id.uuidString, forKey: Self.lastUsedProfileDefaultsKey) + } + } + + func websiteDataStore(for profileID: UUID) -> WKWebsiteDataStore { + if profileID == Self.builtInDefaultProfileID { + return .default() + } + if let existing = dataStores[profileID] { + return existing + } + let store = WKWebsiteDataStore(forIdentifier: profileID) + dataStores[profileID] = store + return store + } + + func historyStore(for profileID: UUID) -> BrowserHistoryStore { + if profileID == Self.builtInDefaultProfileID { + return .shared + } + if let existing = historyStores[profileID] { + return existing + } + let store = BrowserHistoryStore(fileURL: historyFileURL(for: profileID)) + historyStores[profileID] = store + return store + } + + func historyFileURL(for profileID: UUID) -> URL? { + if profileID == Self.builtInDefaultProfileID { + return BrowserHistoryStore.defaultHistoryFileURLForCurrentBundle() + } + + let fm = FileManager.default + guard let appSupport = fm.urls(for: .applicationSupportDirectory, in: .userDomainMask).first else { + return nil + } + let bundleId = Bundle.main.bundleIdentifier ?? "com.darkroom.programa" + let namespace = BrowserHistoryStore.normalizedBrowserHistoryNamespaceForBundleIdentifier(bundleId) + let profilesDir = appSupport + .appendingPathComponent(namespace, isDirectory: true) + .appendingPathComponent("browser_profiles", isDirectory: true) + .appendingPathComponent(profileID.uuidString.lowercased(), isDirectory: true) + return profilesDir.appendingPathComponent("browser_history.json", isDirectory: false) + } + + func flushPendingSaves() { + BrowserHistoryStore.shared.flushPendingSaves() + for store in historyStores.values { + store.flushPendingSaves() + } + } + + private func load() { + let builtInDefaultProfile = BrowserProfileDefinition( + id: Self.builtInDefaultProfileID, + displayName: String(localized: "browser.profile.default", defaultValue: "Default"), + createdAt: Date(timeIntervalSince1970: 0), + isBuiltInDefault: true + ) + + if let data = defaults.data(forKey: Self.profilesDefaultsKey), + let decoded = try? JSONDecoder().decode([BrowserProfileDefinition].self, from: data), + !decoded.isEmpty { + var resolvedProfiles = decoded.filter { $0.id != Self.builtInDefaultProfileID } + resolvedProfiles.append(builtInDefaultProfile) + profiles = sortedProfiles(resolvedProfiles) + } else { + profiles = [builtInDefaultProfile] + persist() + } + + if let rawLastUsed = defaults.string(forKey: Self.lastUsedProfileDefaultsKey), + let parsed = UUID(uuidString: rawLastUsed), + profileDefinition(id: parsed) != nil { + lastUsedProfileID = parsed + } else { + lastUsedProfileID = Self.builtInDefaultProfileID + defaults.set(lastUsedProfileID.uuidString, forKey: Self.lastUsedProfileDefaultsKey) + } + } + + private func persist() { + let encoder = JSONEncoder() + guard let data = try? encoder.encode(profiles) else { return } + defaults.set(data, forKey: Self.profilesDefaultsKey) + } + + private func sortedProfiles(_ profiles: [BrowserProfileDefinition]) -> [BrowserProfileDefinition] { + profiles.sorted { + if $0.isBuiltInDefault != $1.isBuiltInDefault { + return $0.isBuiltInDefault && !$1.isBuiltInDefault + } + return $0.displayName.localizedCaseInsensitiveCompare($1.displayName) == .orderedAscending + } + } +} diff --git a/Sources/Panels/BrowserUserProxySettings.swift b/Sources/Panels/BrowserUserProxySettings.swift new file mode 100644 index 00000000000..76edf93c233 --- /dev/null +++ b/Sources/Panels/BrowserUserProxySettings.swift @@ -0,0 +1,34 @@ +import Foundation + +// MARK: - Remote/User Proxy Settings + +enum BrowserUserProxySettings { + static let hostKey = "browserUserProxyHost" + static let portKey = "browserUserProxyPort" + static let typeKey = "browserUserProxyType" + + enum ProxyType: String { + case socks5 + case httpConnect + } + + struct Descriptor { + let host: String + let port: Int + let proxyType: ProxyType + } + + /// Returns the user-configured proxy descriptor, or nil when no valid proxy is set. + static func descriptor(defaults: UserDefaults = .standard) -> Descriptor? { + guard let host = defaults.string(forKey: hostKey) else { return nil } + let trimmedHost = host.trimmingCharacters(in: .whitespacesAndNewlines) + guard !trimmedHost.isEmpty else { return nil } + // Use object(forKey:) check so that port == 0 stored as a real value is still caught. + guard defaults.object(forKey: portKey) != nil else { return nil } + let port = defaults.integer(forKey: portKey) + guard port >= 1 && port <= 65535 else { return nil } + let typeRaw = defaults.string(forKey: typeKey) ?? ProxyType.socks5.rawValue + guard let proxyType = ProxyType(rawValue: typeRaw) else { return nil } + return Descriptor(host: trimmedHost, port: port, proxyType: proxyType) + } +} diff --git a/Sources/Panels/IMECompositionMessageHandler.swift b/Sources/Panels/IMECompositionMessageHandler.swift new file mode 100644 index 00000000000..249243309ba --- /dev/null +++ b/Sources/Panels/IMECompositionMessageHandler.swift @@ -0,0 +1,19 @@ +import AppKit +import WebKit + +/// Receives `compositionstart`/`compositionend` bridge messages from the web view's +/// JS IME tracking script and forwards them to the native `ProgramaWebView`. +final class IMECompositionMessageHandler: NSObject, WKScriptMessageHandler { + private let onCompositionStateChanged: (Bool) -> Void + + init(onCompositionStateChanged: @escaping (Bool) -> Void) { + self.onCompositionStateChanged = onCompositionStateChanged + super.init() + } + + func userContentController(_ userContentController: WKUserContentController, didReceive message: WKScriptMessage) { + guard let body = message.body as? [String: Any], + let composing = body["composing"] as? Bool else { return } + onCompositionStateChanged(composing) + } +} From 5f44a87a26030f505d54be48e38a11ab80be674d Mon Sep 17 00:00:00 2001 From: arzafran Date: Thu, 9 Jul 2026 14:09:03 -0300 Subject: [PATCH 20/31] 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 c7be63a81789934120ca74d4947ad7667aa34a9d Mon Sep 17 00:00:00 2001 From: arzafran Date: Thu, 9 Jul 2026 14:09:40 -0300 Subject: [PATCH 21/31] refactor(tabmanager): extract stateless git/GitHub CLI prober into GitMetadataProber.swift Moves the ~640-line stateless git/GitHub CLI probing library (branch/dirty-state lookup, gh pr list/checks polling, process running, GitHub remote/slug parsing) out of the TabManager class body into a standalone GitMetadataProber type. TabManager keeps a thin owned instance (gitMetadataProber); the API itself stays static since none of it touches TabManager instance state (all of it was already 'nonisolated static' on TabManager). WorkspaceGitProbeKey stays on TabManager: unlike the prober helpers, it is a plain dictionary-key type used throughout TabManager's own probe-tracking state (workspaceGitProbeGenerationByKey and friends), not something the stateless prober itself needs. Access widening, both required for cross-file use from TabManager.swift now that the declarations live in a different file: - InitialWorkspaceGitMetadataSnapshot / WorkspacePullRequestSnapshot: private -> internal, since TabManager.applyWorkspaceGitMetadataSnapshot/shouldStopWorkspaceGitMetadataRefresh take/switch over these types. - initialWorkspaceGitMetadataSnapshot(for:) / normalizedBranchName(_:): private -> internal, called from TabManager.scheduleWorkspaceGitMetadataRefresh / TabManager.updateSurfaceGitBranch respectively. Updated programaTests/TabManagerUnitTests.swift call sites (TabManager.X -> GitMetadataProber.X) for the moved API: githubRepositorySlugs(fromGitRemoteVOutput:), GitHubPullRequestProbeItem, preferredPullRequest(from:), shouldSkipWorkspacePullRequestLookup(branch:), resolvedCommandPathForTesting(...). Registered the new file in project.pbxproj. Refs #90. --- GhosttyTabs.xcodeproj/project.pbxproj | 4 + Sources/GitMetadataProber.swift | 640 ++++++++++++++++++++++++ Sources/TabManager.swift | 636 +---------------------- programaTests/TabManagerUnitTests.swift | 40 +- 4 files changed, 672 insertions(+), 648 deletions(-) create mode 100644 Sources/GitMetadataProber.swift diff --git a/GhosttyTabs.xcodeproj/project.pbxproj b/GhosttyTabs.xcodeproj/project.pbxproj index edf7178707c..c103b7f7b33 100644 --- a/GhosttyTabs.xcodeproj/project.pbxproj +++ b/GhosttyTabs.xcodeproj/project.pbxproj @@ -18,6 +18,7 @@ A5FF0073 /* TabManager+Splits.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5FF0063 /* TabManager+Splits.swift */; }; A5FF0072 /* TabManager+Browser.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5FF0062 /* TabManager+Browser.swift */; }; A5FF0071 /* TabManager+GitMetadataPolling.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5FF0061 /* TabManager+GitMetadataPolling.swift */; }; + NRTM00000004 /* GitMetadataProber.swift in Sources */ = {isa = PBXBuildFile; fileRef = NRTM00000003 /* GitMetadataProber.swift */; }; NRTM00000002 /* TabManager+UITestHarness.swift in Sources */ = {isa = PBXBuildFile; fileRef = NRTM00000001 /* TabManager+UITestHarness.swift */; }; A5001004 /* GhosttyConfig.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5001014 /* GhosttyConfig.swift */; }; A5001005 /* GhosttyTerminalView.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5001015 /* GhosttyTerminalView.swift */; }; @@ -243,6 +244,7 @@ A5FF0063 /* TabManager+Splits.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "TabManager+Splits.swift"; sourceTree = ""; }; A5FF0062 /* TabManager+Browser.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "TabManager+Browser.swift"; sourceTree = ""; }; A5FF0061 /* TabManager+GitMetadataPolling.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "TabManager+GitMetadataPolling.swift"; sourceTree = ""; }; + NRTM00000003 /* GitMetadataProber.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GitMetadataProber.swift; sourceTree = ""; }; NRTM00000001 /* TabManager+UITestHarness.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "TabManager+UITestHarness.swift"; sourceTree = ""; }; A5001014 /* GhosttyConfig.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GhosttyConfig.swift; sourceTree = ""; }; A5001015 /* GhosttyTerminalView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GhosttyTerminalView.swift; sourceTree = ""; }; @@ -518,6 +520,7 @@ A5FF0062 /* TabManager+Browser.swift */, A5FF0061 /* TabManager+GitMetadataPolling.swift */, NRTM00000001 /* TabManager+UITestHarness.swift */, + NRTM00000003 /* GitMetadataProber.swift */, A5001511 /* UITestRecorder.swift */, A5001416 /* Workspace.swift */, A5FF0002 /* WorkspaceRemoteSession.swift */, @@ -849,6 +852,7 @@ A5FF0072 /* TabManager+Browser.swift in Sources */, A5FF0071 /* TabManager+GitMetadataPolling.swift in Sources */, NRTM00000002 /* TabManager+UITestHarness.swift in Sources */, + NRTM00000004 /* GitMetadataProber.swift in Sources */, A5001501 /* UITestRecorder.swift in Sources */, A5001406 /* Workspace.swift in Sources */, A5FF0012 /* WorkspaceRemoteSession.swift in Sources */, diff --git a/Sources/GitMetadataProber.swift b/Sources/GitMetadataProber.swift new file mode 100644 index 00000000000..2384b1b9c49 --- /dev/null +++ b/Sources/GitMetadataProber.swift @@ -0,0 +1,640 @@ +import Foundation +import Bonsplit + +// MARK: - GitMetadataProber +// +// Stateless git/GitHub CLI probing library: given a working directory, runs `git`/`gh` +// commands and parses their output into workspace sidebar git/PR metadata. Extracted from +// TabManager (which owns the stateful scheduling/timers/dedup around these probes) so the +// probing logic itself has no dependency on TabManager instance state and can be tested and +// reasoned about independently. A `struct` (not an `enum` namespace) so TabManager can hold +// a thin owned instance; the API surface itself remains static/stateless. +struct GitMetadataProber { + enum WorkspacePullRequestSnapshot: Equatable { + case unsupportedRepository + case notFound + case resolved(SidebarPullRequestState) + case transientFailure + } + + struct InitialWorkspaceGitMetadataSnapshot: Equatable { + let branch: String? + let isDirty: Bool + let pullRequest: WorkspacePullRequestSnapshot + } + + private struct CommandResult { + let stdout: String? + let stderr: String? + let exitStatus: Int32? + let timedOut: Bool + let executionError: String? + } + + struct GitHubPullRequestProbeItem: Decodable, Equatable { + let number: Int + let state: String + let url: String + let updatedAt: String? + } + + private struct GitHubPullRequestCheckItem: Decodable { + let bucket: String? + let state: String? + } + + private nonisolated static let workspacePullRequestProbeTimeout: TimeInterval = 5.0 + + // Widened from `private` to `internal`: called from TabManager.swift. + nonisolated static func initialWorkspaceGitMetadataSnapshot( + for directory: String + ) -> InitialWorkspaceGitMetadataSnapshot { + let branch = normalizedBranchName(runGitCommand(directory: directory, arguments: ["branch", "--show-current"])) + guard let branch else { + return InitialWorkspaceGitMetadataSnapshot( + branch: nil, + isDirty: false, + pullRequest: .notFound + ) + } + + let statusOutput = runGitCommand(directory: directory, arguments: ["status", "--porcelain", "-uno"]) + let isDirty = !(statusOutput?.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty ?? true) + let pullRequest = workspacePullRequestSnapshot(directory: directory, branch: branch) + return InitialWorkspaceGitMetadataSnapshot(branch: branch, isDirty: isDirty, pullRequest: pullRequest) + } + + private nonisolated static func runGitCommand(directory: String, arguments: [String]) -> String? { + runCommand( + directory: directory, + executable: "git", + arguments: arguments + ) + } + + private nonisolated static func workspacePullRequestSnapshot( + directory: String, + branch: String + ) -> WorkspacePullRequestSnapshot { + guard !shouldSkipWorkspacePullRequestLookup(branch: branch) else { + return .notFound + } + + let repoSlugs = githubRepositorySlugs(directory: directory) + guard !repoSlugs.isEmpty else { + return .unsupportedRepository + } + + var sawTransientFailure = false + for repoSlug in repoSlugs { + switch workspacePullRequestSnapshot(directory: directory, branch: branch, repoSlug: repoSlug) { + case .resolved(let pullRequest): + return .resolved(pullRequest) + case .transientFailure: + sawTransientFailure = true + case .notFound, .unsupportedRepository: + continue + } + } + + return sawTransientFailure ? .transientFailure : .notFound + } + + private nonisolated static func workspacePullRequestSnapshot( + directory: String, + branch: String, + repoSlug: String + ) -> WorkspacePullRequestSnapshot { + let result = runCommandResult( + directory: directory, + executable: "gh", + arguments: [ + "pr", "list", + "--repo", repoSlug, + "--state", "all", + "--head", branch, + "--json", "number,state,url,updatedAt", + ], + timeout: workspacePullRequestProbeTimeout + ) + + guard let result else { +#if DEBUG + dlog( + "workspace.gitProbe.pr.fail dir=\(directory) branch=\(branch) " + + "repo=\(repoSlug) status=nil" + ) +#endif + return .transientFailure + } + + guard !result.timedOut, + result.executionError == nil, + let exitStatus = result.exitStatus else { +#if DEBUG + let statusText: String + if result.timedOut { + statusText = "timeout" + } else if let executionError = result.executionError { + statusText = "error=\(executionError)" + } else { + statusText = "unknown" + } + let stderr = debugLogSnippet(result.stderr) ?? "none" + dlog( + "workspace.gitProbe.pr.fail dir=\(directory) branch=\(branch) " + + "repo=\(repoSlug) status=\(statusText) stderr=\(stderr)" + ) +#endif + return .transientFailure + } + + if exitStatus != 0 { +#if DEBUG + dlog( + "workspace.gitProbe.pr.fail dir=\(directory) branch=\(branch) " + + "repo=\(repoSlug) status=exit=\(exitStatus) stderr=\(debugLogSnippet(result.stderr) ?? "none")" + ) +#endif + return .transientFailure + } + + let output = result.stdout ?? "" + guard let pullRequests = decodeJSON([GitHubPullRequestProbeItem].self, from: output) else { +#if DEBUG + dlog( + "workspace.gitProbe.pr.parseFail dir=\(directory) branch=\(branch) " + + "repo=\(repoSlug) output=\(debugLogSnippet(output) ?? "none")" + ) +#endif + return .transientFailure + } + + guard let pullRequest = preferredPullRequest(from: pullRequests) else { +#if DEBUG + dlog( + "workspace.gitProbe.pr.none dir=\(directory) branch=\(branch) " + + "repo=\(repoSlug)" + ) +#endif + return .notFound + } + + guard let status = pullRequestStatus(from: pullRequest.state), + let url = URL(string: pullRequest.url) else { +#if DEBUG + dlog( + "workspace.gitProbe.pr.parseFail dir=\(directory) branch=\(branch) " + + "repo=\(repoSlug) output=\(debugLogSnippet(output) ?? "none")" + ) +#endif + return .transientFailure + } + + let checks = status == .open + ? pullRequestChecksStatus(number: pullRequest.number, directory: directory, repoSlug: repoSlug) + : nil + +#if DEBUG + dlog( + "workspace.gitProbe.pr.success dir=\(directory) branch=\(branch) " + + "repo=\(repoSlug) number=\(pullRequest.number) state=\(status.rawValue) checks=\(checks?.rawValue ?? "none")" + ) +#endif + return .resolved( + SidebarPullRequestState( + number: pullRequest.number, + label: "PR", + url: url, + status: status, + branch: branch, + checks: checks + ) + ) + } + + nonisolated static func preferredPullRequest( + from pullRequests: [GitHubPullRequestProbeItem] + ) -> GitHubPullRequestProbeItem? { + func statusPriority(_ status: SidebarPullRequestStatus) -> Int { + switch status { + case .open: + return 3 + case .merged: + return 2 + case .closed: + return 1 + } + } + + func isPreferred( + candidate: GitHubPullRequestProbeItem, + over current: GitHubPullRequestProbeItem + ) -> Bool { + guard let candidateStatus = pullRequestStatus(from: candidate.state), + let currentStatus = pullRequestStatus(from: current.state) else { + return false + } + + let candidatePriority = statusPriority(candidateStatus) + let currentPriority = statusPriority(currentStatus) + if candidatePriority != currentPriority { + return candidatePriority > currentPriority + } + + let candidateUpdatedAt = candidate.updatedAt ?? "" + let currentUpdatedAt = current.updatedAt ?? "" + if candidateUpdatedAt != currentUpdatedAt { + return candidateUpdatedAt > currentUpdatedAt + } + + return candidate.number > current.number + } + + var best: GitHubPullRequestProbeItem? + for pullRequest in pullRequests { + guard pullRequestStatus(from: pullRequest.state) != nil, + URL(string: pullRequest.url) != nil else { + continue + } + guard let currentBest = best else { + best = pullRequest + continue + } + if isPreferred(candidate: pullRequest, over: currentBest) { + best = pullRequest + } + } + return best + } + + private nonisolated static func pullRequestChecksStatus( + number: Int, + directory: String, + repoSlug: String + ) -> SidebarPullRequestChecksStatus? { + let result = runCommandResult( + directory: directory, + executable: "gh", + arguments: [ + "pr", "checks", String(number), + "--repo", repoSlug, + "--json", "bucket,state" + ], + timeout: workspacePullRequestProbeTimeout + ) + + guard let result, + !result.timedOut, + result.executionError == nil, + let output = result.stdout, + let exitStatus = result.exitStatus, + exitStatus == 0 || exitStatus == 8, + let checks = decodeJSON([GitHubPullRequestCheckItem].self, from: output) else { + return nil + } + + var sawPending = false + var sawPass = false + + for check in checks { + let bucket = check.bucket?.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() + let state = check.state?.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() + + if isFailingCheckState(bucket: bucket, state: state) { + return .fail + } + if isPendingCheckState(bucket: bucket, state: state) { + sawPending = true + continue + } + if isPassingCheckState(bucket: bucket, state: state) { + sawPass = true + } + } + + if sawPending { + return .pending + } + if sawPass { + return .pass + } + return nil + } + + private nonisolated static func pullRequestStatus( + from rawState: String + ) -> SidebarPullRequestStatus? { + switch rawState.trimmingCharacters(in: .whitespacesAndNewlines).uppercased() { + case "OPEN": + return .open + case "MERGED": + return .merged + case "CLOSED": + return .closed + default: + return nil + } + } + + private nonisolated static func decodeJSON(_ type: T.Type, from text: String) -> T? { + guard let data = text.data(using: .utf8) else { return nil } + return try? JSONDecoder().decode(T.self, from: data) + } + + private nonisolated static func isFailingCheckState(bucket: String?, state: String?) -> Bool { + switch bucket ?? state ?? "" { + case "fail", "failure", "failed", "error", "timed_out", "timedout", + "cancel", "cancelled", "canceled", "action_required", "startup_failure": + return true + default: + return false + } + } + + private nonisolated static func isPendingCheckState(bucket: String?, state: String?) -> Bool { + switch bucket ?? state ?? "" { + case "pending", "queued", "in_progress", "requested", "waiting", "expected": + return true + default: + return false + } + } + + private nonisolated static func isPassingCheckState(bucket: String?, state: String?) -> Bool { + switch bucket ?? state ?? "" { + case "pass", "success", "successful", "completed", "neutral", "skipping", "skipped": + return true + default: + return false + } + } + + private nonisolated static let fallbackCommandSearchDirectories: [String] = [ + "/opt/homebrew/bin", + "/usr/local/bin", + "/opt/local/bin", + ] + + nonisolated static func resolvedCommandPathForTesting( + executable: String, + environment: [String: String], + fallbackDirectories: [String] + ) -> String? { + resolvedCommandPath( + executable: executable, + environment: environment, + fallbackDirectories: fallbackDirectories + ) + } + + private nonisolated static func resolvedCommandPath( + executable: String, + environment: [String: String] = ProcessInfo.processInfo.environment, + fallbackDirectories: [String] = fallbackCommandSearchDirectories + ) -> String? { + guard !executable.isEmpty else { return nil } + let fileManager = FileManager.default + if executable.contains("/") { + return fileManager.isExecutableFile(atPath: executable) ? executable : nil + } + + var searchDirectories: [String] = [] + var seenDirectories: Set = [] + + func appendSearchPath(_ path: String?) { + guard let path else { return } + for rawComponent in path.split(separator: ":") { + let component = String(rawComponent).trimmingCharacters(in: .whitespacesAndNewlines) + guard !component.isEmpty, + seenDirectories.insert(component).inserted else { + continue + } + searchDirectories.append(component) + } + } + + appendSearchPath(environment["PATH"]) + appendSearchPath(getenv("PATH").map { String(cString: $0) }) + if let bundledBinPath = Bundle.main.resourceURL?.appendingPathComponent("bin").path { + appendSearchPath(bundledBinPath) + } + fallbackDirectories.forEach { appendSearchPath($0) } + appendSearchPath("/usr/bin:/bin:/usr/sbin:/sbin") + + for directory in searchDirectories { + let candidate = URL(fileURLWithPath: directory, isDirectory: true) + .appendingPathComponent(executable) + .path + if fileManager.isExecutableFile(atPath: candidate) { + return candidate + } + } + return nil + } + + private nonisolated static func runCommand( + directory: String, + executable: String, + arguments: [String], + timeout: TimeInterval? = nil + ) -> String? { + let result = runCommandResult( + directory: directory, + executable: executable, + arguments: arguments, + timeout: timeout + ) + guard let result, + result.exitStatus == 0, + !result.timedOut else { + return nil + } + return result.stdout + } + + private nonisolated static func runCommandResult( + directory: String, + executable: String, + arguments: [String], + timeout: TimeInterval? = nil + ) -> CommandResult? { + let process = Process() + let stdout = Pipe() + let stderr = Pipe() + if let resolvedExecutable = resolvedCommandPath(executable: executable) { + process.executableURL = URL(fileURLWithPath: resolvedExecutable) + process.arguments = arguments + } else { + process.executableURL = URL(fileURLWithPath: "/usr/bin/env") + process.arguments = [executable] + arguments + } + process.currentDirectoryURL = URL(fileURLWithPath: directory) + process.standardOutput = stdout + process.standardError = stderr + + let completion = DispatchSemaphore(value: 0) + process.terminationHandler = { _ in + completion.signal() + } + + do { + try process.run() + } catch { + return CommandResult( + stdout: nil, + stderr: nil, + exitStatus: nil, + timedOut: false, + executionError: String(describing: error) + ) + } + + if let timeout, + completion.wait(timeout: .now() + timeout) == .timedOut { + process.terminate() + if completion.wait(timeout: .now() + 0.2) == .timedOut { + kill(process.processIdentifier, SIGKILL) + _ = completion.wait(timeout: .now() + 0.2) + } + return CommandResult( + stdout: nil, + stderr: nil, + exitStatus: nil, + timedOut: true, + executionError: nil + ) + } else if timeout == nil { + completion.wait() + } + + let stdoutData = stdout.fileHandleForReading.readDataToEndOfFile() + let stderrData = stderr.fileHandleForReading.readDataToEndOfFile() + return CommandResult( + stdout: String(data: stdoutData, encoding: .utf8), + stderr: String(data: stderrData, encoding: .utf8), + exitStatus: process.terminationStatus, + timedOut: false, + executionError: nil + ) + } + + nonisolated static func githubRepositorySlugs(fromGitRemoteVOutput output: String) -> [String] { + var slugByRemoteName: [String: String] = [:] + + for line in output.split(whereSeparator: \.isNewline) { + let parts = line.split(whereSeparator: \.isWhitespace) + guard parts.count >= 3 else { continue } + + let remoteName = String(parts[0]) + let remoteURL = String(parts[1]) + let remoteKind = String(parts[2]) + guard remoteKind == "(fetch)", + let repoSlug = githubRepositorySlug(fromRemoteURL: remoteURL) else { + continue + } + + if slugByRemoteName[remoteName] == nil { + slugByRemoteName[remoteName] = repoSlug + } + } + + let orderedRemoteNames = slugByRemoteName.keys.sorted { lhs, rhs in + let lhsPriority = githubRemotePriority(lhs) + let rhsPriority = githubRemotePriority(rhs) + if lhsPriority != rhsPriority { + return lhsPriority < rhsPriority + } + return lhs < rhs + } + + var orderedSlugs: [String] = [] + var seen: Set = [] + for remoteName in orderedRemoteNames { + guard let repoSlug = slugByRemoteName[remoteName], + seen.insert(repoSlug).inserted else { + continue + } + orderedSlugs.append(repoSlug) + } + return orderedSlugs + } + + private nonisolated static func githubRepositorySlugs(directory: String) -> [String] { + guard let output = runGitCommand(directory: directory, arguments: ["remote", "-v"]) else { + return [] + } + return githubRepositorySlugs(fromGitRemoteVOutput: output) + } + + private nonisolated static func githubRemotePriority(_ remoteName: String) -> Int { + switch remoteName.lowercased() { + case "upstream": + return 0 + case "origin": + return 1 + default: + return 2 + } + } + + private nonisolated static func githubRepositorySlug(fromRemoteURL remoteURL: String) -> String? { + let trimmed = remoteURL.trimmingCharacters(in: .whitespacesAndNewlines) + guard !trimmed.isEmpty else { return nil } + + let githubPrefixes = [ + "git@github.com:", + "ssh://git@github.com/", + "https://github.com/", + "http://github.com/", + "git://github.com/", + ] + for prefix in githubPrefixes where trimmed.hasPrefix(prefix) { + let path = String(trimmed.dropFirst(prefix.count)) + return normalizedGitHubRepositorySlug(path) + } + + guard let url = URL(string: trimmed), + let host = url.host?.lowercased(), + host == "github.com" else { + return nil + } + + return normalizedGitHubRepositorySlug(url.path) + } + + private nonisolated static func normalizedGitHubRepositorySlug(_ rawPath: String) -> String? { + let trimmedPath = rawPath.trimmingCharacters(in: CharacterSet(charactersIn: "/")) + guard !trimmedPath.isEmpty else { return nil } + let components = trimmedPath.split(separator: "/").map(String.init) + guard components.count >= 2 else { return nil } + let owner = components[0] + var repo = components[1] + if repo.hasSuffix(".git") { + repo.removeLast(4) + } + guard !owner.isEmpty, !repo.isEmpty else { return nil } + return "\(owner)/\(repo)" + } + + private nonisolated static func debugLogSnippet(_ value: String?) -> String? { + let trimmed = value?.trimmingCharacters(in: .whitespacesAndNewlines) ?? "" + guard !trimmed.isEmpty else { return nil } + return String(trimmed.prefix(180)) + } + + // Widened from `private` to `internal`: called from TabManager.swift. + nonisolated static func normalizedBranchName(_ branch: String?) -> String? { + let trimmed = branch?.trimmingCharacters(in: .whitespacesAndNewlines) ?? "" + return trimmed.isEmpty ? nil : trimmed + } + + nonisolated static func shouldSkipWorkspacePullRequestLookup(branch: String) -> Bool { + switch normalizedBranchName(branch) { + case "main", "master": + return true + default: + return false + } + } +} diff --git a/Sources/TabManager.swift b/Sources/TabManager.swift index b77382076df..d22bd92a495 100644 --- a/Sources/TabManager.swift +++ b/Sources/TabManager.swift @@ -504,43 +504,15 @@ struct RecentlyClosedBrowserStack { @MainActor class TabManager: ObservableObject { - private enum WorkspacePullRequestSnapshot: Equatable { - case unsupportedRepository - case notFound - case resolved(SidebarPullRequestState) - case transientFailure - } - - private struct InitialWorkspaceGitMetadataSnapshot: Equatable { - let branch: String? - let isDirty: Bool - let pullRequest: WorkspacePullRequestSnapshot - } - - private struct CommandResult { - let stdout: String? - let stderr: String? - let exitStatus: Int32? - let timedOut: Bool - let executionError: String? - } - struct WorkspaceGitProbeKey: Hashable { let workspaceId: UUID let panelId: UUID } - struct GitHubPullRequestProbeItem: Decodable, Equatable { - let number: Int - let state: String - let url: String - let updatedAt: String? - } - - private struct GitHubPullRequestCheckItem: Decodable { - let bucket: String? - let state: String? - } + /// Thin owned instance of the stateless git/GitHub CLI probing library (GitMetadataProber.swift). + /// Its API is invoked as static calls (`GitMetadataProber.foo(...)`); this instance exists as + /// TabManager's ownership point for that responsibility. + let gitMetadataProber = GitMetadataProber() /// The window that owns this TabManager. Set by AppDelegate.registerMainWindow(). /// Used to apply title updates to the correct window instead of NSApp.keyWindow. @@ -557,7 +529,6 @@ class TabManager: ObservableObject { static let initialWorkspaceGitProbeDelays: [TimeInterval] = [0, 0.5, 1.5, 3.0, 6.0, 10.0] static let workspaceGitMetadataPollInterval: TimeInterval = 30 static let selectedWorkspaceGitMetadataPollInterval: TimeInterval = 5 - private nonisolated static let workspacePullRequestProbeTimeout: TimeInterval = 5.0 @Published var selectedTabId: UUID? { willSet { #if DEBUG @@ -1138,7 +1109,7 @@ class TabManager: ObservableObject { let timer = DispatchSource.makeTimerSource(queue: initialWorkspaceGitProbeQueue) timer.schedule(deadline: .now() + delay, repeating: .never) timer.setEventHandler { [weak self] in - let snapshot = Self.initialWorkspaceGitMetadataSnapshot(for: normalizedDirectory) + let snapshot = GitMetadataProber.initialWorkspaceGitMetadataSnapshot(for: normalizedDirectory) Task { @MainActor [weak self] in self?.applyWorkspaceGitMetadataSnapshot( snapshot, @@ -1182,7 +1153,7 @@ class TabManager: ObservableObject { } private func applyWorkspaceGitMetadataSnapshot( - _ snapshot: InitialWorkspaceGitMetadataSnapshot, + _ snapshot: GitMetadataProber.InitialWorkspaceGitMetadataSnapshot, generation: UUID, probeKey: WorkspaceGitProbeKey, expectedDirectory: String, @@ -1287,7 +1258,7 @@ class TabManager: ObservableObject { } private func shouldStopWorkspaceGitMetadataRefresh( - _ snapshot: InitialWorkspaceGitMetadataSnapshot + _ snapshot: GitMetadataProber.InitialWorkspaceGitMetadataSnapshot ) -> Bool { switch snapshot.pullRequest { case .transientFailure: @@ -1297,597 +1268,6 @@ class TabManager: ObservableObject { } } - private nonisolated static func initialWorkspaceGitMetadataSnapshot( - for directory: String - ) -> InitialWorkspaceGitMetadataSnapshot { - let branch = normalizedBranchName(runGitCommand(directory: directory, arguments: ["branch", "--show-current"])) - guard let branch else { - return InitialWorkspaceGitMetadataSnapshot( - branch: nil, - isDirty: false, - pullRequest: .notFound - ) - } - - let statusOutput = runGitCommand(directory: directory, arguments: ["status", "--porcelain", "-uno"]) - let isDirty = !(statusOutput?.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty ?? true) - let pullRequest = workspacePullRequestSnapshot(directory: directory, branch: branch) - return InitialWorkspaceGitMetadataSnapshot(branch: branch, isDirty: isDirty, pullRequest: pullRequest) - } - - private nonisolated static func runGitCommand(directory: String, arguments: [String]) -> String? { - runCommand( - directory: directory, - executable: "git", - arguments: arguments - ) - } - - private nonisolated static func workspacePullRequestSnapshot( - directory: String, - branch: String - ) -> WorkspacePullRequestSnapshot { - guard !shouldSkipWorkspacePullRequestLookup(branch: branch) else { - return .notFound - } - - let repoSlugs = githubRepositorySlugs(directory: directory) - guard !repoSlugs.isEmpty else { - return .unsupportedRepository - } - - var sawTransientFailure = false - for repoSlug in repoSlugs { - switch workspacePullRequestSnapshot(directory: directory, branch: branch, repoSlug: repoSlug) { - case .resolved(let pullRequest): - return .resolved(pullRequest) - case .transientFailure: - sawTransientFailure = true - case .notFound, .unsupportedRepository: - continue - } - } - - return sawTransientFailure ? .transientFailure : .notFound - } - - private nonisolated static func workspacePullRequestSnapshot( - directory: String, - branch: String, - repoSlug: String - ) -> WorkspacePullRequestSnapshot { - let result = runCommandResult( - directory: directory, - executable: "gh", - arguments: [ - "pr", "list", - "--repo", repoSlug, - "--state", "all", - "--head", branch, - "--json", "number,state,url,updatedAt", - ], - timeout: workspacePullRequestProbeTimeout - ) - - guard let result else { -#if DEBUG - dlog( - "workspace.gitProbe.pr.fail dir=\(directory) branch=\(branch) " + - "repo=\(repoSlug) status=nil" - ) -#endif - return .transientFailure - } - - guard !result.timedOut, - result.executionError == nil, - let exitStatus = result.exitStatus else { -#if DEBUG - let statusText: String - if result.timedOut { - statusText = "timeout" - } else if let executionError = result.executionError { - statusText = "error=\(executionError)" - } else { - statusText = "unknown" - } - let stderr = debugLogSnippet(result.stderr) ?? "none" - dlog( - "workspace.gitProbe.pr.fail dir=\(directory) branch=\(branch) " + - "repo=\(repoSlug) status=\(statusText) stderr=\(stderr)" - ) -#endif - return .transientFailure - } - - if exitStatus != 0 { -#if DEBUG - dlog( - "workspace.gitProbe.pr.fail dir=\(directory) branch=\(branch) " + - "repo=\(repoSlug) status=exit=\(exitStatus) stderr=\(debugLogSnippet(result.stderr) ?? "none")" - ) -#endif - return .transientFailure - } - - let output = result.stdout ?? "" - guard let pullRequests = decodeJSON([GitHubPullRequestProbeItem].self, from: output) else { -#if DEBUG - dlog( - "workspace.gitProbe.pr.parseFail dir=\(directory) branch=\(branch) " + - "repo=\(repoSlug) output=\(debugLogSnippet(output) ?? "none")" - ) -#endif - return .transientFailure - } - - guard let pullRequest = preferredPullRequest(from: pullRequests) else { -#if DEBUG - dlog( - "workspace.gitProbe.pr.none dir=\(directory) branch=\(branch) " + - "repo=\(repoSlug)" - ) -#endif - return .notFound - } - - guard let status = pullRequestStatus(from: pullRequest.state), - let url = URL(string: pullRequest.url) else { -#if DEBUG - dlog( - "workspace.gitProbe.pr.parseFail dir=\(directory) branch=\(branch) " + - "repo=\(repoSlug) output=\(debugLogSnippet(output) ?? "none")" - ) -#endif - return .transientFailure - } - - let checks = status == .open - ? pullRequestChecksStatus(number: pullRequest.number, directory: directory, repoSlug: repoSlug) - : nil - -#if DEBUG - dlog( - "workspace.gitProbe.pr.success dir=\(directory) branch=\(branch) " + - "repo=\(repoSlug) number=\(pullRequest.number) state=\(status.rawValue) checks=\(checks?.rawValue ?? "none")" - ) -#endif - return .resolved( - SidebarPullRequestState( - number: pullRequest.number, - label: "PR", - url: url, - status: status, - branch: branch, - checks: checks - ) - ) - } - - nonisolated static func preferredPullRequest( - from pullRequests: [GitHubPullRequestProbeItem] - ) -> GitHubPullRequestProbeItem? { - func statusPriority(_ status: SidebarPullRequestStatus) -> Int { - switch status { - case .open: - return 3 - case .merged: - return 2 - case .closed: - return 1 - } - } - - func isPreferred( - candidate: GitHubPullRequestProbeItem, - over current: GitHubPullRequestProbeItem - ) -> Bool { - guard let candidateStatus = pullRequestStatus(from: candidate.state), - let currentStatus = pullRequestStatus(from: current.state) else { - return false - } - - let candidatePriority = statusPriority(candidateStatus) - let currentPriority = statusPriority(currentStatus) - if candidatePriority != currentPriority { - return candidatePriority > currentPriority - } - - let candidateUpdatedAt = candidate.updatedAt ?? "" - let currentUpdatedAt = current.updatedAt ?? "" - if candidateUpdatedAt != currentUpdatedAt { - return candidateUpdatedAt > currentUpdatedAt - } - - return candidate.number > current.number - } - - var best: GitHubPullRequestProbeItem? - for pullRequest in pullRequests { - guard pullRequestStatus(from: pullRequest.state) != nil, - URL(string: pullRequest.url) != nil else { - continue - } - guard let currentBest = best else { - best = pullRequest - continue - } - if isPreferred(candidate: pullRequest, over: currentBest) { - best = pullRequest - } - } - return best - } - - private nonisolated static func pullRequestChecksStatus( - number: Int, - directory: String, - repoSlug: String - ) -> SidebarPullRequestChecksStatus? { - let result = runCommandResult( - directory: directory, - executable: "gh", - arguments: [ - "pr", "checks", String(number), - "--repo", repoSlug, - "--json", "bucket,state" - ], - timeout: workspacePullRequestProbeTimeout - ) - - guard let result, - !result.timedOut, - result.executionError == nil, - let output = result.stdout, - let exitStatus = result.exitStatus, - exitStatus == 0 || exitStatus == 8, - let checks = decodeJSON([GitHubPullRequestCheckItem].self, from: output) else { - return nil - } - - var sawPending = false - var sawPass = false - - for check in checks { - let bucket = check.bucket?.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() - let state = check.state?.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() - - if isFailingCheckState(bucket: bucket, state: state) { - return .fail - } - if isPendingCheckState(bucket: bucket, state: state) { - sawPending = true - continue - } - if isPassingCheckState(bucket: bucket, state: state) { - sawPass = true - } - } - - if sawPending { - return .pending - } - if sawPass { - return .pass - } - return nil - } - - private nonisolated static func pullRequestStatus( - from rawState: String - ) -> SidebarPullRequestStatus? { - switch rawState.trimmingCharacters(in: .whitespacesAndNewlines).uppercased() { - case "OPEN": - return .open - case "MERGED": - return .merged - case "CLOSED": - return .closed - default: - return nil - } - } - - private nonisolated static func decodeJSON(_ type: T.Type, from text: String) -> T? { - guard let data = text.data(using: .utf8) else { return nil } - return try? JSONDecoder().decode(T.self, from: data) - } - - private nonisolated static func isFailingCheckState(bucket: String?, state: String?) -> Bool { - switch bucket ?? state ?? "" { - case "fail", "failure", "failed", "error", "timed_out", "timedout", - "cancel", "cancelled", "canceled", "action_required", "startup_failure": - return true - default: - return false - } - } - - private nonisolated static func isPendingCheckState(bucket: String?, state: String?) -> Bool { - switch bucket ?? state ?? "" { - case "pending", "queued", "in_progress", "requested", "waiting", "expected": - return true - default: - return false - } - } - - private nonisolated static func isPassingCheckState(bucket: String?, state: String?) -> Bool { - switch bucket ?? state ?? "" { - case "pass", "success", "successful", "completed", "neutral", "skipping", "skipped": - return true - default: - return false - } - } - - private nonisolated static let fallbackCommandSearchDirectories: [String] = [ - "/opt/homebrew/bin", - "/usr/local/bin", - "/opt/local/bin", - ] - - nonisolated static func resolvedCommandPathForTesting( - executable: String, - environment: [String: String], - fallbackDirectories: [String] - ) -> String? { - resolvedCommandPath( - executable: executable, - environment: environment, - fallbackDirectories: fallbackDirectories - ) - } - - private nonisolated static func resolvedCommandPath( - executable: String, - environment: [String: String] = ProcessInfo.processInfo.environment, - fallbackDirectories: [String] = fallbackCommandSearchDirectories - ) -> String? { - guard !executable.isEmpty else { return nil } - let fileManager = FileManager.default - if executable.contains("/") { - return fileManager.isExecutableFile(atPath: executable) ? executable : nil - } - - var searchDirectories: [String] = [] - var seenDirectories: Set = [] - - func appendSearchPath(_ path: String?) { - guard let path else { return } - for rawComponent in path.split(separator: ":") { - let component = String(rawComponent).trimmingCharacters(in: .whitespacesAndNewlines) - guard !component.isEmpty, - seenDirectories.insert(component).inserted else { - continue - } - searchDirectories.append(component) - } - } - - appendSearchPath(environment["PATH"]) - appendSearchPath(getenv("PATH").map { String(cString: $0) }) - if let bundledBinPath = Bundle.main.resourceURL?.appendingPathComponent("bin").path { - appendSearchPath(bundledBinPath) - } - fallbackDirectories.forEach { appendSearchPath($0) } - appendSearchPath("/usr/bin:/bin:/usr/sbin:/sbin") - - for directory in searchDirectories { - let candidate = URL(fileURLWithPath: directory, isDirectory: true) - .appendingPathComponent(executable) - .path - if fileManager.isExecutableFile(atPath: candidate) { - return candidate - } - } - return nil - } - - private nonisolated static func runCommand( - directory: String, - executable: String, - arguments: [String], - timeout: TimeInterval? = nil - ) -> String? { - let result = runCommandResult( - directory: directory, - executable: executable, - arguments: arguments, - timeout: timeout - ) - guard let result, - result.exitStatus == 0, - !result.timedOut else { - return nil - } - return result.stdout - } - - private nonisolated static func runCommandResult( - directory: String, - executable: String, - arguments: [String], - timeout: TimeInterval? = nil - ) -> CommandResult? { - let process = Process() - let stdout = Pipe() - let stderr = Pipe() - if let resolvedExecutable = resolvedCommandPath(executable: executable) { - process.executableURL = URL(fileURLWithPath: resolvedExecutable) - process.arguments = arguments - } else { - process.executableURL = URL(fileURLWithPath: "/usr/bin/env") - process.arguments = [executable] + arguments - } - process.currentDirectoryURL = URL(fileURLWithPath: directory) - process.standardOutput = stdout - process.standardError = stderr - - let completion = DispatchSemaphore(value: 0) - process.terminationHandler = { _ in - completion.signal() - } - - do { - try process.run() - } catch { - return CommandResult( - stdout: nil, - stderr: nil, - exitStatus: nil, - timedOut: false, - executionError: String(describing: error) - ) - } - - if let timeout, - completion.wait(timeout: .now() + timeout) == .timedOut { - process.terminate() - if completion.wait(timeout: .now() + 0.2) == .timedOut { - kill(process.processIdentifier, SIGKILL) - _ = completion.wait(timeout: .now() + 0.2) - } - return CommandResult( - stdout: nil, - stderr: nil, - exitStatus: nil, - timedOut: true, - executionError: nil - ) - } else if timeout == nil { - completion.wait() - } - - let stdoutData = stdout.fileHandleForReading.readDataToEndOfFile() - let stderrData = stderr.fileHandleForReading.readDataToEndOfFile() - return CommandResult( - stdout: String(data: stdoutData, encoding: .utf8), - stderr: String(data: stderrData, encoding: .utf8), - exitStatus: process.terminationStatus, - timedOut: false, - executionError: nil - ) - } - - nonisolated static func githubRepositorySlugs(fromGitRemoteVOutput output: String) -> [String] { - var slugByRemoteName: [String: String] = [:] - - for line in output.split(whereSeparator: \.isNewline) { - let parts = line.split(whereSeparator: \.isWhitespace) - guard parts.count >= 3 else { continue } - - let remoteName = String(parts[0]) - let remoteURL = String(parts[1]) - let remoteKind = String(parts[2]) - guard remoteKind == "(fetch)", - let repoSlug = githubRepositorySlug(fromRemoteURL: remoteURL) else { - continue - } - - if slugByRemoteName[remoteName] == nil { - slugByRemoteName[remoteName] = repoSlug - } - } - - let orderedRemoteNames = slugByRemoteName.keys.sorted { lhs, rhs in - let lhsPriority = githubRemotePriority(lhs) - let rhsPriority = githubRemotePriority(rhs) - if lhsPriority != rhsPriority { - return lhsPriority < rhsPriority - } - return lhs < rhs - } - - var orderedSlugs: [String] = [] - var seen: Set = [] - for remoteName in orderedRemoteNames { - guard let repoSlug = slugByRemoteName[remoteName], - seen.insert(repoSlug).inserted else { - continue - } - orderedSlugs.append(repoSlug) - } - return orderedSlugs - } - - private nonisolated static func githubRepositorySlugs(directory: String) -> [String] { - guard let output = runGitCommand(directory: directory, arguments: ["remote", "-v"]) else { - return [] - } - return githubRepositorySlugs(fromGitRemoteVOutput: output) - } - - private nonisolated static func githubRemotePriority(_ remoteName: String) -> Int { - switch remoteName.lowercased() { - case "upstream": - return 0 - case "origin": - return 1 - default: - return 2 - } - } - - private nonisolated static func githubRepositorySlug(fromRemoteURL remoteURL: String) -> String? { - let trimmed = remoteURL.trimmingCharacters(in: .whitespacesAndNewlines) - guard !trimmed.isEmpty else { return nil } - - let githubPrefixes = [ - "git@github.com:", - "ssh://git@github.com/", - "https://github.com/", - "http://github.com/", - "git://github.com/", - ] - for prefix in githubPrefixes where trimmed.hasPrefix(prefix) { - let path = String(trimmed.dropFirst(prefix.count)) - return normalizedGitHubRepositorySlug(path) - } - - guard let url = URL(string: trimmed), - let host = url.host?.lowercased(), - host == "github.com" else { - return nil - } - - return normalizedGitHubRepositorySlug(url.path) - } - - private nonisolated static func normalizedGitHubRepositorySlug(_ rawPath: String) -> String? { - let trimmedPath = rawPath.trimmingCharacters(in: CharacterSet(charactersIn: "/")) - guard !trimmedPath.isEmpty else { return nil } - let components = trimmedPath.split(separator: "/").map(String.init) - guard components.count >= 2 else { return nil } - let owner = components[0] - var repo = components[1] - if repo.hasSuffix(".git") { - repo.removeLast(4) - } - guard !owner.isEmpty, !repo.isEmpty else { return nil } - return "\(owner)/\(repo)" - } - - private nonisolated static func debugLogSnippet(_ value: String?) -> String? { - let trimmed = value?.trimmingCharacters(in: .whitespacesAndNewlines) ?? "" - guard !trimmed.isEmpty else { return nil } - return String(trimmed.prefix(180)) - } - - private nonisolated static func normalizedBranchName(_ branch: String?) -> String? { - let trimmed = branch?.trimmingCharacters(in: .whitespacesAndNewlines) ?? "" - return trimmed.isEmpty ? nil : trimmed - } - - nonisolated static func shouldSkipWorkspacePullRequestLookup(branch: String) -> Bool { - switch normalizedBranchName(branch) { - case "main", "master": - return true - default: - return false - } - } - func requestBackgroundWorkspaceLoad(for workspaceId: UUID) { guard !pendingBackgroundWorkspaceLoadIds.contains(workspaceId) else { return } var updated = pendingBackgroundWorkspaceLoadIds @@ -2292,7 +1672,7 @@ class TabManager: ObservableObject { ) { guard let tab = tabs.first(where: { $0.id == tabId }) else { return } let current = tab.panelGitBranches[surfaceId] - let normalizedBranch = Self.normalizedBranchName(branch) ?? branch + let normalizedBranch = GitMetadataProber.normalizedBranchName(branch) ?? branch guard current?.branch != normalizedBranch || current?.isDirty != isDirty else { return } tab.updatePanelGitBranch(panelId: surfaceId, branch: normalizedBranch, isDirty: isDirty) if let directory = gitProbeDirectory(for: tab, panelId: surfaceId) { diff --git a/programaTests/TabManagerUnitTests.swift b/programaTests/TabManagerUnitTests.swift index 37e9305086d..cb1998415de 100644 --- a/programaTests/TabManagerUnitTests.swift +++ b/programaTests/TabManagerUnitTests.swift @@ -312,26 +312,26 @@ final class TabManagerPullRequestProbeTests: XCTestCase { """ XCTAssertEqual( - TabManager.githubRepositorySlugs(fromGitRemoteVOutput: output), + GitMetadataProber.githubRepositorySlugs(fromGitRemoteVOutput: output), ["manaflow-ai/cmux", "austinwang/cmux"] ) } func testPreferredPullRequestPrefersOpenOverMergedAndClosed() { let candidates = [ - TabManager.GitHubPullRequestProbeItem( + GitMetadataProber.GitHubPullRequestProbeItem( number: 1889, state: "MERGED", url: "https://github.com/manaflow-ai/cmux/pull/1889", updatedAt: "2026-03-20T18:00:00Z" ), - TabManager.GitHubPullRequestProbeItem( + GitMetadataProber.GitHubPullRequestProbeItem( number: 1891, state: "OPEN", url: "https://github.com/manaflow-ai/cmux/pull/1891", updatedAt: "2026-03-19T18:00:00Z" ), - TabManager.GitHubPullRequestProbeItem( + GitMetadataProber.GitHubPullRequestProbeItem( number: 1800, state: "CLOSED", url: "https://github.com/manaflow-ai/cmux/pull/1800", @@ -340,19 +340,19 @@ final class TabManagerPullRequestProbeTests: XCTestCase { ] XCTAssertEqual( - TabManager.preferredPullRequest(from: candidates), + GitMetadataProber.preferredPullRequest(from: candidates), candidates[1] ) } func testPreferredPullRequestPrefersMostRecentlyUpdatedWithinSameStatus() { - let olderOpen = TabManager.GitHubPullRequestProbeItem( + let olderOpen = GitMetadataProber.GitHubPullRequestProbeItem( number: 1880, state: "OPEN", url: "https://github.com/manaflow-ai/cmux/pull/1880", updatedAt: "2026-03-18T18:00:00Z" ) - let newerOpen = TabManager.GitHubPullRequestProbeItem( + let newerOpen = GitMetadataProber.GitHubPullRequestProbeItem( number: 1890, state: "OPEN", url: "https://github.com/manaflow-ai/cmux/pull/1890", @@ -360,13 +360,13 @@ final class TabManagerPullRequestProbeTests: XCTestCase { ) XCTAssertEqual( - TabManager.preferredPullRequest(from: [olderOpen, newerOpen]), + GitMetadataProber.preferredPullRequest(from: [olderOpen, newerOpen]), newerOpen ) } func testPreferredPullRequestIgnoresMalformedCandidates() { - let valid = TabManager.GitHubPullRequestProbeItem( + let valid = GitMetadataProber.GitHubPullRequestProbeItem( number: 1888, state: "OPEN", url: "https://github.com/manaflow-ai/cmux/pull/1888", @@ -374,14 +374,14 @@ final class TabManagerPullRequestProbeTests: XCTestCase { ) XCTAssertEqual( - TabManager.preferredPullRequest(from: [ - TabManager.GitHubPullRequestProbeItem( + GitMetadataProber.preferredPullRequest(from: [ + GitMetadataProber.GitHubPullRequestProbeItem( number: 9999, state: "WHATEVER", url: "https://github.com/manaflow-ai/cmux/pull/9999", updatedAt: "2026-03-21T18:00:00Z" ), - TabManager.GitHubPullRequestProbeItem( + GitMetadataProber.GitHubPullRequestProbeItem( number: 10000, state: "OPEN", url: "not a url", @@ -394,14 +394,14 @@ final class TabManagerPullRequestProbeTests: XCTestCase { } func testShouldSkipWorkspacePullRequestLookupOnlyForExactMainAndMaster() { - XCTAssertTrue(TabManager.shouldSkipWorkspacePullRequestLookup(branch: "main")) - XCTAssertTrue(TabManager.shouldSkipWorkspacePullRequestLookup(branch: "master")) - XCTAssertTrue(TabManager.shouldSkipWorkspacePullRequestLookup(branch: " master \n")) + XCTAssertTrue(GitMetadataProber.shouldSkipWorkspacePullRequestLookup(branch: "main")) + XCTAssertTrue(GitMetadataProber.shouldSkipWorkspacePullRequestLookup(branch: "master")) + XCTAssertTrue(GitMetadataProber.shouldSkipWorkspacePullRequestLookup(branch: " master \n")) - XCTAssertFalse(TabManager.shouldSkipWorkspacePullRequestLookup(branch: "Main")) - XCTAssertFalse(TabManager.shouldSkipWorkspacePullRequestLookup(branch: "mainline")) - XCTAssertFalse(TabManager.shouldSkipWorkspacePullRequestLookup(branch: "feature/main")) - XCTAssertFalse(TabManager.shouldSkipWorkspacePullRequestLookup(branch: "release/master-fix")) + XCTAssertFalse(GitMetadataProber.shouldSkipWorkspacePullRequestLookup(branch: "Main")) + XCTAssertFalse(GitMetadataProber.shouldSkipWorkspacePullRequestLookup(branch: "mainline")) + XCTAssertFalse(GitMetadataProber.shouldSkipWorkspacePullRequestLookup(branch: "feature/main")) + XCTAssertFalse(GitMetadataProber.shouldSkipWorkspacePullRequestLookup(branch: "release/master-fix")) } func testTrackedWorkspaceGitMetadataPollCandidatesIncludeMainAndMasterPanels() throws { @@ -676,7 +676,7 @@ final class TabManagerPullRequestProbeTests: XCTestCase { try fileManager.setAttributes([.posixPermissions: 0o755], ofItemAtPath: executableURL.path) XCTAssertEqual( - TabManager.resolvedCommandPathForTesting( + GitMetadataProber.resolvedCommandPathForTesting( executable: executableName, environment: ["PATH": "/usr/bin:/bin"], fallbackDirectories: [tempDir.path] From e9c9ee8591752010f97e7ce2961fcef80e9aa7fc Mon Sep 17 00:00:00 2001 From: arzafran Date: Thu, 9 Jul 2026 14:10:24 -0300 Subject: [PATCH 22/31] 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 39f25aae3fc5655e4b56e9f893a84b8a90ff9b69 Mon Sep 17 00:00:00 2001 From: arzafran Date: Thu, 9 Jul 2026 14:10:48 -0300 Subject: [PATCH 23/31] refactor(tabmanager): add canonical workspace(withId:) lookup, replace inline scans Adds a single func workspace(withId:) on TabManager and replaces all 50 inline 'tabs.first(where: { $0.id == X })' scans across TabManager.swift and its TabManager+Browser/GitMetadataPolling/Splits extensions with calls to it, including the selectedWorkspace computed property itself and the sites that re-derive it from selectedTabId. Mechanical, behavior-identical (same O(n) scan, now in one place). Refs #90. --- Sources/TabManager+Browser.swift | 12 ++-- Sources/TabManager+GitMetadataPolling.swift | 6 +- Sources/TabManager+Splits.swift | 18 +++--- Sources/TabManager.swift | 66 +++++++++++---------- 4 files changed, 54 insertions(+), 48 deletions(-) diff --git a/Sources/TabManager+Browser.swift b/Sources/TabManager+Browser.swift index 58e049ddb5d..7689b34d4df 100644 --- a/Sources/TabManager+Browser.swift +++ b/Sources/TabManager+Browser.swift @@ -18,7 +18,7 @@ extension TabManager { preferredProfileID: UUID? = nil, focus: Bool = true ) -> UUID? { - guard let tab = tabs.first(where: { $0.id == tabId }) else { return nil } + guard let tab = workspace(withId: tabId) else { return nil } return tab.newBrowserSplit( from: fromPanelId, orientation: orientation, @@ -36,7 +36,7 @@ extension TabManager { url: URL? = nil, preferredProfileID: UUID? = nil ) -> UUID? { - guard let tab = tabs.first(where: { $0.id == tabId }) else { return nil } + guard let tab = workspace(withId: tabId) else { return nil } return tab.newBrowserSurface( inPane: paneId, url: url, @@ -46,7 +46,7 @@ extension TabManager { /// Get a browser panel by ID func browserPanel(tabId: UUID, panelId: UUID) -> BrowserPanel? { - guard let tab = tabs.first(where: { $0.id == tabId }) else { return nil } + guard let tab = workspace(withId: tabId) else { return nil } return tab.browserPanel(for: panelId) } @@ -59,7 +59,7 @@ extension TabManager { preferredProfileID: UUID? = nil, insertAtEnd: Bool = false ) -> UUID? { - guard let workspace = tabs.first(where: { $0.id == tabId }) else { return nil } + guard let workspace = workspace(withId: tabId) else { return nil } if selectedTabId != tabId { selectedTabId = tabId } @@ -142,7 +142,7 @@ extension TabManager { func reopenMostRecentlyClosedBrowserPanel() -> Bool { while let snapshot = recentlyClosedBrowsers.pop() { guard let targetWorkspace = - tabs.first(where: { $0.id == snapshot.workspaceId }) + workspace(withId: snapshot.workspaceId) ?? selectedWorkspace ?? tabs.first else { return false @@ -204,7 +204,7 @@ extension TabManager { preReopenFocusedPanelId: UUID? ) { guard selectedTabId == tabId, - let tab = tabs.first(where: { $0.id == tabId }), + let tab = workspace(withId: tabId), tab.panels[reopenedPanelId] != nil else { return } diff --git a/Sources/TabManager+GitMetadataPolling.swift b/Sources/TabManager+GitMetadataPolling.swift index 5b5804b6a6c..a46ff9f7625 100644 --- a/Sources/TabManager+GitMetadataPolling.swift +++ b/Sources/TabManager+GitMetadataPolling.swift @@ -104,7 +104,7 @@ extension TabManager { func trackedWorkspaceGitMetadataPollCandidatePanelIdsForTesting(workspaceId: UUID) -> Set { let activeProbeKeys = Set(workspaceGitProbeGenerationByKey.keys) - guard let workspace = tabs.first(where: { $0.id == workspaceId }) else { + guard let workspace = workspace(withId: workspaceId) else { return [] } return trackedWorkspaceGitMetadataPollCandidatePanelIds( @@ -198,7 +198,7 @@ extension TabManager { panelId: UUID, reason: String = "initial" ) { - guard let workspace = tabs.first(where: { $0.id == workspaceId }), + guard let workspace = workspace(withId: workspaceId), !workspace.isRemoteWorkspace else { return } @@ -216,7 +216,7 @@ extension TabManager { reason: String, delays: [TimeInterval] = [0] ) { - guard let workspace = tabs.first(where: { $0.id == workspaceId }), + guard let workspace = workspace(withId: workspaceId), workspace.panels[panelId] != nil, let directory = gitProbeDirectory(for: workspace, panelId: panelId) else { return diff --git a/Sources/TabManager+Splits.swift b/Sources/TabManager+Splits.swift index a34ab0825d7..98399d44d7e 100644 --- a/Sources/TabManager+Splits.swift +++ b/Sources/TabManager+Splits.swift @@ -12,7 +12,7 @@ extension TabManager { @discardableResult func createSplit(direction: SplitDirection) -> UUID? { guard let selectedTabId, - let tab = tabs.first(where: { $0.id == selectedTabId }), + let tab = workspace(withId: selectedTabId), let focusedPanelId = tab.focusedPanelId else { return nil } return createSplit(tabId: selectedTabId, surfaceId: focusedPanelId, direction: direction) } @@ -20,7 +20,7 @@ extension TabManager { /// Create a new split from an explicit source panel. @discardableResult func createSplit(tabId: UUID, surfaceId: UUID, direction: SplitDirection, focus: Bool = true) -> UUID? { - guard let tab = tabs.first(where: { $0.id == tabId }), + guard let tab = workspace(withId: tabId), tab.panels[surfaceId] != nil else { return nil } tab.clearSplitZoom() return newSplit(tabId: tabId, surfaceId: surfaceId, direction: direction, focus: focus) @@ -30,7 +30,7 @@ extension TabManager { @discardableResult func createBrowserSplit(direction: SplitDirection, url: URL? = nil) -> UUID? { guard let selectedTabId, - let tab = tabs.first(where: { $0.id == selectedTabId }), + let tab = workspace(withId: selectedTabId), let focusedPanelId = tab.focusedPanelId else { return nil } tab.clearSplitZoom() return newBrowserSplit( @@ -56,7 +56,7 @@ extension TabManager { /// Create a new split in the specified direction /// Returns the new panel's ID (which is also the surface ID for terminals) func newSplit(tabId: UUID, surfaceId: UUID, direction: SplitDirection, focus: Bool = true) -> UUID? { - guard let tab = tabs.first(where: { $0.id == tabId }) else { return nil } + guard let tab = workspace(withId: tabId) else { return nil } return tab.newTerminalSplit( from: surfaceId, orientation: direction.orientation, @@ -67,7 +67,7 @@ extension TabManager { /// Move focus in the specified direction func moveSplitFocus(tabId: UUID, surfaceId: UUID, direction: NavigationDirection) -> Bool { - guard let tab = tabs.first(where: { $0.id == tabId }) else { return false } + guard let tab = workspace(withId: tabId) else { return false } tab.moveFocus(direction: direction) return true } @@ -75,7 +75,7 @@ extension TabManager { /// Resize split - not directly supported by bonsplit, but we can adjust divider positions func resizeSplit(tabId: UUID, surfaceId: UUID, direction: ResizeDirection, amount: UInt16) -> Bool { guard amount > 0, - let tab = tabs.first(where: { $0.id == tabId }), + let tab = workspace(withId: tabId), let paneId = tab.paneId(forPanelId: surfaceId) else { return false } let paneUUID = paneId.id @@ -108,7 +108,7 @@ extension TabManager { /// Equalize splits - not directly supported by bonsplit func equalizeSplits(tabId: UUID) -> Bool { - guard let tab = tabs.first(where: { $0.id == tabId }) else { return false } + guard let tab = workspace(withId: tabId) else { return false } var foundSplit = false var allSucceeded = true @@ -123,7 +123,7 @@ extension TabManager { /// Toggle zoom on a panel. func toggleSplitZoom(tabId: UUID, surfaceId: UUID) -> Bool { - guard let tab = tabs.first(where: { $0.id == tabId }) else { return false } + guard let tab = workspace(withId: tabId) else { return false } return tab.toggleSplitZoom(panelId: surfaceId) } @@ -234,7 +234,7 @@ extension TabManager { /// Close a surface/panel func closeSurface(tabId: UUID, surfaceId: UUID) -> Bool { - guard let tab = tabs.first(where: { $0.id == tabId }) else { return false } + guard let tab = workspace(withId: tabId) else { return false } // Guard against stale close callbacks (e.g. child-exit can trigger multiple actions). // A stale callback must never affect unrelated panels/workspaces. guard tab.panels[surfaceId] != nil, diff --git a/Sources/TabManager.swift b/Sources/TabManager.swift index d22bd92a495..05c4d18ab0c 100644 --- a/Sources/TabManager.swift +++ b/Sources/TabManager.swift @@ -723,9 +723,15 @@ class TabManager: ObservableObject { workspace.onClosedBrowserPanel = nil } + /// Canonical workspace lookup by ID. All workspace-by-ID scans across TabManager and its + /// extensions should go through this helper instead of inlining `tabs.first(where: { $0.id == X })`. + func workspace(withId id: UUID) -> Workspace? { + tabs.first(where: { $0.id == id }) + } + var selectedWorkspace: Workspace? { guard let selectedTabId else { return nil } - return tabs.first(where: { $0.id == selectedTabId }) + return workspace(withId: selectedTabId) } // Keep selectedTab as convenience alias @@ -1167,7 +1173,7 @@ class TabManager: ObservableObject { } guard workspaceGitProbeGenerationByKey[probeKey] == generation else { return } - guard let workspace = tabs.first(where: { $0.id == probeKey.workspaceId }) else { + guard let workspace = workspace(withId: probeKey.workspaceId) else { clearWorkspaceGitProbe(probeKey) return } @@ -1614,7 +1620,7 @@ class TabManager: ObservableObject { } func setTabColor(tabId: UUID, color: String?) { - guard let tab = tabs.first(where: { $0.id == tabId }) else { return } + guard let tab = workspace(withId: tabId) else { return } tab.setCustomColor(color) } @@ -1650,7 +1656,7 @@ class TabManager: ObservableObject { // MARK: - Surface Directory Updates (Backwards Compatibility) func updateSurfaceDirectory(tabId: UUID, surfaceId: UUID, directory: String) { - guard let tab = tabs.first(where: { $0.id == tabId }) else { return } + guard let tab = workspace(withId: tabId) else { return } let previousDirectory = gitProbeDirectory(for: tab, panelId: surfaceId) let normalized = normalizeDirectory(directory) tab.updatePanelDirectory(panelId: surfaceId, directory: normalized) @@ -1670,7 +1676,7 @@ class TabManager: ObservableObject { branch: String, isDirty: Bool ) { - guard let tab = tabs.first(where: { $0.id == tabId }) else { return } + guard let tab = workspace(withId: tabId) else { return } let current = tab.panelGitBranches[surfaceId] let normalizedBranch = GitMetadataProber.normalizedBranchName(branch) ?? branch guard current?.branch != normalizedBranch || current?.isDirty != isDirty else { return } @@ -1687,7 +1693,7 @@ class TabManager: ObservableObject { } func clearSurfaceGitBranch(tabId: UUID, surfaceId: UUID) { - guard let tab = tabs.first(where: { $0.id == tabId }) else { return } + guard let tab = workspace(withId: tabId) else { return } let hadBranch = tab.panelGitBranches[surfaceId] != nil let hadPullRequest = tab.panelPullRequests[surfaceId] != nil guard hadBranch || hadPullRequest else { return } @@ -1711,7 +1717,7 @@ class TabManager: ObservableObject { surfaceId: UUID, state: Workspace.PanelShellActivityState ) -> Bool { - guard let tab = tabs.first(where: { $0.id == tabId }) else { return false } + guard let tab = workspace(withId: tabId) else { return false } return tab.updatePanelShellActivityState(panelId: surfaceId, state: state) } @@ -1797,7 +1803,7 @@ class TabManager: ObservableObject { func closeCurrentWorkspace() { guard let selectedId = selectedTabId, - let workspace = tabs.first(where: { $0.id == selectedId }) else { return } + let workspace = workspace(withId: selectedId) else { return } closeWorkspace(workspace) } @@ -1806,7 +1812,7 @@ class TabManager: ObservableObject { UITestRecorder.incrementInt("closePanelInvocations") #endif guard let selectedId = selectedTabId, - let tab = tabs.first(where: { $0.id == selectedId }) else { return } + let tab = workspace(withId: selectedId) else { return } reconcileFocusedPanelFromFirstResponderForKeyboard() guard let focusedPanelId = shortcutCloseTargetPanelId(in: tab) else { return } closePanelWithConfirmation(tab: tab, panelId: focusedPanelId) @@ -1843,7 +1849,7 @@ class TabManager: ObservableObject { return } guard let selectedId = selectedTabId, - let workspace = tabs.first(where: { $0.id == selectedId }) else { return } + let workspace = workspace(withId: selectedId) else { return } closeWorkspaceWithConfirmation(workspace) } @@ -1873,7 +1879,7 @@ class TabManager: ObservableObject { @discardableResult func closeWorkspaceWithConfirmation(tabId: UUID) -> Bool { - guard let workspace = tabs.first(where: { $0.id == tabId }) else { return false } + guard let workspace = workspace(withId: tabId) else { return false } return closeWorkspaceWithConfirmation(workspace) } @@ -2148,14 +2154,14 @@ class TabManager: ObservableObject { } func closePanelWithConfirmation(tabId: UUID, surfaceId: UUID) { - guard let tab = tabs.first(where: { $0.id == tabId }) else { return } + guard let tab = workspace(withId: tabId) else { return } closePanelWithConfirmation(tab: tab, panelId: surfaceId) } /// Runtime close requests from Ghostty should only ever target the specific surface. /// They must not escalate into workspace/window-close semantics for "last tab". func closeRuntimeSurfaceWithConfirmation(tabId: UUID, surfaceId: UUID) { - guard let tab = tabs.first(where: { $0.id == tabId }) else { return } + guard let tab = workspace(withId: tabId) else { return } guard tab.panels[surfaceId] != nil else { return } if let terminalPanel = tab.terminalPanel(for: surfaceId), @@ -2174,7 +2180,7 @@ class TabManager: ObservableObject { /// Runtime close requests from Ghostty without confirmation (e.g. child-exit). /// This path must only close the addressed surface and must never close the workspace window. func closeRuntimeSurface(tabId: UUID, surfaceId: UUID) { - guard let tab = tabs.first(where: { $0.id == tabId }) else { return } + guard let tab = workspace(withId: tabId) else { return } guard tab.panels[surfaceId] != nil else { return } #if DEBUG @@ -2203,7 +2209,7 @@ class TabManager: ObservableObject { /// This should never prompt: the process is already gone, and Ghostty emits the /// `SHOW_CHILD_EXITED` action specifically so the host app can decide what to do. func closePanelAfterChildExited(tabId: UUID, surfaceId: UUID) { - guard let tab = tabs.first(where: { $0.id == tabId }) else { return } + guard let tab = workspace(withId: tabId) else { return } guard tab.panels[surfaceId] != nil else { return } let keepsRemoteWorkspaceOpen = tab.panels.count <= 1 && tab.shouldDemoteWorkspaceAfterChildExit(surfaceId: surfaceId) @@ -2256,14 +2262,14 @@ class TabManager: ObservableObject { } func titleForTab(_ tabId: UUID) -> String? { - tabs.first(where: { $0.id == tabId })?.title + workspace(withId: tabId)?.title } // MARK: - Panel/Surface ID Access /// Returns the focused panel ID for a tab (replaces focusedSurfaceId) func focusedPanelId(for tabId: UUID) -> UUID? { - tabs.first(where: { $0.id == tabId })?.focusedPanelId + workspace(withId: tabId)?.focusedPanelId } /// Returns the focused panel if it's a BrowserPanel, nil otherwise @@ -2368,14 +2374,14 @@ class TabManager: ObservableObject { func applyWindowBackgroundForSelectedTab() { guard let selectedTabId, - let tab = tabs.first(where: { $0.id == selectedTabId }), + let tab = workspace(withId: selectedTabId), let terminalPanel = tab.focusedTerminalPanel else { return } terminalPanel.applyWindowBackgroundIfActive() } private func focusSelectedTabPanel(previousTabId: UUID?) { guard let selectedTabId, - let tab = tabs.first(where: { $0.id == selectedTabId }) else { return } + let tab = workspace(withId: selectedTabId) else { return } let panelId: UUID if let restoredPanelId = lastFocusedPanelByTab[selectedTabId], @@ -2391,7 +2397,7 @@ class TabManager: ObservableObject { // Defer unfocusing the previous workspace's panel until ContentView confirms handoff // completion (new workspace has focus or timeout fallback), to avoid a visible freeze gap. if let previousTabId, - let previousTab = tabs.first(where: { $0.id == previousTabId }), + let previousTab = workspace(withId: previousTabId), let previousPanelId = previousTab.focusedPanelId, previousTab.panels[previousPanelId] != nil { replacePendingWorkspaceUnfocusTarget( @@ -2483,7 +2489,7 @@ class TabManager: ObservableObject { } private func unfocusWorkspacePanel(tabId: UUID, panelId: UUID) { - guard let tab = tabs.first(where: { $0.id == tabId }), + guard let tab = workspace(withId: tabId), let panel = tab.panels[panelId] else { return } panel.unfocus() } @@ -2521,7 +2527,7 @@ class TabManager: ObservableObject { } notificationStore.clearFocusedReadIndicator(forTabId: tabId, surfaceId: surfaceId) if let panelId = surfaceId, - let tab = tabs.first(where: { $0.id == tabId }) { + let tab = workspace(withId: tabId) { tab.triggerNotificationDismissFlash(panelId: panelId) } return true @@ -2547,7 +2553,7 @@ class TabManager: ObservableObject { } private func updatePanelTitle(tabId: UUID, panelId: UUID, title: String) { - guard let tab = tabs.first(where: { $0.id == tabId }) else { return } + guard let tab = workspace(withId: tabId) else { return } let didChange = tab.updatePanelTitle(panelId: panelId, title: title) guard didChange else { return } @@ -2558,7 +2564,7 @@ class TabManager: ObservableObject { } func focusedSurfaceTitleDidChange(tabId: UUID) { - guard let tab = tabs.first(where: { $0.id == tabId }), + guard let tab = workspace(withId: tabId), let focusedPanelId = tab.focusedPanelId, let title = tab.panelTitles[focusedPanelId] else { return } tab.applyProcessTitle(title) @@ -2569,7 +2575,7 @@ class TabManager: ObservableObject { private func updateWindowTitleForSelectedTab() { guard let selectedTabId, - let tab = tabs.first(where: { $0.id == selectedTabId }) else { + let tab = workspace(withId: selectedTabId) else { updateWindowTitle(for: nil) return } @@ -2593,7 +2599,7 @@ class TabManager: ObservableObject { } func focusTab(_ tabId: UUID, surfaceId: UUID? = nil, suppressFlash: Bool = false) { - guard let tab = tabs.first(where: { $0.id == tabId }) else { return } + guard let tab = workspace(withId: tabId) else { return } if let surfaceId, tab.panels[surfaceId] != nil { // Keep selected-surface intent stable across selectedTabId didSet async restore. lastFocusedPanelByTab[tabId] = surfaceId @@ -2632,7 +2638,7 @@ class TabManager: ObservableObject { @discardableResult func focusTabFromNotification(_ tabId: UUID, surfaceId: UUID? = nil) -> Bool { - guard let tab = tabs.first(where: { $0.id == tabId }) else { + guard let tab = workspace(withId: tabId) else { #if DEBUG dlog("notification.focus.fail tab=\(tabId.uuidString.prefix(5)) reason=missingTab") #endif @@ -2668,7 +2674,7 @@ class TabManager: ObservableObject { } func focusSurface(tabId: UUID, surfaceId: UUID) { - guard let tab = tabs.first(where: { $0.id == tabId }) else { return } + guard let tab = workspace(withId: tabId) else { return } tab.focusPanel(surfaceId) } @@ -2854,7 +2860,7 @@ class TabManager: ObservableObject { /// Move focus to an adjacent pane in the specified direction func movePaneFocus(direction: NavigationDirection) { guard let selectedTabId, - let tab = tabs.first(where: { $0.id == selectedTabId }) else { return } + let tab = workspace(withId: selectedTabId) else { return } tab.moveFocus(direction: direction) } @@ -2962,7 +2968,7 @@ class TabManager: ObservableObject { /// Get a terminal panel by ID func terminalPanel(tabId: UUID, panelId: UUID) -> TerminalPanel? { - guard let tab = tabs.first(where: { $0.id == tabId }) else { return nil } + guard let tab = workspace(withId: tabId) else { return nil } return tab.terminalPanel(for: panelId) } From 69d78c19c2bcc7d6ae49ff707b3b963e72133b96 Mon Sep 17 00:00:00 2001 From: arzafran Date: Thu, 9 Jul 2026 14:11:33 -0300 Subject: [PATCH 24/31] refactor(tabmanager): delete typealias Tab = Workspace, migrate call sites to Workspace MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit typealias Tab = Workspace collided with Bonsplit.Tab (a different type). Migrates its remaining call sites to spell out Workspace directly and deletes the alias: - AppDelegate.resolveTerminalPanelForTextSend(in:preferredPanelId:) / sendTextWhenReady(_:to:...) - ContentView.TabItemView.tab (the typing-latency-protected sidebar row view — this is a type-annotation spelling change only, not a reshape: Tab was already an alias for Workspace, so the stored property's actual type is unchanged) - TerminalController.resolveTab/resolveSidebarMutationTab/tabForSidebarMutation/ scheduleSidebarMutation These call sites live outside the TabManager cluster's core files, but the alias is declared in TabManager.swift and deleting it without updating every call site would not compile, so all of them needed to move together with the deletion. Refs #90, #103. --- Sources/AppDelegate.swift | 4 ++-- Sources/ContentView.swift | 2 +- Sources/TabManager.swift | 4 ---- Sources/TerminalController.swift | 8 ++++---- 4 files changed, 7 insertions(+), 11 deletions(-) diff --git a/Sources/AppDelegate.swift b/Sources/AppDelegate.swift index 72285c20bed..592386d266c 100644 --- a/Sources/AppDelegate.swift +++ b/Sources/AppDelegate.swift @@ -6903,7 +6903,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent id.map { String($0.uuidString.prefix(5)) } ?? "nil" } - static func resolveTerminalPanelForTextSend(in tab: Tab, preferredPanelId: UUID? = nil) -> TerminalPanel? { + static func resolveTerminalPanelForTextSend(in tab: Workspace, preferredPanelId: UUID? = nil) -> TerminalPanel? { if let preferredPanelId { return tab.terminalPanel(for: preferredPanelId) } @@ -6912,7 +6912,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent func sendTextWhenReady( _ text: String, - to tab: Tab, + to tab: Workspace, preferredPanelId: UUID? = nil, beforeSend: (() -> Void)? = nil ) { diff --git a/Sources/ContentView.swift b/Sources/ContentView.swift index e17214dc0e9..8d2f9a9d923 100644 --- a/Sources/ContentView.swift +++ b/Sources/ContentView.swift @@ -10295,7 +10295,7 @@ private struct TabItemView: View, Equatable { let tabManager: TabManager let notificationStore: TerminalNotificationStore @Environment(\.colorScheme) private var colorScheme - let tab: Tab + let tab: Workspace let index: Int let isActive: Bool let workspaceShortcutDigit: Int? diff --git a/Sources/TabManager.swift b/Sources/TabManager.swift index 05c4d18ab0c..ca1ef9b087a 100644 --- a/Sources/TabManager.swift +++ b/Sources/TabManager.swift @@ -5,10 +5,6 @@ import Bonsplit import CoreVideo import Combine -// MARK: - Tab Type Alias for Backwards Compatibility -// The old Tab class is replaced by Workspace -typealias Tab = Workspace - enum NewWorkspacePlacement: String, CaseIterable, Identifiable { case top case afterCurrent diff --git a/Sources/TerminalController.swift b/Sources/TerminalController.swift index 191429df287..198505e6555 100644 --- a/Sources/TerminalController.swift +++ b/Sources/TerminalController.swift @@ -9107,7 +9107,7 @@ class TerminalController { } } - private func resolveTab(from arg: String, tabManager: TabManager) -> Tab? { + private func resolveTab(from arg: String, tabManager: TabManager) -> Workspace? { let trimmed = arg.trimmingCharacters(in: .whitespacesAndNewlines) if trimmed.isEmpty { guard let selected = tabManager.selectedTabId else { return nil } @@ -9501,7 +9501,7 @@ class TerminalController { return (.selected, nil) } - private func resolveSidebarMutationTab(_ target: SidebarMutationTabTarget) -> Tab? { + private func resolveSidebarMutationTab(_ target: SidebarMutationTabTarget) -> Workspace? { switch target { case .selected: guard let tabManager = self.tabManager, @@ -9520,7 +9520,7 @@ class TerminalController { } } - private func tabForSidebarMutation(id: UUID) -> Tab? { + private func tabForSidebarMutation(id: UUID) -> Workspace? { if let tab = tabManager?.tabs.first(where: { $0.id == id }) { return tab } @@ -9549,7 +9549,7 @@ class TerminalController { private func scheduleSidebarMutation( target: SidebarMutationTabTarget, - mutation: @escaping (TerminalController, Tab) -> Void + mutation: @escaping (TerminalController, Workspace) -> Void ) { DispatchQueue.main.async { [weak self] in guard let self, let tab = self.resolveSidebarMutationTab(target) else { return } From 58ac04f1fa21a9418326648cf80eee560a08c4f7 Mon Sep 17 00:00:00 2001 From: arzafran Date: Thu, 9 Jul 2026 14:12:21 -0300 Subject: [PATCH 25/31] refactor(notifications): extract sound-transcoding subsystem into NotificationSoundStaging.swift Pure move of the ~520-line NotificationSoundSettings enum (custom notification sound validation, staging/transcoding into a UNNotificationSound-compatible format via afconvert, and local playback preview) out of TerminalNotificationStore.swift into its own file. It was already a top-level enum, not nested in TerminalNotificationStore, so no access changes were needed. Also flags (does not fix) the AppDelegate.shared layering smell in TerminalNotificationStore.updateFocusedReadIndicator: reaching back into a global to find the owning TabManager needs a real design pass on how TerminalNotificationStore is scoped/injected, not a mechanical move. Registered the new file in project.pbxproj. Refs #90. --- GhosttyTabs.xcodeproj/project.pbxproj | 4 + Sources/NotificationSoundStaging.swift | 530 ++++++++++++++++++++++++ Sources/TerminalNotificationStore.swift | 526 +---------------------- 3 files changed, 540 insertions(+), 520 deletions(-) create mode 100644 Sources/NotificationSoundStaging.swift diff --git a/GhosttyTabs.xcodeproj/project.pbxproj b/GhosttyTabs.xcodeproj/project.pbxproj index c103b7f7b33..b2408677f99 100644 --- a/GhosttyTabs.xcodeproj/project.pbxproj +++ b/GhosttyTabs.xcodeproj/project.pbxproj @@ -63,6 +63,7 @@ A5FF0033 /* AppDelegate+UITestHarnesses.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5FF0023 /* AppDelegate+UITestHarnesses.swift */; }; A5001094 /* NotificationsPage.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5001091 /* NotificationsPage.swift */; }; A5001095 /* TerminalNotificationStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5001092 /* TerminalNotificationStore.swift */; }; + NRTM00000006 /* NotificationSoundStaging.swift in Sources */ = {isa = PBXBuildFile; fileRef = NRTM00000005 /* NotificationSoundStaging.swift */; }; A5001303 /* SurfaceSearchOverlay.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5001301 /* SurfaceSearchOverlay.swift */; }; A5008371 /* BrowserSearchOverlay.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5008370 /* BrowserSearchOverlay.swift */; }; A5008373 /* BrowserFindJavaScript.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5008372 /* BrowserFindJavaScript.swift */; }; @@ -289,6 +290,7 @@ A5FF0023 /* AppDelegate+UITestHarnesses.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "AppDelegate+UITestHarnesses.swift"; sourceTree = ""; }; A5001091 /* NotificationsPage.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NotificationsPage.swift; sourceTree = ""; }; A5001092 /* TerminalNotificationStore.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TerminalNotificationStore.swift; sourceTree = ""; }; + NRTM00000005 /* NotificationSoundStaging.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NotificationSoundStaging.swift; sourceTree = ""; }; A5001301 /* SurfaceSearchOverlay.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Find/SurfaceSearchOverlay.swift; sourceTree = ""; }; A5008370 /* BrowserSearchOverlay.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Find/BrowserSearchOverlay.swift; sourceTree = ""; }; A5008372 /* BrowserFindJavaScript.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Find/BrowserFindJavaScript.swift; sourceTree = ""; }; @@ -549,6 +551,7 @@ A5FF0023 /* AppDelegate+UITestHarnesses.swift */, A5001091 /* NotificationsPage.swift */, A5001092 /* TerminalNotificationStore.swift */, + NRTM00000005 /* NotificationSoundStaging.swift */, A5001301 /* SurfaceSearchOverlay.swift */, A5008370 /* BrowserSearchOverlay.swift */, A5008372 /* BrowserFindJavaScript.swift */, @@ -880,6 +883,7 @@ A5FF0033 /* AppDelegate+UITestHarnesses.swift in Sources */, A5001094 /* NotificationsPage.swift in Sources */, A5001095 /* TerminalNotificationStore.swift in Sources */, + NRTM00000006 /* NotificationSoundStaging.swift in Sources */, A5001303 /* SurfaceSearchOverlay.swift in Sources */, A5008371 /* BrowserSearchOverlay.swift in Sources */, A5008373 /* BrowserFindJavaScript.swift in Sources */, diff --git a/Sources/NotificationSoundStaging.swift b/Sources/NotificationSoundStaging.swift new file mode 100644 index 00000000000..11d0104008e --- /dev/null +++ b/Sources/NotificationSoundStaging.swift @@ -0,0 +1,530 @@ +import AppKit +import Foundation +import UserNotifications + +// MARK: - Notification Sound Staging +// +// Sound-transcoding subsystem for custom notification sounds: validates and stages +// user-selected sound files into a location/format usable as a macOS UNNotificationSound +// (transcoding to a supported extension when needed via afconvert), and local playback +// preview support. Extracted from TerminalNotificationStore.swift, which owns the rest of +// notification delivery/state and is a heavy consumer of this settings surface. +enum NotificationSoundSettings { + static let key = "notificationSound" + static let defaultValue = "default" + static let customFileValue = "custom_file" + static let customFilePathKey = "notificationSoundCustomFilePath" + static let defaultCustomFilePath = "" + private static let stagedCustomSoundBaseName = "cmux-custom-notification-sound" + private static let customSoundPreparationQueue = DispatchQueue( + label: "com.cmuxterm.notification-sound-preparation", + qos: .utility + ) + private static let pendingCustomSoundPreparationLock = NSLock() + private static var pendingCustomSoundPreparationPaths: Set = [] + private static let activePlaybackSoundsLock = NSLock() + private static var activePlaybackSounds: [ObjectIdentifier: NSSound] = [:] + private static let activePlaybackSoundDelegate = ActivePlaybackSoundDelegate() + private static let notificationSoundSupportedExtensions: Set = [ + "aif", + "aiff", + "caf", + "wav", + ] + + private final class ActivePlaybackSoundDelegate: NSObject, NSSoundDelegate { + func sound(_ sound: NSSound, didFinishPlaying finishedPlaying: Bool) { + NotificationSoundSettings.releaseActivePlaybackSound(sound) + } + } + + private struct CustomSoundSourceMetadata: Codable, Equatable { + let sourcePath: String + let sourceSize: UInt64 + let sourceModificationTime: Double + let sourceFileIdentifier: UInt64? + } + + enum CustomSoundPreparationIssue: Error { + case emptyPath + case missingFile(path: String) + case missingFileExtension(path: String) + case stagingFailed(path: String, details: String) + + var logMessage: String { + switch self { + case .emptyPath: + return "Notification custom sound path is empty" + case .missingFile(let path): + return "Notification custom sound file does not exist: \(path)" + case .missingFileExtension(let path): + return "Notification custom sound requires a file extension: \(path)" + case .stagingFailed(let path, let details): + return "Failed to stage custom notification sound from \(path): \(details)" + } + } + } + static let customCommandKey = "notificationCustomCommand" + static let defaultCustomCommand = "" + + static let systemSounds: [(label: String, value: String)] = [ + ("Default", "default"), + ("Basso", "Basso"), + ("Blow", "Blow"), + ("Bottle", "Bottle"), + ("Frog", "Frog"), + ("Funk", "Funk"), + ("Glass", "Glass"), + ("Hero", "Hero"), + ("Morse", "Morse"), + ("Ping", "Ping"), + ("Pop", "Pop"), + ("Purr", "Purr"), + ("Sosumi", "Sosumi"), + ("Submarine", "Submarine"), + ("Tink", "Tink"), + ("Custom File...", customFileValue), + ("None", "none"), + ] + + static func sound(defaults: UserDefaults = .standard) -> UNNotificationSound? { + let value = defaults.string(forKey: key) ?? defaultValue + switch value { + case "default": + return .default + case "none": + return nil + case customFileValue: + guard let customSoundName = stagedCustomSoundName(defaults: defaults) else { + return nil + } + return UNNotificationSound(named: UNNotificationSoundName(rawValue: customSoundName)) + default: + return UNNotificationSound(named: UNNotificationSoundName(rawValue: value)) + } + } + + static func usesSystemSound(defaults: UserDefaults = .standard) -> Bool { + let value = defaults.string(forKey: key) ?? defaultValue + switch value { + case "none": + return false + case customFileValue: + return customFileURL(defaults: defaults) != nil + default: + return true + } + } + + static func isSilent(defaults: UserDefaults = .standard) -> Bool { + return (defaults.string(forKey: key) ?? defaultValue) == "none" + } + + static func isCustomFileSelected(defaults: UserDefaults = .standard) -> Bool { + (defaults.string(forKey: key) ?? defaultValue) == customFileValue + } + + static func stagedCustomSoundName(defaults: UserDefaults = .standard) -> String? { + let rawPath = defaults.string(forKey: customFilePathKey) ?? defaultCustomFilePath + guard let normalizedPath = normalizedCustomFilePath(rawPath) else { + NSLog("Notification custom sound unavailable: \(CustomSoundPreparationIssue.emptyPath.logMessage)") + return nil + } + + let sourceURL = URL(fileURLWithPath: (normalizedPath as NSString).expandingTildeInPath) + let sourceExtension = sourceURL.pathExtension + .trimmingCharacters(in: .whitespacesAndNewlines) + .lowercased() + guard !sourceExtension.isEmpty else { + NSLog("Notification custom sound unavailable: \(CustomSoundPreparationIssue.missingFileExtension(path: sourceURL.path).logMessage)") + return nil + } + + let destinationExtension = stagedCustomSoundFileExtension(forSourceExtension: sourceExtension) + let stagedFileName = stagedCustomSoundFileName( + forSourceURL: sourceURL, + destinationExtension: destinationExtension + ) + let stagedURL = stagedSoundDirectoryURL().appendingPathComponent(stagedFileName, isDirectory: false) + let fileManager = FileManager.default + guard fileManager.fileExists(atPath: sourceURL.path) else { + NSLog("Notification custom sound unavailable: \(CustomSoundPreparationIssue.missingFile(path: sourceURL.path).logMessage)") + return nil + } + + if fileManager.fileExists(atPath: stagedURL.path) { + if let sourceMetadata = currentSourceMetadata(for: sourceURL, fileManager: fileManager), + let stagedMetadata = loadStagedSourceMetadata(for: stagedURL), + stagedMetadata == sourceMetadata { + return stagedFileName + } + } + + if destinationExtension == sourceExtension { + switch prepareCustomFileForNotifications(path: normalizedPath) { + case .success(let preparedName): + return preparedName + case .failure(let issue): + NSLog("Notification custom sound unavailable: \(issue.logMessage)") + return nil + } + } + + queueCustomSoundPreparation(path: normalizedPath) + NSLog("Notification custom sound not ready yet, staging in background: \(sourceURL.path)") + return nil + } + + static func prepareCustomFileForNotifications(path: String) -> Result { + guard let normalizedPath = normalizedCustomFilePath(path) else { + return .failure(.emptyPath) + } + let sourceURL = URL(fileURLWithPath: (normalizedPath as NSString).expandingTildeInPath) + return prepareCustomSound(from: sourceURL) + } + + private static func prepareCustomSound(from sourceURL: URL) -> Result { + let sourcePath = sourceURL.path + let fileManager = FileManager.default + guard fileManager.fileExists(atPath: sourcePath) else { + return .failure(.missingFile(path: sourcePath)) + } + let sourceExtension = sourceURL.pathExtension.trimmingCharacters(in: .whitespacesAndNewlines) + guard !sourceExtension.isEmpty else { + return .failure(.missingFileExtension(path: sourcePath)) + } + let destinationExtension = stagedCustomSoundFileExtension(forSourceExtension: sourceExtension) + + let destinationDirectory = stagedSoundDirectoryURL() + let destinationFileName = stagedCustomSoundFileName( + forSourceURL: sourceURL, + destinationExtension: destinationExtension + ) + let destinationURL = destinationDirectory.appendingPathComponent(destinationFileName, isDirectory: false) + let sourceMetadata = currentSourceMetadata(for: sourceURL, fileManager: fileManager) + + do { + try fileManager.createDirectory(at: destinationDirectory, withIntermediateDirectories: true) + if fileManager.fileExists(atPath: destinationURL.path) { + let stagedMetadata = loadStagedSourceMetadata(for: destinationURL) + if stagedMetadata != sourceMetadata { + try? fileManager.removeItem(at: destinationURL) + } + } + if destinationExtension == sourceExtension.lowercased() { + try copyStagedSoundIfNeeded(from: sourceURL, to: destinationURL, fileManager: fileManager) + } else { + try transcodeStagedSoundIfNeeded(from: sourceURL, to: destinationURL, fileManager: fileManager) + } + if let sourceMetadata { + try saveStagedSourceMetadata(sourceMetadata, for: destinationURL) + } + try cleanupStaleStagedSoundFiles( + in: destinationDirectory, + keeping: destinationFileName, + preservingSourceURL: sourceURL, + fileManager: fileManager + ) + return .success(destinationFileName) + } catch { + return .failure(.stagingFailed(path: sourcePath, details: error.localizedDescription)) + } + } + + static func customFileURL(defaults: UserDefaults = .standard) -> URL? { + guard let path = normalizedCustomFilePath(defaults.string(forKey: customFilePathKey) ?? defaultCustomFilePath) else { + return nil + } + return URL(fileURLWithPath: (path as NSString).expandingTildeInPath) + } + + static func playCustomFileSound(defaults: UserDefaults = .standard) { + guard let url = customFileURL(defaults: defaults) else { return } + playSoundFile(at: url) + } + + static func playCustomFileSound(path: String) { + guard let normalizedPath = normalizedCustomFilePath(path) else { return } + let url = URL(fileURLWithPath: (normalizedPath as NSString).expandingTildeInPath) + playSoundFile(at: url) + } + + static func playSelectedSound(defaults: UserDefaults = .standard) { + let value = defaults.string(forKey: key) ?? defaultValue + playSound(value: value, defaults: defaults) + } + + static func previewSound(value: String, defaults: UserDefaults = .standard) { + playSound(value: value, defaults: defaults) + } + + private static func playSound(value: String, defaults: UserDefaults) { + switch value { + case "default": + NSSound.beep() + case "none": + break + case customFileValue: + playCustomFileSound(defaults: defaults) + default: + NSSound(named: NSSound.Name(value))?.play() + } + } + + static func stagedCustomSoundFileExtension(forSourceExtension sourceExtension: String) -> String { + let normalized = sourceExtension + .trimmingCharacters(in: .whitespacesAndNewlines) + .lowercased() + guard !normalized.isEmpty else { return "caf" } + if notificationSoundSupportedExtensions.contains(normalized) { + return normalized + } + return "caf" + } + + static func stagedCustomSoundFileName(forSourceURL sourceURL: URL, destinationExtension: String) -> String { + let normalizedExtension = destinationExtension + .trimmingCharacters(in: .whitespacesAndNewlines) + .lowercased() + let ext = normalizedExtension.isEmpty ? "caf" : normalizedExtension + let signature = stagedCustomSoundSourceSignature(for: sourceURL) + return "\(stagedCustomSoundBaseName)-\(signature).\(ext)" + } + + private static func normalizedCustomFilePath(_ rawPath: String) -> String? { + let trimmed = rawPath.trimmingCharacters(in: .whitespacesAndNewlines) + guard !trimmed.isEmpty else { return nil } + return trimmed + } + + private static func stagedSoundDirectoryURL() -> URL { + URL(fileURLWithPath: NSHomeDirectory(), isDirectory: true) + .appendingPathComponent("Library", isDirectory: true) + .appendingPathComponent("Sounds", isDirectory: true) + } + + private static func queueCustomSoundPreparation(path: String) { + let expandedPath = (path as NSString).expandingTildeInPath + pendingCustomSoundPreparationLock.lock() + if pendingCustomSoundPreparationPaths.contains(expandedPath) { + pendingCustomSoundPreparationLock.unlock() + return + } + pendingCustomSoundPreparationPaths.insert(expandedPath) + pendingCustomSoundPreparationLock.unlock() + + customSoundPreparationQueue.async { + defer { + pendingCustomSoundPreparationLock.lock() + pendingCustomSoundPreparationPaths.remove(expandedPath) + pendingCustomSoundPreparationLock.unlock() + } + _ = prepareCustomFileForNotifications(path: expandedPath) + } + } + + private static func playSoundFile(at url: URL) { + DispatchQueue.main.async { + guard let sound = NSSound(contentsOf: url, byReference: false) else { + NSLog("Notification custom sound failed to load from path: \(url.path)") + return + } + retainActivePlaybackSound(sound) + sound.delegate = activePlaybackSoundDelegate + if !sound.play() { + releaseActivePlaybackSound(sound) + } + } + } + + private static func retainActivePlaybackSound(_ sound: NSSound) { + activePlaybackSoundsLock.lock() + activePlaybackSounds[ObjectIdentifier(sound)] = sound + activePlaybackSoundsLock.unlock() + } + + private static func releaseActivePlaybackSound(_ sound: NSSound) { + activePlaybackSoundsLock.lock() + activePlaybackSounds.removeValue(forKey: ObjectIdentifier(sound)) + activePlaybackSoundsLock.unlock() + } + + private static func cleanupStaleStagedSoundFiles( + in directoryURL: URL, + keeping fileName: String, + preservingSourceURL: URL, + fileManager: FileManager + ) throws { + let legacyPrefix = "\(stagedCustomSoundBaseName)." + let hashedPrefix = "\(stagedCustomSoundBaseName)-" + let normalizedSource = preservingSourceURL.standardizedFileURL + let keptStagedURL = directoryURL.appendingPathComponent(fileName, isDirectory: false) + let keptMetadataFileName = stagedSourceMetadataURL(for: keptStagedURL).lastPathComponent + for fileNameCandidate in try fileManager.contentsOfDirectory(atPath: directoryURL.path) { + let isManagedName = fileNameCandidate.hasPrefix(legacyPrefix) || fileNameCandidate.hasPrefix(hashedPrefix) + let isKeptManagedFile = fileNameCandidate == fileName || fileNameCandidate == keptMetadataFileName + guard isManagedName, !isKeptManagedFile else { continue } + let staleURL = directoryURL.appendingPathComponent(fileNameCandidate, isDirectory: false) + if staleURL.standardizedFileURL == normalizedSource { + continue + } + try? fileManager.removeItem(at: staleURL) + try? fileManager.removeItem(at: stagedSourceMetadataURL(for: staleURL)) + } + } + + private static func copyStagedSoundIfNeeded( + from sourceURL: URL, + to destinationURL: URL, + fileManager: FileManager + ) throws { + let normalizedSource = sourceURL.standardizedFileURL + let normalizedDestination = destinationURL.standardizedFileURL + guard normalizedSource != normalizedDestination else { return } + + if fileManager.fileExists(atPath: normalizedDestination.path) { + let sourceAttributes = try fileManager.attributesOfItem(atPath: normalizedSource.path) + let destinationAttributes = try fileManager.attributesOfItem(atPath: normalizedDestination.path) + let sourceSize = sourceAttributes[.size] as? NSNumber + let destinationSize = destinationAttributes[.size] as? NSNumber + let sourceDate = sourceAttributes[.modificationDate] as? Date + let destinationDate = destinationAttributes[.modificationDate] as? Date + if sourceSize == destinationSize && sourceDate == destinationDate { + return + } + try fileManager.removeItem(at: normalizedDestination) + } + + try fileManager.copyItem(at: normalizedSource, to: normalizedDestination) + } + + private static func transcodeStagedSoundIfNeeded( + from sourceURL: URL, + to destinationURL: URL, + fileManager: FileManager + ) throws { + let normalizedSource = sourceURL.standardizedFileURL + let normalizedDestination = destinationURL.standardizedFileURL + guard normalizedSource != normalizedDestination else { return } + + if fileManager.fileExists(atPath: normalizedDestination.path) { + let sourceAttributes = try fileManager.attributesOfItem(atPath: normalizedSource.path) + let destinationAttributes = try fileManager.attributesOfItem(atPath: normalizedDestination.path) + let sourceDate = sourceAttributes[.modificationDate] as? Date + let destinationDate = destinationAttributes[.modificationDate] as? Date + if let sourceDate, let destinationDate, destinationDate >= sourceDate { + return + } + try fileManager.removeItem(at: normalizedDestination) + } + + let outputPipe = Pipe() + let errorPipe = Pipe() + let process = Process() + process.executableURL = URL(fileURLWithPath: "/usr/bin/afconvert") + process.arguments = [ + "-f", "caff", + "-d", "LEI16", + normalizedSource.path, + normalizedDestination.path, + ] + process.standardOutput = outputPipe + process.standardError = errorPipe + try process.run() + process.waitUntilExit() + guard process.terminationStatus == 0 else { + let errorData = errorPipe.fileHandleForReading.readDataToEndOfFile() + let errorOutput = String(data: errorData, encoding: .utf8)? + .trimmingCharacters(in: .whitespacesAndNewlines) + if fileManager.fileExists(atPath: normalizedDestination.path) { + try? fileManager.removeItem(at: normalizedDestination) + } + let description: String + if let errorOutput, !errorOutput.isEmpty { + description = errorOutput + } else { + description = "afconvert failed with exit code \(process.terminationStatus)" + } + throw NSError( + domain: "NotificationSoundSettings", + code: Int(process.terminationStatus), + userInfo: [ + NSLocalizedDescriptionKey: description, + ] + ) + } + } + + private static func stagedCustomSoundSourceSignature(for sourceURL: URL) -> String { + let normalizedPath = sourceURL.standardizedFileURL.path + var hash: UInt64 = 0xcbf29ce484222325 + for byte in normalizedPath.utf8 { + hash ^= UInt64(byte) + hash &*= 0x100000001b3 + } + return String(format: "%016llx", hash) + } + + private static func stagedSourceMetadataURL(for stagedURL: URL) -> URL { + stagedURL.appendingPathExtension("source-metadata") + } + + private static func currentSourceMetadata(for sourceURL: URL, fileManager: FileManager) -> CustomSoundSourceMetadata? { + guard let attributes = try? fileManager.attributesOfItem(atPath: sourceURL.path) else { + return nil + } + guard let sourceSizeNumber = attributes[.size] as? NSNumber else { + return nil + } + let sourceDate = (attributes[.modificationDate] as? Date) ?? .distantPast + let fileIdentifier = (attributes[.systemFileNumber] as? NSNumber)?.uint64Value + return CustomSoundSourceMetadata( + sourcePath: sourceURL.standardizedFileURL.path, + sourceSize: sourceSizeNumber.uint64Value, + sourceModificationTime: sourceDate.timeIntervalSinceReferenceDate, + sourceFileIdentifier: fileIdentifier + ) + } + + private static func loadStagedSourceMetadata(for stagedURL: URL) -> CustomSoundSourceMetadata? { + let metadataURL = stagedSourceMetadataURL(for: stagedURL) + guard let data = try? Data(contentsOf: metadataURL) else { + return nil + } + return try? JSONDecoder().decode(CustomSoundSourceMetadata.self, from: data) + } + + private static func saveStagedSourceMetadata(_ metadata: CustomSoundSourceMetadata, for stagedURL: URL) throws { + let metadataURL = stagedSourceMetadataURL(for: stagedURL) + let data = try JSONEncoder().encode(metadata) + try data.write(to: metadataURL, options: .atomic) + } + + private static let customCommandQueue = DispatchQueue( + label: "com.cmuxterm.notification-custom-command", + qos: .utility + ) + + static func runCustomCommand(title: String, subtitle: String, body: String, defaults: UserDefaults = .standard) { + let command = (defaults.string(forKey: customCommandKey) ?? defaultCustomCommand) + .trimmingCharacters(in: .whitespacesAndNewlines) + guard !command.isEmpty else { return } + customCommandQueue.async { + let process = Process() + process.executableURL = URL(fileURLWithPath: "/bin/sh") + process.arguments = ["-c", command] + var env = ProcessInfo.processInfo.environment + env["PROGRAMA_NOTIFICATION_TITLE"] = title + env["PROGRAMA_NOTIFICATION_SUBTITLE"] = subtitle + env["PROGRAMA_NOTIFICATION_BODY"] = body + process.environment = env + process.standardOutput = FileHandle.nullDevice + process.standardError = FileHandle.nullDevice + do { + try process.run() + } catch { + NSLog("Notification command failed to launch: \(error)") + } + } + } +} diff --git a/Sources/TerminalNotificationStore.swift b/Sources/TerminalNotificationStore.swift index 48ba6baf0ea..aa87c4b7abc 100644 --- a/Sources/TerminalNotificationStore.swift +++ b/Sources/TerminalNotificationStore.swift @@ -29,526 +29,6 @@ extension UNUserNotificationCenter { } } -enum NotificationSoundSettings { - static let key = "notificationSound" - static let defaultValue = "default" - static let customFileValue = "custom_file" - static let customFilePathKey = "notificationSoundCustomFilePath" - static let defaultCustomFilePath = "" - private static let stagedCustomSoundBaseName = "cmux-custom-notification-sound" - private static let customSoundPreparationQueue = DispatchQueue( - label: "com.cmuxterm.notification-sound-preparation", - qos: .utility - ) - private static let pendingCustomSoundPreparationLock = NSLock() - private static var pendingCustomSoundPreparationPaths: Set = [] - private static let activePlaybackSoundsLock = NSLock() - private static var activePlaybackSounds: [ObjectIdentifier: NSSound] = [:] - private static let activePlaybackSoundDelegate = ActivePlaybackSoundDelegate() - private static let notificationSoundSupportedExtensions: Set = [ - "aif", - "aiff", - "caf", - "wav", - ] - - private final class ActivePlaybackSoundDelegate: NSObject, NSSoundDelegate { - func sound(_ sound: NSSound, didFinishPlaying finishedPlaying: Bool) { - NotificationSoundSettings.releaseActivePlaybackSound(sound) - } - } - - private struct CustomSoundSourceMetadata: Codable, Equatable { - let sourcePath: String - let sourceSize: UInt64 - let sourceModificationTime: Double - let sourceFileIdentifier: UInt64? - } - - enum CustomSoundPreparationIssue: Error { - case emptyPath - case missingFile(path: String) - case missingFileExtension(path: String) - case stagingFailed(path: String, details: String) - - var logMessage: String { - switch self { - case .emptyPath: - return "Notification custom sound path is empty" - case .missingFile(let path): - return "Notification custom sound file does not exist: \(path)" - case .missingFileExtension(let path): - return "Notification custom sound requires a file extension: \(path)" - case .stagingFailed(let path, let details): - return "Failed to stage custom notification sound from \(path): \(details)" - } - } - } - static let customCommandKey = "notificationCustomCommand" - static let defaultCustomCommand = "" - - static let systemSounds: [(label: String, value: String)] = [ - ("Default", "default"), - ("Basso", "Basso"), - ("Blow", "Blow"), - ("Bottle", "Bottle"), - ("Frog", "Frog"), - ("Funk", "Funk"), - ("Glass", "Glass"), - ("Hero", "Hero"), - ("Morse", "Morse"), - ("Ping", "Ping"), - ("Pop", "Pop"), - ("Purr", "Purr"), - ("Sosumi", "Sosumi"), - ("Submarine", "Submarine"), - ("Tink", "Tink"), - ("Custom File...", customFileValue), - ("None", "none"), - ] - - static func sound(defaults: UserDefaults = .standard) -> UNNotificationSound? { - let value = defaults.string(forKey: key) ?? defaultValue - switch value { - case "default": - return .default - case "none": - return nil - case customFileValue: - guard let customSoundName = stagedCustomSoundName(defaults: defaults) else { - return nil - } - return UNNotificationSound(named: UNNotificationSoundName(rawValue: customSoundName)) - default: - return UNNotificationSound(named: UNNotificationSoundName(rawValue: value)) - } - } - - static func usesSystemSound(defaults: UserDefaults = .standard) -> Bool { - let value = defaults.string(forKey: key) ?? defaultValue - switch value { - case "none": - return false - case customFileValue: - return customFileURL(defaults: defaults) != nil - default: - return true - } - } - - static func isSilent(defaults: UserDefaults = .standard) -> Bool { - return (defaults.string(forKey: key) ?? defaultValue) == "none" - } - - static func isCustomFileSelected(defaults: UserDefaults = .standard) -> Bool { - (defaults.string(forKey: key) ?? defaultValue) == customFileValue - } - - static func stagedCustomSoundName(defaults: UserDefaults = .standard) -> String? { - let rawPath = defaults.string(forKey: customFilePathKey) ?? defaultCustomFilePath - guard let normalizedPath = normalizedCustomFilePath(rawPath) else { - NSLog("Notification custom sound unavailable: \(CustomSoundPreparationIssue.emptyPath.logMessage)") - return nil - } - - let sourceURL = URL(fileURLWithPath: (normalizedPath as NSString).expandingTildeInPath) - let sourceExtension = sourceURL.pathExtension - .trimmingCharacters(in: .whitespacesAndNewlines) - .lowercased() - guard !sourceExtension.isEmpty else { - NSLog("Notification custom sound unavailable: \(CustomSoundPreparationIssue.missingFileExtension(path: sourceURL.path).logMessage)") - return nil - } - - let destinationExtension = stagedCustomSoundFileExtension(forSourceExtension: sourceExtension) - let stagedFileName = stagedCustomSoundFileName( - forSourceURL: sourceURL, - destinationExtension: destinationExtension - ) - let stagedURL = stagedSoundDirectoryURL().appendingPathComponent(stagedFileName, isDirectory: false) - let fileManager = FileManager.default - guard fileManager.fileExists(atPath: sourceURL.path) else { - NSLog("Notification custom sound unavailable: \(CustomSoundPreparationIssue.missingFile(path: sourceURL.path).logMessage)") - return nil - } - - if fileManager.fileExists(atPath: stagedURL.path) { - if let sourceMetadata = currentSourceMetadata(for: sourceURL, fileManager: fileManager), - let stagedMetadata = loadStagedSourceMetadata(for: stagedURL), - stagedMetadata == sourceMetadata { - return stagedFileName - } - } - - if destinationExtension == sourceExtension { - switch prepareCustomFileForNotifications(path: normalizedPath) { - case .success(let preparedName): - return preparedName - case .failure(let issue): - NSLog("Notification custom sound unavailable: \(issue.logMessage)") - return nil - } - } - - queueCustomSoundPreparation(path: normalizedPath) - NSLog("Notification custom sound not ready yet, staging in background: \(sourceURL.path)") - return nil - } - - static func prepareCustomFileForNotifications(path: String) -> Result { - guard let normalizedPath = normalizedCustomFilePath(path) else { - return .failure(.emptyPath) - } - let sourceURL = URL(fileURLWithPath: (normalizedPath as NSString).expandingTildeInPath) - return prepareCustomSound(from: sourceURL) - } - - private static func prepareCustomSound(from sourceURL: URL) -> Result { - let sourcePath = sourceURL.path - let fileManager = FileManager.default - guard fileManager.fileExists(atPath: sourcePath) else { - return .failure(.missingFile(path: sourcePath)) - } - let sourceExtension = sourceURL.pathExtension.trimmingCharacters(in: .whitespacesAndNewlines) - guard !sourceExtension.isEmpty else { - return .failure(.missingFileExtension(path: sourcePath)) - } - let destinationExtension = stagedCustomSoundFileExtension(forSourceExtension: sourceExtension) - - let destinationDirectory = stagedSoundDirectoryURL() - let destinationFileName = stagedCustomSoundFileName( - forSourceURL: sourceURL, - destinationExtension: destinationExtension - ) - let destinationURL = destinationDirectory.appendingPathComponent(destinationFileName, isDirectory: false) - let sourceMetadata = currentSourceMetadata(for: sourceURL, fileManager: fileManager) - - do { - try fileManager.createDirectory(at: destinationDirectory, withIntermediateDirectories: true) - if fileManager.fileExists(atPath: destinationURL.path) { - let stagedMetadata = loadStagedSourceMetadata(for: destinationURL) - if stagedMetadata != sourceMetadata { - try? fileManager.removeItem(at: destinationURL) - } - } - if destinationExtension == sourceExtension.lowercased() { - try copyStagedSoundIfNeeded(from: sourceURL, to: destinationURL, fileManager: fileManager) - } else { - try transcodeStagedSoundIfNeeded(from: sourceURL, to: destinationURL, fileManager: fileManager) - } - if let sourceMetadata { - try saveStagedSourceMetadata(sourceMetadata, for: destinationURL) - } - try cleanupStaleStagedSoundFiles( - in: destinationDirectory, - keeping: destinationFileName, - preservingSourceURL: sourceURL, - fileManager: fileManager - ) - return .success(destinationFileName) - } catch { - return .failure(.stagingFailed(path: sourcePath, details: error.localizedDescription)) - } - } - - static func customFileURL(defaults: UserDefaults = .standard) -> URL? { - guard let path = normalizedCustomFilePath(defaults.string(forKey: customFilePathKey) ?? defaultCustomFilePath) else { - return nil - } - return URL(fileURLWithPath: (path as NSString).expandingTildeInPath) - } - - static func playCustomFileSound(defaults: UserDefaults = .standard) { - guard let url = customFileURL(defaults: defaults) else { return } - playSoundFile(at: url) - } - - static func playCustomFileSound(path: String) { - guard let normalizedPath = normalizedCustomFilePath(path) else { return } - let url = URL(fileURLWithPath: (normalizedPath as NSString).expandingTildeInPath) - playSoundFile(at: url) - } - - static func playSelectedSound(defaults: UserDefaults = .standard) { - let value = defaults.string(forKey: key) ?? defaultValue - playSound(value: value, defaults: defaults) - } - - static func previewSound(value: String, defaults: UserDefaults = .standard) { - playSound(value: value, defaults: defaults) - } - - private static func playSound(value: String, defaults: UserDefaults) { - switch value { - case "default": - NSSound.beep() - case "none": - break - case customFileValue: - playCustomFileSound(defaults: defaults) - default: - NSSound(named: NSSound.Name(value))?.play() - } - } - - static func stagedCustomSoundFileExtension(forSourceExtension sourceExtension: String) -> String { - let normalized = sourceExtension - .trimmingCharacters(in: .whitespacesAndNewlines) - .lowercased() - guard !normalized.isEmpty else { return "caf" } - if notificationSoundSupportedExtensions.contains(normalized) { - return normalized - } - return "caf" - } - - static func stagedCustomSoundFileName(forSourceURL sourceURL: URL, destinationExtension: String) -> String { - let normalizedExtension = destinationExtension - .trimmingCharacters(in: .whitespacesAndNewlines) - .lowercased() - let ext = normalizedExtension.isEmpty ? "caf" : normalizedExtension - let signature = stagedCustomSoundSourceSignature(for: sourceURL) - return "\(stagedCustomSoundBaseName)-\(signature).\(ext)" - } - - private static func normalizedCustomFilePath(_ rawPath: String) -> String? { - let trimmed = rawPath.trimmingCharacters(in: .whitespacesAndNewlines) - guard !trimmed.isEmpty else { return nil } - return trimmed - } - - private static func stagedSoundDirectoryURL() -> URL { - URL(fileURLWithPath: NSHomeDirectory(), isDirectory: true) - .appendingPathComponent("Library", isDirectory: true) - .appendingPathComponent("Sounds", isDirectory: true) - } - - private static func queueCustomSoundPreparation(path: String) { - let expandedPath = (path as NSString).expandingTildeInPath - pendingCustomSoundPreparationLock.lock() - if pendingCustomSoundPreparationPaths.contains(expandedPath) { - pendingCustomSoundPreparationLock.unlock() - return - } - pendingCustomSoundPreparationPaths.insert(expandedPath) - pendingCustomSoundPreparationLock.unlock() - - customSoundPreparationQueue.async { - defer { - pendingCustomSoundPreparationLock.lock() - pendingCustomSoundPreparationPaths.remove(expandedPath) - pendingCustomSoundPreparationLock.unlock() - } - _ = prepareCustomFileForNotifications(path: expandedPath) - } - } - - private static func playSoundFile(at url: URL) { - DispatchQueue.main.async { - guard let sound = NSSound(contentsOf: url, byReference: false) else { - NSLog("Notification custom sound failed to load from path: \(url.path)") - return - } - retainActivePlaybackSound(sound) - sound.delegate = activePlaybackSoundDelegate - if !sound.play() { - releaseActivePlaybackSound(sound) - } - } - } - - private static func retainActivePlaybackSound(_ sound: NSSound) { - activePlaybackSoundsLock.lock() - activePlaybackSounds[ObjectIdentifier(sound)] = sound - activePlaybackSoundsLock.unlock() - } - - private static func releaseActivePlaybackSound(_ sound: NSSound) { - activePlaybackSoundsLock.lock() - activePlaybackSounds.removeValue(forKey: ObjectIdentifier(sound)) - activePlaybackSoundsLock.unlock() - } - - private static func cleanupStaleStagedSoundFiles( - in directoryURL: URL, - keeping fileName: String, - preservingSourceURL: URL, - fileManager: FileManager - ) throws { - let legacyPrefix = "\(stagedCustomSoundBaseName)." - let hashedPrefix = "\(stagedCustomSoundBaseName)-" - let normalizedSource = preservingSourceURL.standardizedFileURL - let keptStagedURL = directoryURL.appendingPathComponent(fileName, isDirectory: false) - let keptMetadataFileName = stagedSourceMetadataURL(for: keptStagedURL).lastPathComponent - for fileNameCandidate in try fileManager.contentsOfDirectory(atPath: directoryURL.path) { - let isManagedName = fileNameCandidate.hasPrefix(legacyPrefix) || fileNameCandidate.hasPrefix(hashedPrefix) - let isKeptManagedFile = fileNameCandidate == fileName || fileNameCandidate == keptMetadataFileName - guard isManagedName, !isKeptManagedFile else { continue } - let staleURL = directoryURL.appendingPathComponent(fileNameCandidate, isDirectory: false) - if staleURL.standardizedFileURL == normalizedSource { - continue - } - try? fileManager.removeItem(at: staleURL) - try? fileManager.removeItem(at: stagedSourceMetadataURL(for: staleURL)) - } - } - - private static func copyStagedSoundIfNeeded( - from sourceURL: URL, - to destinationURL: URL, - fileManager: FileManager - ) throws { - let normalizedSource = sourceURL.standardizedFileURL - let normalizedDestination = destinationURL.standardizedFileURL - guard normalizedSource != normalizedDestination else { return } - - if fileManager.fileExists(atPath: normalizedDestination.path) { - let sourceAttributes = try fileManager.attributesOfItem(atPath: normalizedSource.path) - let destinationAttributes = try fileManager.attributesOfItem(atPath: normalizedDestination.path) - let sourceSize = sourceAttributes[.size] as? NSNumber - let destinationSize = destinationAttributes[.size] as? NSNumber - let sourceDate = sourceAttributes[.modificationDate] as? Date - let destinationDate = destinationAttributes[.modificationDate] as? Date - if sourceSize == destinationSize && sourceDate == destinationDate { - return - } - try fileManager.removeItem(at: normalizedDestination) - } - - try fileManager.copyItem(at: normalizedSource, to: normalizedDestination) - } - - private static func transcodeStagedSoundIfNeeded( - from sourceURL: URL, - to destinationURL: URL, - fileManager: FileManager - ) throws { - let normalizedSource = sourceURL.standardizedFileURL - let normalizedDestination = destinationURL.standardizedFileURL - guard normalizedSource != normalizedDestination else { return } - - if fileManager.fileExists(atPath: normalizedDestination.path) { - let sourceAttributes = try fileManager.attributesOfItem(atPath: normalizedSource.path) - let destinationAttributes = try fileManager.attributesOfItem(atPath: normalizedDestination.path) - let sourceDate = sourceAttributes[.modificationDate] as? Date - let destinationDate = destinationAttributes[.modificationDate] as? Date - if let sourceDate, let destinationDate, destinationDate >= sourceDate { - return - } - try fileManager.removeItem(at: normalizedDestination) - } - - let outputPipe = Pipe() - let errorPipe = Pipe() - let process = Process() - process.executableURL = URL(fileURLWithPath: "/usr/bin/afconvert") - process.arguments = [ - "-f", "caff", - "-d", "LEI16", - normalizedSource.path, - normalizedDestination.path, - ] - process.standardOutput = outputPipe - process.standardError = errorPipe - try process.run() - process.waitUntilExit() - guard process.terminationStatus == 0 else { - let errorData = errorPipe.fileHandleForReading.readDataToEndOfFile() - let errorOutput = String(data: errorData, encoding: .utf8)? - .trimmingCharacters(in: .whitespacesAndNewlines) - if fileManager.fileExists(atPath: normalizedDestination.path) { - try? fileManager.removeItem(at: normalizedDestination) - } - let description: String - if let errorOutput, !errorOutput.isEmpty { - description = errorOutput - } else { - description = "afconvert failed with exit code \(process.terminationStatus)" - } - throw NSError( - domain: "NotificationSoundSettings", - code: Int(process.terminationStatus), - userInfo: [ - NSLocalizedDescriptionKey: description, - ] - ) - } - } - - private static func stagedCustomSoundSourceSignature(for sourceURL: URL) -> String { - let normalizedPath = sourceURL.standardizedFileURL.path - var hash: UInt64 = 0xcbf29ce484222325 - for byte in normalizedPath.utf8 { - hash ^= UInt64(byte) - hash &*= 0x100000001b3 - } - return String(format: "%016llx", hash) - } - - private static func stagedSourceMetadataURL(for stagedURL: URL) -> URL { - stagedURL.appendingPathExtension("source-metadata") - } - - private static func currentSourceMetadata(for sourceURL: URL, fileManager: FileManager) -> CustomSoundSourceMetadata? { - guard let attributes = try? fileManager.attributesOfItem(atPath: sourceURL.path) else { - return nil - } - guard let sourceSizeNumber = attributes[.size] as? NSNumber else { - return nil - } - let sourceDate = (attributes[.modificationDate] as? Date) ?? .distantPast - let fileIdentifier = (attributes[.systemFileNumber] as? NSNumber)?.uint64Value - return CustomSoundSourceMetadata( - sourcePath: sourceURL.standardizedFileURL.path, - sourceSize: sourceSizeNumber.uint64Value, - sourceModificationTime: sourceDate.timeIntervalSinceReferenceDate, - sourceFileIdentifier: fileIdentifier - ) - } - - private static func loadStagedSourceMetadata(for stagedURL: URL) -> CustomSoundSourceMetadata? { - let metadataURL = stagedSourceMetadataURL(for: stagedURL) - guard let data = try? Data(contentsOf: metadataURL) else { - return nil - } - return try? JSONDecoder().decode(CustomSoundSourceMetadata.self, from: data) - } - - private static func saveStagedSourceMetadata(_ metadata: CustomSoundSourceMetadata, for stagedURL: URL) throws { - let metadataURL = stagedSourceMetadataURL(for: stagedURL) - let data = try JSONEncoder().encode(metadata) - try data.write(to: metadataURL, options: .atomic) - } - - private static let customCommandQueue = DispatchQueue( - label: "com.cmuxterm.notification-custom-command", - qos: .utility - ) - - static func runCustomCommand(title: String, subtitle: String, body: String, defaults: UserDefaults = .standard) { - let command = (defaults.string(forKey: customCommandKey) ?? defaultCustomCommand) - .trimmingCharacters(in: .whitespacesAndNewlines) - guard !command.isEmpty else { return } - customCommandQueue.async { - let process = Process() - process.executableURL = URL(fileURLWithPath: "/bin/sh") - process.arguments = ["-c", command] - var env = ProcessInfo.processInfo.environment - env["PROGRAMA_NOTIFICATION_TITLE"] = title - env["PROGRAMA_NOTIFICATION_SUBTITLE"] = subtitle - env["PROGRAMA_NOTIFICATION_BODY"] = body - process.environment = env - process.standardOutput = FileHandle.nullDevice - process.standardError = FileHandle.nullDevice - do { - try process.run() - } catch { - NSLog("Notification command failed to launch: \(error)") - } - } - } -} - enum TaggedRunBadgeSettings { static let environmentKey = "PROGRAMA_TAG" private static let maxTagLength = 10 @@ -871,6 +351,12 @@ final class TerminalNotificationStore: ObservableObject { // Judge focus state by the tab manager that owns this tab — the primary // window's manager gives wrong answers for tabs in secondary windows. + // + // NOTE (nuclear-review #90): TerminalNotificationStore reaching back into + // AppDelegate.shared for tab-manager lookups is a layering smell — this store should + // arguably be handed the owning TabManager(s) rather than reaching for a global. + // Flagged, not fixed here: untangling it needs a real design pass on how + // TerminalNotificationStore is scoped/injected across windows, not a mechanical move. let owningTabManager = AppDelegate.shared?.tabManagerFor(tabId: tabId) ?? AppDelegate.shared?.tabManager let isActiveTab = owningTabManager?.selectedTabId == tabId let focusedSurfaceId = owningTabManager?.focusedSurfaceId(for: tabId) From f10f59eff7baa4a8bcc65464383f083f38a5301b Mon Sep 17 00:00:00 2001 From: arzafran Date: Thu, 9 Jul 2026 14:12:29 -0300 Subject: [PATCH 26/31] refactor(browser): split WebViewRepresentable out of BrowserPanelView.swift MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Move-only file reorganization (#99), done after the inspector-dock dedup (#91) landed as the issue calls for. WebViewRepresentable (the WKWebView NSViewRepresentable wrapper, including its nested Coordinator and HostContainerView) was the last top-level declaration in BrowserPanelView.swift and fully self-contained (~2.3k lines) — moved verbatim into Sources/Panels/WebViewRepresentable.swift. BrowserPanelView.swift: 6728 -> 4395 lines. Access-level widening required for cross-file visibility: the `private extension WKWebView` holding programaBrowserPanelNotifyHidden/ programaBrowserPanelReattachRenderingState/programaBrowserPanelForceRenderingStateRefresh (called from WebViewRepresentable, now in a different file) is widened to a plain (internal) extension. The genuinely private members of that extension (programaBrowserPanelNeedsRenderingStateReattach, programaBrowserPanelApplyRenderingStateRefresh) keep their own explicit `private` modifiers and remain file-scoped to BrowserPanelView.swift. Refs #99. --- GhosttyTabs.xcodeproj/project.pbxproj | 4 + Sources/Panels/BrowserPanelView.swift | 2339 +-------------------- Sources/Panels/WebViewRepresentable.swift | 2337 ++++++++++++++++++++ 3 files changed, 2346 insertions(+), 2334 deletions(-) create mode 100644 Sources/Panels/WebViewRepresentable.swift diff --git a/GhosttyTabs.xcodeproj/project.pbxproj b/GhosttyTabs.xcodeproj/project.pbxproj index d027a7709af..b4fc9c4ad36 100644 --- a/GhosttyTabs.xcodeproj/project.pbxproj +++ b/GhosttyTabs.xcodeproj/project.pbxproj @@ -50,6 +50,7 @@ NRBR0011 /* BrowserPanelWebDelegates.swift in Sources */ = {isa = PBXBuildFile; fileRef = NRBR0012 /* BrowserPanelWebDelegates.swift */; }; NRBR0013 /* BrowserUserProxySettings.swift in Sources */ = {isa = PBXBuildFile; fileRef = NRBR0014 /* BrowserUserProxySettings.swift */; }; NRBR0015 /* IMECompositionMessageHandler.swift in Sources */ = {isa = PBXBuildFile; fileRef = NRBR0016 /* IMECompositionMessageHandler.swift */; }; + NRBR0017 /* WebViewRepresentable.swift in Sources */ = {isa = PBXBuildFile; fileRef = NRBR0018 /* WebViewRepresentable.swift */; }; A5FF0013 /* BrowserDataImport.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5FF0003 /* BrowserDataImport.swift */; }; A500RG01 /* ReactGrab.swift in Sources */ = {isa = PBXBuildFile; fileRef = A500RG00 /* ReactGrab.swift */; }; A5001403 /* TerminalPanelView.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5001413 /* TerminalPanelView.swift */; }; @@ -283,6 +284,7 @@ NRBR0012 /* BrowserPanelWebDelegates.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Panels/BrowserPanelWebDelegates.swift; sourceTree = ""; }; NRBR0014 /* BrowserUserProxySettings.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Panels/BrowserUserProxySettings.swift; sourceTree = ""; }; NRBR0016 /* IMECompositionMessageHandler.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Panels/IMECompositionMessageHandler.swift; sourceTree = ""; }; + NRBR0018 /* WebViewRepresentable.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Panels/WebViewRepresentable.swift; sourceTree = ""; }; A5FF0003 /* BrowserDataImport.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Panels/BrowserDataImport.swift; sourceTree = ""; }; A500RG00 /* ReactGrab.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Panels/ReactGrab.swift; sourceTree = ""; }; A5001413 /* TerminalPanelView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Panels/TerminalPanelView.swift; sourceTree = ""; }; @@ -573,6 +575,7 @@ NRBR0012 /* BrowserPanelWebDelegates.swift */, NRBR0014 /* BrowserUserProxySettings.swift */, NRBR0016 /* IMECompositionMessageHandler.swift */, + NRBR0018 /* WebViewRepresentable.swift */, A5FF0003 /* BrowserDataImport.swift */, A500RG00 /* ReactGrab.swift */, A5001413 /* TerminalPanelView.swift */, @@ -910,6 +913,7 @@ NRBR0011 /* BrowserPanelWebDelegates.swift in Sources */, NRBR0013 /* BrowserUserProxySettings.swift in Sources */, NRBR0015 /* IMECompositionMessageHandler.swift in Sources */, + NRBR0017 /* WebViewRepresentable.swift in Sources */, A5FF0013 /* BrowserDataImport.swift in Sources */, A500RG01 /* ReactGrab.swift in Sources */, A5001403 /* TerminalPanelView.swift in Sources */, diff --git a/Sources/Panels/BrowserPanelView.swift b/Sources/Panels/BrowserPanelView.swift index 69564a764a6..827588f2bb0 100644 --- a/Sources/Panels/BrowserPanelView.swift +++ b/Sources/Panels/BrowserPanelView.swift @@ -27,7 +27,11 @@ private extension NSObject { } } -private extension WKWebView { +// Not `private`: programaBrowserPanelNotifyHidden/Reattach/ForceRenderingStateRefresh below are +// called from WebViewRepresentable.swift (file-reorg for #99). The genuinely private members +// (programaBrowserPanelNeedsRenderingStateReattach, programaBrowserPanelApplyRenderingStateRefresh) +// keep their explicit `private` modifiers below and stay file-scoped to BrowserPanelView.swift. +extension WKWebView { private var programaBrowserPanelNeedsRenderingStateReattach: Bool { get { (objc_getAssociatedObject(self, &programaBrowserPanelNeedsRenderingStateReattachKey) as? NSNumber)? @@ -4393,2336 +4397,3 @@ private struct OmnibarSuggestionsView: View { .accessibilityLabel(String(localized: "browser.addressBarSuggestions", defaultValue: "Address bar suggestions")) } } - -/// NSViewRepresentable wrapper for WKWebView -struct WebViewRepresentable: NSViewRepresentable { - let panel: BrowserPanel - let paneId: PaneID - let shouldAttachWebView: Bool - let useLocalInlineHosting: Bool - let shouldFocusWebView: Bool - let isPanelFocused: Bool - let portalZPriority: Int - let paneDropZone: DropZone? - let searchOverlay: BrowserPortalSearchOverlayConfiguration? - let paneTopChromeHeight: CGFloat - - final class Coordinator { - weak var panel: BrowserPanel? - weak var webView: WKWebView? - var attachGeneration: Int = 0 - var desiredPortalVisibleInUI: Bool = true - var desiredPortalZPriority: Int = 0 - var lastPortalHostId: ObjectIdentifier? - var lastSynchronizedHostGeometryRevision: UInt64 = 0 - } - - final class HostContainerView: NSView { - private final class HostedInspectorSideDockContainerView: NSView { - override init(frame frameRect: NSRect) { - super.init(frame: frameRect) - wantsLayer = true - layer?.masksToBounds = true - } - - @available(*, unavailable) - required init?(coder: NSCoder) { - nil - } - - override var isOpaque: Bool { false } - - override func resizeSubviews(withOldSize oldSize: NSSize) { - // Managed side-docked DevTools use explicit frame updates from the host. - // Letting AppKit autoresize the WK siblings here makes them snap back to - // stale widths while the divider drag or pane resize is in flight. - } - } - - var onDidMoveToWindow: (() -> Void)? - var onGeometryChanged: (() -> Void)? - private(set) var geometryRevision: UInt64 = 0 - private var lastReportedGeometryState: GeometryState? - private var hasPendingGeometryNotification = false - private weak var hostedWebView: WKWebView? - private var hostedWebViewConstraints: [NSLayoutConstraint] = [] - private weak var localInlineSlotView: WindowBrowserSlotView? - private var localInlineSlotConstraints: [NSLayoutConstraint] = [] - private weak var hostedInspectorSideDockContainerView: HostedInspectorSideDockContainerView? - private var hostedInspectorSideDockConstraints: [NSLayoutConstraint] = [] - private weak var hostedInspectorFrontendWebView: WKWebView? - private struct HostedInspectorDividerHit { - let containerView: NSView - let pageView: NSView - let inspectorView: NSView - let dockSide: HostedInspectorDockSide - } - - private struct GeometryState: Equatable { - let frame: CGRect - let bounds: CGRect - let windowNumber: Int? - let superviewID: ObjectIdentifier? - } - - private struct HostedInspectorDividerDragState { - let containerView: NSView - let pageView: NSView - let inspectorView: NSView - let dockSide: HostedInspectorDockSide - let initialWindowX: CGFloat - let initialPageFrame: NSRect - let initialInspectorFrame: NSRect - } - - private enum DividerCursorKind: Equatable { - case vertical - - var cursor: NSCursor { .resizeLeftRight } - } - - private static let hostedInspectorDividerHitExpansion: CGFloat = 10 - private static let minimumHostedInspectorWidth: CGFloat = 120 - private static let minimumHostedInspectorPageWidthForSideDock: CGFloat = 240 - private static let adaptiveBottomDockRequestCooldown: TimeInterval = 0.25 - private var trackingArea: NSTrackingArea? - private var activeDividerCursorKind: DividerCursorKind? - private var hostedInspectorDividerDrag: HostedInspectorDividerDragState? - private var preferredHostedInspectorWidth: CGFloat? - private var preferredHostedInspectorWidthFraction: CGFloat? - var onPreferredHostedInspectorWidthChanged: ((CGFloat, CGFloat?) -> Void)? - private weak var hostedInspectorSideDockPageView: NSView? - private weak var hostedInspectorSideDockInspectorView: NSView? - private var hostedInspectorSideDockDockSide: HostedInspectorDockSide? - private var isHostedInspectorDividerDragActive = false - private var isApplyingHostedInspectorLayout = false - private var hostedInspectorReapplyWorkItem: DispatchWorkItem? - private var hostedInspectorDockConfigurationSyncWorkItem: DispatchWorkItem? - private var adaptiveBottomDockRequestCooldownDeadline: Date? - private var recordedHostedInspectorSideDockWidth: CGFloat? - private var lastHostedInspectorManualSideDockAllowed: Bool? - private var lastHostedInspectorLayoutBoundsSize: NSSize? -#if DEBUG - private var lastLoggedHostedInspectorFrames: (page: NSRect, inspector: NSRect)? - private var hasLoggedMissingHostedInspectorCandidate = false -#endif - - deinit { - hostedInspectorReapplyWorkItem?.cancel() - hostedInspectorDockConfigurationSyncWorkItem?.cancel() - if let trackingArea { - removeTrackingArea(trackingArea) - } - clearActiveDividerCursor(restoreArrow: false) - } - - private func recordPreferredHostedInspectorWidth(_ width: CGFloat, containerBounds: NSRect) { - preferredHostedInspectorWidth = width - guard containerBounds.width > 0 else { - preferredHostedInspectorWidthFraction = nil - onPreferredHostedInspectorWidthChanged?(width, nil) - return - } - preferredHostedInspectorWidthFraction = width / containerBounds.width - onPreferredHostedInspectorWidthChanged?(width, preferredHostedInspectorWidthFraction) - } - - private func resolvedPreferredHostedInspectorWidth(in containerBounds: NSRect) -> CGFloat? { - if let preferredHostedInspectorWidthFraction, containerBounds.width > 0 { - return max(0, containerBounds.width * preferredHostedInspectorWidthFraction) - } - return preferredHostedInspectorWidth - } - - func setPreferredHostedInspectorWidth(width: CGFloat?, widthFraction: CGFloat?) { - preferredHostedInspectorWidth = width - preferredHostedInspectorWidthFraction = widthFraction - } - - private func recordHostedInspectorSideDockWidth(_ width: CGFloat) { - guard width > 1 else { return } - recordedHostedInspectorSideDockWidth = max(Self.minimumHostedInspectorWidth, width) - } - - private func shouldAllowHostedInspectorManualSideDock() -> Bool { - let containerWidth = max(0, bounds.width) - guard containerWidth > 1 else { return true } - let baselineWidth = max( - Self.minimumHostedInspectorWidth, - recordedHostedInspectorSideDockWidth ?? Self.minimumHostedInspectorWidth - ) - return containerWidth - baselineWidth >= Self.minimumHostedInspectorPageWidthForSideDock - } - - private func updateHostedInspectorDockControlAvailabilityIfNeeded(reason: String) { - guard let hostedInspectorFrontendWebView else { - lastHostedInspectorManualSideDockAllowed = nil - return - } - - let sideDockAllowed = shouldAllowHostedInspectorManualSideDock() - guard lastHostedInspectorManualSideDockAllowed != sideDockAllowed else { return } - lastHostedInspectorManualSideDockAllowed = sideDockAllowed - - let sideDockAllowedLiteral = sideDockAllowed ? "true" : "false" -#if DEBUG - let recordedWidthDesc = recordedHostedInspectorSideDockWidth.map { - String(format: "%.1f", $0) - } ?? "nil" - dlog( - "browser.panel.hostedInspector stage=\(reason).dockControls " + - "host=\(Self.debugObjectID(self)) allowSideDock=\(sideDockAllowed ? 1 : 0) " + - "recordedWidth=\(recordedWidthDesc) bounds=\(Self.debugRect(bounds))" - ) -#endif - hostedInspectorFrontendWebView.evaluateJavaScript( - """ - (() => { - if (typeof WI === "undefined") - return null; - const allowSideDock = \(sideDockAllowedLiteral); - if (!WI.__programaOriginalUpdateDockNavigationItems && typeof WI._updateDockNavigationItems === "function") - WI.__programaOriginalUpdateDockNavigationItems = WI._updateDockNavigationItems; - if (!WI.__programaOriginalDockLeft && typeof WI._dockLeft === "function") - WI.__programaOriginalDockLeft = WI._dockLeft; - if (!WI.__programaOriginalDockRight && typeof WI._dockRight === "function") - WI.__programaOriginalDockRight = WI._dockRight; - if (!WI.__programaOriginalTogglePreviousDockConfiguration && typeof WI._togglePreviousDockConfiguration === "function") - WI.__programaOriginalTogglePreviousDockConfiguration = WI._togglePreviousDockConfiguration; - function callOriginal(fn, event) { - return typeof fn === "function" ? fn.call(WI, event) : null; - } - function updateButton(button, hidden) { - if (!button) - return; - button.hidden = hidden; - if (button.element) { - button.element.style.display = hidden ? "none" : ""; - button.element.style.pointerEvents = hidden ? "none" : ""; - } - } - function enforceDockControls() { - const disallowSideDock = !WI.__programaAllowSideDock; - updateButton(WI._dockLeftTabBarButton, disallowSideDock || WI.dockConfiguration === WI.DockConfiguration.Left); - updateButton(WI._dockRightTabBarButton, disallowSideDock || WI.dockConfiguration === WI.DockConfiguration.Right); - } - WI.__programaAllowSideDock = allowSideDock; - WI._dockLeft = function(event) { - if (!WI.__programaAllowSideDock) - return callOriginal(WI._dockBottom, event); - return callOriginal(WI.__programaOriginalDockLeft, event); - }; - WI._dockRight = function(event) { - if (!WI.__programaAllowSideDock) - return callOriginal(WI._dockBottom, event); - return callOriginal(WI.__programaOriginalDockRight, event); - }; - WI._togglePreviousDockConfiguration = function(event) { - const previousSideDock = WI._previousDockConfiguration === WI.DockConfiguration.Left || WI._previousDockConfiguration === WI.DockConfiguration.Right; - if (!WI.__programaAllowSideDock && previousSideDock) - return callOriginal(WI._dockBottom, event); - return callOriginal(WI.__programaOriginalTogglePreviousDockConfiguration, event); - }; - WI._updateDockNavigationItems = function(...args) { - if (typeof WI.__programaOriginalUpdateDockNavigationItems === "function") - WI.__programaOriginalUpdateDockNavigationItems.apply(WI, args); - enforceDockControls(); - }; - WI._updateDockNavigationItems(); - return WI.__programaAllowSideDock; - })(); - """, - completionHandler: nil - ) - } - - func containsManagedLocalInlineContent(_ view: NSView) -> Bool { - if let localInlineSlotView, - view === localInlineSlotView || view.isDescendant(of: localInlineSlotView) { - return true - } - if let hostedInspectorSideDockContainerView, - view === hostedInspectorSideDockContainerView || view.isDescendant(of: hostedInspectorSideDockContainerView) { - return true - } - return false - } - - func currentHostedWebViewContainer(preferredSlotView: WindowBrowserSlotView) -> NSView { - if let hostedInspectorSideDockContainerView, - let hostedInspectorSideDockPageView, - hostedWebView?.isDescendant(of: hostedInspectorSideDockContainerView) == true, - hostedInspectorSideDockPageView.isDescendant(of: hostedInspectorSideDockContainerView) { - return hostedInspectorSideDockContainerView - } - return preferredSlotView - } - - func setHostedInspectorFrontendWebView(_ webView: WKWebView?) { - hostedInspectorFrontendWebView = webView - lastHostedInspectorManualSideDockAllowed = nil - updateHostedInspectorDockControlAvailabilityIfNeeded(reason: "setHostedInspectorFrontendWebView") - } - - private var hasStoredHostedInspectorWidthPreference: Bool { - preferredHostedInspectorWidth != nil || preferredHostedInspectorWidthFraction != nil - } - -#if DEBUG - private static func shouldLogPointerEvent(_ event: NSEvent?) -> Bool { - switch event?.type { - case .leftMouseDown, .leftMouseDragged, .leftMouseUp: - return true - default: - return false - } - } - - private func debugLogHitTest(stage: String, point: NSPoint, passThrough: Bool, hitView: NSView?) { - let event = NSApp.currentEvent - guard Self.shouldLogPointerEvent(event) else { return } - - let hitDesc: String = { - guard let hitView else { return "nil" } - let token = Unmanaged.passUnretained(hitView).toOpaque() - return "\(type(of: hitView))@\(token)" - }() - let hostRectInContent: NSRect = { - guard let window, let contentView = window.contentView else { return .zero } - return contentView.convert(bounds, from: self) - }() - dlog( - "browser.panel.host stage=\(stage) event=\(String(describing: event?.type)) " + - "point=\(String(format: "%.1f,%.1f", point.x, point.y)) pass=\(passThrough ? 1 : 0) " + - "hostFrameInContent=\(String(format: "%.1f,%.1f %.1fx%.1f", hostRectInContent.origin.x, hostRectInContent.origin.y, hostRectInContent.width, hostRectInContent.height)) " + - "hit=\(hitDesc)" - ) - } - - private static func debugObjectID(_ object: AnyObject?) -> String { - guard let object else { return "nil" } - return String(describing: Unmanaged.passUnretained(object).toOpaque()) - } - - private static func debugRect(_ rect: NSRect) -> String { - String(format: "%.1f,%.1f %.1fx%.1f", rect.origin.x, rect.origin.y, rect.width, rect.height) - } - - private func debugLogHostedInspectorFrames( - stage: String, - point: NSPoint? = nil, - hit: HostedInspectorDividerHit - ) { - let pointDesc = point.map { String(format: "%.1f,%.1f", $0.x, $0.y) } ?? "nil" - let preferredWidthDesc = preferredHostedInspectorWidth.map { String(format: "%.1f", $0) } ?? "nil" - dlog( - "browser.panel.hostedInspector stage=\(stage) point=\(pointDesc) " + - "host=\(Self.debugObjectID(self)) container=\(Self.debugObjectID(hit.containerView)) " + - "page=\(Self.debugObjectID(hit.pageView)) inspector=\(Self.debugObjectID(hit.inspectorView)) " + - "preferredWidth=\(preferredWidthDesc) " + - "hostFrame=\(Self.debugRect(frame)) hostBounds=\(Self.debugRect(bounds)) " + - "containerBounds=\(Self.debugRect(hit.containerView.bounds)) " + - "pageFrame=\(Self.debugRect(hit.pageView.frame)) " + - "inspectorFrame=\(Self.debugRect(hit.inspectorView.frame))" - ) - } - - private func debugLogHostedInspectorLayoutIfNeeded(reason: String) { - guard let hit = hostedInspectorDividerCandidate() else { - if !hasLoggedMissingHostedInspectorCandidate, - lastLoggedHostedInspectorFrames != nil || preferredHostedInspectorWidth != nil { - let preferredWidthDesc = preferredHostedInspectorWidth.map { - String(format: "%.1f", $0) - } ?? "nil" - lastLoggedHostedInspectorFrames = nil - hasLoggedMissingHostedInspectorCandidate = true - dlog( - "browser.panel.hostedInspector stage=\(reason).candidateMissing " + - "host=\(Self.debugObjectID(self)) preferredWidth=\(preferredWidthDesc)" - ) - } - return - } - hasLoggedMissingHostedInspectorCandidate = false - - let nextFrames = (page: hit.pageView.frame, inspector: hit.inspectorView.frame) - if let lastLoggedHostedInspectorFrames, - InspectorDock.rectApproximatelyEqual(lastLoggedHostedInspectorFrames.page, nextFrames.page), - InspectorDock.rectApproximatelyEqual(lastLoggedHostedInspectorFrames.inspector, nextFrames.inspector) { - return - } - - lastLoggedHostedInspectorFrames = nextFrames - debugLogHostedInspectorFrames(stage: "\(reason).layout", hit: hit) - } -#endif - - private static func sizeApproximatelyEqual(_ lhs: NSSize, _ rhs: NSSize, epsilon: CGFloat = 0.5) -> Bool { - abs(lhs.width - rhs.width) <= epsilon && - abs(lhs.height - rhs.height) <= epsilon - } - - private func currentGeometryState() -> GeometryState { - GeometryState( - frame: frame, - bounds: bounds, - windowNumber: window?.windowNumber, - superviewID: superview.map(ObjectIdentifier.init) - ) - } - - /// Record that geometry changed without firing the callback immediately. - /// `setFrameOrigin`/`setFrameSize` can fire multiple times before `layout()`; - /// deferring avoids redundant portal-sync cascades during divider drag. - /// A dispatch fallback ensures the callback fires even if `layout()` is not called. - /// Note: `lastReportedGeometryState` and `geometryRevision` are only updated - /// when the callback actually fires, so `updateNSView` sees a revision that - /// is strictly tied to emitted callbacks (no premature increments). - private func markGeometryDirtyIfNeeded() { - let state = currentGeometryState() - guard state != lastReportedGeometryState else { return } - guard !hasPendingGeometryNotification else { return } - hasPendingGeometryNotification = true - DispatchQueue.main.async { [weak self] in - self?.notifyGeometryChangedIfNeeded() - } - } - - /// Check for geometry changes and fire the callback. Also flushes any pending - /// dirty state from `markGeometryDirtyIfNeeded` so `layout()` supersedes the - /// async fallback. Only updates `lastReportedGeometryState` / `geometryRevision` - /// when the callback is emitted, keeping the revision in sync with actual - /// notifications. - private func notifyGeometryChangedIfNeeded() { - hasPendingGeometryNotification = false - let state = currentGeometryState() - guard state != lastReportedGeometryState else { return } - lastReportedGeometryState = state - geometryRevision &+= 1 - onGeometryChanged?() - } - - func ensureLocalInlineSlotView() -> WindowBrowserSlotView { - if let localInlineSlotView, localInlineSlotView.superview === self { - localInlineSlotView.isHidden = false - return localInlineSlotView - } - - let slotView = WindowBrowserSlotView(frame: bounds) - slotView.translatesAutoresizingMaskIntoConstraints = false - addSubview(slotView, positioned: .above, relativeTo: nil) - localInlineSlotConstraints = [ - slotView.topAnchor.constraint(equalTo: topAnchor), - slotView.bottomAnchor.constraint(equalTo: bottomAnchor), - slotView.leadingAnchor.constraint(equalTo: leadingAnchor), - slotView.trailingAnchor.constraint(equalTo: trailingAnchor), - ] - NSLayoutConstraint.activate(localInlineSlotConstraints) - localInlineSlotView = slotView - return slotView - } - - func setLocalInlineSlotHidden(_ hidden: Bool) { - localInlineSlotView?.isHidden = hidden - if hidden { - notifyHostedWebKitHidden(reason: "slotHidden") - } - } - - func clearLocalInlineCallbacks() { - onPreferredHostedInspectorWidthChanged = nil - localInlineSlotView?.onHostedInspectorLayout = nil - } - - private func appendHostedWebKitSubviews( - in root: NSView, - to result: inout [WKWebView], - seen: inout Set - ) { - if let webView = root as? WKWebView { - let id = ObjectIdentifier(webView) - if seen.insert(id).inserted { - result.append(webView) - } - } - for subview in root.subviews { - appendHostedWebKitSubviews(in: subview, to: &result, seen: &seen) - } - } - - private var hostedWebKitSubviews: [WKWebView] { - var result: [WKWebView] = [] - var seen = Set() - - func append(_ webView: WKWebView?) { - guard let webView else { return } - let id = ObjectIdentifier(webView) - guard seen.insert(id).inserted else { return } - result.append(webView) - } - - append(hostedWebView) - append(hostedInspectorFrontendWebView) - appendHostedWebKitSubviews(in: self, to: &result, seen: &seen) - return result - } - - private func notifyHostedWebKitHidden(reason: String) { - for webView in hostedWebKitSubviews { - webView.programaBrowserPanelNotifyHidden(reason: reason) - } - } - - func refreshHostedWebKitPresentation( - reason: String, - forceLifecycleRefresh: Bool = false - ) { - guard let localInlineSlotView else { return } - guard !localInlineSlotView.isHidden else { return } - let hostedWebKitSubviews = hostedWebKitSubviews - guard !hostedWebKitSubviews.isEmpty else { return } - - localInlineSlotView.needsLayout = true - localInlineSlotView.needsDisplay = true - localInlineSlotView.setNeedsDisplay(localInlineSlotView.bounds) - - needsLayout = true - needsDisplay = true - setNeedsDisplay(bounds) - - for webView in hostedWebKitSubviews { - if let scrollView = webView.enclosingScrollView { - scrollView.needsLayout = true - scrollView.needsDisplay = true - scrollView.setNeedsDisplay(scrollView.bounds) - scrollView.contentView.needsLayout = true - scrollView.contentView.needsDisplay = true - } - webView.needsLayout = true - webView.needsDisplay = true - webView.setNeedsDisplay(webView.bounds) - } - - localInlineSlotView.layoutSubtreeIfNeeded() - layoutSubtreeIfNeeded() - - for webView in hostedWebKitSubviews { - if let scrollView = webView.enclosingScrollView { - scrollView.layoutSubtreeIfNeeded() - scrollView.contentView.layoutSubtreeIfNeeded() - scrollView.displayIfNeeded() - } - webView.layoutSubtreeIfNeeded() - if forceLifecycleRefresh { - webView.programaBrowserPanelForceRenderingStateRefresh(reason: reason) - } else { - webView.programaBrowserPanelReattachRenderingState(reason: reason) - } - webView.displayIfNeeded() - } - - localInlineSlotView.displayIfNeeded() - displayIfNeeded() - window?.displayIfNeeded() - } - - func prepareForWindowPortalHosting() { - hostedInspectorDockConfigurationSyncWorkItem?.cancel() - hostedInspectorDockConfigurationSyncWorkItem = nil - notifyHostedWebKitHidden(reason: "prepareForWindowPortalHosting") - deactivateHostedInspectorSideDockIfNeeded(reparentTo: localInlineSlotView) - hostedInspectorFrontendWebView = nil - } - - func releaseHostedWebViewConstraints() { - NSLayoutConstraint.deactivate(hostedWebViewConstraints) - hostedWebViewConstraints = [] - hostedWebView = nil - } - - func pinHostedWebView(_ webView: WKWebView, in container: NSView) { - guard webView.superview === container || webView.isDescendant(of: container) else { return } - - let hasCompanionWKSubviews = Self.hasWebKitCompanionSubview( - in: container, - primaryWebView: webView - ) - let needsPlainWebViewFrameReset = - webView.superview === container && - !hasCompanionWKSubviews && - Self.frameDiffersFromBounds(webView.frame, bounds: container.bounds) - let needsFrameHosting = - hostedWebView !== webView || - !hostedWebViewConstraints.isEmpty || - needsPlainWebViewFrameReset || - !webView.translatesAutoresizingMaskIntoConstraints || - webView.autoresizingMask != [.width, .height] - guard needsFrameHosting else { - needsLayout = true - layoutSubtreeIfNeeded() - return - } - - NSLayoutConstraint.deactivate(hostedWebViewConstraints) - hostedWebViewConstraints = [] - hostedWebView = webView - - // WebKit's attached inspector does not reliably dock into a constraint-managed - // WKWebView hierarchy on macOS. Host the moved webview with autoresizing and - // preserve WebKit-managed split frames when docked DevTools siblings exist. - webView.translatesAutoresizingMaskIntoConstraints = true - webView.autoresizingMask = [.width, .height] - if webView.superview === container && !hasCompanionWKSubviews { - webView.frame = container.bounds - } - needsLayout = true - layoutSubtreeIfNeeded() - } - - private static func frameDiffersFromBounds(_ frame: NSRect, bounds: NSRect, epsilon: CGFloat = 0.5) -> Bool { - abs(frame.minX - bounds.minX) > epsilon || - abs(frame.minY - bounds.minY) > epsilon || - abs(frame.width - bounds.width) > epsilon || - abs(frame.height - bounds.height) > epsilon - } - - private static func hasWebKitCompanionSubview(in host: NSView, primaryWebView: WKWebView) -> Bool { - var stack = host.subviews.filter { $0 !== primaryWebView } - while let current = stack.popLast() { - if current.isDescendant(of: primaryWebView) { - continue - } - if current.isHidden || current.alphaValue <= 0 { - continue - } - if String(describing: type(of: current)).contains("WK") { - let width = max(current.frame.width, current.bounds.width) - let height = max(current.frame.height, current.bounds.height) - if width > 1, height > 1 { - return true - } - continue - } - stack.append(contentsOf: current.subviews) - } - return false - } - - private func ensureHostedInspectorSideDockContainerView() -> HostedInspectorSideDockContainerView { - if let hostedInspectorSideDockContainerView, - hostedInspectorSideDockContainerView.superview === self { - hostedInspectorSideDockContainerView.isHidden = false - return hostedInspectorSideDockContainerView - } - - let containerView = HostedInspectorSideDockContainerView(frame: bounds) - containerView.translatesAutoresizingMaskIntoConstraints = false - addSubview(containerView, positioned: .above, relativeTo: localInlineSlotView) - hostedInspectorSideDockConstraints = [ - containerView.topAnchor.constraint(equalTo: topAnchor), - containerView.bottomAnchor.constraint(equalTo: bottomAnchor), - containerView.leadingAnchor.constraint(equalTo: leadingAnchor), - containerView.trailingAnchor.constraint(equalTo: trailingAnchor), - ] - NSLayoutConstraint.activate(hostedInspectorSideDockConstraints) - hostedInspectorSideDockContainerView = containerView - return containerView - } - - private func moveHostedInspectorSubviewIfNeeded(_ view: NSView, to container: NSView) { - guard view.superview !== container else { return } - let frameInWindow = view.superview?.convert(view.frame, to: nil) ?? convert(view.frame, to: nil) - view.removeFromSuperview() - container.addSubview(view, positioned: .above, relativeTo: nil) - view.frame = container.convert(frameInWindow, from: nil) - } - - private func isHostedInspectorSideDockActive() -> Bool { - guard let hostedInspectorSideDockContainerView, - let hostedInspectorSideDockPageView, - let hostedInspectorSideDockInspectorView else { - return false - } - return hostedInspectorSideDockPageView.superview === hostedInspectorSideDockContainerView && - hostedInspectorSideDockInspectorView.superview === hostedInspectorSideDockContainerView - } - - private func isHostedInspectorSideDockHit(_ hit: HostedInspectorDividerHit) -> Bool { - guard let hostedInspectorSideDockContainerView else { return false } - return hit.containerView === hostedInspectorSideDockContainerView - } - - private func activateHostedInspectorSideDockIfNeeded(using hit: HostedInspectorDividerHit) { - let containerView = ensureHostedInspectorSideDockContainerView() - moveHostedInspectorSubviewIfNeeded(hit.pageView, to: containerView) - moveHostedInspectorSubviewIfNeeded(hit.inspectorView, to: containerView) - hostedInspectorSideDockPageView = hit.pageView - hostedInspectorSideDockInspectorView = hit.inspectorView - hostedInspectorSideDockDockSide = hit.dockSide - layoutHostedInspectorSideDockIfNeeded(reason: "sideDock.activate") - } - - @discardableResult - func promoteHostedInspectorSideDockFromCurrentLayoutIfNeeded() -> Bool { - guard !isHostedInspectorSideDockActive(), - let slotView = localInlineSlotView, - let hit = hostedInspectorDividerCandidateUsingKnownWebViews(in: slotView) else { - return false - } - - // The inspector frontend sometimes reports its dock configuration a tick - // late after local-inline reattach. Promote the visible left/right split - // immediately so drag routing stays symmetric on both dock sides. - activateHostedInspectorSideDockIfNeeded(using: hit) - return isHostedInspectorSideDockActive() - } - - private func deactivateHostedInspectorSideDockIfNeeded(reparentTo slotView: WindowBrowserSlotView?) { - guard let slotView, - let pageView = hostedInspectorSideDockPageView, - let inspectorView = hostedInspectorSideDockInspectorView else { - hostedInspectorSideDockPageView = nil - hostedInspectorSideDockInspectorView = nil - hostedInspectorSideDockDockSide = nil - hostedInspectorSideDockContainerView?.isHidden = true - return - } - - moveHostedInspectorSubviewIfNeeded(pageView, to: slotView) - moveHostedInspectorSubviewIfNeeded(inspectorView, to: slotView) - hostedInspectorSideDockPageView = nil - hostedInspectorSideDockInspectorView = nil - hostedInspectorSideDockDockSide = nil - hostedInspectorSideDockContainerView?.isHidden = true - } - - private func layoutHostedInspectorSideDockIfNeeded(reason: String) { - guard let containerView = hostedInspectorSideDockContainerView, - let pageView = hostedInspectorSideDockPageView, - let inspectorView = hostedInspectorSideDockInspectorView, - let dockSide = hostedInspectorSideDockDockSide else { - return - } - let preferredWidth = resolvedPreferredHostedInspectorWidth(in: containerView.bounds) ?? max(0, inspectorView.frame.width) - _ = applyHostedInspectorDividerWidth( - preferredWidth, - to: HostedInspectorDividerHit( - containerView: containerView, - pageView: pageView, - inspectorView: inspectorView, - dockSide: dockSide - ), - minimumInspectorWidth: Self.minimumHostedInspectorWidth, - reason: reason - ) - } - - func normalizeHostedInspectorLayoutIfNeeded(reason: String) { - if enforceAdaptiveBottomDockIfNeeded(reason: "\(reason).adaptive") { - return - } - _ = promoteHostedInspectorSideDockFromCurrentLayoutIfNeeded() - if isHostedInspectorSideDockActive() { - layoutHostedInspectorSideDockIfNeeded(reason: reason) - } else if !hasStoredHostedInspectorWidthPreference { - captureHostedInspectorPreferredWidthFromCurrentLayout(reason: reason) - } - } - - private func shouldForceHostedInspectorBottomDock(using hit: HostedInspectorDividerHit) -> Bool { - let containerWidth = max(0, hit.containerView.bounds.width) - guard containerWidth > 1 else { return false } - - let currentInspectorWidth = max(0, hit.inspectorView.frame.width) - let currentPageWidth = max(0, hit.pageView.frame.width) - let remainingPageWidth = max(0, containerWidth - max(Self.minimumHostedInspectorWidth, currentInspectorWidth)) - let effectivePageWidth = min(currentPageWidth, remainingPageWidth) - - return effectivePageWidth < Self.minimumHostedInspectorPageWidthForSideDock - } - - @discardableResult - private func requestAdaptiveHostedInspectorBottomDock(reason: String) -> Bool { - let now = Date() - if let adaptiveBottomDockRequestCooldownDeadline, adaptiveBottomDockRequestCooldownDeadline > now { - return true - } - guard let hostedInspectorFrontendWebView else { return false } - - adaptiveBottomDockRequestCooldownDeadline = now.addingTimeInterval(Self.adaptiveBottomDockRequestCooldown) - updateHostedInspectorDockControlAvailabilityIfNeeded(reason: reason) -#if DEBUG - dlog( - "browser.panel.hostedInspector stage=\(reason).adaptiveBottomDock " + - "host=\(Self.debugObjectID(self)) bounds=\(Self.debugRect(bounds))" - ) -#endif - hostedInspectorFrontendWebView.evaluateJavaScript( - "typeof WI !== 'undefined' ? WI._dockBottom() : null" - ) { [weak self] _, _ in - self?.scheduleHostedInspectorDockConfigurationSync( - reason: "\(reason).adaptiveBottomDock" - ) - } - return true - } - - @discardableResult - private func enforceAdaptiveBottomDockIfNeeded(reason: String) -> Bool { - guard let hit = hostedInspectorDividerCandidate(), - shouldForceHostedInspectorBottomDock(using: hit) else { - return false - } - recordHostedInspectorSideDockWidth(hit.inspectorView.frame.width) - return requestAdaptiveHostedInspectorBottomDock(reason: reason) - } - - fileprivate func scheduleHostedInspectorDockConfigurationSync(reason: String) { - hostedInspectorDockConfigurationSyncWorkItem?.cancel() - guard hostedInspectorFrontendWebView != nil else { return } - let workItem = DispatchWorkItem { [weak self] in - self?.syncHostedInspectorDockConfiguration(reason: reason) - } - hostedInspectorDockConfigurationSyncWorkItem = workItem - DispatchQueue.main.async(execute: workItem) - } - - private func syncHostedInspectorDockConfiguration(reason: String) { - hostedInspectorDockConfigurationSyncWorkItem = nil - guard let hostedInspectorFrontendWebView else { return } - hostedInspectorFrontendWebView.evaluateJavaScript( - "typeof WI === 'undefined' ? null : WI.dockConfiguration" - ) { [weak self] result, _ in - self?.applyHostedInspectorDockConfiguration(result as? String, reason: reason) - } - } - - private func applyHostedInspectorDockConfiguration(_ dockConfiguration: String?, reason: String) { - switch dockConfiguration { - case "left": - hostedInspectorSideDockDockSide = .leading - if isHostedInspectorSideDockActive() { - if enforceAdaptiveBottomDockIfNeeded(reason: "\(reason).dockLeft") { - return - } - layoutHostedInspectorSideDockIfNeeded(reason: "\(reason).dockLeft") - } else if let slotView = localInlineSlotView, - let hit = hostedInspectorDividerCandidate(in: slotView), - hit.dockSide == .leading { - if shouldForceHostedInspectorBottomDock(using: hit) { - _ = requestAdaptiveHostedInspectorBottomDock(reason: "\(reason).dockLeft") - return - } - activateHostedInspectorSideDockIfNeeded(using: hit) - } - case "right": - hostedInspectorSideDockDockSide = .trailing - if isHostedInspectorSideDockActive() { - if enforceAdaptiveBottomDockIfNeeded(reason: "\(reason).dockRight") { - return - } - layoutHostedInspectorSideDockIfNeeded(reason: "\(reason).dockRight") - } else if let slotView = localInlineSlotView, - let hit = hostedInspectorDividerCandidate(in: slotView), - hit.dockSide == .trailing { - if shouldForceHostedInspectorBottomDock(using: hit) { - _ = requestAdaptiveHostedInspectorBottomDock(reason: "\(reason).dockRight") - return - } - activateHostedInspectorSideDockIfNeeded(using: hit) - } - default: - adaptiveBottomDockRequestCooldownDeadline = nil - if isHostedInspectorSideDockActive() { - deactivateHostedInspectorSideDockIfNeeded(reparentTo: localInlineSlotView) - if dockConfiguration == "bottom" { - hostedInspectorFrontendWebView?.evaluateJavaScript( - "typeof WI !== 'undefined' ? WI._dockBottom() : null", - completionHandler: nil - ) - } - } - } - updateHostedInspectorDockControlAvailabilityIfNeeded(reason: "\(reason).dockConfiguration") - } - - override func viewDidMoveToWindow() { - super.viewDidMoveToWindow() - if window == nil { - notifyHostedWebKitHidden(reason: "viewDidMoveToWindow") - clearActiveDividerCursor(restoreArrow: false) - } else { - scheduleHostedInspectorDividerReapply(reason: "viewDidMoveToWindow") - scheduleHostedInspectorDockConfigurationSync(reason: "viewDidMoveToWindow") - refreshHostedWebKitPresentation( - reason: "viewDidMoveToWindow", - forceLifecycleRefresh: hostedInspectorFrontendWebView != nil - ) - } - window?.invalidateCursorRects(for: self) - onDidMoveToWindow?() - notifyGeometryChangedIfNeeded() -#if DEBUG - debugLogHostedInspectorLayoutIfNeeded(reason: "viewDidMoveToWindow") -#endif - } - - override func viewDidMoveToSuperview() { - super.viewDidMoveToSuperview() - scheduleHostedInspectorDividerReapply(reason: "viewDidMoveToSuperview") - scheduleHostedInspectorDockConfigurationSync(reason: "viewDidMoveToSuperview") - notifyGeometryChangedIfNeeded() -#if DEBUG - debugLogHostedInspectorLayoutIfNeeded(reason: "viewDidMoveToSuperview") -#endif - } - - override func layout() { - super.layout() - _ = promoteHostedInspectorSideDockFromCurrentLayoutIfNeeded() - if enforceAdaptiveBottomDockIfNeeded(reason: "host.layout") { - updateHostedInspectorDockControlAvailabilityIfNeeded(reason: "host.layout") - notifyGeometryChangedIfNeeded() -#if DEBUG - debugLogHostedInspectorLayoutIfNeeded(reason: "layout") -#endif - return - } - if let previousSize = lastHostedInspectorLayoutBoundsSize, - Self.sizeApproximatelyEqual(previousSize, bounds.size, epsilon: 0.5) { - // Origin-only frame churn is common while the surrounding split layout - // settles. Reapplying the side-docked inspector at the same size fights - // WebKit's own dock layout and shows up as visible flicker. - if !isHostedInspectorDividerDragActive { - if hasStoredHostedInspectorWidthPreference { - reapplyHostedInspectorDividerToStoredWidthIfNeeded(reason: "host.layout.sameSize") - } else if !isHostedInspectorSideDockActive() { - captureHostedInspectorPreferredWidthFromCurrentLayout(reason: "host.layout.sameSize") - } - } - updateHostedInspectorDockControlAvailabilityIfNeeded(reason: "host.layout.sameSize") - notifyGeometryChangedIfNeeded() -#if DEBUG - debugLogHostedInspectorLayoutIfNeeded(reason: "layout") -#endif - return - } - lastHostedInspectorLayoutBoundsSize = bounds.size - if isHostedInspectorSideDockActive() { - layoutHostedInspectorSideDockIfNeeded(reason: "host.layout.sideDock") - } else if hasStoredHostedInspectorWidthPreference { - reapplyHostedInspectorDividerToStoredWidthIfNeeded(reason: "host.layout") - } else { - captureHostedInspectorPreferredWidthFromCurrentLayout(reason: "host.layout") - } - updateHostedInspectorDockControlAvailabilityIfNeeded(reason: "host.layout") - scheduleHostedInspectorDockConfigurationSync(reason: "layout") - notifyGeometryChangedIfNeeded() -#if DEBUG - debugLogHostedInspectorLayoutIfNeeded(reason: "layout") -#endif - } - - override func setFrameOrigin(_ newOrigin: NSPoint) { - super.setFrameOrigin(newOrigin) - window?.invalidateCursorRects(for: self) - // Mark dirty; the callback fires from layout() with the settled geometry. - markGeometryDirtyIfNeeded() -#if DEBUG - debugLogHostedInspectorLayoutIfNeeded(reason: "setFrameOrigin") -#endif - } - - override func setFrameSize(_ newSize: NSSize) { - super.setFrameSize(newSize) - window?.invalidateCursorRects(for: self) - // Mark dirty; the callback fires from layout() with the settled geometry. - markGeometryDirtyIfNeeded() -#if DEBUG - debugLogHostedInspectorLayoutIfNeeded(reason: "setFrameSize") -#endif - } - - override func resetCursorRects() { - super.resetCursorRects() - guard let hostedInspectorHit = hostedInspectorDividerCandidate() else { return } - let clipped = hostedInspectorDividerHitRect(for: hostedInspectorHit).intersection(bounds) - guard !clipped.isNull, clipped.width > 0, clipped.height > 0 else { return } - addCursorRect(clipped, cursor: NSCursor.resizeLeftRight) - } - - override func updateTrackingAreas() { - if let trackingArea { - removeTrackingArea(trackingArea) - } - let options: NSTrackingArea.Options = [ - .inVisibleRect, - .activeAlways, - .cursorUpdate, - .mouseMoved, - .mouseEnteredAndExited, - .enabledDuringMouseDrag, - ] - let next = NSTrackingArea(rect: .zero, options: options, owner: self, userInfo: nil) - addTrackingArea(next) - trackingArea = next - super.updateTrackingAreas() - } - - override func cursorUpdate(with event: NSEvent) { - updateDividerCursor(at: convert(event.locationInWindow, from: nil)) - } - - override func mouseMoved(with event: NSEvent) { - updateDividerCursor(at: convert(event.locationInWindow, from: nil)) - } - - override func mouseExited(with event: NSEvent) { - clearActiveDividerCursor(restoreArrow: true) - } - - override func hitTest(_ point: NSPoint) -> NSView? { - let hostedInspectorHit = hostedInspectorDividerHit(at: point) - updateDividerCursor(at: point, hostedInspectorHit: hostedInspectorHit) - let passThrough = shouldPassThroughToSidebarResizer(at: point, hostedInspectorHit: hostedInspectorHit) - if passThrough { -#if DEBUG - debugLogHitTest(stage: "hitTest.pass", point: point, passThrough: true, hitView: nil) -#endif - return nil - } - if let hostedInspectorHit { - if let nativeHit = nativeHostedInspectorHit(at: point, hostedInspectorHit: hostedInspectorHit) { -#if DEBUG - debugLogHitTest(stage: "hitTest.hostedInspectorNative", point: point, passThrough: false, hitView: nativeHit) -#endif - if nativeHit !== hostedInspectorHit.inspectorView && - !hostedInspectorHit.inspectorView.isDescendant(of: nativeHit) { - return nativeHit - } - } -#if DEBUG - debugLogHitTest( - stage: "hitTest.hostedInspectorManual", - point: point, - passThrough: false, - hitView: self - ) -#endif - return self - } - let hit = super.hitTest(point) -#if DEBUG - debugLogHitTest(stage: "hitTest.result", point: point, passThrough: false, hitView: hit) -#endif - return hit - } - - override func mouseDown(with event: NSEvent) { - let point = convert(event.locationInWindow, from: nil) - guard let hostedInspectorHit = hostedInspectorDividerHit(at: point) else { - super.mouseDown(with: event) - return - } - - hostedInspectorReapplyWorkItem?.cancel() - isHostedInspectorDividerDragActive = true - hostedInspectorDividerDrag = HostedInspectorDividerDragState( - containerView: hostedInspectorHit.containerView, - pageView: hostedInspectorHit.pageView, - inspectorView: hostedInspectorHit.inspectorView, - dockSide: hostedInspectorHit.dockSide, - initialWindowX: event.locationInWindow.x, - initialPageFrame: hostedInspectorHit.pageView.frame, - initialInspectorFrame: hostedInspectorHit.inspectorView.frame - ) -#if DEBUG - debugLogHostedInspectorFrames(stage: "drag.start", point: point, hit: hostedInspectorHit) -#endif - } - - override func mouseDragged(with event: NSEvent) { - guard let dragState = hostedInspectorDividerDrag else { - super.mouseDragged(with: event) - return - } - - let containerBounds = dragState.containerView.bounds - let minimumInspectorWidth = Self.minimumHostedInspectorWidth - let initialDividerX = dragState.dockSide.dividerX( - pageFrame: dragState.initialPageFrame, - inspectorFrame: dragState.initialInspectorFrame - ) - let proposedDividerX = initialDividerX + (event.locationInWindow.x - dragState.initialWindowX) - let clampedDividerX = dragState.dockSide.clampedDividerX( - proposedDividerX, - containerBounds: containerBounds, - pageFrame: dragState.initialPageFrame, - minimumInspectorWidth: minimumInspectorWidth - ) - let inspectorWidth = dragState.dockSide.inspectorWidth( - forDividerX: clampedDividerX, - in: containerBounds - ) - recordPreferredHostedInspectorWidth(inspectorWidth, containerBounds: containerBounds) - _ = applyHostedInspectorDividerWidth( - inspectorWidth, - to: HostedInspectorDividerHit( - containerView: dragState.containerView, - pageView: dragState.pageView, - inspectorView: dragState.inspectorView, - dockSide: dragState.dockSide - ), - minimumInspectorWidth: Self.minimumHostedInspectorWidth, - reason: "drag" - ) -#if DEBUG - debugLogHostedInspectorFrames( - stage: "drag.update", - point: convert(event.locationInWindow, from: nil), - hit: HostedInspectorDividerHit( - containerView: dragState.containerView, - pageView: dragState.pageView, - inspectorView: dragState.inspectorView, - dockSide: dragState.dockSide - ) - ) -#endif - updateDividerCursor( - at: convert(event.locationInWindow, from: nil), - hostedInspectorHit: HostedInspectorDividerHit( - containerView: dragState.containerView, - pageView: dragState.pageView, - inspectorView: dragState.inspectorView, - dockSide: dragState.dockSide - ) - ) - } - - override func mouseUp(with event: NSEvent) { - let finalDragState = hostedInspectorDividerDrag - hostedInspectorDividerDrag = nil - isHostedInspectorDividerDragActive = false - updateDividerCursor(at: convert(event.locationInWindow, from: nil)) - if let finalDragState { -#if DEBUG - debugLogHostedInspectorFrames( - stage: "drag.end", - point: convert(event.locationInWindow, from: nil), - hit: HostedInspectorDividerHit( - containerView: finalDragState.containerView, - pageView: finalDragState.pageView, - inspectorView: finalDragState.inspectorView, - dockSide: finalDragState.dockSide - ) - ) -#endif - reapplyHostedInspectorDividerToStoredWidthIfNeeded(reason: "drag.end") - } - super.mouseUp(with: event) - } - - private func shouldPassThroughToSidebarResizer( - at point: NSPoint, - hostedInspectorHit: HostedInspectorDividerHit? = nil - ) -> Bool { - if hostedInspectorHit != nil { - return false - } - // Pass through a narrow leading-edge band so the shared sidebar divider - // handle can receive hover/click even when WKWebView is attached here. - // Keeping this deterministic avoids flicker from dynamic left-edge scans. - guard point.x >= 0, point.x <= SidebarResizeInteraction.contentSideHitWidth else { - return false - } - guard let window, let contentView = window.contentView else { - return false - } - let hostRectInContent = contentView.convert(bounds, from: self) - return hostRectInContent.minX > 1 - } - - private func updateDividerCursor( - at point: NSPoint, - hostedInspectorHit: HostedInspectorDividerHit? = nil - ) { - let resolvedHostedInspectorHit = hostedInspectorHit ?? hostedInspectorDividerHit(at: point) - if shouldPassThroughToSidebarResizer(at: point, hostedInspectorHit: resolvedHostedInspectorHit) { - clearActiveDividerCursor(restoreArrow: false) - return - } - guard resolvedHostedInspectorHit != nil else { - clearActiveDividerCursor(restoreArrow: true) - return - } - activeDividerCursorKind = .vertical - NSCursor.resizeLeftRight.set() - } - - private func clearActiveDividerCursor(restoreArrow: Bool) { - guard activeDividerCursorKind != nil else { return } - window?.invalidateCursorRects(for: self) - activeDividerCursorKind = nil - if restoreArrow { - NSCursor.arrow.set() - } - } - - private func nativeHostedInspectorHit( - at point: NSPoint, - hostedInspectorHit: HostedInspectorDividerHit - ) -> NSView? { - guard let nativeHit = super.hitTest(point), nativeHit !== self else { return nil } - if nativeHit === hostedInspectorHit.pageView || - nativeHit.isDescendant(of: hostedInspectorHit.pageView) { - return nil - } - if nativeHit === hostedInspectorHit.inspectorView || - nativeHit.isDescendant(of: hostedInspectorHit.inspectorView) { - return nativeHit - } - if hostedInspectorHit.inspectorView.isDescendant(of: nativeHit), - !(hostedInspectorHit.pageView === nativeHit || hostedInspectorHit.pageView.isDescendant(of: nativeHit)) { - return nativeHit - } - return nil - } - - private func hostedInspectorDividerHit(at point: NSPoint) -> HostedInspectorDividerHit? { - guard let hit = hostedInspectorDividerCandidate(), - hostedInspectorDividerHitRect(for: hit).contains(point) else { - return nil - } - return hit - } - - private func hostedInspectorDividerCandidate() -> HostedInspectorDividerHit? { - hostedInspectorDividerCandidate(in: self) - } - - private func hostedInspectorDividerCandidate(in root: NSView) -> HostedInspectorDividerHit? { - if let preferredHit = hostedInspectorDividerCandidateUsingKnownWebViews(in: root) { - return preferredHit - } - - let inspectorCandidates = InspectorDock.visibleDescendants(in: root) - .filter { InspectorDock.isVisibleCandidate($0) && InspectorDock.isInspectorView($0) } - .sorted { lhs, rhs in - let lhsFrame = root.convert(lhs.bounds, from: lhs) - let rhsFrame = root.convert(rhs.bounds, from: rhs) - return lhsFrame.minX < rhsFrame.minX - } - - var bestHit: HostedInspectorDividerHit? - var bestScore = -CGFloat.greatestFiniteMagnitude - - for inspectorCandidate in inspectorCandidates { - guard let candidate = hostedInspectorDividerCandidate(in: root, startingAt: inspectorCandidate) else { - continue - } - let score = hostedInspectorDividerCandidateScore(candidate) - if score > bestScore { - bestScore = score - bestHit = candidate - } - } - - return bestHit - } - - private func hostedInspectorDividerCandidateUsingKnownWebViews(in root: NSView) -> HostedInspectorDividerHit? { - guard let pageLeaf = hostedWebView, - let inspectorLeaf = hostedInspectorFrontendWebView, - pageLeaf.isDescendant(of: root), - inspectorLeaf.isDescendant(of: root), - InspectorDock.isVisibleCandidate(inspectorLeaf) else { - return nil - } - return hostedInspectorDividerCandidate( - in: root, - pageLeaf: pageLeaf, - inspectorLeaf: inspectorLeaf - ) - } - - private func hostedInspectorDividerCandidate( - in root: NSView, - pageLeaf: NSView, - inspectorLeaf: NSView - ) -> HostedInspectorDividerHit? { - var currentInspector: NSView? = inspectorLeaf - - while let inspectorView = currentInspector, inspectorView !== root { - guard let containerView = inspectorView.superview else { break } - guard containerView === root || containerView.isDescendant(of: root) else { - currentInspector = containerView - continue - } - guard let pageView = Self.directChild(of: containerView, containing: pageLeaf) else { - currentInspector = containerView - continue - } - guard pageView !== inspectorView, - InspectorDock.isVisibleSiblingCandidate(pageView, requireMinWidth: false), - InspectorDock.verticalOverlap(between: pageView.frame, and: inspectorView.frame) > 8, - let dockSide = HostedInspectorDockSide.resolve( - pageFrame: pageView.frame, - inspectorFrame: inspectorView.frame - ) else { - currentInspector = containerView - continue - } - return HostedInspectorDividerHit( - containerView: containerView, - pageView: pageView, - inspectorView: inspectorView, - dockSide: dockSide - ) - } - - return nil - } - - private func hostedInspectorDividerHitRect(for hit: HostedInspectorDividerHit) -> NSRect { - let pageFrame = convert(hit.pageView.bounds, from: hit.pageView) - let inspectorFrame = convert(hit.inspectorView.bounds, from: hit.inspectorView) - return hit.dockSide.dividerHitRect( - in: bounds, - pageFrame: pageFrame, - inspectorFrame: inspectorFrame, - expansion: Self.hostedInspectorDividerHitExpansion - ) - } - - private func hostedInspectorDividerCandidate(in root: NSView, startingAt inspectorLeaf: NSView) -> HostedInspectorDividerHit? { - var current: NSView? = inspectorLeaf - var bestHit: HostedInspectorDividerHit? - - while let inspectorView = current, inspectorView !== root { - guard let containerView = inspectorView.superview else { break } - - let pageCandidates = containerView.subviews.compactMap { candidate -> (view: NSView, dockSide: HostedInspectorDockSide)? in - guard InspectorDock.isVisibleSiblingCandidate(candidate, requireMinWidth: false) else { return nil } - guard candidate !== inspectorView else { return nil } - guard InspectorDock.verticalOverlap(between: candidate.frame, and: inspectorView.frame) > 8 else { - return nil - } - guard let dockSide = HostedInspectorDockSide.resolve( - pageFrame: candidate.frame, - inspectorFrame: inspectorView.frame - ) else { - return nil - } - return (view: candidate, dockSide: dockSide) - } - - if let pageCandidate = pageCandidates.max(by: { - hostedInspectorPageCandidateScore($0.view, inspectorView: inspectorView) - < hostedInspectorPageCandidateScore($1.view, inspectorView: inspectorView) - }) { - bestHit = HostedInspectorDividerHit( - containerView: containerView, - pageView: pageCandidate.view, - inspectorView: inspectorView, - dockSide: pageCandidate.dockSide - ) - } - - current = containerView - } - - return bestHit - } - - private func hostedInspectorDividerCandidateScore(_ hit: HostedInspectorDividerHit) -> CGFloat { - let pageFrame = convert(hit.pageView.bounds, from: hit.pageView) - let inspectorFrame = convert(hit.inspectorView.bounds, from: hit.inspectorView) - let overlap = InspectorDock.verticalOverlap(between: pageFrame, and: inspectorFrame) - let coverageWidth = max(pageFrame.maxX, inspectorFrame.maxX) - min(pageFrame.minX, inspectorFrame.minX) - return (overlap * 1_000) + coverageWidth + pageFrame.width - } - - private func hostedInspectorPageCandidateScore(_ pageView: NSView, inspectorView: NSView) -> CGFloat { - let overlap = InspectorDock.verticalOverlap(between: pageView.frame, and: inspectorView.frame) - let coverageWidth = max(pageView.frame.maxX, inspectorView.frame.maxX) - min(pageView.frame.minX, inspectorView.frame.minX) - return (overlap * 1_000) + coverageWidth + pageView.frame.width - } - - fileprivate func scheduleHostedInspectorDividerReapply(reason: String) { - hostedInspectorReapplyWorkItem?.cancel() - let workItem = DispatchWorkItem { [weak self] in - guard let self else { return } - self.hostedInspectorReapplyWorkItem = nil - _ = self.promoteHostedInspectorSideDockFromCurrentLayoutIfNeeded() - if self.hasStoredHostedInspectorWidthPreference { - self.reapplyHostedInspectorDividerToStoredWidthIfNeeded(reason: reason) - } else { - self.captureHostedInspectorPreferredWidthFromCurrentLayout(reason: reason) - } - } - hostedInspectorReapplyWorkItem = workItem - DispatchQueue.main.async(execute: workItem) - } - - private func captureHostedInspectorPreferredWidthFromCurrentLayout(reason: String) { - guard !isApplyingHostedInspectorLayout else { return } - guard !isHostedInspectorDividerDragActive else { return } - guard let hit = hostedInspectorDividerCandidate() else { -#if DEBUG - if !hasLoggedMissingHostedInspectorCandidate { - hasLoggedMissingHostedInspectorCandidate = true - let preferredWidthDesc = preferredHostedInspectorWidth.map { - String(format: "%.1f", $0) - } ?? "nil" - dlog( - "browser.panel.hostedInspector stage=\(reason).captureMissingCandidate " + - "host=\(Self.debugObjectID(self)) preferredWidth=\(preferredWidthDesc)" - ) - } -#endif - return - } - - let inspectorWidth = max(0, hit.inspectorView.frame.width) - guard inspectorWidth > 1 else { return } - recordHostedInspectorSideDockWidth(inspectorWidth) - let currentFraction: CGFloat? = { - guard hit.containerView.bounds.width > 0 else { return nil } - return inspectorWidth / hit.containerView.bounds.width - }() - let widthMatches = preferredHostedInspectorWidth.map { - abs($0 - inspectorWidth) <= 0.5 - } ?? false - let fractionMatches: Bool = { - switch (preferredHostedInspectorWidthFraction, currentFraction) { - case (nil, nil): - return true - case let (lhs?, rhs?): - return abs(lhs - rhs) <= 0.001 - default: - return false - } - }() - guard !(widthMatches && fractionMatches) else { return } - -#if DEBUG - hasLoggedMissingHostedInspectorCandidate = false -#endif - recordPreferredHostedInspectorWidth( - inspectorWidth, - containerBounds: hit.containerView.bounds - ) - } - - private func reapplyHostedInspectorDividerToStoredWidthIfNeeded(reason: String) { - guard !isApplyingHostedInspectorLayout else { return } - guard let hit = hostedInspectorDividerCandidate() else { return } - guard let preferredWidth = resolvedPreferredHostedInspectorWidth(in: hit.containerView.bounds) else { - return - } - let currentInspectorWidth = max(0, hit.inspectorView.frame.width) - guard abs(currentInspectorWidth - preferredWidth) > 0.5 else { return } - _ = applyHostedInspectorDividerWidth( - preferredWidth, - to: hit, - minimumInspectorWidth: Self.minimumHostedInspectorWidth, - reason: reason - ) - } - - @discardableResult - private func applyHostedInspectorDividerWidth( - _ preferredWidth: CGFloat, - to hit: HostedInspectorDividerHit, - minimumInspectorWidth: CGFloat, - reason: String - ) -> (pageFrame: NSRect, inspectorFrame: NSRect) { - let containerBounds = hit.containerView.bounds - let nextFrames = hit.dockSide.resizedFrames( - preferredWidth: preferredWidth, - in: containerBounds, - pageFrame: hit.pageView.frame, - inspectorFrame: hit.inspectorView.frame, - minimumInspectorWidth: minimumInspectorWidth - ) - let pageFrame = nextFrames.pageFrame - let inspectorFrame = nextFrames.inspectorFrame - - let oldPageFrame = hit.pageView.frame - let oldInspectorFrame = hit.inspectorView.frame - let pageChanged = !InspectorDock.rectApproximatelyEqual(pageFrame, oldPageFrame, epsilon: 0.5) - let inspectorChanged = !InspectorDock.rectApproximatelyEqual(inspectorFrame, oldInspectorFrame, epsilon: 0.5) - guard pageChanged || inspectorChanged else { - return (pageFrame, inspectorFrame) - } - recordHostedInspectorSideDockWidth(inspectorFrame.width) - - isApplyingHostedInspectorLayout = true - CATransaction.begin() - CATransaction.setDisableActions(true) - hit.pageView.frame = pageFrame - hit.inspectorView.frame = inspectorFrame - CATransaction.commit() - isApplyingHostedInspectorLayout = false - - hit.pageView.needsDisplay = true - hit.pageView.setNeedsDisplay(hit.pageView.bounds) - hit.inspectorView.needsDisplay = true - hit.inspectorView.setNeedsDisplay(hit.inspectorView.bounds) - hit.containerView.needsDisplay = true - hit.containerView.setNeedsDisplay(hit.containerView.bounds) - if let localInlineSlotView { - localInlineSlotView.needsDisplay = true - localInlineSlotView.setNeedsDisplay(localInlineSlotView.bounds) - } - needsDisplay = true - setNeedsDisplay(bounds) - - let isLiveDrag = reason == "drag" -#if DEBUG - dlog( - "browser.panel.hostedInspector stage=\(reason).reapply " + - "host=\(Self.debugObjectID(self)) preferredWidth=\(String(format: "%.1f", preferredWidth)) " + - "liveDrag=\(isLiveDrag ? 1 : 0) " + - "pageChanged=\(pageChanged ? 1 : 0) inspectorChanged=\(inspectorChanged ? 1 : 0) " + - "oldPage=\(Self.debugRect(oldPageFrame)) oldInspector=\(Self.debugRect(oldInspectorFrame)) " + - "container=\(Self.debugObjectID(hit.containerView)) " + - "pageFrame=\(Self.debugRect(pageFrame)) inspectorFrame=\(Self.debugRect(inspectorFrame))" - ) -#endif - return (pageFrame, inspectorFrame) - } - - private static func directChild(of container: NSView, containing descendant: NSView) -> NSView? { - var current: NSView? = descendant - var directChild: NSView? - while let view = current, view !== container { - directChild = view - current = view.superview - } - guard current === container else { return nil } - return directChild - } - } - - #if DEBUG - private static func logDevToolsState( - _ panel: BrowserPanel, - event: String, - generation: Int, - retryCount: Int, - details: String? = nil - ) { - var line = "browser.devtools event=\(event) panel=\(panel.id.uuidString.prefix(5)) generation=\(generation) retry=\(retryCount) \(panel.debugDeveloperToolsStateSummary())" - if let details, !details.isEmpty { - line += " \(details)" - } - dlog(line) - } - - private static func objectID(_ object: AnyObject?) -> String { - guard let object else { return "nil" } - return String(describing: Unmanaged.passUnretained(object).toOpaque()) - } - - private static func responderDescription(_ responder: NSResponder?) -> String { - guard let responder else { return "nil" } - return "\(type(of: responder))@\(objectID(responder))" - } - - private static func rectDescription(_ rect: NSRect) -> String { - String(format: "%.1f,%.1f %.1fx%.1f", rect.origin.x, rect.origin.y, rect.size.width, rect.size.height) - } - - private static func attachContext(webView: WKWebView, host: NSView) -> String { - let hostWindow = host.window?.windowNumber ?? -1 - let webWindow = webView.window?.windowNumber ?? -1 - let firstResponder = (webView.window ?? host.window)?.firstResponder - return "host=\(objectID(host)) hostWin=\(hostWindow) hostInWin=\(host.window == nil ? 0 : 1) hostFrame=\(rectDescription(host.frame)) hostBounds=\(rectDescription(host.bounds)) oldSuper=\(objectID(webView.superview)) webWin=\(webWindow) webInWin=\(webView.window == nil ? 0 : 1) webFrame=\(rectDescription(webView.frame)) webHidden=\(webView.isHidden ? 1 : 0) fr=\(responderDescription(firstResponder))" - } - #endif - - private static func firstResponderResignState( - _ responder: NSResponder?, - webView: WKWebView - ) -> (needsResign: Bool, flags: String) { - let inWebViewChain = InspectorDock.responderChainContains(responder, target: webView) - let inspectorResponder = InspectorDock.isLikelyInspectorResponder(responder) - let needsResign = inWebViewChain || inspectorResponder - return ( - needsResign: needsResign, - flags: "frInWebChain=\(inWebViewChain ? 1 : 0) frIsInspector=\(inspectorResponder ? 1 : 0)" - ) - } - - func makeCoordinator() -> Coordinator { - let coordinator = Coordinator() - coordinator.panel = panel - return coordinator - } - - func makeNSView(context: Context) -> NSView { - let container = HostContainerView() - container.wantsLayer = true - return container - } - - private static func clearPortalCallbacks(for host: NSView) { - guard let host = host as? HostContainerView else { return } - host.onDidMoveToWindow = nil - host.onGeometryChanged = nil - host.clearLocalInlineCallbacks() - } - - private static func shouldPreserveExternalFullscreenHost( - for webView: WKWebView, - relativeTo expectedWindow: NSWindow? - ) -> Bool { - webView.programaIsManagedByExternalFullscreenWindow(relativeTo: expectedWindow) - } - - private static func localInlineTransferRoot(for webView: WKWebView) -> NSView? { - var current = webView.superview - var last: NSView? - while let view = current { - if view is WindowBrowserSlotView { - return view - } - if view is HostContainerView { - break - } - last = view - current = view.superview - } - return last ?? webView.superview - } - - private static func directTransferChild(of container: NSView, containing descendant: NSView) -> NSView? { - var current: NSView? = descendant - var directChild: NSView? - while let view = current, view !== container { - directChild = view - current = view.superview - } - guard current === container else { return nil } - return directChild - } - - private static func relatedWebKitTransferSubviews( - from sourceSuperview: NSView, - primaryWebView: WKWebView - ) -> [NSView] { - var relatedSubviews: [NSView] = [] - var seen = Set() - - func append(_ candidate: NSView?) { - guard let candidate, candidate !== sourceSuperview else { return } - let id = ObjectIdentifier(candidate) - guard seen.insert(id).inserted else { return } - relatedSubviews.append(candidate) - } - - append(directTransferChild(of: sourceSuperview, containing: primaryWebView) ?? primaryWebView) - - if let inspectorFrontend = primaryWebView.programaInspectorFrontendWebView() { - append(directTransferChild(of: sourceSuperview, containing: inspectorFrontend) ?? inspectorFrontend) - } - - for view in sourceSuperview.subviews { - if view === primaryWebView { continue } - let className = String(describing: type(of: view)) - guard className.contains("WK") else { continue } - if InspectorDock.isInspectorView(view) && !InspectorDock.isVisibleCandidate(view) { - continue - } - append(view) - } - - return relatedSubviews - } - - private static func moveWebKitRelatedSubviewsIntoHostIfNeeded( - from sourceSuperview: NSView, - to container: WindowBrowserSlotView, - primaryWebView: WKWebView, - reason: String - ) { - let relatedSubviews = relatedWebKitTransferSubviews( - from: sourceSuperview, - primaryWebView: primaryWebView - ) - guard !relatedSubviews.isEmpty else { return } - let preserveSlotLocalFrames = sourceSuperview is WindowBrowserSlotView - let sourceSlotBoundsSize = sourceSuperview.bounds.size - var movedSubviewCount = 0 - var reusedSourceLocalFrames = false -#if DEBUG - dlog( - "browser.localHost.reparent.batch reason=\(reason) source=\(Self.objectID(sourceSuperview)) " + - "container=\(Self.objectID(container)) count=\(relatedSubviews.count) " + - "sourceType=\(String(describing: type(of: sourceSuperview))) targetType=\(String(describing: type(of: container)))" - ) -#endif - for view in relatedSubviews { - if view === container || view.isDescendant(of: container) { - continue - } - let className = String(describing: type(of: view)) - let targetFrame: NSRect - let currentSuperview = view.superview - if preserveSlotLocalFrames && currentSuperview === sourceSuperview { - targetFrame = view.frame - reusedSourceLocalFrames = true - } else { - let frameInWindow = currentSuperview?.convert(view.frame, to: nil) - ?? sourceSuperview.convert(view.frame, to: nil) - targetFrame = container.convert(frameInWindow, from: nil) - } - view.removeFromSuperview() - container.addSubview(view, positioned: .above, relativeTo: nil) - view.frame = targetFrame - movedSubviewCount += 1 -#if DEBUG - dlog( - "browser.localHost.reparent.batch.item reason=\(reason) class=\(className) " + - "view=\(Self.objectID(view))" - ) -#endif - } - guard movedSubviewCount > 0 else { return } - if reusedSourceLocalFrames, sourceSlotBoundsSize != container.bounds.size { - container.resizeSubviews(withOldSize: sourceSlotBoundsSize) - container.needsLayout = true - container.layoutSubtreeIfNeeded() - } - } - - private static func installPortalAnchorView(_ anchorView: NSView, in host: NSView) { - // SwiftUI can keep transient replacement hosts alive off-window during split - // reparenting. Never let those hosts steal the shared portal anchor, or the - // portal will bind against an anchor with no real window and WKWebView will - // fall into a hidden/unrendered state. - guard host.window != nil else { return } - if anchorView.superview !== host { - anchorView.removeFromSuperview() - anchorView.translatesAutoresizingMaskIntoConstraints = false - host.addSubview(anchorView) - NSLayoutConstraint.activate([ - anchorView.topAnchor.constraint(equalTo: host.topAnchor), - anchorView.bottomAnchor.constraint(equalTo: host.bottomAnchor), - anchorView.leadingAnchor.constraint(equalTo: host.leadingAnchor), - anchorView.trailingAnchor.constraint(equalTo: host.trailingAnchor), - ]) - } else if anchorView.translatesAutoresizingMaskIntoConstraints { - anchorView.translatesAutoresizingMaskIntoConstraints = false - NSLayoutConstraint.activate([ - anchorView.topAnchor.constraint(equalTo: host.topAnchor), - anchorView.bottomAnchor.constraint(equalTo: host.bottomAnchor), - anchorView.leadingAnchor.constraint(equalTo: host.leadingAnchor), - anchorView.trailingAnchor.constraint(equalTo: host.trailingAnchor), - ]) - } - host.layoutSubtreeIfNeeded() - } - - private func updateUsingLocalInlineHosting(_ nsView: NSView, context: Context, webView: WKWebView) -> Bool { - guard let host = nsView as? HostContainerView else { return false } - let slotView = host.ensureLocalInlineSlotView() - let isAlreadyInLocalHost = host.containsManagedLocalInlineContent(webView) - let shouldPreserveExternalFullscreenHost = Self.shouldPreserveExternalFullscreenHost( - for: webView, - relativeTo: host.window - ) - let didAttachWebViewToLocalHost = - !isAlreadyInLocalHost && !shouldPreserveExternalFullscreenHost - - let coordinator = context.coordinator - coordinator.desiredPortalVisibleInUI = false - coordinator.desiredPortalZPriority = 0 - coordinator.attachGeneration += 1 - - if panel.releasePortalHostIfOwned( - hostId: ObjectIdentifier(host), - reason: "localInlineHosting" - ) { - BrowserWindowPortalRegistry.discard( - webView: webView, - source: "viewStateChanged.localInlineHosting", - preserveCurrentSuperview: true - ) - } - - let shouldPreserveExistingExternalLocalHost = - host.window == nil && - webView.superview != nil && - !host.containsManagedLocalInlineContent(webView) - if shouldPreserveExistingExternalLocalHost { - // Split zoom can instantiate a replacement local host before it joins a window. - // Never let that off-window host steal the live page + inspector hierarchy away - // from the currently visible local host. - host.setLocalInlineSlotHidden(true) - coordinator.lastPortalHostId = nil - coordinator.lastSynchronizedHostGeometryRevision = 0 -#if DEBUG - dlog( - "browser.localHost.reparent.skip web=\(Self.objectID(webView)) " + - "reason=offWindowReplacementHost super=\(Self.objectID(webView.superview)) " + - "host=\(Self.objectID(host)) slot=\(Self.objectID(slotView))" - ) - Self.logDevToolsState( - panel, - event: "localHost.skip", - generation: coordinator.attachGeneration, - retryCount: 0, - details: Self.attachContext(webView: webView, host: host) - ) -#endif - return false - } - -#if DEBUG - if shouldPreserveExternalFullscreenHost { - dlog( - "browser.localHost.reparent.skip web=\(Self.objectID(webView)) " + - "reason=fullscreenExternalHost host=\(Self.objectID(host)) " + - "slot=\(Self.objectID(slotView)) state=\(String(describing: webView.fullscreenState))" - ) - } -#endif - - let preferredAttachedWidthState = panel.preferredAttachedDeveloperToolsWidthState() - host.setPreferredHostedInspectorWidth( - width: preferredAttachedWidthState.width, - widthFraction: preferredAttachedWidthState.widthFraction - ) - host.setHostedInspectorFrontendWebView(webView.programaInspectorFrontendWebView()) - host.onPreferredHostedInspectorWidthChanged = { [weak browserPanel = panel] width, _ in - guard let browserPanel else { return } - browserPanel.recordPreferredAttachedDeveloperToolsWidth( - width, - containerBounds: slotView.bounds - ) - } - slotView.onHostedInspectorLayout = { [weak host] _ in - host?.scheduleHostedInspectorDividerReapply(reason: "slot.layout") - host?.scheduleHostedInspectorDockConfigurationSync(reason: "slot.layout") - } - - if didAttachWebViewToLocalHost { - if let sourceSuperview = Self.localInlineTransferRoot(for: webView) { - Self.moveWebKitRelatedSubviewsIntoHostIfNeeded( - from: sourceSuperview, - to: slotView, - primaryWebView: webView, - reason: "attachLocalHost" - ) - } else { - slotView.addSubview(webView, positioned: .above, relativeTo: nil) - } - } - - slotView.isHidden = false - host.pinHostedWebView( - webView, - in: host.currentHostedWebViewContainer(preferredSlotView: slotView) - ) - // Local-inline hosting takes ownership of the live WKWebView hierarchy. - // Drop any stale portal entry once local-inline hosting owns the live - // WKWebView hierarchy so deferred portal recovery cannot mutate the - // browser after workspace switches. - BrowserWindowPortalRegistry.discard( - webView: webView, - source: "viewStateChanged.localInlineHosting", - preserveCurrentSuperview: true - ) - coordinator.lastPortalHostId = nil - coordinator.lastSynchronizedHostGeometryRevision = 0 - if host.window != nil && !shouldPreserveExternalFullscreenHost { - let wasDeveloperToolsVisible = panel.isDeveloperToolsVisible() - panel.noteDeveloperToolsHostAttached() - panel.restoreDeveloperToolsAfterAttachIfNeeded() - if let sourceSuperview = Self.localInlineTransferRoot(for: webView), - didAttachWebViewToLocalHost || sourceSuperview === slotView { - Self.moveWebKitRelatedSubviewsIntoHostIfNeeded( - from: sourceSuperview, - to: slotView, - primaryWebView: webView, - reason: didAttachWebViewToLocalHost - ? "localInline.reconcile.immediate" - : "localInline.reconcile.existingHost" - ) - } - host.setHostedInspectorFrontendWebView(webView.programaInspectorFrontendWebView()) - let didRevealDeveloperToolsAfterAttach = - !wasDeveloperToolsVisible && panel.isDeveloperToolsVisible() - webView.needsLayout = true - webView.layoutSubtreeIfNeeded() - slotView.layoutSubtreeIfNeeded() - host.layoutSubtreeIfNeeded() - host.refreshHostedWebKitPresentation( - reason: didAttachWebViewToLocalHost - ? "localInline.update.immediate" - : "localInline.update.existingHost", - forceLifecycleRefresh: didRevealDeveloperToolsAfterAttach - ) - host.normalizeHostedInspectorLayoutIfNeeded( - reason: didAttachWebViewToLocalHost - ? "localInline.update.immediate" - : "localInline.update.existingHost" - ) - host.scheduleHostedInspectorDividerReapply( - reason: didAttachWebViewToLocalHost - ? "localInline.update.sync" - : "localInline.update.existingHost" - ) - DispatchQueue.main.async { [weak host, weak webView] in - guard let host, let webView else { return } - if let sourceSuperview = Self.localInlineTransferRoot(for: webView), - sourceSuperview === slotView { - Self.moveWebKitRelatedSubviewsIntoHostIfNeeded( - from: sourceSuperview, - to: slotView, - primaryWebView: webView, - reason: "localInline.reconcile.async" - ) - } - host.setHostedInspectorFrontendWebView(webView.programaInspectorFrontendWebView()) - host.refreshHostedWebKitPresentation( - reason: didAttachWebViewToLocalHost - ? "localInline.update.async" - : "localInline.update.existingHost.async", - forceLifecycleRefresh: didRevealDeveloperToolsAfterAttach - ) - host.scheduleHostedInspectorDockConfigurationSync( - reason: didAttachWebViewToLocalHost - ? "localInline.update.async" - : "localInline.update.existingHost.async" - ) - } - } else if !shouldPreserveExternalFullscreenHost { - panel.consumeAttachedDeveloperToolsManualCloseIfNeeded() - host.scheduleHostedInspectorDockConfigurationSync(reason: "localInline.update") - } - -#if DEBUG - Self.logDevToolsState( - panel, - event: "localHost.update", - generation: coordinator.attachGeneration, - retryCount: 0, - details: Self.attachContext(webView: webView, host: host) - ) -#endif - return !shouldPreserveExternalFullscreenHost - } - - private func updateUsingWindowPortal(_ nsView: NSView, context: Context, webView: WKWebView) -> Bool { - guard let host = nsView as? HostContainerView else { return false } - host.prepareForWindowPortalHosting() - host.setLocalInlineSlotHidden(true) - host.releaseHostedWebViewConstraints() - if panel.shouldUseLocalInlineDeveloperToolsHosting() { - let hostId = ObjectIdentifier(host) - if panel.releasePortalHostIfOwned( - hostId: hostId, - reason: "windowPortalSuppressedForLocalInlineHosting" - ) { - BrowserWindowPortalRegistry.discard( - webView: webView, - source: "viewStateChanged.windowPortalSuppressedForLocalInlineHosting", - preserveCurrentSuperview: true - ) - } - return false - } - let shouldPreserveExternalFullscreenHost = Self.shouldPreserveExternalFullscreenHost( - for: webView, - relativeTo: host.window - ) - - let coordinator = context.coordinator - let paneDropContext = currentPaneDropContext() - let isCurrentPaneOwner = paneDropContext?.paneId.id == paneId.id - let hostId = ObjectIdentifier(host) - let previousVisible = coordinator.desiredPortalVisibleInUI - let previousZPriority = coordinator.desiredPortalZPriority - coordinator.desiredPortalVisibleInUI = shouldAttachWebView && isCurrentPaneOwner - coordinator.desiredPortalZPriority = portalZPriority - coordinator.attachGeneration += 1 - let generation = coordinator.attachGeneration - let activePaneDropContext = coordinator.desiredPortalVisibleInUI ? paneDropContext : nil - let activeSearchOverlay = coordinator.desiredPortalVisibleInUI ? searchOverlay : nil - let portalAnchorView = panel.portalAnchorView - let portalHideReason = !isCurrentPaneOwner ? "lostPaneOwnership" : "hidden" - let didReleasePortalHost: Bool - if !shouldAttachWebView || !isCurrentPaneOwner { - didReleasePortalHost = panel.releasePortalHostIfOwned( - hostId: hostId, - reason: portalHideReason - ) - // Only the host that currently owns the portal is allowed to hide it. - // Older keep-alive hosts can still receive updates after a new owner binds. - if didReleasePortalHost { - BrowserWindowPortalRegistry.hide( - webView: webView, - source: "viewStateChanged.\(portalHideReason)" - ) - } - } else { - didReleasePortalHost = false - } - let portalHostAccepted = - shouldAttachWebView && - isCurrentPaneOwner && - panel.claimPortalHost( - hostId: hostId, - paneId: paneId, - inWindow: host.window != nil, - bounds: host.bounds, - reason: "update" - ) -#if DEBUG - if !isCurrentPaneOwner && (shouldAttachWebView || host.window != nil) { - dlog( - "browser.portal.owner.skip panel=\(panel.id.uuidString.prefix(5)) " + - "viewPane=\(paneId.id.uuidString.prefix(5)) " + - "currentPane=\(paneDropContext?.paneId.id.uuidString.prefix(5) ?? "nil") " + - "host=\(Self.objectID(host)) hostInWin=\(host.window != nil ? 1 : 0) " + - "released=\(didReleasePortalHost ? 1 : 0)" - ) - } -#endif - if host.window != nil, portalHostAccepted { - Self.installPortalAnchorView(portalAnchorView, in: host) - } - - host.onDidMoveToWindow = { [weak host, weak webView, weak coordinator, weak portalAnchorView, weak browserPanel = panel] in - guard let host, let webView, let coordinator, let portalAnchorView, let browserPanel else { return } - guard coordinator.attachGeneration == generation else { return } - guard currentPaneDropContext()?.paneId.id == paneId.id else { return } - guard browserPanel.claimPortalHost( - hostId: ObjectIdentifier(host), - paneId: paneId, - inWindow: host.window != nil, - bounds: host.bounds, - reason: "didMoveToWindow" - ) else { return } - guard host.window != nil else { return } - Self.installPortalAnchorView(portalAnchorView, in: host) - BrowserWindowPortalRegistry.bind( - webView: webView, - to: portalAnchorView, - visibleInUI: coordinator.desiredPortalVisibleInUI, - zPriority: coordinator.desiredPortalZPriority - ) - BrowserWindowPortalRegistry.refresh( - webView: webView, - reason: "portalHostBind.didMoveToWindow" - ) - BrowserWindowPortalRegistry.updatePaneTopChromeHeight( - for: webView, - height: coordinator.desiredPortalVisibleInUI ? paneTopChromeHeight : 0 - ) - BrowserWindowPortalRegistry.updatePaneDropContext(for: webView, context: activePaneDropContext) - BrowserWindowPortalRegistry.updateSearchOverlay(for: webView, configuration: activeSearchOverlay) - coordinator.lastPortalHostId = ObjectIdentifier(host) - coordinator.lastSynchronizedHostGeometryRevision = host.geometryRevision - } - host.onGeometryChanged = { [weak host, weak webView, weak coordinator, weak portalAnchorView, weak browserPanel = panel] in - guard let host, let webView, let coordinator, let portalAnchorView, let browserPanel else { return } - guard coordinator.attachGeneration == generation else { return } - guard currentPaneDropContext()?.paneId.id == paneId.id else { return } - guard browserPanel.claimPortalHost( - hostId: ObjectIdentifier(host), - paneId: paneId, - inWindow: host.window != nil, - bounds: host.bounds, - reason: "geometryChanged" - ) else { return } - guard host.window != nil else { return } - let hostId = ObjectIdentifier(host) - Self.installPortalAnchorView(portalAnchorView, in: host) - if coordinator.lastPortalHostId != hostId || - !BrowserWindowPortalRegistry.isWebView(webView, boundTo: portalAnchorView) { - BrowserWindowPortalRegistry.bind( - webView: webView, - to: portalAnchorView, - visibleInUI: coordinator.desiredPortalVisibleInUI, - zPriority: coordinator.desiredPortalZPriority - ) - BrowserWindowPortalRegistry.refresh( - webView: webView, - reason: "portalHostBind.geometryChanged" - ) - BrowserWindowPortalRegistry.updatePaneTopChromeHeight( - for: webView, - height: coordinator.desiredPortalVisibleInUI ? paneTopChromeHeight : 0 - ) - BrowserWindowPortalRegistry.updatePaneDropContext(for: webView, context: activePaneDropContext) - BrowserWindowPortalRegistry.updateSearchOverlay(for: webView, configuration: activeSearchOverlay) - coordinator.lastPortalHostId = hostId - } - BrowserWindowPortalRegistry.synchronizeForAnchor(portalAnchorView) - coordinator.lastSynchronizedHostGeometryRevision = host.geometryRevision - } - - if !shouldAttachWebView { - // In portal mode we no longer detach/re-attach to preserve DevTools state. - // Sync the inspector preference directly so manual closes are respected. - panel.syncDeveloperToolsPreferenceFromInspector( - preserveVisibleIntent: panel.shouldPreserveDeveloperToolsIntentWhileDetached() - ) - } - - if host.window != nil, portalHostAccepted { - let geometryRevision = host.geometryRevision - let portalEntryMissing = !BrowserWindowPortalRegistry.isWebView(webView, boundTo: portalAnchorView) - let shouldBindNow = - coordinator.lastPortalHostId != hostId || - webView.superview == nil || - portalEntryMissing || - previousVisible != shouldAttachWebView || - previousZPriority != portalZPriority - if shouldBindNow { - Self.installPortalAnchorView(portalAnchorView, in: host) - BrowserWindowPortalRegistry.bind( - webView: webView, - to: portalAnchorView, - visibleInUI: coordinator.desiredPortalVisibleInUI, - zPriority: coordinator.desiredPortalZPriority - ) - // Force a rendering-state reattach after portal host replacement - // (e.g. after a pane split). Without this, WKWebView can freeze - // because _exitInWindow/_enterInWindow are never cycled when the - // web view is reparented to a new container during bind. - BrowserWindowPortalRegistry.refresh( - webView: webView, - reason: "portalHostBind" - ) - coordinator.lastPortalHostId = hostId - coordinator.lastSynchronizedHostGeometryRevision = geometryRevision - } - BrowserWindowPortalRegistry.updatePaneTopChromeHeight( - for: webView, - height: coordinator.desiredPortalVisibleInUI ? paneTopChromeHeight : 0 - ) - BrowserWindowPortalRegistry.updateSearchOverlay(for: webView, configuration: activeSearchOverlay) - if !shouldBindNow, - coordinator.lastSynchronizedHostGeometryRevision != geometryRevision { - BrowserWindowPortalRegistry.synchronizeForAnchor(portalAnchorView) - coordinator.lastSynchronizedHostGeometryRevision = geometryRevision - } - } else if portalHostAccepted { - // Bind is deferred until host moves into a window. Keep the current - // portal entry's desired state in sync so stale callbacks cannot keep - // the previous anchor visible while this host is temporarily off-window. - BrowserWindowPortalRegistry.updateEntryVisibility( - for: webView, - visibleInUI: coordinator.desiredPortalVisibleInUI, - zPriority: coordinator.desiredPortalZPriority - ) - } - - if portalHostAccepted { - BrowserWindowPortalRegistry.updateDropZoneOverlay( - for: webView, - zone: coordinator.desiredPortalVisibleInUI ? paneDropZone : nil - ) - BrowserWindowPortalRegistry.updatePaneTopChromeHeight( - for: webView, - height: coordinator.desiredPortalVisibleInUI ? paneTopChromeHeight : 0 - ) - BrowserWindowPortalRegistry.updatePaneDropContext( - for: webView, - context: activePaneDropContext - ) - BrowserWindowPortalRegistry.updateSearchOverlay(for: webView, configuration: activeSearchOverlay) - } - - panel.restoreDeveloperToolsAfterAttachIfNeeded() - - #if DEBUG - Self.logDevToolsState( - panel, - event: "portal.update", - generation: coordinator.attachGeneration, - retryCount: 0, - details: Self.attachContext(webView: webView, host: host) - ) - #endif - return portalHostAccepted && !shouldPreserveExternalFullscreenHost - } - - func updateNSView(_ nsView: NSView, context: Context) { - let webView = panel.webView - let coordinator = context.coordinator - let isCurrentPaneOwner = currentPaneDropContext()?.paneId.id == paneId.id - if let previousWebView = coordinator.webView, previousWebView !== webView { - BrowserWindowPortalRegistry.detach(webView: previousWebView) - coordinator.lastPortalHostId = nil - coordinator.lastSynchronizedHostGeometryRevision = 0 - } - coordinator.panel = panel - coordinator.webView = webView - - Self.clearPortalCallbacks(for: nsView) - let hostOwnsPortal = useLocalInlineHosting - ? updateUsingLocalInlineHosting(nsView, context: context, webView: webView) - : updateUsingWindowPortal(nsView, context: context, webView: webView) - Self.applyWebViewFirstResponderPolicy( - panel: panel, - webView: webView, - isPanelFocused: isPanelFocused && isCurrentPaneOwner && hostOwnsPortal - ) - - Self.applyFocus( - panel: panel, - webView: webView, - nsView: nsView, - shouldFocusWebView: shouldFocusWebView && isCurrentPaneOwner && hostOwnsPortal, - isPanelFocused: isPanelFocused && isCurrentPaneOwner && hostOwnsPortal - ) - } - - private static func applyFocus( - panel: BrowserPanel, - webView: WKWebView, - nsView: NSView, - shouldFocusWebView: Bool, - isPanelFocused: Bool - ) { - // Focus handling. Avoid fighting the address bar when it is focused. - guard let window = nsView.window else { -#if DEBUG - dlog( - "browser.focus.content.apply panel=\(panel.id.uuidString.prefix(5)) " + - "action=skip reason=no_window shouldFocus=\(shouldFocusWebView ? 1 : 0) " + - "panelFocused=\(isPanelFocused ? 1 : 0)" - ) -#endif - return - } - if isPanelFocused && InspectorDock.responderChainContains(window.firstResponder, target: webView) { - panel.noteWebViewFocused() - } - if shouldFocusWebView { - if panel.shouldSuppressWebViewFocus() { -#if DEBUG - dlog( - "browser.focus.content.apply panel=\(panel.id.uuidString.prefix(5)) " + - "action=skip reason=suppressed panelFocused=\(isPanelFocused ? 1 : 0)" - ) -#endif - return - } - if InspectorDock.responderChainContains(window.firstResponder, target: webView) { -#if DEBUG - dlog( - "browser.focus.content.apply panel=\(panel.id.uuidString.prefix(5)) " + - "action=skip reason=already_first_responder_chain" - ) -#endif - return - } - let result = window.makeFirstResponder(webView) - if result { - panel.noteWebViewFocused() - } -#if DEBUG - dlog( - "browser.focus.content.apply panel=\(panel.id.uuidString.prefix(5)) " + - "action=focus result=\(result ? 1 : 0) fr=\(responderDescription(window.firstResponder))" - ) -#endif - } else if !isPanelFocused && InspectorDock.responderChainContains(window.firstResponder, target: webView) { - // Only force-resign WebView focus when this panel itself is not focused. - // If the panel is focused but the omnibar-focus state is briefly stale, aggressively - // clearing first responder here can undo programmatic webview focus (socket tests). - let result = window.makeFirstResponder(nil) -#if DEBUG - dlog( - "browser.focus.content.apply panel=\(panel.id.uuidString.prefix(5)) " + - "action=resign result=\(result ? 1 : 0) fr=\(responderDescription(window.firstResponder))" - ) -#endif - } - } - - private static func applyWebViewFirstResponderPolicy( - panel: BrowserPanel, - webView: WKWebView, - isPanelFocused: Bool - ) { - guard let programaWebView = webView as? ProgramaWebView else { return } - let next = isPanelFocused && !panel.shouldSuppressWebViewFocus() - if programaWebView.allowsFirstResponderAcquisition != next { -#if DEBUG - dlog( - "browser.focus.policy panel=\(panel.id.uuidString.prefix(5)) " + - "web=\(ObjectIdentifier(programaWebView)) old=\(programaWebView.allowsFirstResponderAcquisition ? 1 : 0) " + - "new=\(next ? 1 : 0) isPanelFocused=\(isPanelFocused ? 1 : 0) " + - "suppress=\(panel.shouldSuppressWebViewFocus() ? 1 : 0)" - ) -#endif - } - programaWebView.allowsFirstResponderAcquisition = next - } - - static func dismantleNSView(_ nsView: NSView, coordinator: Coordinator) { - coordinator.attachGeneration += 1 - clearPortalCallbacks(for: nsView) - if let panel = coordinator.panel, let host = nsView as? HostContainerView { - panel.releasePortalHostIfOwned( - hostId: ObjectIdentifier(host), - reason: "dismantle" - ) - } - - guard let webView = coordinator.webView else { return } - let panel = coordinator.panel - - // If we're being torn down while the WKWebView (or one of its subviews) is first responder, - // resign it before detaching. - let window = webView.window ?? nsView.window - if let window { - let state = firstResponderResignState(window.firstResponder, webView: webView) - if state.needsResign { - #if DEBUG - if let panel { - logDevToolsState( - panel, - event: "dismantle.resignFirstResponder", - generation: coordinator.attachGeneration, - retryCount: 0, - details: attachContext(webView: webView, host: nsView) + " " + state.flags - ) - } - #endif - window.makeFirstResponder(nil) - } - } - - // SwiftUI can transiently dismantle/rebuild the browser host view during split - // rearrangement. Do not detach the portal-hosted WKWebView or clear its pane-drop - // context here; explicit teardown still happens on real web view replacement and - // panel teardown, and preserving this state lets internal tab drags re-enter the - // browser pane while SwiftUI churns underneath. - BrowserWindowPortalRegistry.updateDropZoneOverlay(for: webView, zone: nil) - coordinator.lastPortalHostId = nil - coordinator.lastSynchronizedHostGeometryRevision = 0 - } - - private func currentPaneDropContext() -> BrowserPaneDropContext? { - guard let app = AppDelegate.shared, - let manager = app.tabManagerFor(tabId: panel.workspaceId), - let workspace = manager.tabs.first(where: { $0.id == panel.workspaceId }), - let paneId = workspace.paneId(forPanelId: panel.id) else { - return nil - } - return BrowserPaneDropContext( - workspaceId: panel.workspaceId, - panelId: panel.id, - paneId: paneId - ) - } -} diff --git a/Sources/Panels/WebViewRepresentable.swift b/Sources/Panels/WebViewRepresentable.swift new file mode 100644 index 00000000000..8be309ab6a2 --- /dev/null +++ b/Sources/Panels/WebViewRepresentable.swift @@ -0,0 +1,2337 @@ +import Bonsplit +import SwiftUI +import WebKit +import AppKit +import ObjectiveC + +struct WebViewRepresentable: NSViewRepresentable { + let panel: BrowserPanel + let paneId: PaneID + let shouldAttachWebView: Bool + let useLocalInlineHosting: Bool + let shouldFocusWebView: Bool + let isPanelFocused: Bool + let portalZPriority: Int + let paneDropZone: DropZone? + let searchOverlay: BrowserPortalSearchOverlayConfiguration? + let paneTopChromeHeight: CGFloat + + final class Coordinator { + weak var panel: BrowserPanel? + weak var webView: WKWebView? + var attachGeneration: Int = 0 + var desiredPortalVisibleInUI: Bool = true + var desiredPortalZPriority: Int = 0 + var lastPortalHostId: ObjectIdentifier? + var lastSynchronizedHostGeometryRevision: UInt64 = 0 + } + + final class HostContainerView: NSView { + private final class HostedInspectorSideDockContainerView: NSView { + override init(frame frameRect: NSRect) { + super.init(frame: frameRect) + wantsLayer = true + layer?.masksToBounds = true + } + + @available(*, unavailable) + required init?(coder: NSCoder) { + nil + } + + override var isOpaque: Bool { false } + + override func resizeSubviews(withOldSize oldSize: NSSize) { + // Managed side-docked DevTools use explicit frame updates from the host. + // Letting AppKit autoresize the WK siblings here makes them snap back to + // stale widths while the divider drag or pane resize is in flight. + } + } + + var onDidMoveToWindow: (() -> Void)? + var onGeometryChanged: (() -> Void)? + private(set) var geometryRevision: UInt64 = 0 + private var lastReportedGeometryState: GeometryState? + private var hasPendingGeometryNotification = false + private weak var hostedWebView: WKWebView? + private var hostedWebViewConstraints: [NSLayoutConstraint] = [] + private weak var localInlineSlotView: WindowBrowserSlotView? + private var localInlineSlotConstraints: [NSLayoutConstraint] = [] + private weak var hostedInspectorSideDockContainerView: HostedInspectorSideDockContainerView? + private var hostedInspectorSideDockConstraints: [NSLayoutConstraint] = [] + private weak var hostedInspectorFrontendWebView: WKWebView? + private struct HostedInspectorDividerHit { + let containerView: NSView + let pageView: NSView + let inspectorView: NSView + let dockSide: HostedInspectorDockSide + } + + private struct GeometryState: Equatable { + let frame: CGRect + let bounds: CGRect + let windowNumber: Int? + let superviewID: ObjectIdentifier? + } + + private struct HostedInspectorDividerDragState { + let containerView: NSView + let pageView: NSView + let inspectorView: NSView + let dockSide: HostedInspectorDockSide + let initialWindowX: CGFloat + let initialPageFrame: NSRect + let initialInspectorFrame: NSRect + } + + private enum DividerCursorKind: Equatable { + case vertical + + var cursor: NSCursor { .resizeLeftRight } + } + + private static let hostedInspectorDividerHitExpansion: CGFloat = 10 + private static let minimumHostedInspectorWidth: CGFloat = 120 + private static let minimumHostedInspectorPageWidthForSideDock: CGFloat = 240 + private static let adaptiveBottomDockRequestCooldown: TimeInterval = 0.25 + private var trackingArea: NSTrackingArea? + private var activeDividerCursorKind: DividerCursorKind? + private var hostedInspectorDividerDrag: HostedInspectorDividerDragState? + private var preferredHostedInspectorWidth: CGFloat? + private var preferredHostedInspectorWidthFraction: CGFloat? + var onPreferredHostedInspectorWidthChanged: ((CGFloat, CGFloat?) -> Void)? + private weak var hostedInspectorSideDockPageView: NSView? + private weak var hostedInspectorSideDockInspectorView: NSView? + private var hostedInspectorSideDockDockSide: HostedInspectorDockSide? + private var isHostedInspectorDividerDragActive = false + private var isApplyingHostedInspectorLayout = false + private var hostedInspectorReapplyWorkItem: DispatchWorkItem? + private var hostedInspectorDockConfigurationSyncWorkItem: DispatchWorkItem? + private var adaptiveBottomDockRequestCooldownDeadline: Date? + private var recordedHostedInspectorSideDockWidth: CGFloat? + private var lastHostedInspectorManualSideDockAllowed: Bool? + private var lastHostedInspectorLayoutBoundsSize: NSSize? +#if DEBUG + private var lastLoggedHostedInspectorFrames: (page: NSRect, inspector: NSRect)? + private var hasLoggedMissingHostedInspectorCandidate = false +#endif + + deinit { + hostedInspectorReapplyWorkItem?.cancel() + hostedInspectorDockConfigurationSyncWorkItem?.cancel() + if let trackingArea { + removeTrackingArea(trackingArea) + } + clearActiveDividerCursor(restoreArrow: false) + } + + private func recordPreferredHostedInspectorWidth(_ width: CGFloat, containerBounds: NSRect) { + preferredHostedInspectorWidth = width + guard containerBounds.width > 0 else { + preferredHostedInspectorWidthFraction = nil + onPreferredHostedInspectorWidthChanged?(width, nil) + return + } + preferredHostedInspectorWidthFraction = width / containerBounds.width + onPreferredHostedInspectorWidthChanged?(width, preferredHostedInspectorWidthFraction) + } + + private func resolvedPreferredHostedInspectorWidth(in containerBounds: NSRect) -> CGFloat? { + if let preferredHostedInspectorWidthFraction, containerBounds.width > 0 { + return max(0, containerBounds.width * preferredHostedInspectorWidthFraction) + } + return preferredHostedInspectorWidth + } + + func setPreferredHostedInspectorWidth(width: CGFloat?, widthFraction: CGFloat?) { + preferredHostedInspectorWidth = width + preferredHostedInspectorWidthFraction = widthFraction + } + + private func recordHostedInspectorSideDockWidth(_ width: CGFloat) { + guard width > 1 else { return } + recordedHostedInspectorSideDockWidth = max(Self.minimumHostedInspectorWidth, width) + } + + private func shouldAllowHostedInspectorManualSideDock() -> Bool { + let containerWidth = max(0, bounds.width) + guard containerWidth > 1 else { return true } + let baselineWidth = max( + Self.minimumHostedInspectorWidth, + recordedHostedInspectorSideDockWidth ?? Self.minimumHostedInspectorWidth + ) + return containerWidth - baselineWidth >= Self.minimumHostedInspectorPageWidthForSideDock + } + + private func updateHostedInspectorDockControlAvailabilityIfNeeded(reason: String) { + guard let hostedInspectorFrontendWebView else { + lastHostedInspectorManualSideDockAllowed = nil + return + } + + let sideDockAllowed = shouldAllowHostedInspectorManualSideDock() + guard lastHostedInspectorManualSideDockAllowed != sideDockAllowed else { return } + lastHostedInspectorManualSideDockAllowed = sideDockAllowed + + let sideDockAllowedLiteral = sideDockAllowed ? "true" : "false" +#if DEBUG + let recordedWidthDesc = recordedHostedInspectorSideDockWidth.map { + String(format: "%.1f", $0) + } ?? "nil" + dlog( + "browser.panel.hostedInspector stage=\(reason).dockControls " + + "host=\(Self.debugObjectID(self)) allowSideDock=\(sideDockAllowed ? 1 : 0) " + + "recordedWidth=\(recordedWidthDesc) bounds=\(Self.debugRect(bounds))" + ) +#endif + hostedInspectorFrontendWebView.evaluateJavaScript( + """ + (() => { + if (typeof WI === "undefined") + return null; + const allowSideDock = \(sideDockAllowedLiteral); + if (!WI.__programaOriginalUpdateDockNavigationItems && typeof WI._updateDockNavigationItems === "function") + WI.__programaOriginalUpdateDockNavigationItems = WI._updateDockNavigationItems; + if (!WI.__programaOriginalDockLeft && typeof WI._dockLeft === "function") + WI.__programaOriginalDockLeft = WI._dockLeft; + if (!WI.__programaOriginalDockRight && typeof WI._dockRight === "function") + WI.__programaOriginalDockRight = WI._dockRight; + if (!WI.__programaOriginalTogglePreviousDockConfiguration && typeof WI._togglePreviousDockConfiguration === "function") + WI.__programaOriginalTogglePreviousDockConfiguration = WI._togglePreviousDockConfiguration; + function callOriginal(fn, event) { + return typeof fn === "function" ? fn.call(WI, event) : null; + } + function updateButton(button, hidden) { + if (!button) + return; + button.hidden = hidden; + if (button.element) { + button.element.style.display = hidden ? "none" : ""; + button.element.style.pointerEvents = hidden ? "none" : ""; + } + } + function enforceDockControls() { + const disallowSideDock = !WI.__programaAllowSideDock; + updateButton(WI._dockLeftTabBarButton, disallowSideDock || WI.dockConfiguration === WI.DockConfiguration.Left); + updateButton(WI._dockRightTabBarButton, disallowSideDock || WI.dockConfiguration === WI.DockConfiguration.Right); + } + WI.__programaAllowSideDock = allowSideDock; + WI._dockLeft = function(event) { + if (!WI.__programaAllowSideDock) + return callOriginal(WI._dockBottom, event); + return callOriginal(WI.__programaOriginalDockLeft, event); + }; + WI._dockRight = function(event) { + if (!WI.__programaAllowSideDock) + return callOriginal(WI._dockBottom, event); + return callOriginal(WI.__programaOriginalDockRight, event); + }; + WI._togglePreviousDockConfiguration = function(event) { + const previousSideDock = WI._previousDockConfiguration === WI.DockConfiguration.Left || WI._previousDockConfiguration === WI.DockConfiguration.Right; + if (!WI.__programaAllowSideDock && previousSideDock) + return callOriginal(WI._dockBottom, event); + return callOriginal(WI.__programaOriginalTogglePreviousDockConfiguration, event); + }; + WI._updateDockNavigationItems = function(...args) { + if (typeof WI.__programaOriginalUpdateDockNavigationItems === "function") + WI.__programaOriginalUpdateDockNavigationItems.apply(WI, args); + enforceDockControls(); + }; + WI._updateDockNavigationItems(); + return WI.__programaAllowSideDock; + })(); + """, + completionHandler: nil + ) + } + + func containsManagedLocalInlineContent(_ view: NSView) -> Bool { + if let localInlineSlotView, + view === localInlineSlotView || view.isDescendant(of: localInlineSlotView) { + return true + } + if let hostedInspectorSideDockContainerView, + view === hostedInspectorSideDockContainerView || view.isDescendant(of: hostedInspectorSideDockContainerView) { + return true + } + return false + } + + func currentHostedWebViewContainer(preferredSlotView: WindowBrowserSlotView) -> NSView { + if let hostedInspectorSideDockContainerView, + let hostedInspectorSideDockPageView, + hostedWebView?.isDescendant(of: hostedInspectorSideDockContainerView) == true, + hostedInspectorSideDockPageView.isDescendant(of: hostedInspectorSideDockContainerView) { + return hostedInspectorSideDockContainerView + } + return preferredSlotView + } + + func setHostedInspectorFrontendWebView(_ webView: WKWebView?) { + hostedInspectorFrontendWebView = webView + lastHostedInspectorManualSideDockAllowed = nil + updateHostedInspectorDockControlAvailabilityIfNeeded(reason: "setHostedInspectorFrontendWebView") + } + + private var hasStoredHostedInspectorWidthPreference: Bool { + preferredHostedInspectorWidth != nil || preferredHostedInspectorWidthFraction != nil + } + +#if DEBUG + private static func shouldLogPointerEvent(_ event: NSEvent?) -> Bool { + switch event?.type { + case .leftMouseDown, .leftMouseDragged, .leftMouseUp: + return true + default: + return false + } + } + + private func debugLogHitTest(stage: String, point: NSPoint, passThrough: Bool, hitView: NSView?) { + let event = NSApp.currentEvent + guard Self.shouldLogPointerEvent(event) else { return } + + let hitDesc: String = { + guard let hitView else { return "nil" } + let token = Unmanaged.passUnretained(hitView).toOpaque() + return "\(type(of: hitView))@\(token)" + }() + let hostRectInContent: NSRect = { + guard let window, let contentView = window.contentView else { return .zero } + return contentView.convert(bounds, from: self) + }() + dlog( + "browser.panel.host stage=\(stage) event=\(String(describing: event?.type)) " + + "point=\(String(format: "%.1f,%.1f", point.x, point.y)) pass=\(passThrough ? 1 : 0) " + + "hostFrameInContent=\(String(format: "%.1f,%.1f %.1fx%.1f", hostRectInContent.origin.x, hostRectInContent.origin.y, hostRectInContent.width, hostRectInContent.height)) " + + "hit=\(hitDesc)" + ) + } + + private static func debugObjectID(_ object: AnyObject?) -> String { + guard let object else { return "nil" } + return String(describing: Unmanaged.passUnretained(object).toOpaque()) + } + + private static func debugRect(_ rect: NSRect) -> String { + String(format: "%.1f,%.1f %.1fx%.1f", rect.origin.x, rect.origin.y, rect.width, rect.height) + } + + private func debugLogHostedInspectorFrames( + stage: String, + point: NSPoint? = nil, + hit: HostedInspectorDividerHit + ) { + let pointDesc = point.map { String(format: "%.1f,%.1f", $0.x, $0.y) } ?? "nil" + let preferredWidthDesc = preferredHostedInspectorWidth.map { String(format: "%.1f", $0) } ?? "nil" + dlog( + "browser.panel.hostedInspector stage=\(stage) point=\(pointDesc) " + + "host=\(Self.debugObjectID(self)) container=\(Self.debugObjectID(hit.containerView)) " + + "page=\(Self.debugObjectID(hit.pageView)) inspector=\(Self.debugObjectID(hit.inspectorView)) " + + "preferredWidth=\(preferredWidthDesc) " + + "hostFrame=\(Self.debugRect(frame)) hostBounds=\(Self.debugRect(bounds)) " + + "containerBounds=\(Self.debugRect(hit.containerView.bounds)) " + + "pageFrame=\(Self.debugRect(hit.pageView.frame)) " + + "inspectorFrame=\(Self.debugRect(hit.inspectorView.frame))" + ) + } + + private func debugLogHostedInspectorLayoutIfNeeded(reason: String) { + guard let hit = hostedInspectorDividerCandidate() else { + if !hasLoggedMissingHostedInspectorCandidate, + lastLoggedHostedInspectorFrames != nil || preferredHostedInspectorWidth != nil { + let preferredWidthDesc = preferredHostedInspectorWidth.map { + String(format: "%.1f", $0) + } ?? "nil" + lastLoggedHostedInspectorFrames = nil + hasLoggedMissingHostedInspectorCandidate = true + dlog( + "browser.panel.hostedInspector stage=\(reason).candidateMissing " + + "host=\(Self.debugObjectID(self)) preferredWidth=\(preferredWidthDesc)" + ) + } + return + } + hasLoggedMissingHostedInspectorCandidate = false + + let nextFrames = (page: hit.pageView.frame, inspector: hit.inspectorView.frame) + if let lastLoggedHostedInspectorFrames, + InspectorDock.rectApproximatelyEqual(lastLoggedHostedInspectorFrames.page, nextFrames.page), + InspectorDock.rectApproximatelyEqual(lastLoggedHostedInspectorFrames.inspector, nextFrames.inspector) { + return + } + + lastLoggedHostedInspectorFrames = nextFrames + debugLogHostedInspectorFrames(stage: "\(reason).layout", hit: hit) + } +#endif + + private static func sizeApproximatelyEqual(_ lhs: NSSize, _ rhs: NSSize, epsilon: CGFloat = 0.5) -> Bool { + abs(lhs.width - rhs.width) <= epsilon && + abs(lhs.height - rhs.height) <= epsilon + } + + private func currentGeometryState() -> GeometryState { + GeometryState( + frame: frame, + bounds: bounds, + windowNumber: window?.windowNumber, + superviewID: superview.map(ObjectIdentifier.init) + ) + } + + /// Record that geometry changed without firing the callback immediately. + /// `setFrameOrigin`/`setFrameSize` can fire multiple times before `layout()`; + /// deferring avoids redundant portal-sync cascades during divider drag. + /// A dispatch fallback ensures the callback fires even if `layout()` is not called. + /// Note: `lastReportedGeometryState` and `geometryRevision` are only updated + /// when the callback actually fires, so `updateNSView` sees a revision that + /// is strictly tied to emitted callbacks (no premature increments). + private func markGeometryDirtyIfNeeded() { + let state = currentGeometryState() + guard state != lastReportedGeometryState else { return } + guard !hasPendingGeometryNotification else { return } + hasPendingGeometryNotification = true + DispatchQueue.main.async { [weak self] in + self?.notifyGeometryChangedIfNeeded() + } + } + + /// Check for geometry changes and fire the callback. Also flushes any pending + /// dirty state from `markGeometryDirtyIfNeeded` so `layout()` supersedes the + /// async fallback. Only updates `lastReportedGeometryState` / `geometryRevision` + /// when the callback is emitted, keeping the revision in sync with actual + /// notifications. + private func notifyGeometryChangedIfNeeded() { + hasPendingGeometryNotification = false + let state = currentGeometryState() + guard state != lastReportedGeometryState else { return } + lastReportedGeometryState = state + geometryRevision &+= 1 + onGeometryChanged?() + } + + func ensureLocalInlineSlotView() -> WindowBrowserSlotView { + if let localInlineSlotView, localInlineSlotView.superview === self { + localInlineSlotView.isHidden = false + return localInlineSlotView + } + + let slotView = WindowBrowserSlotView(frame: bounds) + slotView.translatesAutoresizingMaskIntoConstraints = false + addSubview(slotView, positioned: .above, relativeTo: nil) + localInlineSlotConstraints = [ + slotView.topAnchor.constraint(equalTo: topAnchor), + slotView.bottomAnchor.constraint(equalTo: bottomAnchor), + slotView.leadingAnchor.constraint(equalTo: leadingAnchor), + slotView.trailingAnchor.constraint(equalTo: trailingAnchor), + ] + NSLayoutConstraint.activate(localInlineSlotConstraints) + localInlineSlotView = slotView + return slotView + } + + func setLocalInlineSlotHidden(_ hidden: Bool) { + localInlineSlotView?.isHidden = hidden + if hidden { + notifyHostedWebKitHidden(reason: "slotHidden") + } + } + + func clearLocalInlineCallbacks() { + onPreferredHostedInspectorWidthChanged = nil + localInlineSlotView?.onHostedInspectorLayout = nil + } + + private func appendHostedWebKitSubviews( + in root: NSView, + to result: inout [WKWebView], + seen: inout Set + ) { + if let webView = root as? WKWebView { + let id = ObjectIdentifier(webView) + if seen.insert(id).inserted { + result.append(webView) + } + } + for subview in root.subviews { + appendHostedWebKitSubviews(in: subview, to: &result, seen: &seen) + } + } + + private var hostedWebKitSubviews: [WKWebView] { + var result: [WKWebView] = [] + var seen = Set() + + func append(_ webView: WKWebView?) { + guard let webView else { return } + let id = ObjectIdentifier(webView) + guard seen.insert(id).inserted else { return } + result.append(webView) + } + + append(hostedWebView) + append(hostedInspectorFrontendWebView) + appendHostedWebKitSubviews(in: self, to: &result, seen: &seen) + return result + } + + private func notifyHostedWebKitHidden(reason: String) { + for webView in hostedWebKitSubviews { + webView.programaBrowserPanelNotifyHidden(reason: reason) + } + } + + func refreshHostedWebKitPresentation( + reason: String, + forceLifecycleRefresh: Bool = false + ) { + guard let localInlineSlotView else { return } + guard !localInlineSlotView.isHidden else { return } + let hostedWebKitSubviews = hostedWebKitSubviews + guard !hostedWebKitSubviews.isEmpty else { return } + + localInlineSlotView.needsLayout = true + localInlineSlotView.needsDisplay = true + localInlineSlotView.setNeedsDisplay(localInlineSlotView.bounds) + + needsLayout = true + needsDisplay = true + setNeedsDisplay(bounds) + + for webView in hostedWebKitSubviews { + if let scrollView = webView.enclosingScrollView { + scrollView.needsLayout = true + scrollView.needsDisplay = true + scrollView.setNeedsDisplay(scrollView.bounds) + scrollView.contentView.needsLayout = true + scrollView.contentView.needsDisplay = true + } + webView.needsLayout = true + webView.needsDisplay = true + webView.setNeedsDisplay(webView.bounds) + } + + localInlineSlotView.layoutSubtreeIfNeeded() + layoutSubtreeIfNeeded() + + for webView in hostedWebKitSubviews { + if let scrollView = webView.enclosingScrollView { + scrollView.layoutSubtreeIfNeeded() + scrollView.contentView.layoutSubtreeIfNeeded() + scrollView.displayIfNeeded() + } + webView.layoutSubtreeIfNeeded() + if forceLifecycleRefresh { + webView.programaBrowserPanelForceRenderingStateRefresh(reason: reason) + } else { + webView.programaBrowserPanelReattachRenderingState(reason: reason) + } + webView.displayIfNeeded() + } + + localInlineSlotView.displayIfNeeded() + displayIfNeeded() + window?.displayIfNeeded() + } + + func prepareForWindowPortalHosting() { + hostedInspectorDockConfigurationSyncWorkItem?.cancel() + hostedInspectorDockConfigurationSyncWorkItem = nil + notifyHostedWebKitHidden(reason: "prepareForWindowPortalHosting") + deactivateHostedInspectorSideDockIfNeeded(reparentTo: localInlineSlotView) + hostedInspectorFrontendWebView = nil + } + + func releaseHostedWebViewConstraints() { + NSLayoutConstraint.deactivate(hostedWebViewConstraints) + hostedWebViewConstraints = [] + hostedWebView = nil + } + + func pinHostedWebView(_ webView: WKWebView, in container: NSView) { + guard webView.superview === container || webView.isDescendant(of: container) else { return } + + let hasCompanionWKSubviews = Self.hasWebKitCompanionSubview( + in: container, + primaryWebView: webView + ) + let needsPlainWebViewFrameReset = + webView.superview === container && + !hasCompanionWKSubviews && + Self.frameDiffersFromBounds(webView.frame, bounds: container.bounds) + let needsFrameHosting = + hostedWebView !== webView || + !hostedWebViewConstraints.isEmpty || + needsPlainWebViewFrameReset || + !webView.translatesAutoresizingMaskIntoConstraints || + webView.autoresizingMask != [.width, .height] + guard needsFrameHosting else { + needsLayout = true + layoutSubtreeIfNeeded() + return + } + + NSLayoutConstraint.deactivate(hostedWebViewConstraints) + hostedWebViewConstraints = [] + hostedWebView = webView + + // WebKit's attached inspector does not reliably dock into a constraint-managed + // WKWebView hierarchy on macOS. Host the moved webview with autoresizing and + // preserve WebKit-managed split frames when docked DevTools siblings exist. + webView.translatesAutoresizingMaskIntoConstraints = true + webView.autoresizingMask = [.width, .height] + if webView.superview === container && !hasCompanionWKSubviews { + webView.frame = container.bounds + } + needsLayout = true + layoutSubtreeIfNeeded() + } + + private static func frameDiffersFromBounds(_ frame: NSRect, bounds: NSRect, epsilon: CGFloat = 0.5) -> Bool { + abs(frame.minX - bounds.minX) > epsilon || + abs(frame.minY - bounds.minY) > epsilon || + abs(frame.width - bounds.width) > epsilon || + abs(frame.height - bounds.height) > epsilon + } + + private static func hasWebKitCompanionSubview(in host: NSView, primaryWebView: WKWebView) -> Bool { + var stack = host.subviews.filter { $0 !== primaryWebView } + while let current = stack.popLast() { + if current.isDescendant(of: primaryWebView) { + continue + } + if current.isHidden || current.alphaValue <= 0 { + continue + } + if String(describing: type(of: current)).contains("WK") { + let width = max(current.frame.width, current.bounds.width) + let height = max(current.frame.height, current.bounds.height) + if width > 1, height > 1 { + return true + } + continue + } + stack.append(contentsOf: current.subviews) + } + return false + } + + private func ensureHostedInspectorSideDockContainerView() -> HostedInspectorSideDockContainerView { + if let hostedInspectorSideDockContainerView, + hostedInspectorSideDockContainerView.superview === self { + hostedInspectorSideDockContainerView.isHidden = false + return hostedInspectorSideDockContainerView + } + + let containerView = HostedInspectorSideDockContainerView(frame: bounds) + containerView.translatesAutoresizingMaskIntoConstraints = false + addSubview(containerView, positioned: .above, relativeTo: localInlineSlotView) + hostedInspectorSideDockConstraints = [ + containerView.topAnchor.constraint(equalTo: topAnchor), + containerView.bottomAnchor.constraint(equalTo: bottomAnchor), + containerView.leadingAnchor.constraint(equalTo: leadingAnchor), + containerView.trailingAnchor.constraint(equalTo: trailingAnchor), + ] + NSLayoutConstraint.activate(hostedInspectorSideDockConstraints) + hostedInspectorSideDockContainerView = containerView + return containerView + } + + private func moveHostedInspectorSubviewIfNeeded(_ view: NSView, to container: NSView) { + guard view.superview !== container else { return } + let frameInWindow = view.superview?.convert(view.frame, to: nil) ?? convert(view.frame, to: nil) + view.removeFromSuperview() + container.addSubview(view, positioned: .above, relativeTo: nil) + view.frame = container.convert(frameInWindow, from: nil) + } + + private func isHostedInspectorSideDockActive() -> Bool { + guard let hostedInspectorSideDockContainerView, + let hostedInspectorSideDockPageView, + let hostedInspectorSideDockInspectorView else { + return false + } + return hostedInspectorSideDockPageView.superview === hostedInspectorSideDockContainerView && + hostedInspectorSideDockInspectorView.superview === hostedInspectorSideDockContainerView + } + + private func isHostedInspectorSideDockHit(_ hit: HostedInspectorDividerHit) -> Bool { + guard let hostedInspectorSideDockContainerView else { return false } + return hit.containerView === hostedInspectorSideDockContainerView + } + + private func activateHostedInspectorSideDockIfNeeded(using hit: HostedInspectorDividerHit) { + let containerView = ensureHostedInspectorSideDockContainerView() + moveHostedInspectorSubviewIfNeeded(hit.pageView, to: containerView) + moveHostedInspectorSubviewIfNeeded(hit.inspectorView, to: containerView) + hostedInspectorSideDockPageView = hit.pageView + hostedInspectorSideDockInspectorView = hit.inspectorView + hostedInspectorSideDockDockSide = hit.dockSide + layoutHostedInspectorSideDockIfNeeded(reason: "sideDock.activate") + } + + @discardableResult + func promoteHostedInspectorSideDockFromCurrentLayoutIfNeeded() -> Bool { + guard !isHostedInspectorSideDockActive(), + let slotView = localInlineSlotView, + let hit = hostedInspectorDividerCandidateUsingKnownWebViews(in: slotView) else { + return false + } + + // The inspector frontend sometimes reports its dock configuration a tick + // late after local-inline reattach. Promote the visible left/right split + // immediately so drag routing stays symmetric on both dock sides. + activateHostedInspectorSideDockIfNeeded(using: hit) + return isHostedInspectorSideDockActive() + } + + private func deactivateHostedInspectorSideDockIfNeeded(reparentTo slotView: WindowBrowserSlotView?) { + guard let slotView, + let pageView = hostedInspectorSideDockPageView, + let inspectorView = hostedInspectorSideDockInspectorView else { + hostedInspectorSideDockPageView = nil + hostedInspectorSideDockInspectorView = nil + hostedInspectorSideDockDockSide = nil + hostedInspectorSideDockContainerView?.isHidden = true + return + } + + moveHostedInspectorSubviewIfNeeded(pageView, to: slotView) + moveHostedInspectorSubviewIfNeeded(inspectorView, to: slotView) + hostedInspectorSideDockPageView = nil + hostedInspectorSideDockInspectorView = nil + hostedInspectorSideDockDockSide = nil + hostedInspectorSideDockContainerView?.isHidden = true + } + + private func layoutHostedInspectorSideDockIfNeeded(reason: String) { + guard let containerView = hostedInspectorSideDockContainerView, + let pageView = hostedInspectorSideDockPageView, + let inspectorView = hostedInspectorSideDockInspectorView, + let dockSide = hostedInspectorSideDockDockSide else { + return + } + let preferredWidth = resolvedPreferredHostedInspectorWidth(in: containerView.bounds) ?? max(0, inspectorView.frame.width) + _ = applyHostedInspectorDividerWidth( + preferredWidth, + to: HostedInspectorDividerHit( + containerView: containerView, + pageView: pageView, + inspectorView: inspectorView, + dockSide: dockSide + ), + minimumInspectorWidth: Self.minimumHostedInspectorWidth, + reason: reason + ) + } + + func normalizeHostedInspectorLayoutIfNeeded(reason: String) { + if enforceAdaptiveBottomDockIfNeeded(reason: "\(reason).adaptive") { + return + } + _ = promoteHostedInspectorSideDockFromCurrentLayoutIfNeeded() + if isHostedInspectorSideDockActive() { + layoutHostedInspectorSideDockIfNeeded(reason: reason) + } else if !hasStoredHostedInspectorWidthPreference { + captureHostedInspectorPreferredWidthFromCurrentLayout(reason: reason) + } + } + + private func shouldForceHostedInspectorBottomDock(using hit: HostedInspectorDividerHit) -> Bool { + let containerWidth = max(0, hit.containerView.bounds.width) + guard containerWidth > 1 else { return false } + + let currentInspectorWidth = max(0, hit.inspectorView.frame.width) + let currentPageWidth = max(0, hit.pageView.frame.width) + let remainingPageWidth = max(0, containerWidth - max(Self.minimumHostedInspectorWidth, currentInspectorWidth)) + let effectivePageWidth = min(currentPageWidth, remainingPageWidth) + + return effectivePageWidth < Self.minimumHostedInspectorPageWidthForSideDock + } + + @discardableResult + private func requestAdaptiveHostedInspectorBottomDock(reason: String) -> Bool { + let now = Date() + if let adaptiveBottomDockRequestCooldownDeadline, adaptiveBottomDockRequestCooldownDeadline > now { + return true + } + guard let hostedInspectorFrontendWebView else { return false } + + adaptiveBottomDockRequestCooldownDeadline = now.addingTimeInterval(Self.adaptiveBottomDockRequestCooldown) + updateHostedInspectorDockControlAvailabilityIfNeeded(reason: reason) +#if DEBUG + dlog( + "browser.panel.hostedInspector stage=\(reason).adaptiveBottomDock " + + "host=\(Self.debugObjectID(self)) bounds=\(Self.debugRect(bounds))" + ) +#endif + hostedInspectorFrontendWebView.evaluateJavaScript( + "typeof WI !== 'undefined' ? WI._dockBottom() : null" + ) { [weak self] _, _ in + self?.scheduleHostedInspectorDockConfigurationSync( + reason: "\(reason).adaptiveBottomDock" + ) + } + return true + } + + @discardableResult + private func enforceAdaptiveBottomDockIfNeeded(reason: String) -> Bool { + guard let hit = hostedInspectorDividerCandidate(), + shouldForceHostedInspectorBottomDock(using: hit) else { + return false + } + recordHostedInspectorSideDockWidth(hit.inspectorView.frame.width) + return requestAdaptiveHostedInspectorBottomDock(reason: reason) + } + + fileprivate func scheduleHostedInspectorDockConfigurationSync(reason: String) { + hostedInspectorDockConfigurationSyncWorkItem?.cancel() + guard hostedInspectorFrontendWebView != nil else { return } + let workItem = DispatchWorkItem { [weak self] in + self?.syncHostedInspectorDockConfiguration(reason: reason) + } + hostedInspectorDockConfigurationSyncWorkItem = workItem + DispatchQueue.main.async(execute: workItem) + } + + private func syncHostedInspectorDockConfiguration(reason: String) { + hostedInspectorDockConfigurationSyncWorkItem = nil + guard let hostedInspectorFrontendWebView else { return } + hostedInspectorFrontendWebView.evaluateJavaScript( + "typeof WI === 'undefined' ? null : WI.dockConfiguration" + ) { [weak self] result, _ in + self?.applyHostedInspectorDockConfiguration(result as? String, reason: reason) + } + } + + private func applyHostedInspectorDockConfiguration(_ dockConfiguration: String?, reason: String) { + switch dockConfiguration { + case "left": + hostedInspectorSideDockDockSide = .leading + if isHostedInspectorSideDockActive() { + if enforceAdaptiveBottomDockIfNeeded(reason: "\(reason).dockLeft") { + return + } + layoutHostedInspectorSideDockIfNeeded(reason: "\(reason).dockLeft") + } else if let slotView = localInlineSlotView, + let hit = hostedInspectorDividerCandidate(in: slotView), + hit.dockSide == .leading { + if shouldForceHostedInspectorBottomDock(using: hit) { + _ = requestAdaptiveHostedInspectorBottomDock(reason: "\(reason).dockLeft") + return + } + activateHostedInspectorSideDockIfNeeded(using: hit) + } + case "right": + hostedInspectorSideDockDockSide = .trailing + if isHostedInspectorSideDockActive() { + if enforceAdaptiveBottomDockIfNeeded(reason: "\(reason).dockRight") { + return + } + layoutHostedInspectorSideDockIfNeeded(reason: "\(reason).dockRight") + } else if let slotView = localInlineSlotView, + let hit = hostedInspectorDividerCandidate(in: slotView), + hit.dockSide == .trailing { + if shouldForceHostedInspectorBottomDock(using: hit) { + _ = requestAdaptiveHostedInspectorBottomDock(reason: "\(reason).dockRight") + return + } + activateHostedInspectorSideDockIfNeeded(using: hit) + } + default: + adaptiveBottomDockRequestCooldownDeadline = nil + if isHostedInspectorSideDockActive() { + deactivateHostedInspectorSideDockIfNeeded(reparentTo: localInlineSlotView) + if dockConfiguration == "bottom" { + hostedInspectorFrontendWebView?.evaluateJavaScript( + "typeof WI !== 'undefined' ? WI._dockBottom() : null", + completionHandler: nil + ) + } + } + } + updateHostedInspectorDockControlAvailabilityIfNeeded(reason: "\(reason).dockConfiguration") + } + + override func viewDidMoveToWindow() { + super.viewDidMoveToWindow() + if window == nil { + notifyHostedWebKitHidden(reason: "viewDidMoveToWindow") + clearActiveDividerCursor(restoreArrow: false) + } else { + scheduleHostedInspectorDividerReapply(reason: "viewDidMoveToWindow") + scheduleHostedInspectorDockConfigurationSync(reason: "viewDidMoveToWindow") + refreshHostedWebKitPresentation( + reason: "viewDidMoveToWindow", + forceLifecycleRefresh: hostedInspectorFrontendWebView != nil + ) + } + window?.invalidateCursorRects(for: self) + onDidMoveToWindow?() + notifyGeometryChangedIfNeeded() +#if DEBUG + debugLogHostedInspectorLayoutIfNeeded(reason: "viewDidMoveToWindow") +#endif + } + + override func viewDidMoveToSuperview() { + super.viewDidMoveToSuperview() + scheduleHostedInspectorDividerReapply(reason: "viewDidMoveToSuperview") + scheduleHostedInspectorDockConfigurationSync(reason: "viewDidMoveToSuperview") + notifyGeometryChangedIfNeeded() +#if DEBUG + debugLogHostedInspectorLayoutIfNeeded(reason: "viewDidMoveToSuperview") +#endif + } + + override func layout() { + super.layout() + _ = promoteHostedInspectorSideDockFromCurrentLayoutIfNeeded() + if enforceAdaptiveBottomDockIfNeeded(reason: "host.layout") { + updateHostedInspectorDockControlAvailabilityIfNeeded(reason: "host.layout") + notifyGeometryChangedIfNeeded() +#if DEBUG + debugLogHostedInspectorLayoutIfNeeded(reason: "layout") +#endif + return + } + if let previousSize = lastHostedInspectorLayoutBoundsSize, + Self.sizeApproximatelyEqual(previousSize, bounds.size, epsilon: 0.5) { + // Origin-only frame churn is common while the surrounding split layout + // settles. Reapplying the side-docked inspector at the same size fights + // WebKit's own dock layout and shows up as visible flicker. + if !isHostedInspectorDividerDragActive { + if hasStoredHostedInspectorWidthPreference { + reapplyHostedInspectorDividerToStoredWidthIfNeeded(reason: "host.layout.sameSize") + } else if !isHostedInspectorSideDockActive() { + captureHostedInspectorPreferredWidthFromCurrentLayout(reason: "host.layout.sameSize") + } + } + updateHostedInspectorDockControlAvailabilityIfNeeded(reason: "host.layout.sameSize") + notifyGeometryChangedIfNeeded() +#if DEBUG + debugLogHostedInspectorLayoutIfNeeded(reason: "layout") +#endif + return + } + lastHostedInspectorLayoutBoundsSize = bounds.size + if isHostedInspectorSideDockActive() { + layoutHostedInspectorSideDockIfNeeded(reason: "host.layout.sideDock") + } else if hasStoredHostedInspectorWidthPreference { + reapplyHostedInspectorDividerToStoredWidthIfNeeded(reason: "host.layout") + } else { + captureHostedInspectorPreferredWidthFromCurrentLayout(reason: "host.layout") + } + updateHostedInspectorDockControlAvailabilityIfNeeded(reason: "host.layout") + scheduleHostedInspectorDockConfigurationSync(reason: "layout") + notifyGeometryChangedIfNeeded() +#if DEBUG + debugLogHostedInspectorLayoutIfNeeded(reason: "layout") +#endif + } + + override func setFrameOrigin(_ newOrigin: NSPoint) { + super.setFrameOrigin(newOrigin) + window?.invalidateCursorRects(for: self) + // Mark dirty; the callback fires from layout() with the settled geometry. + markGeometryDirtyIfNeeded() +#if DEBUG + debugLogHostedInspectorLayoutIfNeeded(reason: "setFrameOrigin") +#endif + } + + override func setFrameSize(_ newSize: NSSize) { + super.setFrameSize(newSize) + window?.invalidateCursorRects(for: self) + // Mark dirty; the callback fires from layout() with the settled geometry. + markGeometryDirtyIfNeeded() +#if DEBUG + debugLogHostedInspectorLayoutIfNeeded(reason: "setFrameSize") +#endif + } + + override func resetCursorRects() { + super.resetCursorRects() + guard let hostedInspectorHit = hostedInspectorDividerCandidate() else { return } + let clipped = hostedInspectorDividerHitRect(for: hostedInspectorHit).intersection(bounds) + guard !clipped.isNull, clipped.width > 0, clipped.height > 0 else { return } + addCursorRect(clipped, cursor: NSCursor.resizeLeftRight) + } + + override func updateTrackingAreas() { + if let trackingArea { + removeTrackingArea(trackingArea) + } + let options: NSTrackingArea.Options = [ + .inVisibleRect, + .activeAlways, + .cursorUpdate, + .mouseMoved, + .mouseEnteredAndExited, + .enabledDuringMouseDrag, + ] + let next = NSTrackingArea(rect: .zero, options: options, owner: self, userInfo: nil) + addTrackingArea(next) + trackingArea = next + super.updateTrackingAreas() + } + + override func cursorUpdate(with event: NSEvent) { + updateDividerCursor(at: convert(event.locationInWindow, from: nil)) + } + + override func mouseMoved(with event: NSEvent) { + updateDividerCursor(at: convert(event.locationInWindow, from: nil)) + } + + override func mouseExited(with event: NSEvent) { + clearActiveDividerCursor(restoreArrow: true) + } + + override func hitTest(_ point: NSPoint) -> NSView? { + let hostedInspectorHit = hostedInspectorDividerHit(at: point) + updateDividerCursor(at: point, hostedInspectorHit: hostedInspectorHit) + let passThrough = shouldPassThroughToSidebarResizer(at: point, hostedInspectorHit: hostedInspectorHit) + if passThrough { +#if DEBUG + debugLogHitTest(stage: "hitTest.pass", point: point, passThrough: true, hitView: nil) +#endif + return nil + } + if let hostedInspectorHit { + if let nativeHit = nativeHostedInspectorHit(at: point, hostedInspectorHit: hostedInspectorHit) { +#if DEBUG + debugLogHitTest(stage: "hitTest.hostedInspectorNative", point: point, passThrough: false, hitView: nativeHit) +#endif + if nativeHit !== hostedInspectorHit.inspectorView && + !hostedInspectorHit.inspectorView.isDescendant(of: nativeHit) { + return nativeHit + } + } +#if DEBUG + debugLogHitTest( + stage: "hitTest.hostedInspectorManual", + point: point, + passThrough: false, + hitView: self + ) +#endif + return self + } + let hit = super.hitTest(point) +#if DEBUG + debugLogHitTest(stage: "hitTest.result", point: point, passThrough: false, hitView: hit) +#endif + return hit + } + + override func mouseDown(with event: NSEvent) { + let point = convert(event.locationInWindow, from: nil) + guard let hostedInspectorHit = hostedInspectorDividerHit(at: point) else { + super.mouseDown(with: event) + return + } + + hostedInspectorReapplyWorkItem?.cancel() + isHostedInspectorDividerDragActive = true + hostedInspectorDividerDrag = HostedInspectorDividerDragState( + containerView: hostedInspectorHit.containerView, + pageView: hostedInspectorHit.pageView, + inspectorView: hostedInspectorHit.inspectorView, + dockSide: hostedInspectorHit.dockSide, + initialWindowX: event.locationInWindow.x, + initialPageFrame: hostedInspectorHit.pageView.frame, + initialInspectorFrame: hostedInspectorHit.inspectorView.frame + ) +#if DEBUG + debugLogHostedInspectorFrames(stage: "drag.start", point: point, hit: hostedInspectorHit) +#endif + } + + override func mouseDragged(with event: NSEvent) { + guard let dragState = hostedInspectorDividerDrag else { + super.mouseDragged(with: event) + return + } + + let containerBounds = dragState.containerView.bounds + let minimumInspectorWidth = Self.minimumHostedInspectorWidth + let initialDividerX = dragState.dockSide.dividerX( + pageFrame: dragState.initialPageFrame, + inspectorFrame: dragState.initialInspectorFrame + ) + let proposedDividerX = initialDividerX + (event.locationInWindow.x - dragState.initialWindowX) + let clampedDividerX = dragState.dockSide.clampedDividerX( + proposedDividerX, + containerBounds: containerBounds, + pageFrame: dragState.initialPageFrame, + minimumInspectorWidth: minimumInspectorWidth + ) + let inspectorWidth = dragState.dockSide.inspectorWidth( + forDividerX: clampedDividerX, + in: containerBounds + ) + recordPreferredHostedInspectorWidth(inspectorWidth, containerBounds: containerBounds) + _ = applyHostedInspectorDividerWidth( + inspectorWidth, + to: HostedInspectorDividerHit( + containerView: dragState.containerView, + pageView: dragState.pageView, + inspectorView: dragState.inspectorView, + dockSide: dragState.dockSide + ), + minimumInspectorWidth: Self.minimumHostedInspectorWidth, + reason: "drag" + ) +#if DEBUG + debugLogHostedInspectorFrames( + stage: "drag.update", + point: convert(event.locationInWindow, from: nil), + hit: HostedInspectorDividerHit( + containerView: dragState.containerView, + pageView: dragState.pageView, + inspectorView: dragState.inspectorView, + dockSide: dragState.dockSide + ) + ) +#endif + updateDividerCursor( + at: convert(event.locationInWindow, from: nil), + hostedInspectorHit: HostedInspectorDividerHit( + containerView: dragState.containerView, + pageView: dragState.pageView, + inspectorView: dragState.inspectorView, + dockSide: dragState.dockSide + ) + ) + } + + override func mouseUp(with event: NSEvent) { + let finalDragState = hostedInspectorDividerDrag + hostedInspectorDividerDrag = nil + isHostedInspectorDividerDragActive = false + updateDividerCursor(at: convert(event.locationInWindow, from: nil)) + if let finalDragState { +#if DEBUG + debugLogHostedInspectorFrames( + stage: "drag.end", + point: convert(event.locationInWindow, from: nil), + hit: HostedInspectorDividerHit( + containerView: finalDragState.containerView, + pageView: finalDragState.pageView, + inspectorView: finalDragState.inspectorView, + dockSide: finalDragState.dockSide + ) + ) +#endif + reapplyHostedInspectorDividerToStoredWidthIfNeeded(reason: "drag.end") + } + super.mouseUp(with: event) + } + + private func shouldPassThroughToSidebarResizer( + at point: NSPoint, + hostedInspectorHit: HostedInspectorDividerHit? = nil + ) -> Bool { + if hostedInspectorHit != nil { + return false + } + // Pass through a narrow leading-edge band so the shared sidebar divider + // handle can receive hover/click even when WKWebView is attached here. + // Keeping this deterministic avoids flicker from dynamic left-edge scans. + guard point.x >= 0, point.x <= SidebarResizeInteraction.contentSideHitWidth else { + return false + } + guard let window, let contentView = window.contentView else { + return false + } + let hostRectInContent = contentView.convert(bounds, from: self) + return hostRectInContent.minX > 1 + } + + private func updateDividerCursor( + at point: NSPoint, + hostedInspectorHit: HostedInspectorDividerHit? = nil + ) { + let resolvedHostedInspectorHit = hostedInspectorHit ?? hostedInspectorDividerHit(at: point) + if shouldPassThroughToSidebarResizer(at: point, hostedInspectorHit: resolvedHostedInspectorHit) { + clearActiveDividerCursor(restoreArrow: false) + return + } + guard resolvedHostedInspectorHit != nil else { + clearActiveDividerCursor(restoreArrow: true) + return + } + activeDividerCursorKind = .vertical + NSCursor.resizeLeftRight.set() + } + + private func clearActiveDividerCursor(restoreArrow: Bool) { + guard activeDividerCursorKind != nil else { return } + window?.invalidateCursorRects(for: self) + activeDividerCursorKind = nil + if restoreArrow { + NSCursor.arrow.set() + } + } + + private func nativeHostedInspectorHit( + at point: NSPoint, + hostedInspectorHit: HostedInspectorDividerHit + ) -> NSView? { + guard let nativeHit = super.hitTest(point), nativeHit !== self else { return nil } + if nativeHit === hostedInspectorHit.pageView || + nativeHit.isDescendant(of: hostedInspectorHit.pageView) { + return nil + } + if nativeHit === hostedInspectorHit.inspectorView || + nativeHit.isDescendant(of: hostedInspectorHit.inspectorView) { + return nativeHit + } + if hostedInspectorHit.inspectorView.isDescendant(of: nativeHit), + !(hostedInspectorHit.pageView === nativeHit || hostedInspectorHit.pageView.isDescendant(of: nativeHit)) { + return nativeHit + } + return nil + } + + private func hostedInspectorDividerHit(at point: NSPoint) -> HostedInspectorDividerHit? { + guard let hit = hostedInspectorDividerCandidate(), + hostedInspectorDividerHitRect(for: hit).contains(point) else { + return nil + } + return hit + } + + private func hostedInspectorDividerCandidate() -> HostedInspectorDividerHit? { + hostedInspectorDividerCandidate(in: self) + } + + private func hostedInspectorDividerCandidate(in root: NSView) -> HostedInspectorDividerHit? { + if let preferredHit = hostedInspectorDividerCandidateUsingKnownWebViews(in: root) { + return preferredHit + } + + let inspectorCandidates = InspectorDock.visibleDescendants(in: root) + .filter { InspectorDock.isVisibleCandidate($0) && InspectorDock.isInspectorView($0) } + .sorted { lhs, rhs in + let lhsFrame = root.convert(lhs.bounds, from: lhs) + let rhsFrame = root.convert(rhs.bounds, from: rhs) + return lhsFrame.minX < rhsFrame.minX + } + + var bestHit: HostedInspectorDividerHit? + var bestScore = -CGFloat.greatestFiniteMagnitude + + for inspectorCandidate in inspectorCandidates { + guard let candidate = hostedInspectorDividerCandidate(in: root, startingAt: inspectorCandidate) else { + continue + } + let score = hostedInspectorDividerCandidateScore(candidate) + if score > bestScore { + bestScore = score + bestHit = candidate + } + } + + return bestHit + } + + private func hostedInspectorDividerCandidateUsingKnownWebViews(in root: NSView) -> HostedInspectorDividerHit? { + guard let pageLeaf = hostedWebView, + let inspectorLeaf = hostedInspectorFrontendWebView, + pageLeaf.isDescendant(of: root), + inspectorLeaf.isDescendant(of: root), + InspectorDock.isVisibleCandidate(inspectorLeaf) else { + return nil + } + return hostedInspectorDividerCandidate( + in: root, + pageLeaf: pageLeaf, + inspectorLeaf: inspectorLeaf + ) + } + + private func hostedInspectorDividerCandidate( + in root: NSView, + pageLeaf: NSView, + inspectorLeaf: NSView + ) -> HostedInspectorDividerHit? { + var currentInspector: NSView? = inspectorLeaf + + while let inspectorView = currentInspector, inspectorView !== root { + guard let containerView = inspectorView.superview else { break } + guard containerView === root || containerView.isDescendant(of: root) else { + currentInspector = containerView + continue + } + guard let pageView = Self.directChild(of: containerView, containing: pageLeaf) else { + currentInspector = containerView + continue + } + guard pageView !== inspectorView, + InspectorDock.isVisibleSiblingCandidate(pageView, requireMinWidth: false), + InspectorDock.verticalOverlap(between: pageView.frame, and: inspectorView.frame) > 8, + let dockSide = HostedInspectorDockSide.resolve( + pageFrame: pageView.frame, + inspectorFrame: inspectorView.frame + ) else { + currentInspector = containerView + continue + } + return HostedInspectorDividerHit( + containerView: containerView, + pageView: pageView, + inspectorView: inspectorView, + dockSide: dockSide + ) + } + + return nil + } + + private func hostedInspectorDividerHitRect(for hit: HostedInspectorDividerHit) -> NSRect { + let pageFrame = convert(hit.pageView.bounds, from: hit.pageView) + let inspectorFrame = convert(hit.inspectorView.bounds, from: hit.inspectorView) + return hit.dockSide.dividerHitRect( + in: bounds, + pageFrame: pageFrame, + inspectorFrame: inspectorFrame, + expansion: Self.hostedInspectorDividerHitExpansion + ) + } + + private func hostedInspectorDividerCandidate(in root: NSView, startingAt inspectorLeaf: NSView) -> HostedInspectorDividerHit? { + var current: NSView? = inspectorLeaf + var bestHit: HostedInspectorDividerHit? + + while let inspectorView = current, inspectorView !== root { + guard let containerView = inspectorView.superview else { break } + + let pageCandidates = containerView.subviews.compactMap { candidate -> (view: NSView, dockSide: HostedInspectorDockSide)? in + guard InspectorDock.isVisibleSiblingCandidate(candidate, requireMinWidth: false) else { return nil } + guard candidate !== inspectorView else { return nil } + guard InspectorDock.verticalOverlap(between: candidate.frame, and: inspectorView.frame) > 8 else { + return nil + } + guard let dockSide = HostedInspectorDockSide.resolve( + pageFrame: candidate.frame, + inspectorFrame: inspectorView.frame + ) else { + return nil + } + return (view: candidate, dockSide: dockSide) + } + + if let pageCandidate = pageCandidates.max(by: { + hostedInspectorPageCandidateScore($0.view, inspectorView: inspectorView) + < hostedInspectorPageCandidateScore($1.view, inspectorView: inspectorView) + }) { + bestHit = HostedInspectorDividerHit( + containerView: containerView, + pageView: pageCandidate.view, + inspectorView: inspectorView, + dockSide: pageCandidate.dockSide + ) + } + + current = containerView + } + + return bestHit + } + + private func hostedInspectorDividerCandidateScore(_ hit: HostedInspectorDividerHit) -> CGFloat { + let pageFrame = convert(hit.pageView.bounds, from: hit.pageView) + let inspectorFrame = convert(hit.inspectorView.bounds, from: hit.inspectorView) + let overlap = InspectorDock.verticalOverlap(between: pageFrame, and: inspectorFrame) + let coverageWidth = max(pageFrame.maxX, inspectorFrame.maxX) - min(pageFrame.minX, inspectorFrame.minX) + return (overlap * 1_000) + coverageWidth + pageFrame.width + } + + private func hostedInspectorPageCandidateScore(_ pageView: NSView, inspectorView: NSView) -> CGFloat { + let overlap = InspectorDock.verticalOverlap(between: pageView.frame, and: inspectorView.frame) + let coverageWidth = max(pageView.frame.maxX, inspectorView.frame.maxX) - min(pageView.frame.minX, inspectorView.frame.minX) + return (overlap * 1_000) + coverageWidth + pageView.frame.width + } + + fileprivate func scheduleHostedInspectorDividerReapply(reason: String) { + hostedInspectorReapplyWorkItem?.cancel() + let workItem = DispatchWorkItem { [weak self] in + guard let self else { return } + self.hostedInspectorReapplyWorkItem = nil + _ = self.promoteHostedInspectorSideDockFromCurrentLayoutIfNeeded() + if self.hasStoredHostedInspectorWidthPreference { + self.reapplyHostedInspectorDividerToStoredWidthIfNeeded(reason: reason) + } else { + self.captureHostedInspectorPreferredWidthFromCurrentLayout(reason: reason) + } + } + hostedInspectorReapplyWorkItem = workItem + DispatchQueue.main.async(execute: workItem) + } + + private func captureHostedInspectorPreferredWidthFromCurrentLayout(reason: String) { + guard !isApplyingHostedInspectorLayout else { return } + guard !isHostedInspectorDividerDragActive else { return } + guard let hit = hostedInspectorDividerCandidate() else { +#if DEBUG + if !hasLoggedMissingHostedInspectorCandidate { + hasLoggedMissingHostedInspectorCandidate = true + let preferredWidthDesc = preferredHostedInspectorWidth.map { + String(format: "%.1f", $0) + } ?? "nil" + dlog( + "browser.panel.hostedInspector stage=\(reason).captureMissingCandidate " + + "host=\(Self.debugObjectID(self)) preferredWidth=\(preferredWidthDesc)" + ) + } +#endif + return + } + + let inspectorWidth = max(0, hit.inspectorView.frame.width) + guard inspectorWidth > 1 else { return } + recordHostedInspectorSideDockWidth(inspectorWidth) + let currentFraction: CGFloat? = { + guard hit.containerView.bounds.width > 0 else { return nil } + return inspectorWidth / hit.containerView.bounds.width + }() + let widthMatches = preferredHostedInspectorWidth.map { + abs($0 - inspectorWidth) <= 0.5 + } ?? false + let fractionMatches: Bool = { + switch (preferredHostedInspectorWidthFraction, currentFraction) { + case (nil, nil): + return true + case let (lhs?, rhs?): + return abs(lhs - rhs) <= 0.001 + default: + return false + } + }() + guard !(widthMatches && fractionMatches) else { return } + +#if DEBUG + hasLoggedMissingHostedInspectorCandidate = false +#endif + recordPreferredHostedInspectorWidth( + inspectorWidth, + containerBounds: hit.containerView.bounds + ) + } + + private func reapplyHostedInspectorDividerToStoredWidthIfNeeded(reason: String) { + guard !isApplyingHostedInspectorLayout else { return } + guard let hit = hostedInspectorDividerCandidate() else { return } + guard let preferredWidth = resolvedPreferredHostedInspectorWidth(in: hit.containerView.bounds) else { + return + } + let currentInspectorWidth = max(0, hit.inspectorView.frame.width) + guard abs(currentInspectorWidth - preferredWidth) > 0.5 else { return } + _ = applyHostedInspectorDividerWidth( + preferredWidth, + to: hit, + minimumInspectorWidth: Self.minimumHostedInspectorWidth, + reason: reason + ) + } + + @discardableResult + private func applyHostedInspectorDividerWidth( + _ preferredWidth: CGFloat, + to hit: HostedInspectorDividerHit, + minimumInspectorWidth: CGFloat, + reason: String + ) -> (pageFrame: NSRect, inspectorFrame: NSRect) { + let containerBounds = hit.containerView.bounds + let nextFrames = hit.dockSide.resizedFrames( + preferredWidth: preferredWidth, + in: containerBounds, + pageFrame: hit.pageView.frame, + inspectorFrame: hit.inspectorView.frame, + minimumInspectorWidth: minimumInspectorWidth + ) + let pageFrame = nextFrames.pageFrame + let inspectorFrame = nextFrames.inspectorFrame + + let oldPageFrame = hit.pageView.frame + let oldInspectorFrame = hit.inspectorView.frame + let pageChanged = !InspectorDock.rectApproximatelyEqual(pageFrame, oldPageFrame, epsilon: 0.5) + let inspectorChanged = !InspectorDock.rectApproximatelyEqual(inspectorFrame, oldInspectorFrame, epsilon: 0.5) + guard pageChanged || inspectorChanged else { + return (pageFrame, inspectorFrame) + } + recordHostedInspectorSideDockWidth(inspectorFrame.width) + + isApplyingHostedInspectorLayout = true + CATransaction.begin() + CATransaction.setDisableActions(true) + hit.pageView.frame = pageFrame + hit.inspectorView.frame = inspectorFrame + CATransaction.commit() + isApplyingHostedInspectorLayout = false + + hit.pageView.needsDisplay = true + hit.pageView.setNeedsDisplay(hit.pageView.bounds) + hit.inspectorView.needsDisplay = true + hit.inspectorView.setNeedsDisplay(hit.inspectorView.bounds) + hit.containerView.needsDisplay = true + hit.containerView.setNeedsDisplay(hit.containerView.bounds) + if let localInlineSlotView { + localInlineSlotView.needsDisplay = true + localInlineSlotView.setNeedsDisplay(localInlineSlotView.bounds) + } + needsDisplay = true + setNeedsDisplay(bounds) + + let isLiveDrag = reason == "drag" +#if DEBUG + dlog( + "browser.panel.hostedInspector stage=\(reason).reapply " + + "host=\(Self.debugObjectID(self)) preferredWidth=\(String(format: "%.1f", preferredWidth)) " + + "liveDrag=\(isLiveDrag ? 1 : 0) " + + "pageChanged=\(pageChanged ? 1 : 0) inspectorChanged=\(inspectorChanged ? 1 : 0) " + + "oldPage=\(Self.debugRect(oldPageFrame)) oldInspector=\(Self.debugRect(oldInspectorFrame)) " + + "container=\(Self.debugObjectID(hit.containerView)) " + + "pageFrame=\(Self.debugRect(pageFrame)) inspectorFrame=\(Self.debugRect(inspectorFrame))" + ) +#endif + return (pageFrame, inspectorFrame) + } + + private static func directChild(of container: NSView, containing descendant: NSView) -> NSView? { + var current: NSView? = descendant + var directChild: NSView? + while let view = current, view !== container { + directChild = view + current = view.superview + } + guard current === container else { return nil } + return directChild + } + } + + #if DEBUG + private static func logDevToolsState( + _ panel: BrowserPanel, + event: String, + generation: Int, + retryCount: Int, + details: String? = nil + ) { + var line = "browser.devtools event=\(event) panel=\(panel.id.uuidString.prefix(5)) generation=\(generation) retry=\(retryCount) \(panel.debugDeveloperToolsStateSummary())" + if let details, !details.isEmpty { + line += " \(details)" + } + dlog(line) + } + + private static func objectID(_ object: AnyObject?) -> String { + guard let object else { return "nil" } + return String(describing: Unmanaged.passUnretained(object).toOpaque()) + } + + private static func responderDescription(_ responder: NSResponder?) -> String { + guard let responder else { return "nil" } + return "\(type(of: responder))@\(objectID(responder))" + } + + private static func rectDescription(_ rect: NSRect) -> String { + String(format: "%.1f,%.1f %.1fx%.1f", rect.origin.x, rect.origin.y, rect.size.width, rect.size.height) + } + + private static func attachContext(webView: WKWebView, host: NSView) -> String { + let hostWindow = host.window?.windowNumber ?? -1 + let webWindow = webView.window?.windowNumber ?? -1 + let firstResponder = (webView.window ?? host.window)?.firstResponder + return "host=\(objectID(host)) hostWin=\(hostWindow) hostInWin=\(host.window == nil ? 0 : 1) hostFrame=\(rectDescription(host.frame)) hostBounds=\(rectDescription(host.bounds)) oldSuper=\(objectID(webView.superview)) webWin=\(webWindow) webInWin=\(webView.window == nil ? 0 : 1) webFrame=\(rectDescription(webView.frame)) webHidden=\(webView.isHidden ? 1 : 0) fr=\(responderDescription(firstResponder))" + } + #endif + + private static func firstResponderResignState( + _ responder: NSResponder?, + webView: WKWebView + ) -> (needsResign: Bool, flags: String) { + let inWebViewChain = InspectorDock.responderChainContains(responder, target: webView) + let inspectorResponder = InspectorDock.isLikelyInspectorResponder(responder) + let needsResign = inWebViewChain || inspectorResponder + return ( + needsResign: needsResign, + flags: "frInWebChain=\(inWebViewChain ? 1 : 0) frIsInspector=\(inspectorResponder ? 1 : 0)" + ) + } + + func makeCoordinator() -> Coordinator { + let coordinator = Coordinator() + coordinator.panel = panel + return coordinator + } + + func makeNSView(context: Context) -> NSView { + let container = HostContainerView() + container.wantsLayer = true + return container + } + + private static func clearPortalCallbacks(for host: NSView) { + guard let host = host as? HostContainerView else { return } + host.onDidMoveToWindow = nil + host.onGeometryChanged = nil + host.clearLocalInlineCallbacks() + } + + private static func shouldPreserveExternalFullscreenHost( + for webView: WKWebView, + relativeTo expectedWindow: NSWindow? + ) -> Bool { + webView.programaIsManagedByExternalFullscreenWindow(relativeTo: expectedWindow) + } + + private static func localInlineTransferRoot(for webView: WKWebView) -> NSView? { + var current = webView.superview + var last: NSView? + while let view = current { + if view is WindowBrowserSlotView { + return view + } + if view is HostContainerView { + break + } + last = view + current = view.superview + } + return last ?? webView.superview + } + + private static func directTransferChild(of container: NSView, containing descendant: NSView) -> NSView? { + var current: NSView? = descendant + var directChild: NSView? + while let view = current, view !== container { + directChild = view + current = view.superview + } + guard current === container else { return nil } + return directChild + } + + private static func relatedWebKitTransferSubviews( + from sourceSuperview: NSView, + primaryWebView: WKWebView + ) -> [NSView] { + var relatedSubviews: [NSView] = [] + var seen = Set() + + func append(_ candidate: NSView?) { + guard let candidate, candidate !== sourceSuperview else { return } + let id = ObjectIdentifier(candidate) + guard seen.insert(id).inserted else { return } + relatedSubviews.append(candidate) + } + + append(directTransferChild(of: sourceSuperview, containing: primaryWebView) ?? primaryWebView) + + if let inspectorFrontend = primaryWebView.programaInspectorFrontendWebView() { + append(directTransferChild(of: sourceSuperview, containing: inspectorFrontend) ?? inspectorFrontend) + } + + for view in sourceSuperview.subviews { + if view === primaryWebView { continue } + let className = String(describing: type(of: view)) + guard className.contains("WK") else { continue } + if InspectorDock.isInspectorView(view) && !InspectorDock.isVisibleCandidate(view) { + continue + } + append(view) + } + + return relatedSubviews + } + + private static func moveWebKitRelatedSubviewsIntoHostIfNeeded( + from sourceSuperview: NSView, + to container: WindowBrowserSlotView, + primaryWebView: WKWebView, + reason: String + ) { + let relatedSubviews = relatedWebKitTransferSubviews( + from: sourceSuperview, + primaryWebView: primaryWebView + ) + guard !relatedSubviews.isEmpty else { return } + let preserveSlotLocalFrames = sourceSuperview is WindowBrowserSlotView + let sourceSlotBoundsSize = sourceSuperview.bounds.size + var movedSubviewCount = 0 + var reusedSourceLocalFrames = false +#if DEBUG + dlog( + "browser.localHost.reparent.batch reason=\(reason) source=\(Self.objectID(sourceSuperview)) " + + "container=\(Self.objectID(container)) count=\(relatedSubviews.count) " + + "sourceType=\(String(describing: type(of: sourceSuperview))) targetType=\(String(describing: type(of: container)))" + ) +#endif + for view in relatedSubviews { + if view === container || view.isDescendant(of: container) { + continue + } + let className = String(describing: type(of: view)) + let targetFrame: NSRect + let currentSuperview = view.superview + if preserveSlotLocalFrames && currentSuperview === sourceSuperview { + targetFrame = view.frame + reusedSourceLocalFrames = true + } else { + let frameInWindow = currentSuperview?.convert(view.frame, to: nil) + ?? sourceSuperview.convert(view.frame, to: nil) + targetFrame = container.convert(frameInWindow, from: nil) + } + view.removeFromSuperview() + container.addSubview(view, positioned: .above, relativeTo: nil) + view.frame = targetFrame + movedSubviewCount += 1 +#if DEBUG + dlog( + "browser.localHost.reparent.batch.item reason=\(reason) class=\(className) " + + "view=\(Self.objectID(view))" + ) +#endif + } + guard movedSubviewCount > 0 else { return } + if reusedSourceLocalFrames, sourceSlotBoundsSize != container.bounds.size { + container.resizeSubviews(withOldSize: sourceSlotBoundsSize) + container.needsLayout = true + container.layoutSubtreeIfNeeded() + } + } + + private static func installPortalAnchorView(_ anchorView: NSView, in host: NSView) { + // SwiftUI can keep transient replacement hosts alive off-window during split + // reparenting. Never let those hosts steal the shared portal anchor, or the + // portal will bind against an anchor with no real window and WKWebView will + // fall into a hidden/unrendered state. + guard host.window != nil else { return } + if anchorView.superview !== host { + anchorView.removeFromSuperview() + anchorView.translatesAutoresizingMaskIntoConstraints = false + host.addSubview(anchorView) + NSLayoutConstraint.activate([ + anchorView.topAnchor.constraint(equalTo: host.topAnchor), + anchorView.bottomAnchor.constraint(equalTo: host.bottomAnchor), + anchorView.leadingAnchor.constraint(equalTo: host.leadingAnchor), + anchorView.trailingAnchor.constraint(equalTo: host.trailingAnchor), + ]) + } else if anchorView.translatesAutoresizingMaskIntoConstraints { + anchorView.translatesAutoresizingMaskIntoConstraints = false + NSLayoutConstraint.activate([ + anchorView.topAnchor.constraint(equalTo: host.topAnchor), + anchorView.bottomAnchor.constraint(equalTo: host.bottomAnchor), + anchorView.leadingAnchor.constraint(equalTo: host.leadingAnchor), + anchorView.trailingAnchor.constraint(equalTo: host.trailingAnchor), + ]) + } + host.layoutSubtreeIfNeeded() + } + + private func updateUsingLocalInlineHosting(_ nsView: NSView, context: Context, webView: WKWebView) -> Bool { + guard let host = nsView as? HostContainerView else { return false } + let slotView = host.ensureLocalInlineSlotView() + let isAlreadyInLocalHost = host.containsManagedLocalInlineContent(webView) + let shouldPreserveExternalFullscreenHost = Self.shouldPreserveExternalFullscreenHost( + for: webView, + relativeTo: host.window + ) + let didAttachWebViewToLocalHost = + !isAlreadyInLocalHost && !shouldPreserveExternalFullscreenHost + + let coordinator = context.coordinator + coordinator.desiredPortalVisibleInUI = false + coordinator.desiredPortalZPriority = 0 + coordinator.attachGeneration += 1 + + if panel.releasePortalHostIfOwned( + hostId: ObjectIdentifier(host), + reason: "localInlineHosting" + ) { + BrowserWindowPortalRegistry.discard( + webView: webView, + source: "viewStateChanged.localInlineHosting", + preserveCurrentSuperview: true + ) + } + + let shouldPreserveExistingExternalLocalHost = + host.window == nil && + webView.superview != nil && + !host.containsManagedLocalInlineContent(webView) + if shouldPreserveExistingExternalLocalHost { + // Split zoom can instantiate a replacement local host before it joins a window. + // Never let that off-window host steal the live page + inspector hierarchy away + // from the currently visible local host. + host.setLocalInlineSlotHidden(true) + coordinator.lastPortalHostId = nil + coordinator.lastSynchronizedHostGeometryRevision = 0 +#if DEBUG + dlog( + "browser.localHost.reparent.skip web=\(Self.objectID(webView)) " + + "reason=offWindowReplacementHost super=\(Self.objectID(webView.superview)) " + + "host=\(Self.objectID(host)) slot=\(Self.objectID(slotView))" + ) + Self.logDevToolsState( + panel, + event: "localHost.skip", + generation: coordinator.attachGeneration, + retryCount: 0, + details: Self.attachContext(webView: webView, host: host) + ) +#endif + return false + } + +#if DEBUG + if shouldPreserveExternalFullscreenHost { + dlog( + "browser.localHost.reparent.skip web=\(Self.objectID(webView)) " + + "reason=fullscreenExternalHost host=\(Self.objectID(host)) " + + "slot=\(Self.objectID(slotView)) state=\(String(describing: webView.fullscreenState))" + ) + } +#endif + + let preferredAttachedWidthState = panel.preferredAttachedDeveloperToolsWidthState() + host.setPreferredHostedInspectorWidth( + width: preferredAttachedWidthState.width, + widthFraction: preferredAttachedWidthState.widthFraction + ) + host.setHostedInspectorFrontendWebView(webView.programaInspectorFrontendWebView()) + host.onPreferredHostedInspectorWidthChanged = { [weak browserPanel = panel] width, _ in + guard let browserPanel else { return } + browserPanel.recordPreferredAttachedDeveloperToolsWidth( + width, + containerBounds: slotView.bounds + ) + } + slotView.onHostedInspectorLayout = { [weak host] _ in + host?.scheduleHostedInspectorDividerReapply(reason: "slot.layout") + host?.scheduleHostedInspectorDockConfigurationSync(reason: "slot.layout") + } + + if didAttachWebViewToLocalHost { + if let sourceSuperview = Self.localInlineTransferRoot(for: webView) { + Self.moveWebKitRelatedSubviewsIntoHostIfNeeded( + from: sourceSuperview, + to: slotView, + primaryWebView: webView, + reason: "attachLocalHost" + ) + } else { + slotView.addSubview(webView, positioned: .above, relativeTo: nil) + } + } + + slotView.isHidden = false + host.pinHostedWebView( + webView, + in: host.currentHostedWebViewContainer(preferredSlotView: slotView) + ) + // Local-inline hosting takes ownership of the live WKWebView hierarchy. + // Drop any stale portal entry once local-inline hosting owns the live + // WKWebView hierarchy so deferred portal recovery cannot mutate the + // browser after workspace switches. + BrowserWindowPortalRegistry.discard( + webView: webView, + source: "viewStateChanged.localInlineHosting", + preserveCurrentSuperview: true + ) + coordinator.lastPortalHostId = nil + coordinator.lastSynchronizedHostGeometryRevision = 0 + if host.window != nil && !shouldPreserveExternalFullscreenHost { + let wasDeveloperToolsVisible = panel.isDeveloperToolsVisible() + panel.noteDeveloperToolsHostAttached() + panel.restoreDeveloperToolsAfterAttachIfNeeded() + if let sourceSuperview = Self.localInlineTransferRoot(for: webView), + didAttachWebViewToLocalHost || sourceSuperview === slotView { + Self.moveWebKitRelatedSubviewsIntoHostIfNeeded( + from: sourceSuperview, + to: slotView, + primaryWebView: webView, + reason: didAttachWebViewToLocalHost + ? "localInline.reconcile.immediate" + : "localInline.reconcile.existingHost" + ) + } + host.setHostedInspectorFrontendWebView(webView.programaInspectorFrontendWebView()) + let didRevealDeveloperToolsAfterAttach = + !wasDeveloperToolsVisible && panel.isDeveloperToolsVisible() + webView.needsLayout = true + webView.layoutSubtreeIfNeeded() + slotView.layoutSubtreeIfNeeded() + host.layoutSubtreeIfNeeded() + host.refreshHostedWebKitPresentation( + reason: didAttachWebViewToLocalHost + ? "localInline.update.immediate" + : "localInline.update.existingHost", + forceLifecycleRefresh: didRevealDeveloperToolsAfterAttach + ) + host.normalizeHostedInspectorLayoutIfNeeded( + reason: didAttachWebViewToLocalHost + ? "localInline.update.immediate" + : "localInline.update.existingHost" + ) + host.scheduleHostedInspectorDividerReapply( + reason: didAttachWebViewToLocalHost + ? "localInline.update.sync" + : "localInline.update.existingHost" + ) + DispatchQueue.main.async { [weak host, weak webView] in + guard let host, let webView else { return } + if let sourceSuperview = Self.localInlineTransferRoot(for: webView), + sourceSuperview === slotView { + Self.moveWebKitRelatedSubviewsIntoHostIfNeeded( + from: sourceSuperview, + to: slotView, + primaryWebView: webView, + reason: "localInline.reconcile.async" + ) + } + host.setHostedInspectorFrontendWebView(webView.programaInspectorFrontendWebView()) + host.refreshHostedWebKitPresentation( + reason: didAttachWebViewToLocalHost + ? "localInline.update.async" + : "localInline.update.existingHost.async", + forceLifecycleRefresh: didRevealDeveloperToolsAfterAttach + ) + host.scheduleHostedInspectorDockConfigurationSync( + reason: didAttachWebViewToLocalHost + ? "localInline.update.async" + : "localInline.update.existingHost.async" + ) + } + } else if !shouldPreserveExternalFullscreenHost { + panel.consumeAttachedDeveloperToolsManualCloseIfNeeded() + host.scheduleHostedInspectorDockConfigurationSync(reason: "localInline.update") + } + +#if DEBUG + Self.logDevToolsState( + panel, + event: "localHost.update", + generation: coordinator.attachGeneration, + retryCount: 0, + details: Self.attachContext(webView: webView, host: host) + ) +#endif + return !shouldPreserveExternalFullscreenHost + } + + private func updateUsingWindowPortal(_ nsView: NSView, context: Context, webView: WKWebView) -> Bool { + guard let host = nsView as? HostContainerView else { return false } + host.prepareForWindowPortalHosting() + host.setLocalInlineSlotHidden(true) + host.releaseHostedWebViewConstraints() + if panel.shouldUseLocalInlineDeveloperToolsHosting() { + let hostId = ObjectIdentifier(host) + if panel.releasePortalHostIfOwned( + hostId: hostId, + reason: "windowPortalSuppressedForLocalInlineHosting" + ) { + BrowserWindowPortalRegistry.discard( + webView: webView, + source: "viewStateChanged.windowPortalSuppressedForLocalInlineHosting", + preserveCurrentSuperview: true + ) + } + return false + } + let shouldPreserveExternalFullscreenHost = Self.shouldPreserveExternalFullscreenHost( + for: webView, + relativeTo: host.window + ) + + let coordinator = context.coordinator + let paneDropContext = currentPaneDropContext() + let isCurrentPaneOwner = paneDropContext?.paneId.id == paneId.id + let hostId = ObjectIdentifier(host) + let previousVisible = coordinator.desiredPortalVisibleInUI + let previousZPriority = coordinator.desiredPortalZPriority + coordinator.desiredPortalVisibleInUI = shouldAttachWebView && isCurrentPaneOwner + coordinator.desiredPortalZPriority = portalZPriority + coordinator.attachGeneration += 1 + let generation = coordinator.attachGeneration + let activePaneDropContext = coordinator.desiredPortalVisibleInUI ? paneDropContext : nil + let activeSearchOverlay = coordinator.desiredPortalVisibleInUI ? searchOverlay : nil + let portalAnchorView = panel.portalAnchorView + let portalHideReason = !isCurrentPaneOwner ? "lostPaneOwnership" : "hidden" + let didReleasePortalHost: Bool + if !shouldAttachWebView || !isCurrentPaneOwner { + didReleasePortalHost = panel.releasePortalHostIfOwned( + hostId: hostId, + reason: portalHideReason + ) + // Only the host that currently owns the portal is allowed to hide it. + // Older keep-alive hosts can still receive updates after a new owner binds. + if didReleasePortalHost { + BrowserWindowPortalRegistry.hide( + webView: webView, + source: "viewStateChanged.\(portalHideReason)" + ) + } + } else { + didReleasePortalHost = false + } + let portalHostAccepted = + shouldAttachWebView && + isCurrentPaneOwner && + panel.claimPortalHost( + hostId: hostId, + paneId: paneId, + inWindow: host.window != nil, + bounds: host.bounds, + reason: "update" + ) +#if DEBUG + if !isCurrentPaneOwner && (shouldAttachWebView || host.window != nil) { + dlog( + "browser.portal.owner.skip panel=\(panel.id.uuidString.prefix(5)) " + + "viewPane=\(paneId.id.uuidString.prefix(5)) " + + "currentPane=\(paneDropContext?.paneId.id.uuidString.prefix(5) ?? "nil") " + + "host=\(Self.objectID(host)) hostInWin=\(host.window != nil ? 1 : 0) " + + "released=\(didReleasePortalHost ? 1 : 0)" + ) + } +#endif + if host.window != nil, portalHostAccepted { + Self.installPortalAnchorView(portalAnchorView, in: host) + } + + host.onDidMoveToWindow = { [weak host, weak webView, weak coordinator, weak portalAnchorView, weak browserPanel = panel] in + guard let host, let webView, let coordinator, let portalAnchorView, let browserPanel else { return } + guard coordinator.attachGeneration == generation else { return } + guard currentPaneDropContext()?.paneId.id == paneId.id else { return } + guard browserPanel.claimPortalHost( + hostId: ObjectIdentifier(host), + paneId: paneId, + inWindow: host.window != nil, + bounds: host.bounds, + reason: "didMoveToWindow" + ) else { return } + guard host.window != nil else { return } + Self.installPortalAnchorView(portalAnchorView, in: host) + BrowserWindowPortalRegistry.bind( + webView: webView, + to: portalAnchorView, + visibleInUI: coordinator.desiredPortalVisibleInUI, + zPriority: coordinator.desiredPortalZPriority + ) + BrowserWindowPortalRegistry.refresh( + webView: webView, + reason: "portalHostBind.didMoveToWindow" + ) + BrowserWindowPortalRegistry.updatePaneTopChromeHeight( + for: webView, + height: coordinator.desiredPortalVisibleInUI ? paneTopChromeHeight : 0 + ) + BrowserWindowPortalRegistry.updatePaneDropContext(for: webView, context: activePaneDropContext) + BrowserWindowPortalRegistry.updateSearchOverlay(for: webView, configuration: activeSearchOverlay) + coordinator.lastPortalHostId = ObjectIdentifier(host) + coordinator.lastSynchronizedHostGeometryRevision = host.geometryRevision + } + host.onGeometryChanged = { [weak host, weak webView, weak coordinator, weak portalAnchorView, weak browserPanel = panel] in + guard let host, let webView, let coordinator, let portalAnchorView, let browserPanel else { return } + guard coordinator.attachGeneration == generation else { return } + guard currentPaneDropContext()?.paneId.id == paneId.id else { return } + guard browserPanel.claimPortalHost( + hostId: ObjectIdentifier(host), + paneId: paneId, + inWindow: host.window != nil, + bounds: host.bounds, + reason: "geometryChanged" + ) else { return } + guard host.window != nil else { return } + let hostId = ObjectIdentifier(host) + Self.installPortalAnchorView(portalAnchorView, in: host) + if coordinator.lastPortalHostId != hostId || + !BrowserWindowPortalRegistry.isWebView(webView, boundTo: portalAnchorView) { + BrowserWindowPortalRegistry.bind( + webView: webView, + to: portalAnchorView, + visibleInUI: coordinator.desiredPortalVisibleInUI, + zPriority: coordinator.desiredPortalZPriority + ) + BrowserWindowPortalRegistry.refresh( + webView: webView, + reason: "portalHostBind.geometryChanged" + ) + BrowserWindowPortalRegistry.updatePaneTopChromeHeight( + for: webView, + height: coordinator.desiredPortalVisibleInUI ? paneTopChromeHeight : 0 + ) + BrowserWindowPortalRegistry.updatePaneDropContext(for: webView, context: activePaneDropContext) + BrowserWindowPortalRegistry.updateSearchOverlay(for: webView, configuration: activeSearchOverlay) + coordinator.lastPortalHostId = hostId + } + BrowserWindowPortalRegistry.synchronizeForAnchor(portalAnchorView) + coordinator.lastSynchronizedHostGeometryRevision = host.geometryRevision + } + + if !shouldAttachWebView { + // In portal mode we no longer detach/re-attach to preserve DevTools state. + // Sync the inspector preference directly so manual closes are respected. + panel.syncDeveloperToolsPreferenceFromInspector( + preserveVisibleIntent: panel.shouldPreserveDeveloperToolsIntentWhileDetached() + ) + } + + if host.window != nil, portalHostAccepted { + let geometryRevision = host.geometryRevision + let portalEntryMissing = !BrowserWindowPortalRegistry.isWebView(webView, boundTo: portalAnchorView) + let shouldBindNow = + coordinator.lastPortalHostId != hostId || + webView.superview == nil || + portalEntryMissing || + previousVisible != shouldAttachWebView || + previousZPriority != portalZPriority + if shouldBindNow { + Self.installPortalAnchorView(portalAnchorView, in: host) + BrowserWindowPortalRegistry.bind( + webView: webView, + to: portalAnchorView, + visibleInUI: coordinator.desiredPortalVisibleInUI, + zPriority: coordinator.desiredPortalZPriority + ) + // Force a rendering-state reattach after portal host replacement + // (e.g. after a pane split). Without this, WKWebView can freeze + // because _exitInWindow/_enterInWindow are never cycled when the + // web view is reparented to a new container during bind. + BrowserWindowPortalRegistry.refresh( + webView: webView, + reason: "portalHostBind" + ) + coordinator.lastPortalHostId = hostId + coordinator.lastSynchronizedHostGeometryRevision = geometryRevision + } + BrowserWindowPortalRegistry.updatePaneTopChromeHeight( + for: webView, + height: coordinator.desiredPortalVisibleInUI ? paneTopChromeHeight : 0 + ) + BrowserWindowPortalRegistry.updateSearchOverlay(for: webView, configuration: activeSearchOverlay) + if !shouldBindNow, + coordinator.lastSynchronizedHostGeometryRevision != geometryRevision { + BrowserWindowPortalRegistry.synchronizeForAnchor(portalAnchorView) + coordinator.lastSynchronizedHostGeometryRevision = geometryRevision + } + } else if portalHostAccepted { + // Bind is deferred until host moves into a window. Keep the current + // portal entry's desired state in sync so stale callbacks cannot keep + // the previous anchor visible while this host is temporarily off-window. + BrowserWindowPortalRegistry.updateEntryVisibility( + for: webView, + visibleInUI: coordinator.desiredPortalVisibleInUI, + zPriority: coordinator.desiredPortalZPriority + ) + } + + if portalHostAccepted { + BrowserWindowPortalRegistry.updateDropZoneOverlay( + for: webView, + zone: coordinator.desiredPortalVisibleInUI ? paneDropZone : nil + ) + BrowserWindowPortalRegistry.updatePaneTopChromeHeight( + for: webView, + height: coordinator.desiredPortalVisibleInUI ? paneTopChromeHeight : 0 + ) + BrowserWindowPortalRegistry.updatePaneDropContext( + for: webView, + context: activePaneDropContext + ) + BrowserWindowPortalRegistry.updateSearchOverlay(for: webView, configuration: activeSearchOverlay) + } + + panel.restoreDeveloperToolsAfterAttachIfNeeded() + + #if DEBUG + Self.logDevToolsState( + panel, + event: "portal.update", + generation: coordinator.attachGeneration, + retryCount: 0, + details: Self.attachContext(webView: webView, host: host) + ) + #endif + return portalHostAccepted && !shouldPreserveExternalFullscreenHost + } + + func updateNSView(_ nsView: NSView, context: Context) { + let webView = panel.webView + let coordinator = context.coordinator + let isCurrentPaneOwner = currentPaneDropContext()?.paneId.id == paneId.id + if let previousWebView = coordinator.webView, previousWebView !== webView { + BrowserWindowPortalRegistry.detach(webView: previousWebView) + coordinator.lastPortalHostId = nil + coordinator.lastSynchronizedHostGeometryRevision = 0 + } + coordinator.panel = panel + coordinator.webView = webView + + Self.clearPortalCallbacks(for: nsView) + let hostOwnsPortal = useLocalInlineHosting + ? updateUsingLocalInlineHosting(nsView, context: context, webView: webView) + : updateUsingWindowPortal(nsView, context: context, webView: webView) + Self.applyWebViewFirstResponderPolicy( + panel: panel, + webView: webView, + isPanelFocused: isPanelFocused && isCurrentPaneOwner && hostOwnsPortal + ) + + Self.applyFocus( + panel: panel, + webView: webView, + nsView: nsView, + shouldFocusWebView: shouldFocusWebView && isCurrentPaneOwner && hostOwnsPortal, + isPanelFocused: isPanelFocused && isCurrentPaneOwner && hostOwnsPortal + ) + } + + private static func applyFocus( + panel: BrowserPanel, + webView: WKWebView, + nsView: NSView, + shouldFocusWebView: Bool, + isPanelFocused: Bool + ) { + // Focus handling. Avoid fighting the address bar when it is focused. + guard let window = nsView.window else { +#if DEBUG + dlog( + "browser.focus.content.apply panel=\(panel.id.uuidString.prefix(5)) " + + "action=skip reason=no_window shouldFocus=\(shouldFocusWebView ? 1 : 0) " + + "panelFocused=\(isPanelFocused ? 1 : 0)" + ) +#endif + return + } + if isPanelFocused && InspectorDock.responderChainContains(window.firstResponder, target: webView) { + panel.noteWebViewFocused() + } + if shouldFocusWebView { + if panel.shouldSuppressWebViewFocus() { +#if DEBUG + dlog( + "browser.focus.content.apply panel=\(panel.id.uuidString.prefix(5)) " + + "action=skip reason=suppressed panelFocused=\(isPanelFocused ? 1 : 0)" + ) +#endif + return + } + if InspectorDock.responderChainContains(window.firstResponder, target: webView) { +#if DEBUG + dlog( + "browser.focus.content.apply panel=\(panel.id.uuidString.prefix(5)) " + + "action=skip reason=already_first_responder_chain" + ) +#endif + return + } + let result = window.makeFirstResponder(webView) + if result { + panel.noteWebViewFocused() + } +#if DEBUG + dlog( + "browser.focus.content.apply panel=\(panel.id.uuidString.prefix(5)) " + + "action=focus result=\(result ? 1 : 0) fr=\(responderDescription(window.firstResponder))" + ) +#endif + } else if !isPanelFocused && InspectorDock.responderChainContains(window.firstResponder, target: webView) { + // Only force-resign WebView focus when this panel itself is not focused. + // If the panel is focused but the omnibar-focus state is briefly stale, aggressively + // clearing first responder here can undo programmatic webview focus (socket tests). + let result = window.makeFirstResponder(nil) +#if DEBUG + dlog( + "browser.focus.content.apply panel=\(panel.id.uuidString.prefix(5)) " + + "action=resign result=\(result ? 1 : 0) fr=\(responderDescription(window.firstResponder))" + ) +#endif + } + } + + private static func applyWebViewFirstResponderPolicy( + panel: BrowserPanel, + webView: WKWebView, + isPanelFocused: Bool + ) { + guard let programaWebView = webView as? ProgramaWebView else { return } + let next = isPanelFocused && !panel.shouldSuppressWebViewFocus() + if programaWebView.allowsFirstResponderAcquisition != next { +#if DEBUG + dlog( + "browser.focus.policy panel=\(panel.id.uuidString.prefix(5)) " + + "web=\(ObjectIdentifier(programaWebView)) old=\(programaWebView.allowsFirstResponderAcquisition ? 1 : 0) " + + "new=\(next ? 1 : 0) isPanelFocused=\(isPanelFocused ? 1 : 0) " + + "suppress=\(panel.shouldSuppressWebViewFocus() ? 1 : 0)" + ) +#endif + } + programaWebView.allowsFirstResponderAcquisition = next + } + + static func dismantleNSView(_ nsView: NSView, coordinator: Coordinator) { + coordinator.attachGeneration += 1 + clearPortalCallbacks(for: nsView) + if let panel = coordinator.panel, let host = nsView as? HostContainerView { + panel.releasePortalHostIfOwned( + hostId: ObjectIdentifier(host), + reason: "dismantle" + ) + } + + guard let webView = coordinator.webView else { return } + let panel = coordinator.panel + + // If we're being torn down while the WKWebView (or one of its subviews) is first responder, + // resign it before detaching. + let window = webView.window ?? nsView.window + if let window { + let state = firstResponderResignState(window.firstResponder, webView: webView) + if state.needsResign { + #if DEBUG + if let panel { + logDevToolsState( + panel, + event: "dismantle.resignFirstResponder", + generation: coordinator.attachGeneration, + retryCount: 0, + details: attachContext(webView: webView, host: nsView) + " " + state.flags + ) + } + #endif + window.makeFirstResponder(nil) + } + } + + // SwiftUI can transiently dismantle/rebuild the browser host view during split + // rearrangement. Do not detach the portal-hosted WKWebView or clear its pane-drop + // context here; explicit teardown still happens on real web view replacement and + // panel teardown, and preserving this state lets internal tab drags re-enter the + // browser pane while SwiftUI churns underneath. + BrowserWindowPortalRegistry.updateDropZoneOverlay(for: webView, zone: nil) + coordinator.lastPortalHostId = nil + coordinator.lastSynchronizedHostGeometryRevision = 0 + } + + private func currentPaneDropContext() -> BrowserPaneDropContext? { + guard let app = AppDelegate.shared, + let manager = app.tabManagerFor(tabId: panel.workspaceId), + let workspace = manager.tabs.first(where: { $0.id == panel.workspaceId }), + let paneId = workspace.paneId(forPanelId: panel.id) else { + return nil + } + return BrowserPaneDropContext( + workspaceId: panel.workspaceId, + panelId: panel.id, + paneId: paneId + ) + } +} From 593ea63c55b13d432da3bca9d73ec17454e1dda1 Mon Sep 17 00:00:00 2001 From: arzafran Date: Thu, 9 Jul 2026 14:12:05 -0300 Subject: [PATCH 27/31] 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 9419a946263881c1629a5a4310e2b874ec08c651 Mon Sep 17 00:00:00 2001 From: arzafran Date: Thu, 9 Jul 2026 14:13:09 -0300 Subject: [PATCH 28/31] refactor: split ProgramaApp.swift into DebugWindows/SettingsView/SettingsModels/SettingsComponents MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ProgramaApp.swift was 5,854 lines. Pure file reorganization, no behavior change intended: - DebugWindows.swift: all Debug-menu window controllers + their SwiftUI views (Settings/About Titlebar Debug, Debug Window Controls, Browser Profile Popover Debug, Sidebar Debug, Menu Bar Extra Debug, Split Button Layout Debug, Background Debug), plus their supporting option/store types. None of these were actually under #if DEBUG at the type level in the original file (only the CommandMenu that surfaces them, and a couple of AppDelegate methods, are #if DEBUG-gated) — preserved exactly as-is, no #if DEBUG added or removed. - SettingsModels.swift: the small @AppStorage-key settings-namespace enums (WorkspacePresentationModeSettings, PaneFirstClickFocusSettings, AppearanceMode/AppearanceSettings, QuitWarningSettings, ScrollbackPersistenceSettings, CommandPaletteRenameSelectionSettings, CommandPaletteSwitcherSearchSettings, ClaudeCodeIntegrationSettings, WelcomeSettings, PreferredEditorSettings). - SettingsComponents.swift: the reusable settings design-system views (HexColorPicker, SettingsSectionHeader, SettingsCard, SettingsCardRow, SettingsPickerRow, SettingsCardDivider, SettingsCardNote, SettingsHeaderActionButton) plus their private View.applyIf helper. - SettingsView.swift: the SettingsView struct itself (unchanged state/ properties, no state moves), its SettingsView-specific helper views (ThemeWindowThumbnail, ThemePickerRow, ShortcutSettingRow, SettingsTopOffsetPreferenceKey, SettingsTitleLeadingInsetReader), and SettingsRootView which hosts it. Its 1,200-line body was decomposed into per-section @ViewBuilder computed properties matching the view's existing section headers (appSection, workspaceColorsSection, sidebarAppearanceSection, automationSection, customCommandsSection, browserSection, keyboardShortcutsSection, resetSection) — pure structural extraction, each section's content moved verbatim, no logic changes. Access-level widening (private -> internal) applied only where required for cross-file visibility introduced by the split, each because some other type that stayed in ProgramaApp.swift (or moved to a different new file) still references it: - SettingsAboutWindowKind, SettingsAboutTitlebarDebugOptions, TitlebarVisibilityOption, TitlebarToolbarStyleOption, SettingsAboutTitlebarDebugStore: referenced by AboutWindowController / SettingsWindowController, which stayed in ProgramaApp.swift. - The *DebugWindowController classes (SettingsAboutTitlebarDebug, DebugWindowControls, BrowserProfilePopoverDebug, SidebarDebug, MenuBarExtraDebug, SplitButtonLayoutDebug, BackgroundDebug): referenced by the Debug CommandMenu and openAllDebugWindows(), which stayed in ProgramaApp.swift. - AboutVisualEffectBackground: stayed in ProgramaApp.swift (used by AboutPanelView) but is also used by SettingsView's title bar overlay, which moved to SettingsView.swift. - SettingsRootView: hosted by SettingsWindowController.init in ProgramaApp.swift, moved to SettingsView.swift. - openProgramaSettingsFileInTextEdit(): a free function in ProgramaApp.swift called from SettingsView's body, which moved to SettingsView.swift. - The SettingsComponents.swift design-system views: used throughout SettingsView.swift, a different file after the split. AboutWindowController, AcknowledgmentsWindowController, AcknowledgmentsView, SettingsWindowController, SettingsNavigationTarget/Request, AboutPanelView, AboutPropertyRow, AppIconAppearanceObserver, ProgramaUITestCapture, and ProgramaRuntimeDebugCapture were left in ProgramaApp.swift — none matched one of the four named destination buckets cleanly. The @AppStorage-vs-resetAllSettings double enumeration did not fall out of this split (resetAllSettings() still manually re-lists every @AppStorage property); left as remaining work. Refs #100. --- GhosttyTabs.xcodeproj/project.pbxproj | 16 + Sources/DebugWindows.swift | 1749 +++++++++ Sources/ProgramaApp.swift | 5020 +++---------------------- Sources/SettingsComponents.swift | 261 ++ Sources/SettingsModels.swift | 209 + Sources/SettingsView.swift | 2025 ++++++++++ 6 files changed, 4682 insertions(+), 4598 deletions(-) create mode 100644 Sources/DebugWindows.swift create mode 100644 Sources/SettingsComponents.swift create mode 100644 Sources/SettingsModels.swift create mode 100644 Sources/SettingsView.swift diff --git a/GhosttyTabs.xcodeproj/project.pbxproj b/GhosttyTabs.xcodeproj/project.pbxproj index 0099d1f7d28..f52b780a459 100644 --- a/GhosttyTabs.xcodeproj/project.pbxproj +++ b/GhosttyTabs.xcodeproj/project.pbxproj @@ -8,6 +8,10 @@ /* Begin PBXBuildFile section */ A5001001 /* ProgramaApp.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5001011 /* ProgramaApp.swift */; }; + NRPA00003 /* DebugWindows.swift in Sources */ = {isa = PBXBuildFile; fileRef = NRPA00004 /* DebugWindows.swift */; }; + NRPA00005 /* SettingsModels.swift in Sources */ = {isa = PBXBuildFile; fileRef = NRPA00006 /* SettingsModels.swift */; }; + NRPA00007 /* SettingsComponents.swift in Sources */ = {isa = PBXBuildFile; fileRef = NRPA00008 /* SettingsComponents.swift */; }; + NRPA00009 /* SettingsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = NRPA00010 /* SettingsView.swift */; }; 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 */; }; @@ -233,6 +237,10 @@ F1000002A1B2C3D4E5F60718 /* programaTests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = programaTests.xctest; sourceTree = BUILT_PRODUCTS_DIR; }; 7E7E6EF344A568AC7FEE3715 /* programaUITests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = programaUITests.xctest; sourceTree = BUILT_PRODUCTS_DIR; }; A5001011 /* ProgramaApp.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ProgramaApp.swift; sourceTree = ""; }; + NRPA00004 /* DebugWindows.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DebugWindows.swift; sourceTree = ""; }; + NRPA00006 /* SettingsModels.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SettingsModels.swift; sourceTree = ""; }; + NRPA00008 /* SettingsComponents.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SettingsComponents.swift; sourceTree = ""; }; + NRPA00010 /* SettingsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SettingsView.swift; sourceTree = ""; }; 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 = ""; }; @@ -503,6 +511,10 @@ isa = PBXGroup; children = ( A5001011 /* ProgramaApp.swift */, + NRPA00004 /* DebugWindows.swift */, + NRPA00006 /* SettingsModels.swift */, + NRPA00008 /* SettingsComponents.swift */, + NRPA00010 /* SettingsView.swift */, A5FF0017 /* SettingDefinition.swift */, A5001012 /* ContentView.swift */, A5FF0015 /* ContentView+CommandPalette.swift */, @@ -834,6 +846,10 @@ buildActionMask = 2147483647; files = ( A5001001 /* ProgramaApp.swift in Sources */, + NRPA00003 /* DebugWindows.swift in Sources */, + NRPA00005 /* SettingsModels.swift in Sources */, + NRPA00007 /* SettingsComponents.swift in Sources */, + NRPA00009 /* SettingsView.swift in Sources */, A5FF0007 /* SettingDefinition.swift in Sources */, A5001002 /* ContentView.swift in Sources */, A5FF0005 /* ContentView+CommandPalette.swift in Sources */, diff --git a/Sources/DebugWindows.swift b/Sources/DebugWindows.swift new file mode 100644 index 00000000000..73bacda3e35 --- /dev/null +++ b/Sources/DebugWindows.swift @@ -0,0 +1,1749 @@ +import AppKit +import SwiftUI +import Darwin +import Bonsplit +import UniformTypeIdentifiers + + +enum SettingsAboutWindowKind: String, CaseIterable, Identifiable { + case settings + case about + + var id: String { rawValue } + + var displayTitle: String { + switch self { + case .settings: + return "Settings Window" + case .about: + return "About Window" + } + } + + var windowIdentifier: String { + switch self { + case .settings: + return "cmux.settings" + case .about: + return "cmux.about" + } + } + + var fallbackTitle: String { + switch self { + case .settings: + return "Settings" + case .about: + return "About Programa" + } + } + + var minimumSize: NSSize { + switch self { + case .settings: + return NSSize(width: 420, height: 360) + case .about: + return NSSize(width: 360, height: 520) + } + } +} + +enum TitlebarVisibilityOption: String, CaseIterable, Identifiable { + case hidden + case visible + + var id: String { rawValue } + + var displayTitle: String { + switch self { + case .hidden: + return "Hidden" + case .visible: + return "Visible" + } + } + + var windowValue: NSWindow.TitleVisibility { + switch self { + case .hidden: + return .hidden + case .visible: + return .visible + } + } +} + +enum TitlebarToolbarStyleOption: String, CaseIterable, Identifiable { + case automatic + case expanded + case preference + case unified + case unifiedCompact + + var id: String { rawValue } + + var displayTitle: String { + switch self { + case .automatic: + return "Automatic" + case .expanded: + return "Expanded" + case .preference: + return "Preference" + case .unified: + return "Unified" + case .unifiedCompact: + return "Unified Compact" + } + } + + var windowValue: NSWindow.ToolbarStyle { + switch self { + case .automatic: + return .automatic + case .expanded: + return .expanded + case .preference: + return .preference + case .unified: + return .unified + case .unifiedCompact: + return .unifiedCompact + } + } +} + +struct SettingsAboutTitlebarDebugOptions: Equatable { + var overridesEnabled: Bool + var windowTitle: String + var titleVisibility: TitlebarVisibilityOption + var titlebarAppearsTransparent: Bool + var movableByWindowBackground: Bool + var titled: Bool + var closable: Bool + var miniaturizable: Bool + var resizable: Bool + var fullSizeContentView: Bool + var showToolbar: Bool + var toolbarStyle: TitlebarToolbarStyleOption + + static func defaults(for kind: SettingsAboutWindowKind) -> SettingsAboutTitlebarDebugOptions { + switch kind { + case .settings: + return SettingsAboutTitlebarDebugOptions( + overridesEnabled: false, + windowTitle: "Settings", + titleVisibility: .hidden, + titlebarAppearsTransparent: true, + movableByWindowBackground: true, + titled: true, + closable: true, + miniaturizable: true, + resizable: true, + fullSizeContentView: true, + showToolbar: false, + toolbarStyle: .unifiedCompact + ) + case .about: + return SettingsAboutTitlebarDebugOptions( + overridesEnabled: false, + windowTitle: "About Programa", + titleVisibility: .hidden, + titlebarAppearsTransparent: true, + movableByWindowBackground: false, + titled: true, + closable: true, + miniaturizable: true, + resizable: false, + fullSizeContentView: false, + showToolbar: false, + toolbarStyle: .automatic + ) + } + } +} + +@MainActor +final class SettingsAboutTitlebarDebugStore: ObservableObject { + static let shared = SettingsAboutTitlebarDebugStore() + + @Published var settingsOptions = SettingsAboutTitlebarDebugOptions.defaults(for: .settings) { + didSet { applyToOpenWindows(for: .settings) } + } + @Published var aboutOptions = SettingsAboutTitlebarDebugOptions.defaults(for: .about) { + didSet { applyToOpenWindows(for: .about) } + } + + private init() {} + + func options(for kind: SettingsAboutWindowKind) -> SettingsAboutTitlebarDebugOptions { + switch kind { + case .settings: + return settingsOptions + case .about: + return aboutOptions + } + } + + func update(_ newValue: SettingsAboutTitlebarDebugOptions, for kind: SettingsAboutWindowKind) { + switch kind { + case .settings: + settingsOptions = newValue + case .about: + aboutOptions = newValue + } + } + + func reset(_ kind: SettingsAboutWindowKind) { + update(SettingsAboutTitlebarDebugOptions.defaults(for: kind), for: kind) + } + + func applyToOpenWindows(for kind: SettingsAboutWindowKind) { + for window in NSApp.windows where window.identifier?.rawValue == kind.windowIdentifier { + apply(options(for: kind), to: window, for: kind) + } + } + + func applyToOpenWindows() { + applyToOpenWindows(for: .settings) + applyToOpenWindows(for: .about) + } + + func applyCurrentOptions(to window: NSWindow, for kind: SettingsAboutWindowKind) { + apply(options(for: kind), to: window, for: kind) + } + + func copyConfigToPasteboard() { + let settings = options(for: .settings) + let about = options(for: .about) + let payload = """ + # Settings/About Titlebar Debug + settings.overridesEnabled=\(settings.overridesEnabled) + settings.title=\(settings.windowTitle) + settings.titleVisibility=\(settings.titleVisibility.rawValue) + settings.titlebarAppearsTransparent=\(settings.titlebarAppearsTransparent) + settings.movableByWindowBackground=\(settings.movableByWindowBackground) + settings.titled=\(settings.titled) + settings.closable=\(settings.closable) + settings.miniaturizable=\(settings.miniaturizable) + settings.resizable=\(settings.resizable) + settings.fullSizeContentView=\(settings.fullSizeContentView) + settings.showToolbar=\(settings.showToolbar) + settings.toolbarStyle=\(settings.toolbarStyle.rawValue) + about.overridesEnabled=\(about.overridesEnabled) + about.title=\(about.windowTitle) + about.titleVisibility=\(about.titleVisibility.rawValue) + about.titlebarAppearsTransparent=\(about.titlebarAppearsTransparent) + about.movableByWindowBackground=\(about.movableByWindowBackground) + about.titled=\(about.titled) + about.closable=\(about.closable) + about.miniaturizable=\(about.miniaturizable) + about.resizable=\(about.resizable) + about.fullSizeContentView=\(about.fullSizeContentView) + about.showToolbar=\(about.showToolbar) + about.toolbarStyle=\(about.toolbarStyle.rawValue) + """ + let pasteboard = NSPasteboard.general + pasteboard.clearContents() + pasteboard.setString(payload, forType: .string) + } + + private func apply(_ options: SettingsAboutTitlebarDebugOptions, to window: NSWindow, for kind: SettingsAboutWindowKind) { + let effective = options.overridesEnabled ? options : SettingsAboutTitlebarDebugOptions.defaults(for: kind) + let resolvedTitle = effective.windowTitle.trimmingCharacters(in: .whitespacesAndNewlines) + window.title = resolvedTitle.isEmpty ? kind.fallbackTitle : resolvedTitle + window.titleVisibility = effective.titleVisibility.windowValue + window.titlebarAppearsTransparent = effective.titlebarAppearsTransparent + window.isMovableByWindowBackground = effective.movableByWindowBackground + window.toolbarStyle = effective.toolbarStyle.windowValue + + if effective.showToolbar { + ensureToolbar(on: window, kind: kind) + } else if window.toolbar != nil { + window.toolbar = nil + } + + var styleMask = window.styleMask + setStyleMaskBit(&styleMask, .titled, enabled: effective.titled) + setStyleMaskBit(&styleMask, .closable, enabled: effective.closable) + setStyleMaskBit(&styleMask, .miniaturizable, enabled: effective.miniaturizable) + setStyleMaskBit(&styleMask, .resizable, enabled: effective.resizable) + setStyleMaskBit(&styleMask, .fullSizeContentView, enabled: effective.fullSizeContentView) + window.styleMask = styleMask + + let maxSize = effective.resizable ? NSSize(width: 8192, height: 8192) : kind.minimumSize + window.minSize = kind.minimumSize + window.maxSize = maxSize + window.contentMinSize = kind.minimumSize + window.contentMaxSize = maxSize + window.invalidateShadow() + AppDelegate.shared?.applyWindowDecorations(to: window) + } + + private func ensureToolbar(on window: NSWindow, kind: SettingsAboutWindowKind) { + guard window.toolbar == nil else { return } + let identifier = NSToolbar.Identifier("programa.debug.titlebar.\(kind.rawValue)") + let toolbar = NSToolbar(identifier: identifier) + toolbar.allowsUserCustomization = false + toolbar.autosavesConfiguration = false + toolbar.displayMode = .iconOnly + toolbar.showsBaselineSeparator = false + window.toolbar = toolbar + } + + private func setStyleMaskBit( + _ styleMask: inout NSWindow.StyleMask, + _ bit: NSWindow.StyleMask, + enabled: Bool + ) { + if enabled { + styleMask.insert(bit) + } else { + styleMask.remove(bit) + } + } +} + +final class SettingsAboutTitlebarDebugWindowController: NSWindowController, NSWindowDelegate { + static let shared = SettingsAboutTitlebarDebugWindowController() + + private init() { + let window = NSPanel( + contentRect: NSRect(x: 0, y: 0, width: 470, height: 690), + styleMask: [.titled, .closable, .resizable, .utilityWindow], + backing: .buffered, + defer: false + ) + window.title = "Settings/About Titlebar Debug" + window.titleVisibility = .visible + window.titlebarAppearsTransparent = false + window.isMovableByWindowBackground = true + window.isReleasedWhenClosed = false + window.identifier = NSUserInterfaceItemIdentifier("programa.settingsAboutTitlebarDebug") + window.center() + window.contentView = NSHostingView(rootView: SettingsAboutTitlebarDebugView()) + AppDelegate.shared?.applyWindowDecorations(to: window) + super.init(window: window) + window.delegate = self + } + + @available(*, unavailable) + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + func show() { + window?.center() + window?.makeKeyAndOrderFront(nil) + SettingsAboutTitlebarDebugStore.shared.applyToOpenWindows() + } +} + +private struct SettingsAboutTitlebarDebugView: View { + @ObservedObject private var store = SettingsAboutTitlebarDebugStore.shared + + var body: some View { + ScrollView { + VStack(alignment: .leading, spacing: 14) { + Text("Settings/About Titlebar Debug") + .font(.headline) + + editor(for: .settings) + editor(for: .about) + + GroupBox("Actions") { + HStack(spacing: 10) { + Button("Reset All") { + store.reset(.settings) + store.reset(.about) + } + Button("Reapply to Open Windows") { + store.applyToOpenWindows() + } + Button("Copy Config") { + store.copyConfigToPasteboard() + } + } + .frame(maxWidth: .infinity, alignment: .leading) + .padding(.top, 2) + } + + Spacer(minLength: 0) + } + .padding(16) + .frame(maxWidth: .infinity, alignment: .topLeading) + } + .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading) + } + + private func editor(for kind: SettingsAboutWindowKind) -> some View { + let overridesEnabled = binding(for: kind, keyPath: \.overridesEnabled) + + return GroupBox(kind.displayTitle) { + VStack(alignment: .leading, spacing: 10) { + Toggle("Enable Debug Overrides", isOn: overridesEnabled) + + Text("When disabled, Programa uses normal default titlebar behavior for this window.") + .font(.caption) + .foregroundColor(.secondary) + + Divider() + + VStack(alignment: .leading, spacing: 10) { + HStack(spacing: 8) { + Text("Window Title") + TextField("", text: binding(for: kind, keyPath: \.windowTitle)) + } + + HStack(spacing: 10) { + Picker("Title Visibility", selection: binding(for: kind, keyPath: \.titleVisibility)) { + ForEach(TitlebarVisibilityOption.allCases) { option in + Text(option.displayTitle).tag(option) + } + } + Picker("Toolbar Style", selection: binding(for: kind, keyPath: \.toolbarStyle)) { + ForEach(TitlebarToolbarStyleOption.allCases) { option in + Text(option.displayTitle).tag(option) + } + } + } + + Toggle("Show Toolbar", isOn: binding(for: kind, keyPath: \.showToolbar)) + Toggle("Transparent Titlebar", isOn: binding(for: kind, keyPath: \.titlebarAppearsTransparent)) + Toggle("Movable by Window Background", isOn: binding(for: kind, keyPath: \.movableByWindowBackground)) + + Divider() + + Text("Style Mask") + .font(.caption) + .foregroundColor(.secondary) + + Toggle("Titled", isOn: binding(for: kind, keyPath: \.titled)) + Toggle("Closable", isOn: binding(for: kind, keyPath: \.closable)) + Toggle("Miniaturizable", isOn: binding(for: kind, keyPath: \.miniaturizable)) + Toggle("Resizable", isOn: binding(for: kind, keyPath: \.resizable)) + Toggle("Full Size Content View", isOn: binding(for: kind, keyPath: \.fullSizeContentView)) + + HStack(spacing: 10) { + Button("Reset \(kind == .settings ? "Settings" : "About")") { + store.reset(kind) + } + Button("Apply Now") { + store.applyToOpenWindows(for: kind) + } + } + } + .disabled(!overridesEnabled.wrappedValue) + .opacity(overridesEnabled.wrappedValue ? 1 : 0.75) + } + .padding(.top, 2) + } + } + + private func binding( + for kind: SettingsAboutWindowKind, + keyPath: WritableKeyPath + ) -> Binding { + Binding( + get: { store.options(for: kind)[keyPath: keyPath] }, + set: { newValue in + var updated = store.options(for: kind) + updated[keyPath: keyPath] = newValue + store.update(updated, for: kind) + } + ) + } +} + +private enum DebugWindowConfigSnapshot { + static func copyCombinedToPasteboard(defaults: UserDefaults = .standard) { + let pasteboard = NSPasteboard.general + pasteboard.clearContents() + pasteboard.setString(combinedPayload(defaults: defaults), forType: .string) + } + + static func combinedPayload(defaults: UserDefaults = .standard) -> String { + let sidebarPayload = """ + sidebarPreset=\(stringValue(defaults, key: "sidebarPreset", fallback: SidebarPresetOption.nativeSidebar.rawValue)) + sidebarMaterial=\(stringValue(defaults, key: "sidebarMaterial", fallback: SidebarMaterialOption.sidebar.rawValue)) + sidebarBlendMode=\(stringValue(defaults, key: "sidebarBlendMode", fallback: SidebarBlendModeOption.withinWindow.rawValue)) + sidebarState=\(stringValue(defaults, key: "sidebarState", fallback: SidebarStateOption.followWindow.rawValue)) + sidebarBlurOpacity=\(String(format: "%.2f", doubleValue(defaults, key: "sidebarBlurOpacity", fallback: 1.0))) + sidebarTintHex=\(stringValue(defaults, key: "sidebarTintHex", fallback: "#000000")) + sidebarTintHexLight=\(stringValue(defaults, key: "sidebarTintHexLight", fallback: "(nil)")) + sidebarTintHexDark=\(stringValue(defaults, key: "sidebarTintHexDark", fallback: "(nil)")) + sidebarTintOpacity=\(String(format: "%.2f", doubleValue(defaults, key: "sidebarTintOpacity", fallback: 0.18))) + sidebarCornerRadius=\(String(format: "%.1f", doubleValue(defaults, key: "sidebarCornerRadius", fallback: 0.0))) + sidebarActiveTabIndicatorStyle=\(stringValue(defaults, key: SidebarActiveTabIndicatorSettings.styleKey, fallback: SidebarActiveTabIndicatorSettings.defaultStyle.rawValue)) + sidebarDevBuildBannerVisible=\(boolValue(defaults, key: DevBuildBannerDebugSettings.sidebarBannerVisibleKey, fallback: DevBuildBannerDebugSettings.defaultShowSidebarBanner)) + shortcutHintSidebarXOffset=\(String(format: "%.1f", doubleValue(defaults, key: ShortcutHintDebugSettings.sidebarHintXKey, fallback: ShortcutHintDebugSettings.defaultSidebarHintX))) + shortcutHintSidebarYOffset=\(String(format: "%.1f", doubleValue(defaults, key: ShortcutHintDebugSettings.sidebarHintYKey, fallback: ShortcutHintDebugSettings.defaultSidebarHintY))) + shortcutHintTitlebarXOffset=\(String(format: "%.1f", doubleValue(defaults, key: ShortcutHintDebugSettings.titlebarHintXKey, fallback: ShortcutHintDebugSettings.defaultTitlebarHintX))) + shortcutHintTitlebarYOffset=\(String(format: "%.1f", doubleValue(defaults, key: ShortcutHintDebugSettings.titlebarHintYKey, fallback: ShortcutHintDebugSettings.defaultTitlebarHintY))) + shortcutHintPaneTabXOffset=\(String(format: "%.1f", doubleValue(defaults, key: ShortcutHintDebugSettings.paneHintXKey, fallback: ShortcutHintDebugSettings.defaultPaneHintX))) + shortcutHintPaneTabYOffset=\(String(format: "%.1f", doubleValue(defaults, key: ShortcutHintDebugSettings.paneHintYKey, fallback: ShortcutHintDebugSettings.defaultPaneHintY))) + shortcutHintAlwaysShow=\(boolValue(defaults, key: ShortcutHintDebugSettings.alwaysShowHintsKey, fallback: ShortcutHintDebugSettings.defaultAlwaysShowHints)) + shortcutHintShowOnCommandHold=\(boolValue(defaults, key: ShortcutHintDebugSettings.showHintsOnCommandHoldKey, fallback: ShortcutHintDebugSettings.defaultShowHintsOnCommandHold)) + """ + + let backgroundPayload = """ + bgGlassEnabled=\(boolValue(defaults, key: "bgGlassEnabled", fallback: false)) + bgGlassMaterial=\(stringValue(defaults, key: "bgGlassMaterial", fallback: "hudWindow")) + bgGlassTintHex=\(stringValue(defaults, key: "bgGlassTintHex", fallback: "#000000")) + bgGlassTintOpacity=\(String(format: "%.2f", doubleValue(defaults, key: "bgGlassTintOpacity", fallback: 0.03))) + """ + + let menuBarPayload = MenuBarIconDebugSettings.copyPayload(defaults: defaults) + let browserDevToolsPayload = BrowserDevToolsButtonDebugSettings.copyPayload(defaults: defaults) + + return """ + # Sidebar Debug + \(sidebarPayload) + + # Background Debug + \(backgroundPayload) + + # Menu Bar Extra Debug + \(menuBarPayload) + + # Browser DevTools Button + \(browserDevToolsPayload) + """ + } + + private static func stringValue(_ defaults: UserDefaults, key: String, fallback: String) -> String { + defaults.string(forKey: key) ?? fallback + } + + private static func doubleValue(_ defaults: UserDefaults, key: String, fallback: Double) -> Double { + if let value = defaults.object(forKey: key) as? NSNumber { + return value.doubleValue + } + if let text = defaults.string(forKey: key), let parsed = Double(text) { + return parsed + } + return fallback + } + + private static func boolValue(_ defaults: UserDefaults, key: String, fallback: Bool) -> Bool { + guard defaults.object(forKey: key) != nil else { return fallback } + return defaults.bool(forKey: key) + } +} + +final class DebugWindowControlsWindowController: NSWindowController, NSWindowDelegate { + static let shared = DebugWindowControlsWindowController() + + private init() { + let window = NSPanel( + contentRect: NSRect(x: 0, y: 0, width: 420, height: 560), + styleMask: [.titled, .closable, .utilityWindow], + backing: .buffered, + defer: false + ) + window.title = "Debug Window Controls" + window.titleVisibility = .visible + window.titlebarAppearsTransparent = false + window.isMovableByWindowBackground = true + window.isReleasedWhenClosed = false + window.identifier = NSUserInterfaceItemIdentifier("programa.debugWindowControls") + window.center() + window.contentView = NSHostingView(rootView: DebugWindowControlsView()) + AppDelegate.shared?.applyWindowDecorations(to: window) + super.init(window: window) + window.delegate = self + } + + @available(*, unavailable) + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + func show() { + window?.center() + window?.makeKeyAndOrderFront(nil) + } +} + +private struct DebugWindowControlsView: View { + @AppStorage(ShortcutHintDebugSettings.sidebarHintXKey) private var sidebarShortcutHintXOffset = ShortcutHintDebugSettings.defaultSidebarHintX + @AppStorage(ShortcutHintDebugSettings.sidebarHintYKey) private var sidebarShortcutHintYOffset = ShortcutHintDebugSettings.defaultSidebarHintY + @AppStorage(ShortcutHintDebugSettings.titlebarHintXKey) private var titlebarShortcutHintXOffset = ShortcutHintDebugSettings.defaultTitlebarHintX + @AppStorage(ShortcutHintDebugSettings.titlebarHintYKey) private var titlebarShortcutHintYOffset = ShortcutHintDebugSettings.defaultTitlebarHintY + @AppStorage(ShortcutHintDebugSettings.paneHintXKey) private var paneShortcutHintXOffset = ShortcutHintDebugSettings.defaultPaneHintX + @AppStorage(ShortcutHintDebugSettings.paneHintYKey) private var paneShortcutHintYOffset = ShortcutHintDebugSettings.defaultPaneHintY + @AppStorage(ShortcutHintDebugSettings.alwaysShowHintsKey) private var alwaysShowShortcutHints = ShortcutHintDebugSettings.defaultAlwaysShowHints + @AppStorage(SidebarActiveTabIndicatorSettings.styleKey) + private var sidebarActiveTabIndicatorStyle = SidebarActiveTabIndicatorSettings.defaultStyle.rawValue + @AppStorage("debugTitlebarLeadingExtra") private var titlebarLeadingExtra: Double = 0 + @AppStorage(BrowserDevToolsButtonDebugSettings.iconNameKey) private var browserDevToolsIconNameRaw = BrowserDevToolsButtonDebugSettings.defaultIcon.rawValue + @AppStorage(BrowserDevToolsButtonDebugSettings.iconColorKey) private var browserDevToolsIconColorRaw = BrowserDevToolsButtonDebugSettings.defaultColor.rawValue + + private var selectedDevToolsIconOption: BrowserDevToolsIconOption { + BrowserDevToolsIconOption(rawValue: browserDevToolsIconNameRaw) ?? BrowserDevToolsButtonDebugSettings.defaultIcon + } + + private var selectedDevToolsColorOption: BrowserDevToolsIconColorOption { + BrowserDevToolsIconColorOption(rawValue: browserDevToolsIconColorRaw) ?? BrowserDevToolsButtonDebugSettings.defaultColor + } + + private var selectedSidebarActiveTabIndicatorStyle: SidebarActiveTabIndicatorStyle { + SidebarActiveTabIndicatorSettings.resolvedStyle(rawValue: sidebarActiveTabIndicatorStyle) + } + + private var sidebarIndicatorStyleSelection: Binding { + Binding( + get: { selectedSidebarActiveTabIndicatorStyle.rawValue }, + set: { sidebarActiveTabIndicatorStyle = $0 } + ) + } + + var body: some View { + ScrollView { + VStack(alignment: .leading, spacing: 14) { + Text("Debug Window Controls") + .font(.headline) + + GroupBox("Open") { + VStack(alignment: .leading, spacing: 8) { + Button( + String( + localized: "debug.menu.browserProfilePopoverDebug", + defaultValue: "Browser Profile Popover Debug…" + ) + ) { + BrowserProfilePopoverDebugWindowController.shared.show() + } + Button("Settings/About Titlebar Debug…") { + SettingsAboutTitlebarDebugWindowController.shared.show() + } + Button("Sidebar Debug…") { + SidebarDebugWindowController.shared.show() + } + Button("Background Debug…") { + BackgroundDebugWindowController.shared.show() + } + Button("Menu Bar Extra Debug…") { + MenuBarExtraDebugWindowController.shared.show() + } + Button("Open All Debug Windows") { + BrowserProfilePopoverDebugWindowController.shared.show() + SettingsAboutTitlebarDebugWindowController.shared.show() + SidebarDebugWindowController.shared.show() + BackgroundDebugWindowController.shared.show() + MenuBarExtraDebugWindowController.shared.show() + } + } + .frame(maxWidth: .infinity, alignment: .leading) + .padding(.top, 2) + } + + GroupBox("Shortcut Hints") { + VStack(alignment: .leading, spacing: 10) { + Toggle("Always show shortcut hints", isOn: $alwaysShowShortcutHints) + + hintOffsetSection( + "Sidebar Cmd+1…9", + x: $sidebarShortcutHintXOffset, + y: $sidebarShortcutHintYOffset + ) + + hintOffsetSection( + "Titlebar Buttons", + x: $titlebarShortcutHintXOffset, + y: $titlebarShortcutHintYOffset + ) + + hintOffsetSection( + "Pane Ctrl/Cmd+1…9", + x: $paneShortcutHintXOffset, + y: $paneShortcutHintYOffset + ) + + HStack(spacing: 12) { + Button("Reset Hints") { + resetShortcutHintOffsets() + } + Button("Copy Hint Config") { + copyShortcutHintConfig() + } + } + } + .padding(.top, 2) + } + + GroupBox("Active Workspace Indicator") { + VStack(alignment: .leading, spacing: 8) { + Picker("Style", selection: sidebarIndicatorStyleSelection) { + ForEach(SidebarActiveTabIndicatorStyle.allCases) { style in + Text(style.displayName).tag(style.rawValue) + } + } + .pickerStyle(.menu) + + Button("Reset Indicator Style") { + sidebarActiveTabIndicatorStyle = SidebarActiveTabIndicatorSettings.defaultStyle.rawValue + } + } + .padding(.top, 2) + } + + GroupBox("Titlebar Spacing") { + VStack(alignment: .leading, spacing: 6) { + HStack(spacing: 8) { + Text("Leading extra") + Slider(value: $titlebarLeadingExtra, in: 0...40) + Text(String(format: "%.0f", titlebarLeadingExtra)) + .font(.caption) + .monospacedDigit() + .frame(width: 30, alignment: .trailing) + } + Button("Reset (0)") { + titlebarLeadingExtra = 0 + } + } + .padding(.top, 2) + } + + GroupBox("Browser DevTools Button") { + VStack(alignment: .leading, spacing: 10) { + HStack(spacing: 8) { + Text("Icon") + Picker("Icon", selection: $browserDevToolsIconNameRaw) { + ForEach(BrowserDevToolsIconOption.allCases) { option in + Text(option.title).tag(option.rawValue) + } + } + .labelsHidden() + .pickerStyle(.menu) + Spacer() + } + + HStack(spacing: 8) { + Text("Color") + Picker("Color", selection: $browserDevToolsIconColorRaw) { + ForEach(BrowserDevToolsIconColorOption.allCases) { option in + Text(option.title).tag(option.rawValue) + } + } + .labelsHidden() + .pickerStyle(.menu) + Spacer() + } + + HStack(spacing: 8) { + Text("Preview") + Spacer() + Image(systemName: selectedDevToolsIconOption.rawValue) + .font(.system(size: 12, weight: .medium)) + .foregroundStyle(selectedDevToolsColorOption.color) + } + + HStack(spacing: 12) { + Button("Reset Button") { + resetBrowserDevToolsButton() + } + Button("Copy Button Config") { + copyBrowserDevToolsButtonConfig() + } + } + } + .padding(.top, 2) + } + + GroupBox("Copy") { + VStack(alignment: .leading, spacing: 8) { + Button("Copy All Debug Config") { + DebugWindowConfigSnapshot.copyCombinedToPasteboard() + } + Text("Copies sidebar, background, menu bar, and browser devtools settings as one payload.") + .font(.caption) + .foregroundColor(.secondary) + } + .frame(maxWidth: .infinity, alignment: .leading) + .padding(.top, 2) + } + + Spacer(minLength: 0) + } + .padding(16) + .frame(maxWidth: .infinity, alignment: .topLeading) + } + .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading) + } + + private func hintOffsetSection(_ title: String, x: Binding, y: Binding) -> some View { + VStack(alignment: .leading, spacing: 6) { + Text(title) + .font(.caption) + .foregroundColor(.secondary) + sliderRow("X", value: x) + sliderRow("Y", value: y) + } + } + + private func sliderRow(_ label: String, value: Binding) -> some View { + HStack(spacing: 8) { + Text(label) + Slider(value: value, in: ShortcutHintDebugSettings.offsetRange) + Text(String(format: "%.1f", ShortcutHintDebugSettings.clamped(value.wrappedValue))) + .font(.caption) + .monospacedDigit() + .frame(width: 44, alignment: .trailing) + } + } + + private func resetShortcutHintOffsets() { + sidebarShortcutHintXOffset = ShortcutHintDebugSettings.defaultSidebarHintX + sidebarShortcutHintYOffset = ShortcutHintDebugSettings.defaultSidebarHintY + titlebarShortcutHintXOffset = ShortcutHintDebugSettings.defaultTitlebarHintX + titlebarShortcutHintYOffset = ShortcutHintDebugSettings.defaultTitlebarHintY + paneShortcutHintXOffset = ShortcutHintDebugSettings.defaultPaneHintX + paneShortcutHintYOffset = ShortcutHintDebugSettings.defaultPaneHintY + alwaysShowShortcutHints = ShortcutHintDebugSettings.defaultAlwaysShowHints + } + + private func copyShortcutHintConfig() { + let payload = """ + shortcutHintSidebarXOffset=\(String(format: "%.1f", ShortcutHintDebugSettings.clamped(sidebarShortcutHintXOffset))) + shortcutHintSidebarYOffset=\(String(format: "%.1f", ShortcutHintDebugSettings.clamped(sidebarShortcutHintYOffset))) + shortcutHintTitlebarXOffset=\(String(format: "%.1f", ShortcutHintDebugSettings.clamped(titlebarShortcutHintXOffset))) + shortcutHintTitlebarYOffset=\(String(format: "%.1f", ShortcutHintDebugSettings.clamped(titlebarShortcutHintYOffset))) + shortcutHintPaneTabXOffset=\(String(format: "%.1f", ShortcutHintDebugSettings.clamped(paneShortcutHintXOffset))) + shortcutHintPaneTabYOffset=\(String(format: "%.1f", ShortcutHintDebugSettings.clamped(paneShortcutHintYOffset))) + shortcutHintAlwaysShow=\(alwaysShowShortcutHints) + """ + let pasteboard = NSPasteboard.general + pasteboard.clearContents() + pasteboard.setString(payload, forType: .string) + } + + private func resetBrowserDevToolsButton() { + browserDevToolsIconNameRaw = BrowserDevToolsButtonDebugSettings.defaultIcon.rawValue + browserDevToolsIconColorRaw = BrowserDevToolsButtonDebugSettings.defaultColor.rawValue + } + + private func copyBrowserDevToolsButtonConfig() { + let payload = BrowserDevToolsButtonDebugSettings.copyPayload(defaults: .standard) + let pasteboard = NSPasteboard.general + pasteboard.clearContents() + pasteboard.setString(payload, forType: .string) + } +} + +final class BrowserProfilePopoverDebugWindowController: NSWindowController, NSWindowDelegate { + static let shared = BrowserProfilePopoverDebugWindowController() + + private init() { + let window = NSPanel( + contentRect: NSRect(x: 0, y: 0, width: 360, height: 340), + styleMask: [.titled, .closable, .utilityWindow], + backing: .buffered, + defer: false + ) + window.title = String( + localized: "debug.windows.browserProfilePopover.title", + defaultValue: "Browser Profile Popover Debug" + ) + window.titleVisibility = .visible + window.titlebarAppearsTransparent = false + window.isMovableByWindowBackground = true + window.isReleasedWhenClosed = false + window.identifier = NSUserInterfaceItemIdentifier("programa.browserProfilePopoverDebug") + window.center() + window.contentView = NSHostingView(rootView: BrowserProfilePopoverDebugView()) + AppDelegate.shared?.applyWindowDecorations(to: window) + super.init(window: window) + window.delegate = self + } + + @available(*, unavailable) + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + func show() { + window?.center() + window?.makeKeyAndOrderFront(nil) + } +} + +private struct BrowserProfilePopoverDebugView: View { + @AppStorage(BrowserProfilePopoverDebugSettings.horizontalPaddingKey) + private var horizontalPaddingRaw = BrowserProfilePopoverDebugSettings.defaultHorizontalPadding + @AppStorage(BrowserProfilePopoverDebugSettings.verticalPaddingKey) + private var verticalPaddingRaw = BrowserProfilePopoverDebugSettings.defaultVerticalPadding + + private var horizontalPaddingBinding: Binding { + Binding( + get: { BrowserProfilePopoverDebugSettings.resolvedHorizontalPadding(horizontalPaddingRaw) }, + set: { horizontalPaddingRaw = BrowserProfilePopoverDebugSettings.resolvedHorizontalPadding($0) } + ) + } + + private var verticalPaddingBinding: Binding { + Binding( + get: { BrowserProfilePopoverDebugSettings.resolvedVerticalPadding(verticalPaddingRaw) }, + set: { verticalPaddingRaw = BrowserProfilePopoverDebugSettings.resolvedVerticalPadding($0) } + ) + } + + var body: some View { + ScrollView { + VStack(alignment: .leading, spacing: 14) { + Text( + String( + localized: "debug.browserProfilePopover.heading", + defaultValue: "Browser Profile Popover" + ) + ) + .font(.headline) + + Text( + String( + localized: "debug.browserProfilePopover.note", + defaultValue: "Tune the profile popover padding live while comparing it against the browser toolbar menu." + ) + ) + .font(.caption) + .foregroundStyle(.secondary) + + GroupBox( + String( + localized: "debug.browserProfilePopover.group.padding", + defaultValue: "Padding" + ) + ) { + VStack(alignment: .leading, spacing: 8) { + sliderRow( + String( + localized: "debug.browserProfilePopover.label.horizontal", + defaultValue: "Horizontal" + ), + value: horizontalPaddingBinding, + range: BrowserProfilePopoverDebugSettings.horizontalPaddingRange + ) + sliderRow( + String( + localized: "debug.browserProfilePopover.label.vertical", + defaultValue: "Vertical" + ), + value: verticalPaddingBinding, + range: BrowserProfilePopoverDebugSettings.verticalPaddingRange + ) + } + .padding(.top, 2) + } + + GroupBox( + String( + localized: "debug.browserProfilePopover.group.preview", + defaultValue: "Preview" + ) + ) { + profilePopoverPreview + .padding(.top, 2) + } + + HStack(spacing: 12) { + Button( + String( + localized: "debug.browserProfilePopover.reset", + defaultValue: "Reset" + ) + ) { + horizontalPaddingRaw = BrowserProfilePopoverDebugSettings.defaultHorizontalPadding + verticalPaddingRaw = BrowserProfilePopoverDebugSettings.defaultVerticalPadding + } + } + + Text( + String( + localized: "debug.browserProfilePopover.liveNote", + defaultValue: "Changes apply live to the browser profile popover." + ) + ) + .font(.caption) + .foregroundStyle(.secondary) + + Spacer(minLength: 0) + } + .padding(16) + .frame(maxWidth: .infinity, alignment: .topLeading) + } + .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading) + } + + private var profilePopoverPreview: some View { + VStack(alignment: .leading, spacing: 8) { + Text(String(localized: "browser.profile.menu.title", defaultValue: "Profiles")) + .font(.system(size: 12, weight: .semibold)) + .foregroundStyle(.secondary) + + VStack(alignment: .leading, spacing: 2) { + HStack(spacing: 8) { + Image(systemName: "checkmark") + .font(.system(size: 10, weight: .semibold)) + .frame(width: 12, alignment: .center) + Text(String(localized: "browser.profile.default", defaultValue: "Default")) + .font(.system(size: 12)) + Spacer(minLength: 0) + } + .padding(.horizontal, 8) + .frame(height: 24) + .background( + RoundedRectangle(cornerRadius: 6, style: .continuous) + .fill(Color.primary.opacity(0.12)) + ) + } + + Divider() + + Text(String(localized: "browser.profile.new", defaultValue: "New Profile...")) + .font(.system(size: 12)) + + Text(String(localized: "menu.view.importFromBrowser", defaultValue: "Import Browser Data…")) + .font(.system(size: 12)) + } + .padding(.horizontal, BrowserProfilePopoverDebugSettings.resolvedHorizontalPadding(horizontalPaddingRaw)) + .padding(.vertical, BrowserProfilePopoverDebugSettings.resolvedVerticalPadding(verticalPaddingRaw)) + .frame(maxWidth: .infinity, alignment: .leading) + .background( + RoundedRectangle(cornerRadius: 16, style: .continuous) + .fill(Color(nsColor: .windowBackgroundColor)) + .overlay( + RoundedRectangle(cornerRadius: 16, style: .continuous) + .stroke(Color.primary.opacity(0.08)) + ) + ) + } + + private func sliderRow(_ label: String, value: Binding, range: ClosedRange) -> some View { + HStack(spacing: 8) { + Text(label) + Slider(value: value, in: range, step: 1) + Text(String(format: "%.0f", value.wrappedValue)) + .font(.caption) + .monospacedDigit() + .frame(width: 32, alignment: .trailing) + } + } +} + +final class SidebarDebugWindowController: NSWindowController, NSWindowDelegate { + static let shared = SidebarDebugWindowController() + + private init() { + let window = NSPanel( + contentRect: NSRect(x: 0, y: 0, width: 360, height: 520), + styleMask: [.titled, .closable, .utilityWindow], + backing: .buffered, + defer: false + ) + window.title = "Sidebar Debug" + window.titleVisibility = .visible + window.titlebarAppearsTransparent = false + window.isMovableByWindowBackground = true + window.isReleasedWhenClosed = false + window.identifier = NSUserInterfaceItemIdentifier("programa.sidebarDebug") + window.center() + window.contentView = NSHostingView(rootView: SidebarDebugView()) + AppDelegate.shared?.applyWindowDecorations(to: window) + super.init(window: window) + window.delegate = self + } + + @available(*, unavailable) + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + func show() { + window?.center() + window?.makeKeyAndOrderFront(nil) + } +} + +private struct SidebarDebugView: View { + @AppStorage("sidebarMatchTerminalBackground") private var matchTerminalBackground = false + @AppStorage("sidebarPreset") private var sidebarPreset = SidebarPresetOption.nativeSidebar.rawValue + @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 + @AppStorage(ShortcutHintDebugSettings.sidebarHintXKey) private var sidebarShortcutHintXOffset = ShortcutHintDebugSettings.defaultSidebarHintX + @AppStorage(ShortcutHintDebugSettings.sidebarHintYKey) private var sidebarShortcutHintYOffset = ShortcutHintDebugSettings.defaultSidebarHintY + @AppStorage(ShortcutHintDebugSettings.titlebarHintXKey) private var titlebarShortcutHintXOffset = ShortcutHintDebugSettings.defaultTitlebarHintX + @AppStorage(ShortcutHintDebugSettings.titlebarHintYKey) private var titlebarShortcutHintYOffset = ShortcutHintDebugSettings.defaultTitlebarHintY + @AppStorage(ShortcutHintDebugSettings.paneHintXKey) private var paneShortcutHintXOffset = ShortcutHintDebugSettings.defaultPaneHintX + @AppStorage(ShortcutHintDebugSettings.paneHintYKey) private var paneShortcutHintYOffset = ShortcutHintDebugSettings.defaultPaneHintY + @AppStorage(ShortcutHintDebugSettings.alwaysShowHintsKey) private var alwaysShowShortcutHints = ShortcutHintDebugSettings.defaultAlwaysShowHints + @AppStorage(DevBuildBannerDebugSettings.sidebarBannerVisibleKey) + private var showSidebarDevBuildBanner = DevBuildBannerDebugSettings.defaultShowSidebarBanner + @AppStorage(SidebarActiveTabIndicatorSettings.styleKey) + private var sidebarActiveTabIndicatorStyle = SidebarActiveTabIndicatorSettings.defaultStyle.rawValue + @AppStorage("sidebarSelectionColorHex") private var sidebarSelectionColorHex: String? + + private var selectedSidebarIndicatorStyle: SidebarActiveTabIndicatorStyle { + SidebarActiveTabIndicatorSettings.resolvedStyle(rawValue: sidebarActiveTabIndicatorStyle) + } + + private var sidebarIndicatorStyleSelection: Binding { + Binding( + get: { selectedSidebarIndicatorStyle.rawValue }, + set: { sidebarActiveTabIndicatorStyle = $0 } + ) + } + + private var selectionColorBinding: Binding { + Binding( + get: { + if let hex = sidebarSelectionColorHex, let nsColor = NSColor(hex: hex) { + return Color(nsColor: nsColor) + } + return programaAccentColor() + }, + set: { newColor in + let nsColor = NSColor(newColor) + sidebarSelectionColorHex = nsColor.hexString() + } + ) + } + + var body: some View { + ScrollView { + VStack(alignment: .leading, spacing: 14) { + Text("Sidebar Appearance") + .font(.headline) + + Toggle(String(localized: "settings.sidebarAppearance.matchTerminalBackground", defaultValue: "Match Terminal Background"), isOn: $matchTerminalBackground) + + GroupBox("Presets") { + Picker("Preset", selection: $sidebarPreset) { + ForEach(SidebarPresetOption.allCases) { option in + Text(option.title).tag(option.rawValue) + } + } + .onChange(of: sidebarPreset) { _ in + applyPreset() + } + .padding(.top, 2) + } + + GroupBox("Blur") { + VStack(alignment: .leading, spacing: 8) { + Picker("Material", selection: $sidebarMaterial) { + ForEach(SidebarMaterialOption.allCases) { option in + Text(option.title).tag(option.rawValue) + } + } + + Picker("Blending", selection: $sidebarBlendMode) { + ForEach(SidebarBlendModeOption.allCases) { option in + Text(option.title).tag(option.rawValue) + } + } + + Picker("State", selection: $sidebarState) { + ForEach(SidebarStateOption.allCases) { option in + Text(option.title).tag(option.rawValue) + } + } + + HStack(spacing: 8) { + Text("Strength") + Slider(value: $sidebarBlurOpacity, in: 0...1) + Text(String(format: "%.0f%%", sidebarBlurOpacity * 100)) + .font(.caption) + .frame(width: 44, alignment: .trailing) + } + } + .padding(.top, 2) + } + + GroupBox("Tint") { + VStack(alignment: .leading, spacing: 8) { + ColorPicker("Tint Color", selection: tintColorBinding, supportsOpacity: false) + + HStack(spacing: 8) { + Text("Opacity") + Slider(value: $sidebarTintOpacity, in: 0...0.7) + Text(String(format: "%.0f%%", sidebarTintOpacity * 100)) + .font(.caption) + .frame(width: 44, alignment: .trailing) + } + } + .padding(.top, 2) + } + + GroupBox("Shape") { + HStack(spacing: 8) { + Text("Corner Radius") + Slider(value: $sidebarCornerRadius, in: 0...20) + Text(String(format: "%.0f", sidebarCornerRadius)) + .font(.caption) + .frame(width: 32, alignment: .trailing) + } + .padding(.top, 2) + } + + GroupBox("Shortcut Hints") { + VStack(alignment: .leading, spacing: 10) { + Toggle("Always show shortcut hints", isOn: $alwaysShowShortcutHints) + + hintOffsetSection( + "Sidebar Cmd+1…9", + x: $sidebarShortcutHintXOffset, + y: $sidebarShortcutHintYOffset + ) + + hintOffsetSection( + "Titlebar Buttons", + x: $titlebarShortcutHintXOffset, + y: $titlebarShortcutHintYOffset + ) + + hintOffsetSection( + "Pane Ctrl/Cmd+1…9", + x: $paneShortcutHintXOffset, + y: $paneShortcutHintYOffset + ) + } + .padding(.top, 2) + } + + GroupBox("Active Workspace Indicator") { + VStack(alignment: .leading, spacing: 8) { + Picker("Style", selection: sidebarIndicatorStyleSelection) { + ForEach(SidebarActiveTabIndicatorStyle.allCases) { style in + Text(style.displayName).tag(style.rawValue) + } + } + + ColorPicker(String(localized: "sidebar.debug.selectionColor", defaultValue: "Selection Color"), selection: selectionColorBinding, supportsOpacity: false) + + if sidebarSelectionColorHex != nil { + Button(String(localized: "sidebar.debug.resetSelectionColor", defaultValue: "Reset to Default")) { + sidebarSelectionColorHex = nil + } + .font(.caption) + } + } + .padding(.top, 2) + } + + HStack(spacing: 12) { + Button("Reset Tint") { + sidebarTintOpacity = 0.62 + sidebarTintHex = SidebarTintDefaults.hex + sidebarTintHexLight = nil + sidebarTintHexDark = nil + } + Button("Reset Blur") { + sidebarMaterial = SidebarMaterialOption.hudWindow.rawValue + sidebarBlendMode = SidebarBlendModeOption.withinWindow.rawValue + sidebarState = SidebarStateOption.active.rawValue + sidebarBlurOpacity = 0.98 + } + Button("Reset Shape") { + sidebarCornerRadius = 0.0 + } + Button("Reset Hints") { + resetShortcutHintOffsets() + } + Button("Reset Active Indicator") { + sidebarActiveTabIndicatorStyle = SidebarActiveTabIndicatorSettings.defaultStyle.rawValue + sidebarSelectionColorHex = nil + } + } + + Button("Copy Config") { + copySidebarConfig() + } + + Spacer(minLength: 0) + } + .padding(16) + .frame(maxWidth: .infinity, alignment: .topLeading) + } + } + + private var tintColorBinding: Binding { + Binding( + get: { + Color(nsColor: NSColor(hex: sidebarTintHex) ?? .black) + }, + set: { newColor in + let nsColor = NSColor(newColor) + sidebarTintHex = nsColor.hexString() + } + ) + } + + private func hintOffsetSection(_ title: String, x: Binding, y: Binding) -> some View { + VStack(alignment: .leading, spacing: 6) { + Text(title) + .font(.caption) + .foregroundColor(.secondary) + sliderRow("X", value: x) + sliderRow("Y", value: y) + } + } + + private func sliderRow(_ label: String, value: Binding) -> some View { + HStack(spacing: 8) { + Text(label) + Slider(value: value, in: ShortcutHintDebugSettings.offsetRange) + Text(String(format: "%.1f", ShortcutHintDebugSettings.clamped(value.wrappedValue))) + .font(.caption) + .monospacedDigit() + .frame(width: 44, alignment: .trailing) + } + } + + private func resetShortcutHintOffsets() { + sidebarShortcutHintXOffset = ShortcutHintDebugSettings.defaultSidebarHintX + sidebarShortcutHintYOffset = ShortcutHintDebugSettings.defaultSidebarHintY + titlebarShortcutHintXOffset = ShortcutHintDebugSettings.defaultTitlebarHintX + titlebarShortcutHintYOffset = ShortcutHintDebugSettings.defaultTitlebarHintY + paneShortcutHintXOffset = ShortcutHintDebugSettings.defaultPaneHintX + paneShortcutHintYOffset = ShortcutHintDebugSettings.defaultPaneHintY + alwaysShowShortcutHints = ShortcutHintDebugSettings.defaultAlwaysShowHints + } + + private func copySidebarConfig() { + let payload = """ + sidebarPreset=\(sidebarPreset) + sidebarMaterial=\(sidebarMaterial) + sidebarBlendMode=\(sidebarBlendMode) + sidebarState=\(sidebarState) + sidebarBlurOpacity=\(String(format: "%.2f", sidebarBlurOpacity)) + sidebarTintHex=\(sidebarTintHex) + sidebarTintHexLight=\(sidebarTintHexLight ?? "(nil)") + sidebarTintHexDark=\(sidebarTintHexDark ?? "(nil)") + sidebarTintOpacity=\(String(format: "%.2f", sidebarTintOpacity)) + sidebarCornerRadius=\(String(format: "%.1f", sidebarCornerRadius)) + sidebarActiveTabIndicatorStyle=\(sidebarActiveTabIndicatorStyle) + sidebarDevBuildBannerVisible=\(showSidebarDevBuildBanner) + shortcutHintSidebarXOffset=\(String(format: "%.1f", ShortcutHintDebugSettings.clamped(sidebarShortcutHintXOffset))) + shortcutHintSidebarYOffset=\(String(format: "%.1f", ShortcutHintDebugSettings.clamped(sidebarShortcutHintYOffset))) + shortcutHintTitlebarXOffset=\(String(format: "%.1f", ShortcutHintDebugSettings.clamped(titlebarShortcutHintXOffset))) + shortcutHintTitlebarYOffset=\(String(format: "%.1f", ShortcutHintDebugSettings.clamped(titlebarShortcutHintYOffset))) + shortcutHintPaneTabXOffset=\(String(format: "%.1f", ShortcutHintDebugSettings.clamped(paneShortcutHintXOffset))) + shortcutHintPaneTabYOffset=\(String(format: "%.1f", ShortcutHintDebugSettings.clamped(paneShortcutHintYOffset))) + shortcutHintAlwaysShow=\(alwaysShowShortcutHints) + """ + let pasteboard = NSPasteboard.general + pasteboard.clearContents() + pasteboard.setString(payload, forType: .string) + } + + private func applyPreset() { + guard let preset = SidebarPresetOption(rawValue: sidebarPreset) else { return } + sidebarMaterial = preset.material.rawValue + sidebarBlendMode = preset.blendMode.rawValue + sidebarState = preset.state.rawValue + sidebarTintHex = preset.tintHex + sidebarTintOpacity = preset.tintOpacity + sidebarCornerRadius = preset.cornerRadius + sidebarBlurOpacity = preset.blurOpacity + sidebarTintHexLight = nil + sidebarTintHexDark = nil + } +} + +// MARK: - Menu Bar Extra Debug Window + +final class MenuBarExtraDebugWindowController: NSWindowController, NSWindowDelegate { + static let shared = MenuBarExtraDebugWindowController() + + private init() { + let window = NSPanel( + contentRect: NSRect(x: 0, y: 0, width: 420, height: 430), + styleMask: [.titled, .closable, .utilityWindow], + backing: .buffered, + defer: false + ) + window.title = "Menu Bar Extra Debug" + window.titleVisibility = .visible + window.titlebarAppearsTransparent = false + window.isMovableByWindowBackground = true + window.isReleasedWhenClosed = false + window.identifier = NSUserInterfaceItemIdentifier("programa.menubarDebug") + window.center() + window.contentView = NSHostingView(rootView: MenuBarExtraDebugView()) + AppDelegate.shared?.applyWindowDecorations(to: window) + super.init(window: window) + window.delegate = self + } + + @available(*, unavailable) + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + func show() { + window?.center() + window?.makeKeyAndOrderFront(nil) + } +} + +private struct MenuBarExtraDebugView: View { + @AppStorage(MenuBarIconDebugSettings.previewEnabledKey) private var previewEnabled = false + @AppStorage(MenuBarIconDebugSettings.previewCountKey) private var previewCount = 1 + @AppStorage(MenuBarIconDebugSettings.badgeRectXKey) private var badgeRectX = Double(MenuBarIconDebugSettings.defaultBadgeRect.origin.x) + @AppStorage(MenuBarIconDebugSettings.badgeRectYKey) private var badgeRectY = Double(MenuBarIconDebugSettings.defaultBadgeRect.origin.y) + @AppStorage(MenuBarIconDebugSettings.badgeRectWidthKey) private var badgeRectWidth = Double(MenuBarIconDebugSettings.defaultBadgeRect.width) + @AppStorage(MenuBarIconDebugSettings.badgeRectHeightKey) private var badgeRectHeight = Double(MenuBarIconDebugSettings.defaultBadgeRect.height) + @AppStorage(MenuBarIconDebugSettings.singleDigitFontSizeKey) private var singleDigitFontSize = Double(MenuBarIconDebugSettings.defaultSingleDigitFontSize) + @AppStorage(MenuBarIconDebugSettings.multiDigitFontSizeKey) private var multiDigitFontSize = Double(MenuBarIconDebugSettings.defaultMultiDigitFontSize) + @AppStorage(MenuBarIconDebugSettings.singleDigitYOffsetKey) private var singleDigitYOffset = Double(MenuBarIconDebugSettings.defaultSingleDigitYOffset) + @AppStorage(MenuBarIconDebugSettings.multiDigitYOffsetKey) private var multiDigitYOffset = Double(MenuBarIconDebugSettings.defaultMultiDigitYOffset) + @AppStorage(MenuBarIconDebugSettings.singleDigitXAdjustKey) private var singleDigitXAdjust = Double(MenuBarIconDebugSettings.defaultSingleDigitXAdjust) + @AppStorage(MenuBarIconDebugSettings.multiDigitXAdjustKey) private var multiDigitXAdjust = Double(MenuBarIconDebugSettings.defaultMultiDigitXAdjust) + @AppStorage(MenuBarIconDebugSettings.textRectWidthAdjustKey) private var textRectWidthAdjust = Double(MenuBarIconDebugSettings.defaultTextRectWidthAdjust) + + var body: some View { + ScrollView { + VStack(alignment: .leading, spacing: 14) { + Text("Menu Bar Extra Icon") + .font(.headline) + + GroupBox("Preview Count") { + VStack(alignment: .leading, spacing: 8) { + Toggle("Override unread count", isOn: $previewEnabled) + + Stepper(value: $previewCount, in: 0...99) { + HStack { + Text("Unread Count") + Spacer() + Text("\(previewCount)") + .font(.caption) + .monospacedDigit() + } + } + .disabled(!previewEnabled) + } + .padding(.top, 2) + } + + GroupBox("Badge Rect") { + VStack(alignment: .leading, spacing: 8) { + sliderRow("X", value: $badgeRectX, range: 0...20, format: "%.2f") + sliderRow("Y", value: $badgeRectY, range: 0...20, format: "%.2f") + sliderRow("Width", value: $badgeRectWidth, range: 4...14, format: "%.2f") + sliderRow("Height", value: $badgeRectHeight, range: 4...14, format: "%.2f") + } + .padding(.top, 2) + } + + GroupBox("Badge Text") { + VStack(alignment: .leading, spacing: 8) { + sliderRow("1-digit size", value: $singleDigitFontSize, range: 6...14, format: "%.2f") + sliderRow("2-digit size", value: $multiDigitFontSize, range: 6...14, format: "%.2f") + sliderRow("1-digit X", value: $singleDigitXAdjust, range: -4...4, format: "%.2f") + sliderRow("2-digit X", value: $multiDigitXAdjust, range: -4...4, format: "%.2f") + sliderRow("1-digit Y", value: $singleDigitYOffset, range: -3...4, format: "%.2f") + sliderRow("2-digit Y", value: $multiDigitYOffset, range: -3...4, format: "%.2f") + sliderRow("Text width adjust", value: $textRectWidthAdjust, range: -3...5, format: "%.2f") + } + .padding(.top, 2) + } + + HStack(spacing: 12) { + Button("Reset") { + previewEnabled = false + previewCount = 1 + badgeRectX = Double(MenuBarIconDebugSettings.defaultBadgeRect.origin.x) + badgeRectY = Double(MenuBarIconDebugSettings.defaultBadgeRect.origin.y) + badgeRectWidth = Double(MenuBarIconDebugSettings.defaultBadgeRect.width) + badgeRectHeight = Double(MenuBarIconDebugSettings.defaultBadgeRect.height) + singleDigitFontSize = Double(MenuBarIconDebugSettings.defaultSingleDigitFontSize) + multiDigitFontSize = Double(MenuBarIconDebugSettings.defaultMultiDigitFontSize) + singleDigitYOffset = Double(MenuBarIconDebugSettings.defaultSingleDigitYOffset) + multiDigitYOffset = Double(MenuBarIconDebugSettings.defaultMultiDigitYOffset) + singleDigitXAdjust = Double(MenuBarIconDebugSettings.defaultSingleDigitXAdjust) + multiDigitXAdjust = Double(MenuBarIconDebugSettings.defaultMultiDigitXAdjust) + textRectWidthAdjust = Double(MenuBarIconDebugSettings.defaultTextRectWidthAdjust) + applyLiveUpdate() + } + + Button("Copy Config") { + let payload = MenuBarIconDebugSettings.copyPayload() + let pasteboard = NSPasteboard.general + pasteboard.clearContents() + pasteboard.setString(payload, forType: .string) + } + } + + Text("Tip: enable override count, then tune until the menu bar icon looks right.") + .font(.caption) + .foregroundColor(.secondary) + + Spacer(minLength: 0) + } + .padding(16) + .frame(maxWidth: .infinity, alignment: .topLeading) + } + .onAppear { applyLiveUpdate() } + .onChange(of: previewEnabled) { _ in applyLiveUpdate() } + .onChange(of: previewCount) { _ in applyLiveUpdate() } + .onChange(of: badgeRectX) { _ in applyLiveUpdate() } + .onChange(of: badgeRectY) { _ in applyLiveUpdate() } + .onChange(of: badgeRectWidth) { _ in applyLiveUpdate() } + .onChange(of: badgeRectHeight) { _ in applyLiveUpdate() } + .onChange(of: singleDigitFontSize) { _ in applyLiveUpdate() } + .onChange(of: multiDigitFontSize) { _ in applyLiveUpdate() } + .onChange(of: singleDigitXAdjust) { _ in applyLiveUpdate() } + .onChange(of: multiDigitXAdjust) { _ in applyLiveUpdate() } + .onChange(of: singleDigitYOffset) { _ in applyLiveUpdate() } + .onChange(of: multiDigitYOffset) { _ in applyLiveUpdate() } + .onChange(of: textRectWidthAdjust) { _ in applyLiveUpdate() } + } + + private func sliderRow( + _ label: String, + value: Binding, + range: ClosedRange, + format: String + ) -> some View { + HStack(spacing: 8) { + Text(label) + Slider(value: value, in: range) + Text(String(format: format, value.wrappedValue)) + .font(.caption) + .monospacedDigit() + .frame(width: 58, alignment: .trailing) + } + } + + private func applyLiveUpdate() { + AppDelegate.shared?.refreshMenuBarExtraForDebug() + } +} + +// MARK: - Split Button Layout Debug Window + +final class SplitButtonLayoutDebugWindowController: NSWindowController, NSWindowDelegate { + static let shared = SplitButtonLayoutDebugWindowController() + + private init() { + let window = NSPanel( + contentRect: NSRect(x: 0, y: 0, width: 320, height: 240), + styleMask: [.titled, .closable, .utilityWindow], + backing: .buffered, + defer: false + ) + window.title = "Split Button Layout" + window.titleVisibility = .visible + window.titlebarAppearsTransparent = false + window.isMovableByWindowBackground = true + window.isReleasedWhenClosed = false + window.identifier = NSUserInterfaceItemIdentifier("programa.splitButtonLayoutDebug") + window.center() + window.contentView = NSHostingView(rootView: SplitButtonLayoutDebugView()) + AppDelegate.shared?.applyWindowDecorations(to: window) + super.init(window: window) + window.delegate = self + } + + @available(*, unavailable) + required init?(coder: NSCoder) { fatalError() } + + func show() { + window?.center() + window?.makeKeyAndOrderFront(nil) + } +} + +private struct SplitButtonLayoutDebugView: View { + @AppStorage("debugFadeColorStyle") private var backdropStyle = 0 + + private let options: [(Int, String)] = [ + (0, "Pre-composited paneBackground"), + (1, "Raw paneBackground (opaque)"), + (2, "barBackground (tab chrome)"), + (3, "windowBackgroundColor"), + (4, "controlBackgroundColor"), + (5, "Pre-composited barBackground"), + ] + + var body: some View { + VStack(alignment: .leading, spacing: 10) { + Text("Button Backdrop Color") + .font(.headline) + + ForEach(options, id: \.0) { id, label in + HStack { + Image(systemName: backdropStyle == id ? "checkmark.circle.fill" : "circle") + .foregroundColor(backdropStyle == id ? .accentColor : .secondary) + Text(label) + } + .contentShape(Rectangle()) + .onTapGesture { backdropStyle = id } + } + + Text("Changes apply live.") + .font(.caption) + .foregroundColor(.secondary) + } + .padding(16) + .frame(maxWidth: .infinity, alignment: .topLeading) + } +} + +// MARK: - Background Debug Window + +final class BackgroundDebugWindowController: NSWindowController, NSWindowDelegate { + static let shared = BackgroundDebugWindowController() + + private init() { + let window = NSPanel( + contentRect: NSRect(x: 0, y: 0, width: 360, height: 300), + styleMask: [.titled, .closable, .utilityWindow], + backing: .buffered, + defer: false + ) + window.title = "Background Debug" + window.titleVisibility = .visible + window.titlebarAppearsTransparent = false + window.isMovableByWindowBackground = true + window.isReleasedWhenClosed = false + window.identifier = NSUserInterfaceItemIdentifier("programa.backgroundDebug") + window.center() + window.contentView = NSHostingView(rootView: BackgroundDebugView()) + AppDelegate.shared?.applyWindowDecorations(to: window) + super.init(window: window) + window.delegate = self + } + + @available(*, unavailable) + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + func show() { + window?.center() + window?.makeKeyAndOrderFront(nil) + } +} + +private struct BackgroundDebugView: View { + @AppStorage("bgGlassTintHex") private var bgGlassTintHex = "#000000" + @AppStorage("bgGlassTintOpacity") private var bgGlassTintOpacity = 0.03 + @AppStorage("bgGlassMaterial") private var bgGlassMaterial = "hudWindow" + @AppStorage("bgGlassEnabled") private var bgGlassEnabled = false + + var body: some View { + ScrollView { + VStack(alignment: .leading, spacing: 14) { + Text("Window Background Glass") + .font(.headline) + + GroupBox("Glass Effect") { + VStack(alignment: .leading, spacing: 8) { + Toggle("Enable Glass Effect", isOn: $bgGlassEnabled) + + Picker("Material", selection: $bgGlassMaterial) { + Text("HUD Window").tag("hudWindow") + Text("Under Window").tag("underWindowBackground") + Text("Sidebar").tag("sidebar") + Text("Menu").tag("menu") + Text("Popover").tag("popover") + } + .disabled(!bgGlassEnabled) + } + .padding(.top, 2) + } + + GroupBox("Tint") { + VStack(alignment: .leading, spacing: 8) { + ColorPicker("Tint Color", selection: tintColorBinding, supportsOpacity: false) + .disabled(!bgGlassEnabled) + + HStack(spacing: 8) { + Text("Opacity") + Slider(value: $bgGlassTintOpacity, in: 0...0.8) + .disabled(!bgGlassEnabled) + Text(String(format: "%.0f%%", bgGlassTintOpacity * 100)) + .font(.caption) + .frame(width: 44, alignment: .trailing) + } + } + .padding(.top, 2) + } + + HStack(spacing: 12) { + Button("Reset") { + bgGlassTintHex = "#000000" + bgGlassTintOpacity = 0.03 + bgGlassMaterial = "hudWindow" + bgGlassEnabled = false + updateWindowGlassTint() + } + + Button("Copy Config") { + copyBgConfig() + } + } + + Text("Tint changes apply live. Enable/disable requires reload.") + .font(.caption) + .foregroundColor(.secondary) + + Spacer(minLength: 0) + } + .padding(16) + .frame(maxWidth: .infinity, alignment: .topLeading) + } + .onChange(of: bgGlassTintHex) { _ in updateWindowGlassTint() } + .onChange(of: bgGlassTintOpacity) { _ in updateWindowGlassTint() } + } + + private func updateWindowGlassTint() { + let window: NSWindow? = { + if let key = NSApp.keyWindow, + let raw = key.identifier?.rawValue, + raw == "cmux.main" || raw.hasPrefix("cmux.main.") { + return key + } + return NSApp.windows.first(where: { + guard let raw = $0.identifier?.rawValue else { return false } + return raw == "cmux.main" || raw.hasPrefix("cmux.main.") + }) + }() + guard let window else { return } + let tintColor = (NSColor(hex: bgGlassTintHex) ?? .black).withAlphaComponent(bgGlassTintOpacity) + WindowGlassEffect.updateTint(to: window, color: tintColor) + } + + private var tintColorBinding: Binding { + Binding( + get: { + Color(nsColor: NSColor(hex: bgGlassTintHex) ?? .black) + }, + set: { newColor in + let nsColor = NSColor(newColor) + bgGlassTintHex = nsColor.hexString() + } + ) + } + + private func copyBgConfig() { + let payload = """ + bgGlassEnabled=\(bgGlassEnabled) + bgGlassMaterial=\(bgGlassMaterial) + bgGlassTintHex=\(bgGlassTintHex) + bgGlassTintOpacity=\(String(format: "%.2f", bgGlassTintOpacity)) + """ + let pasteboard = NSPasteboard.general + pasteboard.clearContents() + pasteboard.setString(payload, forType: .string) + } +} + diff --git a/Sources/ProgramaApp.swift b/Sources/ProgramaApp.swift index b03ce611818..377529e275d 100644 --- a/Sources/ProgramaApp.swift +++ b/Sources/ProgramaApp.swift @@ -4,39 +4,6 @@ import Darwin import Bonsplit import UniformTypeIdentifiers -enum WorkspacePresentationModeSettings { - static let modeKey = "workspacePresentationMode" - - enum Mode: String { - case standard - case minimal - } - - static let defaultMode: Mode = .minimal - - static func mode(for rawValue: String?) -> Mode { - Mode(rawValue: rawValue ?? "") ?? defaultMode - } - - static func mode(defaults: UserDefaults = .standard) -> Mode { - mode(for: defaults.string(forKey: modeKey)) - } - - static func isMinimal(defaults: UserDefaults = .standard) -> Bool { - mode(defaults: defaults) == .minimal - } -} - -enum PaneFirstClickFocusSettings { - static let enabledKey = "paneFirstClickFocus.enabled" - static let defaultEnabled = false - private static let flag = UserDefaultsFlag(key: enabledKey, defaultValue: defaultEnabled) - - static func isEnabled(defaults: UserDefaults = .standard) -> Bool { - flag.isEnabled(defaults: defaults) - } -} - enum UITestLaunchManifest { static let argumentName = "-cmuxUITestLaunchManifest" @@ -1119,324 +1086,54 @@ func programaWindowShouldOwnCloseShortcut(_ window: NSWindow?) -> Bool { return programaAuxiliaryWindowIdentifiers.contains(identifier) } -private enum SettingsAboutWindowKind: String, CaseIterable, Identifiable { - case settings - case about - - var id: String { rawValue } - - var displayTitle: String { - switch self { - case .settings: - return "Settings Window" - case .about: - return "About Window" - } - } - - var windowIdentifier: String { - switch self { - case .settings: - return "cmux.settings" - case .about: - return "cmux.about" - } - } - - var fallbackTitle: String { - switch self { - case .settings: - return "Settings" - case .about: - return "About Programa" - } - } - - var minimumSize: NSSize { - switch self { - case .settings: - return NSSize(width: 420, height: 360) - case .about: - return NSSize(width: 360, height: 520) - } - } -} - -private enum TitlebarVisibilityOption: String, CaseIterable, Identifiable { - case hidden - case visible - - var id: String { rawValue } - - var displayTitle: String { - switch self { - case .hidden: - return "Hidden" - case .visible: - return "Visible" - } - } - - var windowValue: NSWindow.TitleVisibility { - switch self { - case .hidden: - return .hidden - case .visible: - return .visible - } - } -} - -private enum TitlebarToolbarStyleOption: String, CaseIterable, Identifiable { - case automatic - case expanded - case preference - case unified - case unifiedCompact - - var id: String { rawValue } - - var displayTitle: String { - switch self { - case .automatic: - return "Automatic" - case .expanded: - return "Expanded" - case .preference: - return "Preference" - case .unified: - return "Unified" - case .unifiedCompact: - return "Unified Compact" - } - } - - var windowValue: NSWindow.ToolbarStyle { - switch self { - case .automatic: - return .automatic - case .expanded: - return .expanded - case .preference: - return .preference - case .unified: - return .unified - case .unifiedCompact: - return .unifiedCompact - } - } -} - -private struct SettingsAboutTitlebarDebugOptions: Equatable { - var overridesEnabled: Bool - var windowTitle: String - var titleVisibility: TitlebarVisibilityOption - var titlebarAppearsTransparent: Bool - var movableByWindowBackground: Bool - var titled: Bool - var closable: Bool - var miniaturizable: Bool - var resizable: Bool - var fullSizeContentView: Bool - var showToolbar: Bool - var toolbarStyle: TitlebarToolbarStyleOption - - static func defaults(for kind: SettingsAboutWindowKind) -> SettingsAboutTitlebarDebugOptions { - switch kind { - case .settings: - return SettingsAboutTitlebarDebugOptions( - overridesEnabled: false, - windowTitle: "Settings", - titleVisibility: .hidden, - titlebarAppearsTransparent: true, - movableByWindowBackground: true, - titled: true, - closable: true, - miniaturizable: true, - resizable: true, - fullSizeContentView: true, - showToolbar: false, - toolbarStyle: .unifiedCompact - ) - case .about: - return SettingsAboutTitlebarDebugOptions( - overridesEnabled: false, - windowTitle: "About Programa", - titleVisibility: .hidden, - titlebarAppearsTransparent: true, - movableByWindowBackground: false, - titled: true, - closable: true, - miniaturizable: true, - resizable: false, - fullSizeContentView: false, - showToolbar: false, - toolbarStyle: .automatic - ) - } - } -} - -@MainActor -private final class SettingsAboutTitlebarDebugStore: ObservableObject { - static let shared = SettingsAboutTitlebarDebugStore() - - @Published var settingsOptions = SettingsAboutTitlebarDebugOptions.defaults(for: .settings) { - didSet { applyToOpenWindows(for: .settings) } - } - @Published var aboutOptions = SettingsAboutTitlebarDebugOptions.defaults(for: .about) { - didSet { applyToOpenWindows(for: .about) } - } - - private init() {} - - func options(for kind: SettingsAboutWindowKind) -> SettingsAboutTitlebarDebugOptions { - switch kind { - case .settings: - return settingsOptions - case .about: - return aboutOptions - } - } - - func update(_ newValue: SettingsAboutTitlebarDebugOptions, for kind: SettingsAboutWindowKind) { - switch kind { - case .settings: - settingsOptions = newValue - case .about: - aboutOptions = newValue - } - } - - func reset(_ kind: SettingsAboutWindowKind) { - update(SettingsAboutTitlebarDebugOptions.defaults(for: kind), for: kind) - } - - func applyToOpenWindows(for kind: SettingsAboutWindowKind) { - for window in NSApp.windows where window.identifier?.rawValue == kind.windowIdentifier { - apply(options(for: kind), to: window, for: kind) - } - } - - func applyToOpenWindows() { - applyToOpenWindows(for: .settings) - applyToOpenWindows(for: .about) - } - - func applyCurrentOptions(to window: NSWindow, for kind: SettingsAboutWindowKind) { - apply(options(for: kind), to: window, for: kind) - } - - func copyConfigToPasteboard() { - let settings = options(for: .settings) - let about = options(for: .about) - let payload = """ - # Settings/About Titlebar Debug - settings.overridesEnabled=\(settings.overridesEnabled) - settings.title=\(settings.windowTitle) - settings.titleVisibility=\(settings.titleVisibility.rawValue) - settings.titlebarAppearsTransparent=\(settings.titlebarAppearsTransparent) - settings.movableByWindowBackground=\(settings.movableByWindowBackground) - settings.titled=\(settings.titled) - settings.closable=\(settings.closable) - settings.miniaturizable=\(settings.miniaturizable) - settings.resizable=\(settings.resizable) - settings.fullSizeContentView=\(settings.fullSizeContentView) - settings.showToolbar=\(settings.showToolbar) - settings.toolbarStyle=\(settings.toolbarStyle.rawValue) - about.overridesEnabled=\(about.overridesEnabled) - about.title=\(about.windowTitle) - about.titleVisibility=\(about.titleVisibility.rawValue) - about.titlebarAppearsTransparent=\(about.titlebarAppearsTransparent) - about.movableByWindowBackground=\(about.movableByWindowBackground) - about.titled=\(about.titled) - about.closable=\(about.closable) - about.miniaturizable=\(about.miniaturizable) - about.resizable=\(about.resizable) - about.fullSizeContentView=\(about.fullSizeContentView) - about.showToolbar=\(about.showToolbar) - about.toolbarStyle=\(about.toolbarStyle.rawValue) - """ - let pasteboard = NSPasteboard.general - pasteboard.clearContents() - pasteboard.setString(payload, forType: .string) - } - - private func apply(_ options: SettingsAboutTitlebarDebugOptions, to window: NSWindow, for kind: SettingsAboutWindowKind) { - let effective = options.overridesEnabled ? options : SettingsAboutTitlebarDebugOptions.defaults(for: kind) - let resolvedTitle = effective.windowTitle.trimmingCharacters(in: .whitespacesAndNewlines) - window.title = resolvedTitle.isEmpty ? kind.fallbackTitle : resolvedTitle - window.titleVisibility = effective.titleVisibility.windowValue - window.titlebarAppearsTransparent = effective.titlebarAppearsTransparent - window.isMovableByWindowBackground = effective.movableByWindowBackground - window.toolbarStyle = effective.toolbarStyle.windowValue - - if effective.showToolbar { - ensureToolbar(on: window, kind: kind) - } else if window.toolbar != nil { - window.toolbar = nil - } +private final class AboutWindowController: NSWindowController, NSWindowDelegate { + static let shared = AboutWindowController() - var styleMask = window.styleMask - setStyleMaskBit(&styleMask, .titled, enabled: effective.titled) - setStyleMaskBit(&styleMask, .closable, enabled: effective.closable) - setStyleMaskBit(&styleMask, .miniaturizable, enabled: effective.miniaturizable) - setStyleMaskBit(&styleMask, .resizable, enabled: effective.resizable) - setStyleMaskBit(&styleMask, .fullSizeContentView, enabled: effective.fullSizeContentView) - window.styleMask = styleMask - - let maxSize = effective.resizable ? NSSize(width: 8192, height: 8192) : kind.minimumSize - window.minSize = kind.minimumSize - window.maxSize = maxSize - window.contentMinSize = kind.minimumSize - window.contentMaxSize = maxSize - window.invalidateShadow() + private init() { + let window = NSWindow( + contentRect: NSRect(x: 0, y: 0, width: 360, height: 520), + styleMask: [.titled, .closable, .miniaturizable], + backing: .buffered, + defer: false + ) + window.isReleasedWhenClosed = false + window.identifier = NSUserInterfaceItemIdentifier("cmux.about") + window.center() + window.contentView = NSHostingView(rootView: AboutPanelView()) + SettingsAboutTitlebarDebugStore.shared.applyCurrentOptions(to: window, for: .about) AppDelegate.shared?.applyWindowDecorations(to: window) + super.init(window: window) + window.delegate = self } - private func ensureToolbar(on window: NSWindow, kind: SettingsAboutWindowKind) { - guard window.toolbar == nil else { return } - let identifier = NSToolbar.Identifier("programa.debug.titlebar.\(kind.rawValue)") - let toolbar = NSToolbar(identifier: identifier) - toolbar.allowsUserCustomization = false - toolbar.autosavesConfiguration = false - toolbar.displayMode = .iconOnly - toolbar.showsBaselineSeparator = false - window.toolbar = toolbar + @available(*, unavailable) + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") } - private func setStyleMaskBit( - _ styleMask: inout NSWindow.StyleMask, - _ bit: NSWindow.StyleMask, - enabled: Bool - ) { - if enabled { - styleMask.insert(bit) - } else { - styleMask.remove(bit) - } + func show() { + guard let window else { return } + SettingsAboutTitlebarDebugStore.shared.applyCurrentOptions(to: window, for: .about) + window.center() + window.makeKeyAndOrderFront(nil) } } -private final class SettingsAboutTitlebarDebugWindowController: NSWindowController, NSWindowDelegate { - static let shared = SettingsAboutTitlebarDebugWindowController() +private final class AcknowledgmentsWindowController: NSWindowController, NSWindowDelegate { + static let shared = AcknowledgmentsWindowController() private init() { - let window = NSPanel( - contentRect: NSRect(x: 0, y: 0, width: 470, height: 690), - styleMask: [.titled, .closable, .resizable, .utilityWindow], + let window = NSWindow( + contentRect: NSRect(x: 0, y: 0, width: 500, height: 480), + styleMask: [.titled, .closable, .miniaturizable, .resizable], backing: .buffered, defer: false ) - window.title = "Settings/About Titlebar Debug" - window.titleVisibility = .visible - window.titlebarAppearsTransparent = false - window.isMovableByWindowBackground = true window.isReleasedWhenClosed = false - window.identifier = NSUserInterfaceItemIdentifier("programa.settingsAboutTitlebarDebug") + window.title = String(localized: "about.licenses.windowTitle", defaultValue: "Third-Party Licenses") + window.identifier = NSUserInterfaceItemIdentifier("programa.licenses") window.center() - window.contentView = NSHostingView(rootView: SettingsAboutTitlebarDebugView()) - AppDelegate.shared?.applyWindowDecorations(to: window) + window.contentView = NSHostingView(rootView: AcknowledgmentsView()) super.init(window: window) window.delegate = self } @@ -1447,222 +1144,48 @@ private final class SettingsAboutTitlebarDebugWindowController: NSWindowControll } func show() { - window?.center() - window?.makeKeyAndOrderFront(nil) - SettingsAboutTitlebarDebugStore.shared.applyToOpenWindows() + guard let window else { return } + window.makeKeyAndOrderFront(nil) } } -private struct SettingsAboutTitlebarDebugView: View { - @ObservedObject private var store = SettingsAboutTitlebarDebugStore.shared +private struct AcknowledgmentsView: View { + private let content: String = { + if let url = Bundle.main.url(forResource: "THIRD_PARTY_LICENSES", withExtension: "md"), + let text = try? String(contentsOf: url) { + return text + } + return String(localized: "about.licenses.notFound", defaultValue: "Licenses file not found.") + }() var body: some View { ScrollView { - VStack(alignment: .leading, spacing: 14) { - Text("Settings/About Titlebar Debug") - .font(.headline) - - editor(for: .settings) - editor(for: .about) - - GroupBox("Actions") { - HStack(spacing: 10) { - Button("Reset All") { - store.reset(.settings) - store.reset(.about) - } - Button("Reapply to Open Windows") { - store.applyToOpenWindows() - } - Button("Copy Config") { - store.copyConfigToPasteboard() - } - } - .frame(maxWidth: .infinity, alignment: .leading) - .padding(.top, 2) - } - - Spacer(minLength: 0) - } - .padding(16) - .frame(maxWidth: .infinity, alignment: .topLeading) - } - .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading) - } - - private func editor(for kind: SettingsAboutWindowKind) -> some View { - let overridesEnabled = binding(for: kind, keyPath: \.overridesEnabled) - - return GroupBox(kind.displayTitle) { - VStack(alignment: .leading, spacing: 10) { - Toggle("Enable Debug Overrides", isOn: overridesEnabled) - - Text("When disabled, Programa uses normal default titlebar behavior for this window.") - .font(.caption) - .foregroundColor(.secondary) - - Divider() - - VStack(alignment: .leading, spacing: 10) { - HStack(spacing: 8) { - Text("Window Title") - TextField("", text: binding(for: kind, keyPath: \.windowTitle)) - } - - HStack(spacing: 10) { - Picker("Title Visibility", selection: binding(for: kind, keyPath: \.titleVisibility)) { - ForEach(TitlebarVisibilityOption.allCases) { option in - Text(option.displayTitle).tag(option) - } - } - Picker("Toolbar Style", selection: binding(for: kind, keyPath: \.toolbarStyle)) { - ForEach(TitlebarToolbarStyleOption.allCases) { option in - Text(option.displayTitle).tag(option) - } - } - } - - Toggle("Show Toolbar", isOn: binding(for: kind, keyPath: \.showToolbar)) - Toggle("Transparent Titlebar", isOn: binding(for: kind, keyPath: \.titlebarAppearsTransparent)) - Toggle("Movable by Window Background", isOn: binding(for: kind, keyPath: \.movableByWindowBackground)) - - Divider() - - Text("Style Mask") - .font(.caption) - .foregroundColor(.secondary) - - Toggle("Titled", isOn: binding(for: kind, keyPath: \.titled)) - Toggle("Closable", isOn: binding(for: kind, keyPath: \.closable)) - Toggle("Miniaturizable", isOn: binding(for: kind, keyPath: \.miniaturizable)) - Toggle("Resizable", isOn: binding(for: kind, keyPath: \.resizable)) - Toggle("Full Size Content View", isOn: binding(for: kind, keyPath: \.fullSizeContentView)) - - HStack(spacing: 10) { - Button("Reset \(kind == .settings ? "Settings" : "About")") { - store.reset(kind) - } - Button("Apply Now") { - store.applyToOpenWindows(for: kind) - } - } - } - .disabled(!overridesEnabled.wrappedValue) - .opacity(overridesEnabled.wrappedValue ? 1 : 0.75) - } - .padding(.top, 2) - } - } - - private func binding( - for kind: SettingsAboutWindowKind, - keyPath: WritableKeyPath - ) -> Binding { - Binding( - get: { store.options(for: kind)[keyPath: keyPath] }, - set: { newValue in - var updated = store.options(for: kind) - updated[keyPath: keyPath] = newValue - store.update(updated, for: kind) - } - ) - } -} - -private enum DebugWindowConfigSnapshot { - static func copyCombinedToPasteboard(defaults: UserDefaults = .standard) { - let pasteboard = NSPasteboard.general - pasteboard.clearContents() - pasteboard.setString(combinedPayload(defaults: defaults), forType: .string) - } - - static func combinedPayload(defaults: UserDefaults = .standard) -> String { - let sidebarPayload = """ - sidebarPreset=\(stringValue(defaults, key: "sidebarPreset", fallback: SidebarPresetOption.nativeSidebar.rawValue)) - sidebarMaterial=\(stringValue(defaults, key: "sidebarMaterial", fallback: SidebarMaterialOption.sidebar.rawValue)) - sidebarBlendMode=\(stringValue(defaults, key: "sidebarBlendMode", fallback: SidebarBlendModeOption.withinWindow.rawValue)) - sidebarState=\(stringValue(defaults, key: "sidebarState", fallback: SidebarStateOption.followWindow.rawValue)) - sidebarBlurOpacity=\(String(format: "%.2f", doubleValue(defaults, key: "sidebarBlurOpacity", fallback: 1.0))) - sidebarTintHex=\(stringValue(defaults, key: "sidebarTintHex", fallback: "#000000")) - sidebarTintHexLight=\(stringValue(defaults, key: "sidebarTintHexLight", fallback: "(nil)")) - sidebarTintHexDark=\(stringValue(defaults, key: "sidebarTintHexDark", fallback: "(nil)")) - sidebarTintOpacity=\(String(format: "%.2f", doubleValue(defaults, key: "sidebarTintOpacity", fallback: 0.18))) - sidebarCornerRadius=\(String(format: "%.1f", doubleValue(defaults, key: "sidebarCornerRadius", fallback: 0.0))) - sidebarActiveTabIndicatorStyle=\(stringValue(defaults, key: SidebarActiveTabIndicatorSettings.styleKey, fallback: SidebarActiveTabIndicatorSettings.defaultStyle.rawValue)) - sidebarDevBuildBannerVisible=\(boolValue(defaults, key: DevBuildBannerDebugSettings.sidebarBannerVisibleKey, fallback: DevBuildBannerDebugSettings.defaultShowSidebarBanner)) - shortcutHintSidebarXOffset=\(String(format: "%.1f", doubleValue(defaults, key: ShortcutHintDebugSettings.sidebarHintXKey, fallback: ShortcutHintDebugSettings.defaultSidebarHintX))) - shortcutHintSidebarYOffset=\(String(format: "%.1f", doubleValue(defaults, key: ShortcutHintDebugSettings.sidebarHintYKey, fallback: ShortcutHintDebugSettings.defaultSidebarHintY))) - shortcutHintTitlebarXOffset=\(String(format: "%.1f", doubleValue(defaults, key: ShortcutHintDebugSettings.titlebarHintXKey, fallback: ShortcutHintDebugSettings.defaultTitlebarHintX))) - shortcutHintTitlebarYOffset=\(String(format: "%.1f", doubleValue(defaults, key: ShortcutHintDebugSettings.titlebarHintYKey, fallback: ShortcutHintDebugSettings.defaultTitlebarHintY))) - shortcutHintPaneTabXOffset=\(String(format: "%.1f", doubleValue(defaults, key: ShortcutHintDebugSettings.paneHintXKey, fallback: ShortcutHintDebugSettings.defaultPaneHintX))) - shortcutHintPaneTabYOffset=\(String(format: "%.1f", doubleValue(defaults, key: ShortcutHintDebugSettings.paneHintYKey, fallback: ShortcutHintDebugSettings.defaultPaneHintY))) - shortcutHintAlwaysShow=\(boolValue(defaults, key: ShortcutHintDebugSettings.alwaysShowHintsKey, fallback: ShortcutHintDebugSettings.defaultAlwaysShowHints)) - shortcutHintShowOnCommandHold=\(boolValue(defaults, key: ShortcutHintDebugSettings.showHintsOnCommandHoldKey, fallback: ShortcutHintDebugSettings.defaultShowHintsOnCommandHold)) - """ - - let backgroundPayload = """ - bgGlassEnabled=\(boolValue(defaults, key: "bgGlassEnabled", fallback: false)) - bgGlassMaterial=\(stringValue(defaults, key: "bgGlassMaterial", fallback: "hudWindow")) - bgGlassTintHex=\(stringValue(defaults, key: "bgGlassTintHex", fallback: "#000000")) - bgGlassTintOpacity=\(String(format: "%.2f", doubleValue(defaults, key: "bgGlassTintOpacity", fallback: 0.03))) - """ - - let menuBarPayload = MenuBarIconDebugSettings.copyPayload(defaults: defaults) - let browserDevToolsPayload = BrowserDevToolsButtonDebugSettings.copyPayload(defaults: defaults) - - return """ - # Sidebar Debug - \(sidebarPayload) - - # Background Debug - \(backgroundPayload) - - # Menu Bar Extra Debug - \(menuBarPayload) - - # Browser DevTools Button - \(browserDevToolsPayload) - """ - } - - private static func stringValue(_ defaults: UserDefaults, key: String, fallback: String) -> String { - defaults.string(forKey: key) ?? fallback - } - - private static func doubleValue(_ defaults: UserDefaults, key: String, fallback: Double) -> Double { - if let value = defaults.object(forKey: key) as? NSNumber { - return value.doubleValue - } - if let text = defaults.string(forKey: key), let parsed = Double(text) { - return parsed + Text(content) + .font(.system(.body, design: .monospaced)) + .textSelection(.enabled) + .frame(maxWidth: .infinity, alignment: .leading) + .padding() } - return fallback - } - - private static func boolValue(_ defaults: UserDefaults, key: String, fallback: Bool) -> Bool { - guard defaults.object(forKey: key) != nil else { return fallback } - return defaults.bool(forKey: key) } } -private final class DebugWindowControlsWindowController: NSWindowController, NSWindowDelegate { - static let shared = DebugWindowControlsWindowController() +final class SettingsWindowController: NSWindowController, NSWindowDelegate { + static let shared = SettingsWindowController() + private var pendingFocusRestoreWorkItems: [DispatchWorkItem] = [] + private var focusRestoreGeneration = 0 private init() { - let window = NSPanel( - contentRect: NSRect(x: 0, y: 0, width: 420, height: 560), - styleMask: [.titled, .closable, .utilityWindow], + let window = NSWindow( + contentRect: NSRect(x: 0, y: 0, width: 640, height: 520), + styleMask: [.titled, .closable, .miniaturizable, .resizable, .fullSizeContentView], backing: .buffered, defer: false ) - window.title = "Debug Window Controls" - window.titleVisibility = .visible - window.titlebarAppearsTransparent = false - window.isMovableByWindowBackground = true window.isReleasedWhenClosed = false - window.identifier = NSUserInterfaceItemIdentifier("programa.debugWindowControls") + window.identifier = NSUserInterfaceItemIdentifier("cmux.settings") window.center() - window.contentView = NSHostingView(rootView: DebugWindowControlsView()) + window.contentView = NSHostingView(rootView: SettingsRootView()) + SettingsAboutTitlebarDebugStore.shared.applyCurrentOptions(to: window, for: .settings) AppDelegate.shared?.applyWindowDecorations(to: window) super.init(window: window) window.delegate = self @@ -1673,4182 +1196,483 @@ private final class DebugWindowControlsWindowController: NSWindowController, NSW fatalError("init(coder:) has not been implemented") } - func show() { - window?.center() - window?.makeKeyAndOrderFront(nil) + func show(navigationTarget: SettingsNavigationTarget? = nil) { + guard let window else { return } +#if DEBUG + dlog("settings.window.show requested isVisible=\(window.isVisible ? 1 : 0) isKey=\(window.isKeyWindow ? 1 : 0)") +#endif + SettingsAboutTitlebarDebugStore.shared.applyCurrentOptions(to: window, for: .settings) + if !window.isVisible { + window.center() + } + window.makeKeyAndOrderFront(nil) + if let navigationTarget { + DispatchQueue.main.asyncAfter(deadline: .now() + 0.05) { + SettingsNavigationRequest.post(navigationTarget) + } + } +#if DEBUG + dlog("settings.window.show completed isVisible=\(window.isVisible ? 1 : 0) isKey=\(window.isKeyWindow ? 1 : 0)") +#endif } -} -private struct DebugWindowControlsView: View { - @AppStorage(ShortcutHintDebugSettings.sidebarHintXKey) private var sidebarShortcutHintXOffset = ShortcutHintDebugSettings.defaultSidebarHintX - @AppStorage(ShortcutHintDebugSettings.sidebarHintYKey) private var sidebarShortcutHintYOffset = ShortcutHintDebugSettings.defaultSidebarHintY - @AppStorage(ShortcutHintDebugSettings.titlebarHintXKey) private var titlebarShortcutHintXOffset = ShortcutHintDebugSettings.defaultTitlebarHintX - @AppStorage(ShortcutHintDebugSettings.titlebarHintYKey) private var titlebarShortcutHintYOffset = ShortcutHintDebugSettings.defaultTitlebarHintY - @AppStorage(ShortcutHintDebugSettings.paneHintXKey) private var paneShortcutHintXOffset = ShortcutHintDebugSettings.defaultPaneHintX - @AppStorage(ShortcutHintDebugSettings.paneHintYKey) private var paneShortcutHintYOffset = ShortcutHintDebugSettings.defaultPaneHintY - @AppStorage(ShortcutHintDebugSettings.alwaysShowHintsKey) private var alwaysShowShortcutHints = ShortcutHintDebugSettings.defaultAlwaysShowHints - @AppStorage(SidebarActiveTabIndicatorSettings.styleKey) - private var sidebarActiveTabIndicatorStyle = SidebarActiveTabIndicatorSettings.defaultStyle.rawValue - @AppStorage("debugTitlebarLeadingExtra") private var titlebarLeadingExtra: Double = 0 - @AppStorage(BrowserDevToolsButtonDebugSettings.iconNameKey) private var browserDevToolsIconNameRaw = BrowserDevToolsButtonDebugSettings.defaultIcon.rawValue - @AppStorage(BrowserDevToolsButtonDebugSettings.iconColorKey) private var browserDevToolsIconColorRaw = BrowserDevToolsButtonDebugSettings.defaultColor.rawValue - - private var selectedDevToolsIconOption: BrowserDevToolsIconOption { - BrowserDevToolsIconOption(rawValue: browserDevToolsIconNameRaw) ?? BrowserDevToolsButtonDebugSettings.defaultIcon + func preserveFocusAfterPreferenceMutation() { + guard let window, window.isVisible else { return } + cancelPendingFocusRestore() + focusRestoreGeneration += 1 + let generation = focusRestoreGeneration + writeFocusDiagnosticsIfNeeded(stage: "requested") + scheduleFocusRestore( + for: window, + generation: generation, + delays: [0, 0.04, 0.12, 0.24, 0.4, 0.7] + ) } - private var selectedDevToolsColorOption: BrowserDevToolsIconColorOption { - BrowserDevToolsIconColorOption(rawValue: browserDevToolsIconColorRaw) ?? BrowserDevToolsButtonDebugSettings.defaultColor + func windowWillClose(_ notification: Notification) { + cancelPendingFocusRestore() + writeFocusDiagnosticsIfNeeded(stage: "windowWillClose") } - private var selectedSidebarActiveTabIndicatorStyle: SidebarActiveTabIndicatorStyle { - SidebarActiveTabIndicatorSettings.resolvedStyle(rawValue: sidebarActiveTabIndicatorStyle) + func windowDidBecomeKey(_ notification: Notification) { + writeFocusDiagnosticsIfNeeded(stage: "didBecomeKey") } - private var sidebarIndicatorStyleSelection: Binding { - Binding( - get: { selectedSidebarActiveTabIndicatorStyle.rawValue }, - set: { sidebarActiveTabIndicatorStyle = $0 } + func windowDidResignKey(_ notification: Notification) { + guard let window else { return } + writeFocusDiagnosticsIfNeeded(stage: "didResignKey") + guard focusRestoreGeneration > 0 else { return } + scheduleFocusRestore( + for: window, + generation: focusRestoreGeneration, + delays: [0, 0.03, 0.1] ) } - var body: some View { - ScrollView { - VStack(alignment: .leading, spacing: 14) { - Text("Debug Window Controls") - .font(.headline) - - GroupBox("Open") { - VStack(alignment: .leading, spacing: 8) { - Button( - String( - localized: "debug.menu.browserProfilePopoverDebug", - defaultValue: "Browser Profile Popover Debug…" - ) - ) { - BrowserProfilePopoverDebugWindowController.shared.show() - } - Button("Settings/About Titlebar Debug…") { - SettingsAboutTitlebarDebugWindowController.shared.show() - } - Button("Sidebar Debug…") { - SidebarDebugWindowController.shared.show() - } - Button("Background Debug…") { - BackgroundDebugWindowController.shared.show() - } - Button("Menu Bar Extra Debug…") { - MenuBarExtraDebugWindowController.shared.show() - } - Button("Open All Debug Windows") { - BrowserProfilePopoverDebugWindowController.shared.show() - SettingsAboutTitlebarDebugWindowController.shared.show() - SidebarDebugWindowController.shared.show() - BackgroundDebugWindowController.shared.show() - MenuBarExtraDebugWindowController.shared.show() - } - } - .frame(maxWidth: .infinity, alignment: .leading) - .padding(.top, 2) - } - - GroupBox("Shortcut Hints") { - VStack(alignment: .leading, spacing: 10) { - Toggle("Always show shortcut hints", isOn: $alwaysShowShortcutHints) - - hintOffsetSection( - "Sidebar Cmd+1…9", - x: $sidebarShortcutHintXOffset, - y: $sidebarShortcutHintYOffset - ) - - hintOffsetSection( - "Titlebar Buttons", - x: $titlebarShortcutHintXOffset, - y: $titlebarShortcutHintYOffset - ) - - hintOffsetSection( - "Pane Ctrl/Cmd+1…9", - x: $paneShortcutHintXOffset, - y: $paneShortcutHintYOffset - ) - - HStack(spacing: 12) { - Button("Reset Hints") { - resetShortcutHintOffsets() - } - Button("Copy Hint Config") { - copyShortcutHintConfig() - } - } - } - .padding(.top, 2) - } - - GroupBox("Active Workspace Indicator") { - VStack(alignment: .leading, spacing: 8) { - Picker("Style", selection: sidebarIndicatorStyleSelection) { - ForEach(SidebarActiveTabIndicatorStyle.allCases) { style in - Text(style.displayName).tag(style.rawValue) - } - } - .pickerStyle(.menu) - - Button("Reset Indicator Style") { - sidebarActiveTabIndicatorStyle = SidebarActiveTabIndicatorSettings.defaultStyle.rawValue - } - } - .padding(.top, 2) - } - - GroupBox("Titlebar Spacing") { - VStack(alignment: .leading, spacing: 6) { - HStack(spacing: 8) { - Text("Leading extra") - Slider(value: $titlebarLeadingExtra, in: 0...40) - Text(String(format: "%.0f", titlebarLeadingExtra)) - .font(.caption) - .monospacedDigit() - .frame(width: 30, alignment: .trailing) - } - Button("Reset (0)") { - titlebarLeadingExtra = 0 - } - } - .padding(.top, 2) - } - - GroupBox("Browser DevTools Button") { - VStack(alignment: .leading, spacing: 10) { - HStack(spacing: 8) { - Text("Icon") - Picker("Icon", selection: $browserDevToolsIconNameRaw) { - ForEach(BrowserDevToolsIconOption.allCases) { option in - Text(option.title).tag(option.rawValue) - } - } - .labelsHidden() - .pickerStyle(.menu) - Spacer() - } - - HStack(spacing: 8) { - Text("Color") - Picker("Color", selection: $browserDevToolsIconColorRaw) { - ForEach(BrowserDevToolsIconColorOption.allCases) { option in - Text(option.title).tag(option.rawValue) - } - } - .labelsHidden() - .pickerStyle(.menu) - Spacer() - } - - HStack(spacing: 8) { - Text("Preview") - Spacer() - Image(systemName: selectedDevToolsIconOption.rawValue) - .font(.system(size: 12, weight: .medium)) - .foregroundStyle(selectedDevToolsColorOption.color) - } - - HStack(spacing: 12) { - Button("Reset Button") { - resetBrowserDevToolsButton() - } - Button("Copy Button Config") { - copyBrowserDevToolsButtonConfig() - } - } - } - .padding(.top, 2) + private func scheduleFocusRestore( + for window: NSWindow, + generation: Int, + delays: [TimeInterval] + ) { + for (index, delay) in delays.enumerated() { + let isLastAttempt = index == delays.count - 1 + let workItem = DispatchWorkItem { [weak self, weak window] in + guard let self, let window, window.isVisible else { return } + guard self.focusRestoreGeneration == generation else { return } + self.writeFocusDiagnosticsIfNeeded(stage: "restoreAttempt.\(index)") + if !window.isKeyWindow { + NSApp.activate(ignoringOtherApps: true) + window.orderFrontRegardless() + window.makeKeyAndOrderFront(nil) + self.writeFocusDiagnosticsIfNeeded(stage: "restoreApplied.\(index)") } - - GroupBox("Copy") { - VStack(alignment: .leading, spacing: 8) { - Button("Copy All Debug Config") { - DebugWindowConfigSnapshot.copyCombinedToPasteboard() - } - Text("Copies sidebar, background, menu bar, and browser devtools settings as one payload.") - .font(.caption) - .foregroundColor(.secondary) - } - .frame(maxWidth: .infinity, alignment: .leading) - .padding(.top, 2) + if isLastAttempt, self.focusRestoreGeneration == generation { + self.focusRestoreGeneration = 0 } - - Spacer(minLength: 0) } - .padding(16) - .frame(maxWidth: .infinity, alignment: .topLeading) + pendingFocusRestoreWorkItems.append(workItem) + DispatchQueue.main.asyncAfter(deadline: .now() + delay, execute: workItem) } - .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading) } - private func hintOffsetSection(_ title: String, x: Binding, y: Binding) -> some View { - VStack(alignment: .leading, spacing: 6) { - Text(title) - .font(.caption) - .foregroundColor(.secondary) - sliderRow("X", value: x) - sliderRow("Y", value: y) - } + private func cancelPendingFocusRestore() { + pendingFocusRestoreWorkItems.forEach { $0.cancel() } + pendingFocusRestoreWorkItems.removeAll() + focusRestoreGeneration = 0 } - private func sliderRow(_ label: String, value: Binding) -> some View { - HStack(spacing: 8) { - Text(label) - Slider(value: value, in: ShortcutHintDebugSettings.offsetRange) - Text(String(format: "%.1f", ShortcutHintDebugSettings.clamped(value.wrappedValue))) - .font(.caption) - .monospacedDigit() - .frame(width: 44, alignment: .trailing) - } - } + private func writeFocusDiagnosticsIfNeeded(stage: String) { + let env = ProcessInfo.processInfo.environment + guard let path = env["PROGRAMA_UI_TEST_DIAGNOSTICS_PATH"], !path.isEmpty else { return } - private func resetShortcutHintOffsets() { - sidebarShortcutHintXOffset = ShortcutHintDebugSettings.defaultSidebarHintX - sidebarShortcutHintYOffset = ShortcutHintDebugSettings.defaultSidebarHintY - titlebarShortcutHintXOffset = ShortcutHintDebugSettings.defaultTitlebarHintX - titlebarShortcutHintYOffset = ShortcutHintDebugSettings.defaultTitlebarHintY - paneShortcutHintXOffset = ShortcutHintDebugSettings.defaultPaneHintX - paneShortcutHintYOffset = ShortcutHintDebugSettings.defaultPaneHintY - alwaysShowShortcutHints = ShortcutHintDebugSettings.defaultAlwaysShowHints - } + var payload = loadFocusDiagnostics(at: path) + payload["focusStage"] = stage + payload["keyWindowIdentifier"] = NSApp.keyWindow?.identifier?.rawValue ?? "" + payload["mainWindowIdentifier"] = NSApp.mainWindow?.identifier?.rawValue ?? "" + payload["settingsWindowIsKey"] = (window?.isKeyWindow ?? false) ? "1" : "0" - private func copyShortcutHintConfig() { - let payload = """ - shortcutHintSidebarXOffset=\(String(format: "%.1f", ShortcutHintDebugSettings.clamped(sidebarShortcutHintXOffset))) - shortcutHintSidebarYOffset=\(String(format: "%.1f", ShortcutHintDebugSettings.clamped(sidebarShortcutHintYOffset))) - shortcutHintTitlebarXOffset=\(String(format: "%.1f", ShortcutHintDebugSettings.clamped(titlebarShortcutHintXOffset))) - shortcutHintTitlebarYOffset=\(String(format: "%.1f", ShortcutHintDebugSettings.clamped(titlebarShortcutHintYOffset))) - shortcutHintPaneTabXOffset=\(String(format: "%.1f", ShortcutHintDebugSettings.clamped(paneShortcutHintXOffset))) - shortcutHintPaneTabYOffset=\(String(format: "%.1f", ShortcutHintDebugSettings.clamped(paneShortcutHintYOffset))) - shortcutHintAlwaysShow=\(alwaysShowShortcutHints) - """ - let pasteboard = NSPasteboard.general - pasteboard.clearContents() - pasteboard.setString(payload, forType: .string) + guard let data = try? JSONSerialization.data(withJSONObject: payload) else { return } + try? data.write(to: URL(fileURLWithPath: path), options: .atomic) } - private func resetBrowserDevToolsButton() { - browserDevToolsIconNameRaw = BrowserDevToolsButtonDebugSettings.defaultIcon.rawValue - browserDevToolsIconColorRaw = BrowserDevToolsButtonDebugSettings.defaultColor.rawValue + private func loadFocusDiagnostics(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 copyBrowserDevToolsButtonConfig() { - let payload = BrowserDevToolsButtonDebugSettings.copyPayload(defaults: .standard) - let pasteboard = NSPasteboard.general - pasteboard.clearContents() - pasteboard.setString(payload, forType: .string) - } +enum SettingsNavigationTarget: String { + case browser + case browserImport + case keyboardShortcuts } -private final class BrowserProfilePopoverDebugWindowController: NSWindowController, NSWindowDelegate { - static let shared = BrowserProfilePopoverDebugWindowController() +enum SettingsNavigationRequest { + static let notificationName = Notification.Name("programa.settings.navigate") + private static let targetKey = "target" - private init() { - let window = NSPanel( - contentRect: NSRect(x: 0, y: 0, width: 360, height: 340), - styleMask: [.titled, .closable, .utilityWindow], - backing: .buffered, - defer: false - ) - window.title = String( - localized: "debug.windows.browserProfilePopover.title", - defaultValue: "Browser Profile Popover Debug" + static func post(_ target: SettingsNavigationTarget) { + NotificationCenter.default.post( + name: notificationName, + object: nil, + userInfo: [targetKey: target.rawValue] ) - window.titleVisibility = .visible - window.titlebarAppearsTransparent = false - window.isMovableByWindowBackground = true - window.isReleasedWhenClosed = false - window.identifier = NSUserInterfaceItemIdentifier("programa.browserProfilePopoverDebug") - window.center() - window.contentView = NSHostingView(rootView: BrowserProfilePopoverDebugView()) - AppDelegate.shared?.applyWindowDecorations(to: window) - super.init(window: window) - window.delegate = self - } - - @available(*, unavailable) - required init?(coder: NSCoder) { - fatalError("init(coder:) has not been implemented") } - func show() { - window?.center() - window?.makeKeyAndOrderFront(nil) + static func target(from notification: Notification) -> SettingsNavigationTarget? { + guard let rawValue = notification.userInfo?[targetKey] as? String else { return nil } + return SettingsNavigationTarget(rawValue: rawValue) } } -private struct BrowserProfilePopoverDebugView: View { - @AppStorage(BrowserProfilePopoverDebugSettings.horizontalPaddingKey) - private var horizontalPaddingRaw = BrowserProfilePopoverDebugSettings.defaultHorizontalPadding - @AppStorage(BrowserProfilePopoverDebugSettings.verticalPaddingKey) - private var verticalPaddingRaw = BrowserProfilePopoverDebugSettings.defaultVerticalPadding +private struct AboutPanelView: View { + @Environment(\.openURL) private var openURL - private var horizontalPaddingBinding: Binding { - Binding( - get: { BrowserProfilePopoverDebugSettings.resolvedHorizontalPadding(horizontalPaddingRaw) }, - set: { horizontalPaddingRaw = BrowserProfilePopoverDebugSettings.resolvedHorizontalPadding($0) } - ) - } + private let githubURL = URL(string: "https://github.com/darkroomengineering/programa") + private let docsURL = URL(string: "https://github.com/darkroomengineering/programa/tree/main/docs") - private var verticalPaddingBinding: Binding { - Binding( - get: { BrowserProfilePopoverDebugSettings.resolvedVerticalPadding(verticalPaddingRaw) }, - set: { verticalPaddingRaw = BrowserProfilePopoverDebugSettings.resolvedVerticalPadding($0) } - ) + private var version: String? { Bundle.main.infoDictionary?["CFBundleShortVersionString"] as? String } + private var build: String? { Bundle.main.infoDictionary?["CFBundleVersion"] as? String } + private var commit: String? { + if let value = Bundle.main.infoDictionary?["ProgramaCommit"] as? String, !value.isEmpty { + return value + } + let env = ProcessInfo.processInfo.environment["PROGRAMA_COMMIT"] ?? "" + return env.isEmpty ? nil : env } + private var copyright: String? { Bundle.main.infoDictionary?["NSHumanReadableCopyright"] as? String } var body: some View { - ScrollView { - VStack(alignment: .leading, spacing: 14) { - Text( - String( - localized: "debug.browserProfilePopover.heading", - defaultValue: "Browser Profile Popover" - ) - ) - .font(.headline) - - Text( - String( - localized: "debug.browserProfilePopover.note", - defaultValue: "Tune the profile popover padding live while comparing it against the browser toolbar menu." - ) - ) - .font(.caption) - .foregroundStyle(.secondary) + VStack(alignment: .center) { + Image(nsImage: NSApplication.shared.applicationIconImage) + .resizable() + .renderingMode(.original) + .frame(width: 96, height: 96) + .shadow(color: .black.opacity(0.18), radius: 8, x: 0, y: 3) - GroupBox( - String( - localized: "debug.browserProfilePopover.group.padding", - defaultValue: "Padding" - ) - ) { - VStack(alignment: .leading, spacing: 8) { - sliderRow( - String( - localized: "debug.browserProfilePopover.label.horizontal", - defaultValue: "Horizontal" - ), - value: horizontalPaddingBinding, - range: BrowserProfilePopoverDebugSettings.horizontalPaddingRange - ) - sliderRow( - String( - localized: "debug.browserProfilePopover.label.vertical", - defaultValue: "Vertical" - ), - value: verticalPaddingBinding, - range: BrowserProfilePopoverDebugSettings.verticalPaddingRange - ) - } - .padding(.top, 2) + VStack(alignment: .center, spacing: 32) { + VStack(alignment: .center, spacing: 8) { + Text(String(localized: "about.appName", defaultValue: "Programa")) + .bold() + .font(.title) + Text(String(localized: "about.description", defaultValue: "A Ghostty-based terminal with vertical tabs\nand a notification panel for macOS.")) + .multilineTextAlignment(.center) + .fixedSize(horizontal: false, vertical: true) + .font(.caption) + .tint(.secondary) + .opacity(0.8) } + .textSelection(.enabled) - GroupBox( - String( - localized: "debug.browserProfilePopover.group.preview", - defaultValue: "Preview" - ) - ) { - profilePopoverPreview - .padding(.top, 2) + VStack(spacing: 2) { + if let version { + AboutPropertyRow(label: String(localized: "about.version", defaultValue: "Version"), text: version) + } + if let build { + AboutPropertyRow(label: String(localized: "about.build", defaultValue: "Build"), text: build) + } + let commitText = commit ?? "—" + let commitURL = commit.flatMap { hash in + URL(string: "https://github.com/darkroomengineering/programa/commit/\(hash)") + } + AboutPropertyRow(label: String(localized: "about.commit", defaultValue: "Commit"), text: commitText, url: commitURL) } + .frame(maxWidth: .infinity) - HStack(spacing: 12) { - Button( - String( - localized: "debug.browserProfilePopover.reset", - defaultValue: "Reset" - ) - ) { - horizontalPaddingRaw = BrowserProfilePopoverDebugSettings.defaultHorizontalPadding - verticalPaddingRaw = BrowserProfilePopoverDebugSettings.defaultVerticalPadding + HStack(spacing: 8) { + if let url = docsURL { + Button(String(localized: "about.docs", defaultValue: "Docs")) { + openURL(url) + } + } + if let url = githubURL { + Button(String(localized: "about.github", defaultValue: "GitHub")) { + openURL(url) + } + } + Button(String(localized: "about.licenses", defaultValue: "Licenses")) { + AcknowledgmentsWindowController.shared.show() } } - Text( - String( - localized: "debug.browserProfilePopover.liveNote", - defaultValue: "Changes apply live to the browser profile popover." - ) - ) - .font(.caption) - .foregroundStyle(.secondary) - - Spacer(minLength: 0) - } - .padding(16) - .frame(maxWidth: .infinity, alignment: .topLeading) - } - .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading) - } - - private var profilePopoverPreview: some View { - VStack(alignment: .leading, spacing: 8) { - Text(String(localized: "browser.profile.menu.title", defaultValue: "Profiles")) - .font(.system(size: 12, weight: .semibold)) - .foregroundStyle(.secondary) - - VStack(alignment: .leading, spacing: 2) { - HStack(spacing: 8) { - Image(systemName: "checkmark") - .font(.system(size: 10, weight: .semibold)) - .frame(width: 12, alignment: .center) - Text(String(localized: "browser.profile.default", defaultValue: "Default")) - .font(.system(size: 12)) - Spacer(minLength: 0) + if let copy = copyright, !copy.isEmpty { + Text(copy) + .font(.caption) + .textSelection(.enabled) + .tint(.secondary) + .opacity(0.8) + .multilineTextAlignment(.center) + .frame(maxWidth: .infinity) } - .padding(.horizontal, 8) - .frame(height: 24) - .background( - RoundedRectangle(cornerRadius: 6, style: .continuous) - .fill(Color.primary.opacity(0.12)) - ) } - - Divider() - - Text(String(localized: "browser.profile.new", defaultValue: "New Profile...")) - .font(.system(size: 12)) - - Text(String(localized: "menu.view.importFromBrowser", defaultValue: "Import Browser Data…")) - .font(.system(size: 12)) - } - .padding(.horizontal, BrowserProfilePopoverDebugSettings.resolvedHorizontalPadding(horizontalPaddingRaw)) - .padding(.vertical, BrowserProfilePopoverDebugSettings.resolvedVerticalPadding(verticalPaddingRaw)) - .frame(maxWidth: .infinity, alignment: .leading) - .background( - RoundedRectangle(cornerRadius: 16, style: .continuous) - .fill(Color(nsColor: .windowBackgroundColor)) - .overlay( - RoundedRectangle(cornerRadius: 16, style: .continuous) - .stroke(Color.primary.opacity(0.08)) - ) - ) - } - - private func sliderRow(_ label: String, value: Binding, range: ClosedRange) -> some View { - HStack(spacing: 8) { - Text(label) - Slider(value: value, in: range, step: 1) - Text(String(format: "%.0f", value.wrappedValue)) - .font(.caption) - .monospacedDigit() - .frame(width: 32, alignment: .trailing) + .frame(maxWidth: .infinity) } + .padding(.top, 8) + .padding(32) + .frame(minWidth: 280) + .background(AboutVisualEffectBackground(material: .underWindowBackground).ignoresSafeArea()) } } -private final class AboutWindowController: NSWindowController, NSWindowDelegate { - static let shared = AboutWindowController() - - private init() { - let window = NSWindow( - contentRect: NSRect(x: 0, y: 0, width: 360, height: 520), - styleMask: [.titled, .closable, .miniaturizable], - backing: .buffered, - defer: false - ) - window.isReleasedWhenClosed = false - window.identifier = NSUserInterfaceItemIdentifier("cmux.about") - window.center() - window.contentView = NSHostingView(rootView: AboutPanelView()) - SettingsAboutTitlebarDebugStore.shared.applyCurrentOptions(to: window, for: .about) - AppDelegate.shared?.applyWindowDecorations(to: window) - super.init(window: window) - window.delegate = self - } +private struct AboutPropertyRow: View { + private let label: String + private let text: String + private let url: URL? - @available(*, unavailable) - required init?(coder: NSCoder) { - fatalError("init(coder:) has not been implemented") + init(label: String, text: String, url: URL? = nil) { + self.label = label + self.text = text + self.url = url } - func show() { - guard let window else { return } - SettingsAboutTitlebarDebugStore.shared.applyCurrentOptions(to: window, for: .about) - window.center() - window.makeKeyAndOrderFront(nil) - } -} - -private final class AcknowledgmentsWindowController: NSWindowController, NSWindowDelegate { - static let shared = AcknowledgmentsWindowController() - - private init() { - let window = NSWindow( - contentRect: NSRect(x: 0, y: 0, width: 500, height: 480), - styleMask: [.titled, .closable, .miniaturizable, .resizable], - backing: .buffered, - defer: false - ) - window.isReleasedWhenClosed = false - window.title = String(localized: "about.licenses.windowTitle", defaultValue: "Third-Party Licenses") - window.identifier = NSUserInterfaceItemIdentifier("programa.licenses") - window.center() - window.contentView = NSHostingView(rootView: AcknowledgmentsView()) - super.init(window: window) - window.delegate = self - } - - @available(*, unavailable) - required init?(coder: NSCoder) { - fatalError("init(coder:) has not been implemented") - } - - func show() { - guard let window else { return } - window.makeKeyAndOrderFront(nil) - } -} - -private struct AcknowledgmentsView: View { - private let content: String = { - if let url = Bundle.main.url(forResource: "THIRD_PARTY_LICENSES", withExtension: "md"), - let text = try? String(contentsOf: url) { - return text - } - return String(localized: "about.licenses.notFound", defaultValue: "Licenses file not found.") - }() - - var body: some View { - ScrollView { - Text(content) - .font(.system(.body, design: .monospaced)) - .textSelection(.enabled) - .frame(maxWidth: .infinity, alignment: .leading) - .padding() - } - } -} - -final class SettingsWindowController: NSWindowController, NSWindowDelegate { - static let shared = SettingsWindowController() - private var pendingFocusRestoreWorkItems: [DispatchWorkItem] = [] - private var focusRestoreGeneration = 0 - - private init() { - let window = NSWindow( - contentRect: NSRect(x: 0, y: 0, width: 640, height: 520), - styleMask: [.titled, .closable, .miniaturizable, .resizable, .fullSizeContentView], - backing: .buffered, - defer: false - ) - window.isReleasedWhenClosed = false - window.identifier = NSUserInterfaceItemIdentifier("cmux.settings") - window.center() - window.contentView = NSHostingView(rootView: SettingsRootView()) - SettingsAboutTitlebarDebugStore.shared.applyCurrentOptions(to: window, for: .settings) - AppDelegate.shared?.applyWindowDecorations(to: window) - super.init(window: window) - window.delegate = self - } - - @available(*, unavailable) - required init?(coder: NSCoder) { - fatalError("init(coder:) has not been implemented") - } - - func show(navigationTarget: SettingsNavigationTarget? = nil) { - guard let window else { return } -#if DEBUG - dlog("settings.window.show requested isVisible=\(window.isVisible ? 1 : 0) isKey=\(window.isKeyWindow ? 1 : 0)") -#endif - SettingsAboutTitlebarDebugStore.shared.applyCurrentOptions(to: window, for: .settings) - if !window.isVisible { - window.center() - } - window.makeKeyAndOrderFront(nil) - if let navigationTarget { - DispatchQueue.main.asyncAfter(deadline: .now() + 0.05) { - SettingsNavigationRequest.post(navigationTarget) - } - } -#if DEBUG - dlog("settings.window.show completed isVisible=\(window.isVisible ? 1 : 0) isKey=\(window.isKeyWindow ? 1 : 0)") -#endif - } - - func preserveFocusAfterPreferenceMutation() { - guard let window, window.isVisible else { return } - cancelPendingFocusRestore() - focusRestoreGeneration += 1 - let generation = focusRestoreGeneration - writeFocusDiagnosticsIfNeeded(stage: "requested") - scheduleFocusRestore( - for: window, - generation: generation, - delays: [0, 0.04, 0.12, 0.24, 0.4, 0.7] - ) - } - - func windowWillClose(_ notification: Notification) { - cancelPendingFocusRestore() - writeFocusDiagnosticsIfNeeded(stage: "windowWillClose") - } - - func windowDidBecomeKey(_ notification: Notification) { - writeFocusDiagnosticsIfNeeded(stage: "didBecomeKey") - } - - func windowDidResignKey(_ notification: Notification) { - guard let window else { return } - writeFocusDiagnosticsIfNeeded(stage: "didResignKey") - guard focusRestoreGeneration > 0 else { return } - scheduleFocusRestore( - for: window, - generation: focusRestoreGeneration, - delays: [0, 0.03, 0.1] - ) - } - - private func scheduleFocusRestore( - for window: NSWindow, - generation: Int, - delays: [TimeInterval] - ) { - for (index, delay) in delays.enumerated() { - let isLastAttempt = index == delays.count - 1 - let workItem = DispatchWorkItem { [weak self, weak window] in - guard let self, let window, window.isVisible else { return } - guard self.focusRestoreGeneration == generation else { return } - self.writeFocusDiagnosticsIfNeeded(stage: "restoreAttempt.\(index)") - if !window.isKeyWindow { - NSApp.activate(ignoringOtherApps: true) - window.orderFrontRegardless() - window.makeKeyAndOrderFront(nil) - self.writeFocusDiagnosticsIfNeeded(stage: "restoreApplied.\(index)") - } - if isLastAttempt, self.focusRestoreGeneration == generation { - self.focusRestoreGeneration = 0 - } - } - pendingFocusRestoreWorkItems.append(workItem) - DispatchQueue.main.asyncAfter(deadline: .now() + delay, execute: workItem) - } - } - - private func cancelPendingFocusRestore() { - pendingFocusRestoreWorkItems.forEach { $0.cancel() } - pendingFocusRestoreWorkItems.removeAll() - focusRestoreGeneration = 0 - } - - private func writeFocusDiagnosticsIfNeeded(stage: String) { - let env = ProcessInfo.processInfo.environment - guard let path = env["PROGRAMA_UI_TEST_DIAGNOSTICS_PATH"], !path.isEmpty else { return } - - var payload = loadFocusDiagnostics(at: path) - payload["focusStage"] = stage - payload["keyWindowIdentifier"] = NSApp.keyWindow?.identifier?.rawValue ?? "" - payload["mainWindowIdentifier"] = NSApp.mainWindow?.identifier?.rawValue ?? "" - payload["settingsWindowIsKey"] = (window?.isKeyWindow ?? false) ? "1" : "0" - - guard let data = try? JSONSerialization.data(withJSONObject: payload) else { return } - try? data.write(to: URL(fileURLWithPath: path), options: .atomic) - } - - private func loadFocusDiagnostics(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 - } -} - -enum SettingsNavigationTarget: String { - case browser - case browserImport - case keyboardShortcuts -} - -enum SettingsNavigationRequest { - static let notificationName = Notification.Name("programa.settings.navigate") - private static let targetKey = "target" - - static func post(_ target: SettingsNavigationTarget) { - NotificationCenter.default.post( - name: notificationName, - object: nil, - userInfo: [targetKey: target.rawValue] - ) - } - - static func target(from notification: Notification) -> SettingsNavigationTarget? { - guard let rawValue = notification.userInfo?[targetKey] as? String else { return nil } - return SettingsNavigationTarget(rawValue: rawValue) - } -} - -private final class SidebarDebugWindowController: NSWindowController, NSWindowDelegate { - static let shared = SidebarDebugWindowController() - - private init() { - let window = NSPanel( - contentRect: NSRect(x: 0, y: 0, width: 360, height: 520), - styleMask: [.titled, .closable, .utilityWindow], - backing: .buffered, - defer: false - ) - window.title = "Sidebar Debug" - window.titleVisibility = .visible - window.titlebarAppearsTransparent = false - window.isMovableByWindowBackground = true - window.isReleasedWhenClosed = false - window.identifier = NSUserInterfaceItemIdentifier("programa.sidebarDebug") - window.center() - window.contentView = NSHostingView(rootView: SidebarDebugView()) - AppDelegate.shared?.applyWindowDecorations(to: window) - super.init(window: window) - window.delegate = self - } - - @available(*, unavailable) - required init?(coder: NSCoder) { - fatalError("init(coder:) has not been implemented") - } - - func show() { - window?.center() - window?.makeKeyAndOrderFront(nil) - } -} - -private struct AboutPanelView: View { - @Environment(\.openURL) private var openURL - - private let githubURL = URL(string: "https://github.com/darkroomengineering/programa") - private let docsURL = URL(string: "https://github.com/darkroomengineering/programa/tree/main/docs") - - private var version: String? { Bundle.main.infoDictionary?["CFBundleShortVersionString"] as? String } - private var build: String? { Bundle.main.infoDictionary?["CFBundleVersion"] as? String } - private var commit: String? { - if let value = Bundle.main.infoDictionary?["ProgramaCommit"] as? String, !value.isEmpty { - return value - } - let env = ProcessInfo.processInfo.environment["PROGRAMA_COMMIT"] ?? "" - return env.isEmpty ? nil : env - } - private var copyright: String? { Bundle.main.infoDictionary?["NSHumanReadableCopyright"] as? String } - - var body: some View { - VStack(alignment: .center) { - Image(nsImage: NSApplication.shared.applicationIconImage) - .resizable() - .renderingMode(.original) - .frame(width: 96, height: 96) - .shadow(color: .black.opacity(0.18), radius: 8, x: 0, y: 3) - - VStack(alignment: .center, spacing: 32) { - VStack(alignment: .center, spacing: 8) { - Text(String(localized: "about.appName", defaultValue: "Programa")) - .bold() - .font(.title) - Text(String(localized: "about.description", defaultValue: "A Ghostty-based terminal with vertical tabs\nand a notification panel for macOS.")) - .multilineTextAlignment(.center) - .fixedSize(horizontal: false, vertical: true) - .font(.caption) - .tint(.secondary) - .opacity(0.8) - } - .textSelection(.enabled) - - VStack(spacing: 2) { - if let version { - AboutPropertyRow(label: String(localized: "about.version", defaultValue: "Version"), text: version) - } - if let build { - AboutPropertyRow(label: String(localized: "about.build", defaultValue: "Build"), text: build) - } - let commitText = commit ?? "—" - let commitURL = commit.flatMap { hash in - URL(string: "https://github.com/darkroomengineering/programa/commit/\(hash)") - } - AboutPropertyRow(label: String(localized: "about.commit", defaultValue: "Commit"), text: commitText, url: commitURL) - } - .frame(maxWidth: .infinity) - - HStack(spacing: 8) { - if let url = docsURL { - Button(String(localized: "about.docs", defaultValue: "Docs")) { - openURL(url) - } - } - if let url = githubURL { - Button(String(localized: "about.github", defaultValue: "GitHub")) { - openURL(url) - } - } - Button(String(localized: "about.licenses", defaultValue: "Licenses")) { - AcknowledgmentsWindowController.shared.show() - } - } - - if let copy = copyright, !copy.isEmpty { - Text(copy) - .font(.caption) - .textSelection(.enabled) - .tint(.secondary) - .opacity(0.8) - .multilineTextAlignment(.center) - .frame(maxWidth: .infinity) - } - } - .frame(maxWidth: .infinity) - } - .padding(.top, 8) - .padding(32) - .frame(minWidth: 280) - .background(AboutVisualEffectBackground(material: .underWindowBackground).ignoresSafeArea()) - } -} - -private struct SidebarDebugView: View { - @AppStorage("sidebarMatchTerminalBackground") private var matchTerminalBackground = false - @AppStorage("sidebarPreset") private var sidebarPreset = SidebarPresetOption.nativeSidebar.rawValue - @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 - @AppStorage(ShortcutHintDebugSettings.sidebarHintXKey) private var sidebarShortcutHintXOffset = ShortcutHintDebugSettings.defaultSidebarHintX - @AppStorage(ShortcutHintDebugSettings.sidebarHintYKey) private var sidebarShortcutHintYOffset = ShortcutHintDebugSettings.defaultSidebarHintY - @AppStorage(ShortcutHintDebugSettings.titlebarHintXKey) private var titlebarShortcutHintXOffset = ShortcutHintDebugSettings.defaultTitlebarHintX - @AppStorage(ShortcutHintDebugSettings.titlebarHintYKey) private var titlebarShortcutHintYOffset = ShortcutHintDebugSettings.defaultTitlebarHintY - @AppStorage(ShortcutHintDebugSettings.paneHintXKey) private var paneShortcutHintXOffset = ShortcutHintDebugSettings.defaultPaneHintX - @AppStorage(ShortcutHintDebugSettings.paneHintYKey) private var paneShortcutHintYOffset = ShortcutHintDebugSettings.defaultPaneHintY - @AppStorage(ShortcutHintDebugSettings.alwaysShowHintsKey) private var alwaysShowShortcutHints = ShortcutHintDebugSettings.defaultAlwaysShowHints - @AppStorage(DevBuildBannerDebugSettings.sidebarBannerVisibleKey) - private var showSidebarDevBuildBanner = DevBuildBannerDebugSettings.defaultShowSidebarBanner - @AppStorage(SidebarActiveTabIndicatorSettings.styleKey) - private var sidebarActiveTabIndicatorStyle = SidebarActiveTabIndicatorSettings.defaultStyle.rawValue - @AppStorage("sidebarSelectionColorHex") private var sidebarSelectionColorHex: String? - - private var selectedSidebarIndicatorStyle: SidebarActiveTabIndicatorStyle { - SidebarActiveTabIndicatorSettings.resolvedStyle(rawValue: sidebarActiveTabIndicatorStyle) - } - - private var sidebarIndicatorStyleSelection: Binding { - Binding( - get: { selectedSidebarIndicatorStyle.rawValue }, - set: { sidebarActiveTabIndicatorStyle = $0 } - ) - } - - private var selectionColorBinding: Binding { - Binding( - get: { - if let hex = sidebarSelectionColorHex, let nsColor = NSColor(hex: hex) { - return Color(nsColor: nsColor) - } - return programaAccentColor() - }, - set: { newColor in - let nsColor = NSColor(newColor) - sidebarSelectionColorHex = nsColor.hexString() - } - ) - } - - var body: some View { - ScrollView { - VStack(alignment: .leading, spacing: 14) { - Text("Sidebar Appearance") - .font(.headline) - - Toggle(String(localized: "settings.sidebarAppearance.matchTerminalBackground", defaultValue: "Match Terminal Background"), isOn: $matchTerminalBackground) - - GroupBox("Presets") { - Picker("Preset", selection: $sidebarPreset) { - ForEach(SidebarPresetOption.allCases) { option in - Text(option.title).tag(option.rawValue) - } - } - .onChange(of: sidebarPreset) { _ in - applyPreset() - } - .padding(.top, 2) - } - - GroupBox("Blur") { - VStack(alignment: .leading, spacing: 8) { - Picker("Material", selection: $sidebarMaterial) { - ForEach(SidebarMaterialOption.allCases) { option in - Text(option.title).tag(option.rawValue) - } - } - - Picker("Blending", selection: $sidebarBlendMode) { - ForEach(SidebarBlendModeOption.allCases) { option in - Text(option.title).tag(option.rawValue) - } - } - - Picker("State", selection: $sidebarState) { - ForEach(SidebarStateOption.allCases) { option in - Text(option.title).tag(option.rawValue) - } - } - - HStack(spacing: 8) { - Text("Strength") - Slider(value: $sidebarBlurOpacity, in: 0...1) - Text(String(format: "%.0f%%", sidebarBlurOpacity * 100)) - .font(.caption) - .frame(width: 44, alignment: .trailing) - } - } - .padding(.top, 2) - } - - GroupBox("Tint") { - VStack(alignment: .leading, spacing: 8) { - ColorPicker("Tint Color", selection: tintColorBinding, supportsOpacity: false) - - HStack(spacing: 8) { - Text("Opacity") - Slider(value: $sidebarTintOpacity, in: 0...0.7) - Text(String(format: "%.0f%%", sidebarTintOpacity * 100)) - .font(.caption) - .frame(width: 44, alignment: .trailing) - } - } - .padding(.top, 2) - } - - GroupBox("Shape") { - HStack(spacing: 8) { - Text("Corner Radius") - Slider(value: $sidebarCornerRadius, in: 0...20) - Text(String(format: "%.0f", sidebarCornerRadius)) - .font(.caption) - .frame(width: 32, alignment: .trailing) - } - .padding(.top, 2) - } - - GroupBox("Shortcut Hints") { - VStack(alignment: .leading, spacing: 10) { - Toggle("Always show shortcut hints", isOn: $alwaysShowShortcutHints) - - hintOffsetSection( - "Sidebar Cmd+1…9", - x: $sidebarShortcutHintXOffset, - y: $sidebarShortcutHintYOffset - ) - - hintOffsetSection( - "Titlebar Buttons", - x: $titlebarShortcutHintXOffset, - y: $titlebarShortcutHintYOffset - ) - - hintOffsetSection( - "Pane Ctrl/Cmd+1…9", - x: $paneShortcutHintXOffset, - y: $paneShortcutHintYOffset - ) - } - .padding(.top, 2) - } - - GroupBox("Active Workspace Indicator") { - VStack(alignment: .leading, spacing: 8) { - Picker("Style", selection: sidebarIndicatorStyleSelection) { - ForEach(SidebarActiveTabIndicatorStyle.allCases) { style in - Text(style.displayName).tag(style.rawValue) - } - } - - ColorPicker(String(localized: "sidebar.debug.selectionColor", defaultValue: "Selection Color"), selection: selectionColorBinding, supportsOpacity: false) - - if sidebarSelectionColorHex != nil { - Button(String(localized: "sidebar.debug.resetSelectionColor", defaultValue: "Reset to Default")) { - sidebarSelectionColorHex = nil - } - .font(.caption) - } - } - .padding(.top, 2) - } - - HStack(spacing: 12) { - Button("Reset Tint") { - sidebarTintOpacity = 0.62 - sidebarTintHex = SidebarTintDefaults.hex - sidebarTintHexLight = nil - sidebarTintHexDark = nil - } - Button("Reset Blur") { - sidebarMaterial = SidebarMaterialOption.hudWindow.rawValue - sidebarBlendMode = SidebarBlendModeOption.withinWindow.rawValue - sidebarState = SidebarStateOption.active.rawValue - sidebarBlurOpacity = 0.98 - } - Button("Reset Shape") { - sidebarCornerRadius = 0.0 - } - Button("Reset Hints") { - resetShortcutHintOffsets() - } - Button("Reset Active Indicator") { - sidebarActiveTabIndicatorStyle = SidebarActiveTabIndicatorSettings.defaultStyle.rawValue - sidebarSelectionColorHex = nil - } - } - - Button("Copy Config") { - copySidebarConfig() - } - - Spacer(minLength: 0) - } - .padding(16) - .frame(maxWidth: .infinity, alignment: .topLeading) - } - } - - private var tintColorBinding: Binding { - Binding( - get: { - Color(nsColor: NSColor(hex: sidebarTintHex) ?? .black) - }, - set: { newColor in - let nsColor = NSColor(newColor) - sidebarTintHex = nsColor.hexString() - } - ) - } - - private func hintOffsetSection(_ title: String, x: Binding, y: Binding) -> some View { - VStack(alignment: .leading, spacing: 6) { - Text(title) - .font(.caption) - .foregroundColor(.secondary) - sliderRow("X", value: x) - sliderRow("Y", value: y) - } - } - - private func sliderRow(_ label: String, value: Binding) -> some View { - HStack(spacing: 8) { - Text(label) - Slider(value: value, in: ShortcutHintDebugSettings.offsetRange) - Text(String(format: "%.1f", ShortcutHintDebugSettings.clamped(value.wrappedValue))) - .font(.caption) - .monospacedDigit() - .frame(width: 44, alignment: .trailing) - } - } - - private func resetShortcutHintOffsets() { - sidebarShortcutHintXOffset = ShortcutHintDebugSettings.defaultSidebarHintX - sidebarShortcutHintYOffset = ShortcutHintDebugSettings.defaultSidebarHintY - titlebarShortcutHintXOffset = ShortcutHintDebugSettings.defaultTitlebarHintX - titlebarShortcutHintYOffset = ShortcutHintDebugSettings.defaultTitlebarHintY - paneShortcutHintXOffset = ShortcutHintDebugSettings.defaultPaneHintX - paneShortcutHintYOffset = ShortcutHintDebugSettings.defaultPaneHintY - alwaysShowShortcutHints = ShortcutHintDebugSettings.defaultAlwaysShowHints - } - - private func copySidebarConfig() { - let payload = """ - sidebarPreset=\(sidebarPreset) - sidebarMaterial=\(sidebarMaterial) - sidebarBlendMode=\(sidebarBlendMode) - sidebarState=\(sidebarState) - sidebarBlurOpacity=\(String(format: "%.2f", sidebarBlurOpacity)) - sidebarTintHex=\(sidebarTintHex) - sidebarTintHexLight=\(sidebarTintHexLight ?? "(nil)") - sidebarTintHexDark=\(sidebarTintHexDark ?? "(nil)") - sidebarTintOpacity=\(String(format: "%.2f", sidebarTintOpacity)) - sidebarCornerRadius=\(String(format: "%.1f", sidebarCornerRadius)) - sidebarActiveTabIndicatorStyle=\(sidebarActiveTabIndicatorStyle) - sidebarDevBuildBannerVisible=\(showSidebarDevBuildBanner) - shortcutHintSidebarXOffset=\(String(format: "%.1f", ShortcutHintDebugSettings.clamped(sidebarShortcutHintXOffset))) - shortcutHintSidebarYOffset=\(String(format: "%.1f", ShortcutHintDebugSettings.clamped(sidebarShortcutHintYOffset))) - shortcutHintTitlebarXOffset=\(String(format: "%.1f", ShortcutHintDebugSettings.clamped(titlebarShortcutHintXOffset))) - shortcutHintTitlebarYOffset=\(String(format: "%.1f", ShortcutHintDebugSettings.clamped(titlebarShortcutHintYOffset))) - shortcutHintPaneTabXOffset=\(String(format: "%.1f", ShortcutHintDebugSettings.clamped(paneShortcutHintXOffset))) - shortcutHintPaneTabYOffset=\(String(format: "%.1f", ShortcutHintDebugSettings.clamped(paneShortcutHintYOffset))) - shortcutHintAlwaysShow=\(alwaysShowShortcutHints) - """ - let pasteboard = NSPasteboard.general - pasteboard.clearContents() - pasteboard.setString(payload, forType: .string) - } - - private func applyPreset() { - guard let preset = SidebarPresetOption(rawValue: sidebarPreset) else { return } - sidebarMaterial = preset.material.rawValue - sidebarBlendMode = preset.blendMode.rawValue - sidebarState = preset.state.rawValue - sidebarTintHex = preset.tintHex - sidebarTintOpacity = preset.tintOpacity - sidebarCornerRadius = preset.cornerRadius - sidebarBlurOpacity = preset.blurOpacity - sidebarTintHexLight = nil - sidebarTintHexDark = nil - } -} - -// MARK: - Menu Bar Extra Debug Window - -private final class MenuBarExtraDebugWindowController: NSWindowController, NSWindowDelegate { - static let shared = MenuBarExtraDebugWindowController() - - private init() { - let window = NSPanel( - contentRect: NSRect(x: 0, y: 0, width: 420, height: 430), - styleMask: [.titled, .closable, .utilityWindow], - backing: .buffered, - defer: false - ) - window.title = "Menu Bar Extra Debug" - window.titleVisibility = .visible - window.titlebarAppearsTransparent = false - window.isMovableByWindowBackground = true - window.isReleasedWhenClosed = false - window.identifier = NSUserInterfaceItemIdentifier("programa.menubarDebug") - window.center() - window.contentView = NSHostingView(rootView: MenuBarExtraDebugView()) - AppDelegate.shared?.applyWindowDecorations(to: window) - super.init(window: window) - window.delegate = self - } - - @available(*, unavailable) - required init?(coder: NSCoder) { - fatalError("init(coder:) has not been implemented") - } - - func show() { - window?.center() - window?.makeKeyAndOrderFront(nil) - } -} - -private struct MenuBarExtraDebugView: View { - @AppStorage(MenuBarIconDebugSettings.previewEnabledKey) private var previewEnabled = false - @AppStorage(MenuBarIconDebugSettings.previewCountKey) private var previewCount = 1 - @AppStorage(MenuBarIconDebugSettings.badgeRectXKey) private var badgeRectX = Double(MenuBarIconDebugSettings.defaultBadgeRect.origin.x) - @AppStorage(MenuBarIconDebugSettings.badgeRectYKey) private var badgeRectY = Double(MenuBarIconDebugSettings.defaultBadgeRect.origin.y) - @AppStorage(MenuBarIconDebugSettings.badgeRectWidthKey) private var badgeRectWidth = Double(MenuBarIconDebugSettings.defaultBadgeRect.width) - @AppStorage(MenuBarIconDebugSettings.badgeRectHeightKey) private var badgeRectHeight = Double(MenuBarIconDebugSettings.defaultBadgeRect.height) - @AppStorage(MenuBarIconDebugSettings.singleDigitFontSizeKey) private var singleDigitFontSize = Double(MenuBarIconDebugSettings.defaultSingleDigitFontSize) - @AppStorage(MenuBarIconDebugSettings.multiDigitFontSizeKey) private var multiDigitFontSize = Double(MenuBarIconDebugSettings.defaultMultiDigitFontSize) - @AppStorage(MenuBarIconDebugSettings.singleDigitYOffsetKey) private var singleDigitYOffset = Double(MenuBarIconDebugSettings.defaultSingleDigitYOffset) - @AppStorage(MenuBarIconDebugSettings.multiDigitYOffsetKey) private var multiDigitYOffset = Double(MenuBarIconDebugSettings.defaultMultiDigitYOffset) - @AppStorage(MenuBarIconDebugSettings.singleDigitXAdjustKey) private var singleDigitXAdjust = Double(MenuBarIconDebugSettings.defaultSingleDigitXAdjust) - @AppStorage(MenuBarIconDebugSettings.multiDigitXAdjustKey) private var multiDigitXAdjust = Double(MenuBarIconDebugSettings.defaultMultiDigitXAdjust) - @AppStorage(MenuBarIconDebugSettings.textRectWidthAdjustKey) private var textRectWidthAdjust = Double(MenuBarIconDebugSettings.defaultTextRectWidthAdjust) - - var body: some View { - ScrollView { - VStack(alignment: .leading, spacing: 14) { - Text("Menu Bar Extra Icon") - .font(.headline) - - GroupBox("Preview Count") { - VStack(alignment: .leading, spacing: 8) { - Toggle("Override unread count", isOn: $previewEnabled) - - Stepper(value: $previewCount, in: 0...99) { - HStack { - Text("Unread Count") - Spacer() - Text("\(previewCount)") - .font(.caption) - .monospacedDigit() - } - } - .disabled(!previewEnabled) - } - .padding(.top, 2) - } - - GroupBox("Badge Rect") { - VStack(alignment: .leading, spacing: 8) { - sliderRow("X", value: $badgeRectX, range: 0...20, format: "%.2f") - sliderRow("Y", value: $badgeRectY, range: 0...20, format: "%.2f") - sliderRow("Width", value: $badgeRectWidth, range: 4...14, format: "%.2f") - sliderRow("Height", value: $badgeRectHeight, range: 4...14, format: "%.2f") - } - .padding(.top, 2) - } - - GroupBox("Badge Text") { - VStack(alignment: .leading, spacing: 8) { - sliderRow("1-digit size", value: $singleDigitFontSize, range: 6...14, format: "%.2f") - sliderRow("2-digit size", value: $multiDigitFontSize, range: 6...14, format: "%.2f") - sliderRow("1-digit X", value: $singleDigitXAdjust, range: -4...4, format: "%.2f") - sliderRow("2-digit X", value: $multiDigitXAdjust, range: -4...4, format: "%.2f") - sliderRow("1-digit Y", value: $singleDigitYOffset, range: -3...4, format: "%.2f") - sliderRow("2-digit Y", value: $multiDigitYOffset, range: -3...4, format: "%.2f") - sliderRow("Text width adjust", value: $textRectWidthAdjust, range: -3...5, format: "%.2f") - } - .padding(.top, 2) - } - - HStack(spacing: 12) { - Button("Reset") { - previewEnabled = false - previewCount = 1 - badgeRectX = Double(MenuBarIconDebugSettings.defaultBadgeRect.origin.x) - badgeRectY = Double(MenuBarIconDebugSettings.defaultBadgeRect.origin.y) - badgeRectWidth = Double(MenuBarIconDebugSettings.defaultBadgeRect.width) - badgeRectHeight = Double(MenuBarIconDebugSettings.defaultBadgeRect.height) - singleDigitFontSize = Double(MenuBarIconDebugSettings.defaultSingleDigitFontSize) - multiDigitFontSize = Double(MenuBarIconDebugSettings.defaultMultiDigitFontSize) - singleDigitYOffset = Double(MenuBarIconDebugSettings.defaultSingleDigitYOffset) - multiDigitYOffset = Double(MenuBarIconDebugSettings.defaultMultiDigitYOffset) - singleDigitXAdjust = Double(MenuBarIconDebugSettings.defaultSingleDigitXAdjust) - multiDigitXAdjust = Double(MenuBarIconDebugSettings.defaultMultiDigitXAdjust) - textRectWidthAdjust = Double(MenuBarIconDebugSettings.defaultTextRectWidthAdjust) - applyLiveUpdate() - } - - Button("Copy Config") { - let payload = MenuBarIconDebugSettings.copyPayload() - let pasteboard = NSPasteboard.general - pasteboard.clearContents() - pasteboard.setString(payload, forType: .string) - } - } - - Text("Tip: enable override count, then tune until the menu bar icon looks right.") - .font(.caption) - .foregroundColor(.secondary) - - Spacer(minLength: 0) - } - .padding(16) - .frame(maxWidth: .infinity, alignment: .topLeading) - } - .onAppear { applyLiveUpdate() } - .onChange(of: previewEnabled) { _ in applyLiveUpdate() } - .onChange(of: previewCount) { _ in applyLiveUpdate() } - .onChange(of: badgeRectX) { _ in applyLiveUpdate() } - .onChange(of: badgeRectY) { _ in applyLiveUpdate() } - .onChange(of: badgeRectWidth) { _ in applyLiveUpdate() } - .onChange(of: badgeRectHeight) { _ in applyLiveUpdate() } - .onChange(of: singleDigitFontSize) { _ in applyLiveUpdate() } - .onChange(of: multiDigitFontSize) { _ in applyLiveUpdate() } - .onChange(of: singleDigitXAdjust) { _ in applyLiveUpdate() } - .onChange(of: multiDigitXAdjust) { _ in applyLiveUpdate() } - .onChange(of: singleDigitYOffset) { _ in applyLiveUpdate() } - .onChange(of: multiDigitYOffset) { _ in applyLiveUpdate() } - .onChange(of: textRectWidthAdjust) { _ in applyLiveUpdate() } - } - - private func sliderRow( - _ label: String, - value: Binding, - range: ClosedRange, - format: String - ) -> some View { - HStack(spacing: 8) { - Text(label) - Slider(value: value, in: range) - Text(String(format: format, value.wrappedValue)) - .font(.caption) - .monospacedDigit() - .frame(width: 58, alignment: .trailing) - } - } - - private func applyLiveUpdate() { - AppDelegate.shared?.refreshMenuBarExtraForDebug() - } -} - -// MARK: - Split Button Layout Debug Window - -private final class SplitButtonLayoutDebugWindowController: NSWindowController, NSWindowDelegate { - static let shared = SplitButtonLayoutDebugWindowController() - - private init() { - let window = NSPanel( - contentRect: NSRect(x: 0, y: 0, width: 320, height: 240), - styleMask: [.titled, .closable, .utilityWindow], - backing: .buffered, - defer: false - ) - window.title = "Split Button Layout" - window.titleVisibility = .visible - window.titlebarAppearsTransparent = false - window.isMovableByWindowBackground = true - window.isReleasedWhenClosed = false - window.identifier = NSUserInterfaceItemIdentifier("programa.splitButtonLayoutDebug") - window.center() - window.contentView = NSHostingView(rootView: SplitButtonLayoutDebugView()) - AppDelegate.shared?.applyWindowDecorations(to: window) - super.init(window: window) - window.delegate = self - } - - @available(*, unavailable) - required init?(coder: NSCoder) { fatalError() } - - func show() { - window?.center() - window?.makeKeyAndOrderFront(nil) - } -} - -private struct SplitButtonLayoutDebugView: View { - @AppStorage("debugFadeColorStyle") private var backdropStyle = 0 - - private let options: [(Int, String)] = [ - (0, "Pre-composited paneBackground"), - (1, "Raw paneBackground (opaque)"), - (2, "barBackground (tab chrome)"), - (3, "windowBackgroundColor"), - (4, "controlBackgroundColor"), - (5, "Pre-composited barBackground"), - ] - - var body: some View { - VStack(alignment: .leading, spacing: 10) { - Text("Button Backdrop Color") - .font(.headline) - - ForEach(options, id: \.0) { id, label in - HStack { - Image(systemName: backdropStyle == id ? "checkmark.circle.fill" : "circle") - .foregroundColor(backdropStyle == id ? .accentColor : .secondary) - Text(label) - } - .contentShape(Rectangle()) - .onTapGesture { backdropStyle = id } - } - - Text("Changes apply live.") - .font(.caption) - .foregroundColor(.secondary) - } - .padding(16) - .frame(maxWidth: .infinity, alignment: .topLeading) - } -} - -// MARK: - Background Debug Window - -private final class BackgroundDebugWindowController: NSWindowController, NSWindowDelegate { - static let shared = BackgroundDebugWindowController() - - private init() { - let window = NSPanel( - contentRect: NSRect(x: 0, y: 0, width: 360, height: 300), - styleMask: [.titled, .closable, .utilityWindow], - backing: .buffered, - defer: false - ) - window.title = "Background Debug" - window.titleVisibility = .visible - window.titlebarAppearsTransparent = false - window.isMovableByWindowBackground = true - window.isReleasedWhenClosed = false - window.identifier = NSUserInterfaceItemIdentifier("programa.backgroundDebug") - window.center() - window.contentView = NSHostingView(rootView: BackgroundDebugView()) - AppDelegate.shared?.applyWindowDecorations(to: window) - super.init(window: window) - window.delegate = self - } - - @available(*, unavailable) - required init?(coder: NSCoder) { - fatalError("init(coder:) has not been implemented") - } - - func show() { - window?.center() - window?.makeKeyAndOrderFront(nil) - } -} - -private struct BackgroundDebugView: View { - @AppStorage("bgGlassTintHex") private var bgGlassTintHex = "#000000" - @AppStorage("bgGlassTintOpacity") private var bgGlassTintOpacity = 0.03 - @AppStorage("bgGlassMaterial") private var bgGlassMaterial = "hudWindow" - @AppStorage("bgGlassEnabled") private var bgGlassEnabled = false - - var body: some View { - ScrollView { - VStack(alignment: .leading, spacing: 14) { - Text("Window Background Glass") - .font(.headline) - - GroupBox("Glass Effect") { - VStack(alignment: .leading, spacing: 8) { - Toggle("Enable Glass Effect", isOn: $bgGlassEnabled) - - Picker("Material", selection: $bgGlassMaterial) { - Text("HUD Window").tag("hudWindow") - Text("Under Window").tag("underWindowBackground") - Text("Sidebar").tag("sidebar") - Text("Menu").tag("menu") - Text("Popover").tag("popover") - } - .disabled(!bgGlassEnabled) - } - .padding(.top, 2) - } - - GroupBox("Tint") { - VStack(alignment: .leading, spacing: 8) { - ColorPicker("Tint Color", selection: tintColorBinding, supportsOpacity: false) - .disabled(!bgGlassEnabled) - - HStack(spacing: 8) { - Text("Opacity") - Slider(value: $bgGlassTintOpacity, in: 0...0.8) - .disabled(!bgGlassEnabled) - Text(String(format: "%.0f%%", bgGlassTintOpacity * 100)) - .font(.caption) - .frame(width: 44, alignment: .trailing) - } - } - .padding(.top, 2) - } - - HStack(spacing: 12) { - Button("Reset") { - bgGlassTintHex = "#000000" - bgGlassTintOpacity = 0.03 - bgGlassMaterial = "hudWindow" - bgGlassEnabled = false - updateWindowGlassTint() - } - - Button("Copy Config") { - copyBgConfig() - } - } - - Text("Tint changes apply live. Enable/disable requires reload.") - .font(.caption) - .foregroundColor(.secondary) - - Spacer(minLength: 0) - } - .padding(16) - .frame(maxWidth: .infinity, alignment: .topLeading) - } - .onChange(of: bgGlassTintHex) { _ in updateWindowGlassTint() } - .onChange(of: bgGlassTintOpacity) { _ in updateWindowGlassTint() } - } - - private func updateWindowGlassTint() { - let window: NSWindow? = { - if let key = NSApp.keyWindow, - let raw = key.identifier?.rawValue, - raw == "cmux.main" || raw.hasPrefix("cmux.main.") { - return key - } - return NSApp.windows.first(where: { - guard let raw = $0.identifier?.rawValue else { return false } - return raw == "cmux.main" || raw.hasPrefix("cmux.main.") - }) - }() - guard let window else { return } - let tintColor = (NSColor(hex: bgGlassTintHex) ?? .black).withAlphaComponent(bgGlassTintOpacity) - WindowGlassEffect.updateTint(to: window, color: tintColor) - } - - private var tintColorBinding: Binding { - Binding( - get: { - Color(nsColor: NSColor(hex: bgGlassTintHex) ?? .black) - }, - set: { newColor in - let nsColor = NSColor(newColor) - bgGlassTintHex = nsColor.hexString() - } - ) - } - - private func copyBgConfig() { - let payload = """ - bgGlassEnabled=\(bgGlassEnabled) - bgGlassMaterial=\(bgGlassMaterial) - bgGlassTintHex=\(bgGlassTintHex) - bgGlassTintOpacity=\(String(format: "%.2f", bgGlassTintOpacity)) - """ - let pasteboard = NSPasteboard.general - pasteboard.clearContents() - pasteboard.setString(payload, forType: .string) - } -} - -private struct AboutPropertyRow: View { - private let label: String - private let text: String - private let url: URL? - - init(label: String, text: String, url: URL? = nil) { - self.label = label - self.text = text - self.url = url - } - - @ViewBuilder private var textView: some View { - Text(text) - .frame(width: 140, alignment: .leading) - .padding(.leading, 2) - .tint(.secondary) - .opacity(0.8) - .monospaced() - } - - var body: some View { - HStack(spacing: 4) { - Text(label) - .frame(width: 126, alignment: .trailing) - .padding(.trailing, 2) - if let url { - Link(destination: url) { - textView - } - } else { - textView - } - } - .font(.callout) - .textSelection(.enabled) - .frame(maxWidth: .infinity) - } -} - -private struct AboutVisualEffectBackground: NSViewRepresentable { - let material: NSVisualEffectView.Material - let blendingMode: NSVisualEffectView.BlendingMode - let isEmphasized: Bool - - init( - material: NSVisualEffectView.Material, - blendingMode: NSVisualEffectView.BlendingMode = .behindWindow, - isEmphasized: Bool = false - ) { - self.material = material - self.blendingMode = blendingMode - self.isEmphasized = isEmphasized - } - - func updateNSView(_ nsView: NSVisualEffectView, context: Context) { - nsView.material = material - nsView.blendingMode = blendingMode - nsView.isEmphasized = isEmphasized - } - - func makeNSView(context: Context) -> NSVisualEffectView { - let visualEffect = NSVisualEffectView() - visualEffect.autoresizingMask = [.width, .height] - return visualEffect - } -} - -enum AppearanceMode: String, CaseIterable, Identifiable { - case system - case light - case dark - case auto - - var id: String { rawValue } - - static var visibleCases: [AppearanceMode] { - [.system, .light, .dark] - } - - var displayName: String { - switch self { - case .system: - return String(localized: "appearance.system", defaultValue: "System") - case .light: - return String(localized: "appearance.light", defaultValue: "Light") - case .dark: - return String(localized: "appearance.dark", defaultValue: "Dark") - case .auto: - return String(localized: "appearance.auto", defaultValue: "Auto") - } - } -} - -enum AppearanceSettings { - static let appearanceModeKey = "appearanceMode" - static let defaultMode: AppearanceMode = .system - - static func mode(for rawValue: String?) -> AppearanceMode { - guard let rawValue, let mode = AppearanceMode(rawValue: rawValue) else { - return defaultMode - } - if mode == .auto { - return .system - } - return mode - } - - @discardableResult - static func resolvedMode(defaults: UserDefaults = .standard) -> AppearanceMode { - let stored = defaults.string(forKey: appearanceModeKey) - let resolved = mode(for: stored) - if stored != resolved.rawValue { - defaults.set(resolved.rawValue, forKey: appearanceModeKey) - } - return resolved - } -} - - - -final class AppIconAppearanceObserver: NSObject { - static let shared = AppIconAppearanceObserver() - private var observation: NSKeyValueObservation? - - private override init() { super.init() } - - func startObserving() { - applyIconForCurrentAppearance() - guard observation == nil else { return } - observation = NSApp.observe(\.effectiveAppearance, options: []) { [weak self] _, _ in - DispatchQueue.main.async { - guard let self, self.observation != nil else { return } - self.applyIconForCurrentAppearance() - } - } - } - - func stopObserving() { - observation?.invalidate() - observation = nil - } - - private func applyIconForCurrentAppearance() { - let isDark = NSApp.effectiveAppearance.bestMatch(from: [.darkAqua, .aqua]) == .darkAqua - let imageName = isDark ? "AppIconDark" : "AppIconLight" - if let icon = NSImage(named: imageName) { - NSApplication.shared.applicationIconImage = icon - } - } -} - -enum QuitWarningSettings { - static let warnBeforeQuitKey = "warnBeforeQuitShortcut" - static let defaultWarnBeforeQuit = true - private static let flag = UserDefaultsFlag(key: warnBeforeQuitKey, defaultValue: defaultWarnBeforeQuit) - - static func isEnabled(defaults: UserDefaults = .standard) -> Bool { - flag.isEnabled(defaults: defaults) - } - - static func setEnabled(_ isEnabled: Bool, defaults: UserDefaults = .standard) { - flag.setEnabled(isEnabled, defaults: defaults) - } -} - -enum ScrollbackPersistenceSettings { - static let persistScrollbackKey = "sessionPersistScrollback" - static let defaultPersistScrollback = true - private static let flag = UserDefaultsFlag(key: persistScrollbackKey, defaultValue: defaultPersistScrollback) - - static func isEnabled(defaults: UserDefaults = .standard) -> Bool { - flag.isEnabled(defaults: defaults) - } -} - -enum CommandPaletteRenameSelectionSettings { - static let selectAllOnFocusKey = "commandPalette.renameSelectAllOnFocus" - static let defaultSelectAllOnFocus = true - private static let flag = UserDefaultsFlag(key: selectAllOnFocusKey, defaultValue: defaultSelectAllOnFocus) - - static func selectAllOnFocusEnabled(defaults: UserDefaults = .standard) -> Bool { - flag.isEnabled(defaults: defaults) - } -} - -enum CommandPaletteSwitcherSearchSettings { - static let searchAllSurfacesKey = "commandPalette.switcherSearchAllSurfaces" - static let defaultSearchAllSurfaces = false - private static let flag = UserDefaultsFlag(key: searchAllSurfacesKey, defaultValue: defaultSearchAllSurfaces) - - static func searchAllSurfacesEnabled(defaults: UserDefaults = .standard) -> Bool { - flag.isEnabled(defaults: defaults) - } -} - -enum ClaudeCodeIntegrationSettings { - static let hooksEnabledKey = "claudeCodeHooksEnabled" - static let defaultHooksEnabled = true - static let customClaudePathKey = "claudeCodeCustomClaudePath" - private static let hooksFlag = UserDefaultsFlag(key: hooksEnabledKey, defaultValue: defaultHooksEnabled) - - static func hooksEnabled(defaults: UserDefaults = .standard) -> Bool { - hooksFlag.isEnabled(defaults: defaults) - } - - static func customClaudePath(defaults: UserDefaults = .standard) -> String? { - let value = defaults.string(forKey: customClaudePathKey)? - .trimmingCharacters(in: .whitespacesAndNewlines) ?? "" - return value.isEmpty ? nil : value - } -} - -enum WelcomeSettings { - static let shownKey = "programaWelcomeShown" -} - -enum PreferredEditorSettings { - static let key = "preferredEditorCommand" - - /// Returns the configured editor command, or nil to use system default. - static func resolvedCommand(defaults: UserDefaults = .standard) -> String? { - guard let stored = defaults.string(forKey: key)?.trimmingCharacters(in: .whitespacesAndNewlines), - !stored.isEmpty else { - return nil - } - return stored - } - - /// Open a file path with the user's preferred editor, falling back to system default. - static func open(_ url: URL) { - if ProgramaUITestCapture.appendLineIfConfigured( - envKey: "PROGRAMA_UI_TEST_CAPTURE_OPEN_PATH", - line: url.path - ) { - return - } - - guard let command = resolvedCommand() else { - NSWorkspace.shared.open(url) - return - } - let path = url.path - let process = Process() - process.executableURL = URL(fileURLWithPath: "/bin/sh") - process.arguments = ["-c", "\(command) \(shellQuote(path))"] - process.standardOutput = FileHandle.nullDevice - process.standardError = FileHandle.nullDevice - do { - try process.run() - // Check exit status on a background thread; fall back on failure - // (e.g. command not found exits 127 but /bin/sh itself succeeds) - DispatchQueue.global(qos: .userInitiated).async { - process.waitUntilExit() - if process.terminationStatus != 0 { - DispatchQueue.main.async { NSWorkspace.shared.open(url) } - } - } - } catch { - NSWorkspace.shared.open(url) - } - } - - private static func shellQuote(_ s: String) -> String { - "'" + s.replacingOccurrences(of: "'", with: "'\\''") + "'" - } -} - -enum ProgramaUITestCapture { - static func appendLineIfConfigured(envKey: String, line: String) -> Bool { - guard let url = configuredURL(for: envKey) else { return false } - appendLine(line, to: url) - return true - } - - static func mutateJSONObjectIfConfigured( - envKey: String, - _ update: (inout [String: Any]) -> Void - ) -> Bool { - guard let url = configuredURL(for: envKey) else { return false } - mutateJSONObject(at: url, update) - return true - } - - private static func configuredURL(for envKey: String) -> URL? { - let env = ProcessInfo.processInfo.environment - guard let rawPath = env[envKey]?.trimmingCharacters(in: .whitespacesAndNewlines), - !rawPath.isEmpty else { - return nil - } - return URL(fileURLWithPath: rawPath) - } - - private static func appendLine(_ line: String, to url: URL) { - ensureParentDirectory(for: url) - let payload = (line + "\n").data(using: .utf8) ?? Data() - - if FileManager.default.fileExists(atPath: url.path) { - do { - let handle = try FileHandle(forWritingTo: url) - defer { try? handle.close() } - try handle.seekToEnd() - try handle.write(contentsOf: payload) - } catch { - if let existing = try? Data(contentsOf: url) { - var combined = existing - combined.append(payload) - try? combined.write(to: url, options: .atomic) - } else { - try? payload.write(to: url, options: .atomic) - } - } - return - } - - try? payload.write(to: url, options: .atomic) - } - - private static func mutateJSONObject( - at url: URL, - _ update: (inout [String: Any]) -> Void - ) { - ensureParentDirectory(for: url) - var payload: [String: Any] = [:] - if let data = try? Data(contentsOf: url), - let object = try? JSONSerialization.jsonObject(with: data) as? [String: Any] { - payload = object - } - update(&payload) - guard let data = try? JSONSerialization.data(withJSONObject: payload, options: [.sortedKeys]) else { - return - } - try? data.write(to: url, options: .atomic) - } - - private static func ensureParentDirectory(for url: URL) { - let directory = url.deletingLastPathComponent() - guard !directory.path.isEmpty else { return } - try? FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true) - } -} - -enum ProgramaRuntimeDebugCapture { - private struct Configuration { - let baseURL: URL - let token: String - let sessionID: String - } - - private static let configuration: Configuration? = { - let env = ProcessInfo.processInfo.environment - guard let baseURLString = env["PROGRAMA_RUNTIME_DEBUG_BASE_URL"]?.trimmingCharacters(in: .whitespacesAndNewlines), - let baseURL = URL(string: baseURLString), - let token = env["PROGRAMA_RUNTIME_DEBUG_TOKEN"]?.trimmingCharacters(in: .whitespacesAndNewlines), - !token.isEmpty, - let sessionID = env["PROGRAMA_RUNTIME_DEBUG_SESSION_ID"]?.trimmingCharacters(in: .whitespacesAndNewlines), - !sessionID.isEmpty else { - return nil - } - return Configuration(baseURL: baseURL, token: token, sessionID: sessionID) - }() - - private static let lock = NSLock() - private static var sequence: Int = 0 - - static func logIfConfigured( - hypothesisID: String, - source: String, - name: String, - expected: String? = nil, - actual: String? = nil, - data: [String: Any] = [:] - ) { - guard let configuration else { return } - - var payload: [String: Any] = [ - "session_id": configuration.sessionID, - "hypothesis_id": hypothesisID, - "service": "cmux-macos", - "source": source, - "name": name, - "ts": ISO8601DateFormatter().string(from: Date()), - "mono_ms": ProcessInfo.processInfo.systemUptime * 1000, - "seq": nextSequence(), - "data": data - ] - if let expected { - payload["expected"] = expected - } - if let actual { - payload["actual"] = actual - } - - guard JSONSerialization.isValidJSONObject(payload), - let requestBody = try? JSONSerialization.data(withJSONObject: payload, options: []) else { - return - } - - var request = URLRequest(url: configuration.baseURL.appendingPathComponent("api/logs")) - request.httpMethod = "POST" - request.setValue("application/json", forHTTPHeaderField: "Content-Type") - request.setValue(configuration.token, forHTTPHeaderField: "X-Debug-Token") - request.httpBody = requestBody - - URLSession.shared.dataTask(with: request).resume() - } - - private static func nextSequence() -> Int { - lock.lock() - defer { lock.unlock() } - sequence += 1 - return sequence - } -} - -private func openProgramaSettingsFileInEditor() { - let url = KeyboardShortcutSettings.settingsFileStore.settingsFileURLForEditing() - PreferredEditorSettings.open(url) -} - -private func openProgramaSettingsFileInTextEdit() { - #if os(macOS) - let fileURL = KeyboardShortcutSettings.settingsFileStore.settingsFileURLForEditing() - let editorURL = URL(fileURLWithPath: "/System/Applications/TextEdit.app") - let configuration = NSWorkspace.OpenConfiguration() - NSWorkspace.shared.open([fileURL], withApplicationAt: editorURL, configuration: configuration) - #endif -} - -struct SettingsView: View { - private let contentTopInset: CGFloat = 8 - private let pickerColumnWidth: CGFloat = 196 - private let notificationSoundControlWidth: CGFloat = 280 - private let shortcutChordsDocsURL = URL(string: "https://github.com/darkroomengineering/programa/tree/main/docs")! - - @AppStorage(AppearanceSettings.appearanceModeKey) private var appearanceMode = AppearanceSettings.defaultMode.rawValue - @AppStorage(WorkspacePresentationModeSettings.modeKey) - private var workspacePresentationMode = WorkspacePresentationModeSettings.defaultMode.rawValue - @AppStorage(SocketControlSettings.appStorageKey) private var socketControlMode = SocketControlSettings.defaultMode.rawValue - @AppStorage(ClaudeCodeIntegrationSettings.hooksEnabledKey) - private var claudeCodeHooksEnabled = ClaudeCodeIntegrationSettings.defaultHooksEnabled - @AppStorage(ClaudeCodeIntegrationSettings.customClaudePathKey) - private var customClaudePath = "" - @AppStorage(PreferredEditorSettings.key) private var preferredEditorCommand = "" - @AppStorage("cmuxPortBase") private var programaPortBase = 9100 - @AppStorage("cmuxPortRange") private var programaPortRange = 10 - @AppStorage(BrowserSearchSettings.searchEngineKey) private var browserSearchEngine = BrowserSearchSettings.defaultSearchEngine.rawValue - @AppStorage(BrowserSearchSettings.searchSuggestionsEnabledKey) private var browserSearchSuggestionsEnabled = BrowserSearchSettings.defaultSearchSuggestionsEnabled - @AppStorage(BrowserThemeSettings.modeKey) private var browserThemeMode = BrowserThemeSettings.defaultMode.rawValue - @AppStorage(BrowserImportHintSettings.showOnBlankTabsKey) private var showBrowserImportHintOnBlankTabs = BrowserImportHintSettings.defaultShowOnBlankTabs - @AppStorage(BrowserImportHintSettings.dismissedKey) private var isBrowserImportHintDismissed = BrowserImportHintSettings.defaultDismissed - @AppStorage(BrowserLinkOpenSettings.openTerminalLinksInProgramaBrowserKey) private var openTerminalLinksInProgramaBrowser = BrowserLinkOpenSettings.defaultOpenTerminalLinksInProgramaBrowser - @AppStorage(BrowserLinkOpenSettings.interceptTerminalOpenCommandInProgramaBrowserKey) - private var interceptTerminalOpenCommandInProgramaBrowser = BrowserLinkOpenSettings.initialInterceptTerminalOpenCommandInProgramaBrowserValue() - @AppStorage(BrowserLinkOpenSettings.browserHostWhitelistKey) private var browserHostWhitelist = BrowserLinkOpenSettings.defaultBrowserHostWhitelist - @AppStorage(BrowserLinkOpenSettings.browserExternalOpenPatternsKey) - private var browserExternalOpenPatterns = BrowserLinkOpenSettings.defaultBrowserExternalOpenPatterns - @AppStorage(BrowserInsecureHTTPSettings.allowlistKey) private var browserInsecureHTTPAllowlist = BrowserInsecureHTTPSettings.defaultAllowlistText - @AppStorage(NotificationSoundSettings.key) private var notificationSound = NotificationSoundSettings.defaultValue - @AppStorage(NotificationSoundSettings.customFilePathKey) - private var notificationSoundCustomFilePath = NotificationSoundSettings.defaultCustomFilePath - @AppStorage(NotificationSoundSettings.customCommandKey) private var notificationCustomCommand = NotificationSoundSettings.defaultCustomCommand - @AppStorage(MenuBarExtraSettings.showInMenuBarKey) private var showMenuBarExtra = MenuBarExtraSettings.defaultShowInMenuBar - @AppStorage(QuitWarningSettings.warnBeforeQuitKey) private var warnBeforeQuitShortcut = QuitWarningSettings.defaultWarnBeforeQuit - @AppStorage(ScrollbackPersistenceSettings.persistScrollbackKey) private var sessionPersistScrollback = ScrollbackPersistenceSettings.defaultPersistScrollback - @AppStorage(CommandPaletteRenameSelectionSettings.selectAllOnFocusKey) - private var commandPaletteRenameSelectAllOnFocus = CommandPaletteRenameSelectionSettings.defaultSelectAllOnFocus - @AppStorage(CommandPaletteSwitcherSearchSettings.searchAllSurfacesKey) - private var commandPaletteSearchAllSurfaces = CommandPaletteSwitcherSearchSettings.defaultSearchAllSurfaces - @AppStorage(ShortcutHintDebugSettings.alwaysShowHintsKey) - private var alwaysShowShortcutHints = ShortcutHintDebugSettings.defaultAlwaysShowHints - @AppStorage(WorkspacePlacementSettings.placementKey) private var newWorkspacePlacement = WorkspacePlacementSettings.defaultPlacement.rawValue - @AppStorage(LastSurfaceCloseShortcutSettings.key) - private var closeWorkspaceOnLastSurfaceShortcut = LastSurfaceCloseShortcutSettings.defaultValue - @AppStorage(PaneFirstClickFocusSettings.enabledKey) - private var paneFirstClickFocusEnabled = PaneFirstClickFocusSettings.defaultEnabled - @AppStorage(WorkspaceAutoReorderSettings.key) private var workspaceAutoReorder = WorkspaceAutoReorderSettings.defaultValue - @AppStorage(SidebarActiveTabIndicatorSettings.styleKey) - private var sidebarActiveTabIndicatorStyle = SidebarActiveTabIndicatorSettings.defaultStyle.rawValue - @AppStorage("sidebarSelectionColorHex") private var sidebarSelectionColorHex: String? - @AppStorage("sidebarNotificationBadgeColorHex") private var sidebarNotificationBadgeColorHex: String? - @AppStorage(ShortcutHintDebugSettings.showHintsOnCommandHoldKey) - private var showShortcutHintsOnCommandHold = ShortcutHintDebugSettings.defaultShowHintsOnCommandHold - @AppStorage("sidebarTintHex") private var sidebarTintHex = SidebarTintDefaults.hex - @AppStorage("sidebarTintHexLight") private var sidebarTintHexLight: String? - @AppStorage("sidebarTintHexDark") private var sidebarTintHexDark: String? - @AppStorage("sidebarTintOpacity") private var sidebarTintOpacity = SidebarTintDefaults.opacity - @AppStorage("sidebarMatchTerminalBackground") private var sidebarMatchTerminalBackground = false - - @ObservedObject private var notificationStore = TerminalNotificationStore.shared - @StateObject private var keyboardShortcutSettingsObserver = KeyboardShortcutSettingsObserver.shared - @State private var shortcutResetToken = UUID() - @State private var topBlurOpacity: Double = 0 - @State private var topBlurBaselineOffset: CGFloat? - @State private var settingsTitleLeadingInset: CGFloat = 92 - @State private var showClearBrowserHistoryConfirmation = false - @State private var showOpenAccessConfirmation = false - @State private var pendingOpenAccessMode: SocketControlMode? - @State private var browserHistoryEntryCount: Int = 0 - @State private var detectedImportBrowsers: [InstalledBrowserCandidate] = [] - @State private var browserInsecureHTTPAllowlistDraft = BrowserInsecureHTTPSettings.defaultAllowlistText - @State private var socketPasswordDraft = "" - @State private var socketPasswordStatusMessage: String? - @State private var socketPasswordStatusIsError = false - @State private var notificationCustomSoundStatusMessage: String? - @State private var notificationCustomSoundStatusIsError = false - @State private var showNotificationCustomSoundErrorAlert = false - @State private var notificationCustomSoundErrorAlertMessage = "" - @State private var workspaceTabPaletteEntries = WorkspaceTabColorSettings.palette() - @State private var trustedDirectoriesDraft: String = ProgramaDirectoryTrust.shared.allTrustedPaths.joined(separator: "\n") - - private var selectedWorkspacePlacement: NewWorkspacePlacement { - NewWorkspacePlacement(rawValue: newWorkspacePlacement) ?? WorkspacePlacementSettings.defaultPlacement - } - - private var minimalModeEnabled: Bool { - WorkspacePresentationModeSettings.mode(for: workspacePresentationMode) == .minimal - } - - private var minimalModeSubtitle: String { - if minimalModeEnabled { - return String( - localized: "settings.app.minimalMode.subtitleOn", - defaultValue: "Hide the workspace title bar and move workspace controls into the sidebar." - ) - } - return String( - localized: "settings.app.minimalMode.subtitleOff", - defaultValue: "Use the standard workspace title bar and controls." - ) - } - - private var keepWorkspaceOpenOnLastSurfaceShortcut: Bool { - !closeWorkspaceOnLastSurfaceShortcut - } - - private var keepWorkspaceOpenOnLastSurfaceShortcutBinding: Binding { - Binding( - get: { keepWorkspaceOpenOnLastSurfaceShortcut }, - set: { closeWorkspaceOnLastSurfaceShortcut = !$0 } - ) - } - - private var closeWorkspaceOnLastSurfaceShortcutSubtitle: String { - if keepWorkspaceOpenOnLastSurfaceShortcut { - return String( - localized: "settings.app.closeWorkspaceOnLastSurfaceShortcut.subtitleOn", - defaultValue: "When the focused surface is the last one in its workspace, the close-surface shortcut closes only the surface and keeps the workspace open. Use the close-workspace shortcut to close the workspace explicitly." - ) - } - return String( - localized: "settings.app.closeWorkspaceOnLastSurfaceShortcut.subtitleOff", - defaultValue: "When the focused surface is the last one in its workspace, the close-surface shortcut also closes the workspace." - ) - } - - private var paneFirstClickFocusSubtitle: String { - if paneFirstClickFocusEnabled { - return String( - localized: "settings.app.paneFirstClickFocus.subtitleOn", - defaultValue: "When Programa is inactive, clicking a pane activates the window and focuses that pane in one click." - ) - } - return String( - localized: "settings.app.paneFirstClickFocus.subtitleOff", - defaultValue: "When Programa is inactive, the first click only activates the window. Click again to focus the pane." - ) - } - - private var selectedSidebarActiveTabIndicatorStyle: SidebarActiveTabIndicatorStyle { - SidebarActiveTabIndicatorSettings.resolvedStyle(rawValue: sidebarActiveTabIndicatorStyle) - } - - private var sidebarIndicatorStyleSelection: Binding { - Binding( - get: { selectedSidebarActiveTabIndicatorStyle.rawValue }, - set: { sidebarActiveTabIndicatorStyle = $0 } - ) - } - - private var selectedSocketControlMode: SocketControlMode { - SocketControlSettings.migrateMode(socketControlMode) - } - - private var selectedBrowserThemeMode: BrowserThemeMode { - BrowserThemeSettings.mode(for: browserThemeMode) - } - - private var browserThemeModeSelection: Binding { - Binding( - get: { browserThemeMode }, - set: { newValue in - browserThemeMode = BrowserThemeSettings.mode(for: newValue).rawValue - } - ) - } - - private var browserImportHintPresentation: BrowserImportHintPresentation { - BrowserImportHintPresentation( - showOnBlankTabs: showBrowserImportHintOnBlankTabs, - isDismissed: isBrowserImportHintDismissed - ) - } - - private var browserImportHintVisibilityBinding: Binding { - Binding( - get: { showBrowserImportHintOnBlankTabs }, - set: { newValue in - showBrowserImportHintOnBlankTabs = newValue - if newValue { - isBrowserImportHintDismissed = false - } - } - ) - } - - private var socketModeSelection: Binding { - Binding( - get: { socketControlMode }, - set: { newValue in - let normalized = SocketControlSettings.migrateMode(newValue) - if normalized == .allowAll && selectedSocketControlMode != .allowAll { - pendingOpenAccessMode = normalized - showOpenAccessConfirmation = true - return - } - socketControlMode = normalized.rawValue - if normalized != .password { - socketPasswordStatusMessage = nil - socketPasswordStatusIsError = false - } - } - ) - } - - private var minimalModeBinding: Binding { - Binding( - get: { minimalModeEnabled }, - set: { newValue in - workspacePresentationMode = newValue - ? WorkspacePresentationModeSettings.Mode.minimal.rawValue - : WorkspacePresentationModeSettings.Mode.standard.rawValue - SettingsWindowController.shared.preserveFocusAfterPreferenceMutation() - } - ) - } - - private var hasSocketPasswordConfigured: Bool { - SocketControlPasswordStore.hasConfiguredPassword() - } - - private var browserHistorySubtitle: String { - switch browserHistoryEntryCount { - case 0: - return String(localized: "settings.browser.history.subtitleEmpty", defaultValue: "No saved pages yet.") - case 1: - return String(localized: "settings.browser.history.subtitleOne", defaultValue: "1 saved page appears in omnibar suggestions.") - default: - return String(localized: "settings.browser.history.subtitleMany", defaultValue: "\(browserHistoryEntryCount) saved pages appear in omnibar suggestions.") - } - } - - private var browserImportSubtitle: String { - InstalledBrowserDetector.summaryText(for: detectedImportBrowsers) - } - - private var browserImportHintSettingsNote: String { - switch browserImportHintPresentation.settingsStatus { - case .visible: - return String(localized: "settings.browser.import.hint.note.visible", defaultValue: "Blank browser tabs can show this import suggestion. Hide or re-enable it here.") - case .hidden: - return String(localized: "settings.browser.import.hint.note.hidden", defaultValue: "The blank-tab import hint is hidden. Turn it back on here any time.") - } - } - - private var browserInsecureHTTPAllowlistHasUnsavedChanges: Bool { - browserInsecureHTTPAllowlistDraft != browserInsecureHTTPAllowlist - } - - private func saveTrustedDirectories() { - let paths = trustedDirectoriesDraft - .split(separator: "\n", omittingEmptySubsequences: true) - .map { $0.trimmingCharacters(in: .whitespaces) } - .filter { !$0.isEmpty } - ProgramaDirectoryTrust.shared.replaceAll(with: paths) - } - - private var hasCustomNotificationSoundFilePath: Bool { - !notificationSoundCustomFilePath.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty - } - - private var notificationSoundCustomFileDisplayName: String { - guard hasCustomNotificationSoundFilePath else { - return String( - localized: "settings.notifications.sound.custom.file.none", - defaultValue: "No file selected" - ) - } - return URL(fileURLWithPath: notificationSoundCustomFilePath).lastPathComponent - } - - private var canPreviewNotificationSound: Bool { - switch notificationSound { - case "none": - return false - case NotificationSoundSettings.customFileValue: - return hasCustomNotificationSoundFilePath - default: - return true - } - } - - private var notificationPermissionStatusText: String { - notificationStore.authorizationState.statusLabel - } - - private var notificationPermissionStatusColor: Color { - switch notificationStore.authorizationState { - case .authorized, .provisional, .ephemeral: - return .green - case .denied: - return .red - case .unknown, .notDetermined: - return .secondary - } - } - - private var notificationPermissionSubtitle: String { - switch notificationStore.authorizationState { - case .unknown, .notDetermined: - return "Desktop notifications are not enabled yet." - case .authorized: - return "Desktop notifications are enabled." - case .denied: - return "Desktop notifications are disabled in System Settings." - case .provisional: - return "Desktop notifications are enabled with quiet delivery." - case .ephemeral: - return "Desktop notifications are temporarily enabled." - } - } - - private var notificationPermissionActionTitle: String { - switch notificationStore.authorizationState { - case .unknown, .notDetermined: - return "Enable" - case .authorized, .denied, .provisional, .ephemeral: - return "Open Settings" - } - } - - private func blurOpacity(forContentOffset offset: CGFloat) -> Double { - guard let baseline = topBlurBaselineOffset else { return 0 } - let reveal = (baseline - offset) / 24 - return Double(min(max(reveal, 0), 1)) - } - - private func previewNotificationSound() { - if notificationSound == NotificationSoundSettings.customFileValue { - NotificationSoundSettings.playCustomFileSound(path: notificationSoundCustomFilePath) - return - } - NotificationSoundSettings.previewSound(value: notificationSound) - } - - private func notificationCustomSoundIssueMessage(_ issue: NotificationSoundSettings.CustomSoundPreparationIssue) -> String { - switch issue { - case .emptyPath: - return String( - localized: "settings.notifications.sound.custom.status.empty", - defaultValue: "Choose a custom audio file first." - ) - case .missingFile(let path): - let fileName = URL(fileURLWithPath: path).lastPathComponent - return String( - localized: "settings.notifications.sound.custom.status.missingFilePrefix", - defaultValue: "File not found: " - ) + fileName - case .missingFileExtension(let path): - let fileName = URL(fileURLWithPath: path).lastPathComponent - return String( - localized: "settings.notifications.sound.custom.status.missingExtensionPrefix", - defaultValue: "File needs an extension: " - ) + fileName - case .stagingFailed(_, let details): - let prefix = String( - localized: "settings.notifications.sound.custom.status.prepareFailed", - defaultValue: "Could not prepare this file for notifications. Try WAV, AIFF, or CAF." - ) - return "\(prefix) (\(details))" - } - } - - private func notificationCustomSoundReadyStatusMessage(for path: String) -> String { - let sourceExtension = URL(fileURLWithPath: path).pathExtension - .trimmingCharacters(in: .whitespacesAndNewlines) - .lowercased() - let stagedExtension = NotificationSoundSettings.stagedCustomSoundFileExtension(forSourceExtension: sourceExtension) - if !sourceExtension.isEmpty, stagedExtension != sourceExtension { - return String( - localized: "settings.notifications.sound.custom.status.readyConverted", - defaultValue: "Prepared for notifications (converted to CAF)." - ) - } - return String( - localized: "settings.notifications.sound.custom.status.ready", - defaultValue: "Ready for notifications." - ) - } - - private func refreshNotificationCustomSoundStatus(showAlertOnFailure: Bool = false) { - guard notificationSound == NotificationSoundSettings.customFileValue else { - notificationCustomSoundStatusMessage = nil - notificationCustomSoundStatusIsError = false - return - } - let pathSnapshot = notificationSoundCustomFilePath - DispatchQueue.global(qos: .userInitiated).async { - let result = NotificationSoundSettings.prepareCustomFileForNotifications(path: pathSnapshot) - DispatchQueue.main.async { - guard notificationSound == NotificationSoundSettings.customFileValue else { - notificationCustomSoundStatusMessage = nil - notificationCustomSoundStatusIsError = false - return - } - guard notificationSoundCustomFilePath == pathSnapshot else { return } - switch result { - case .success: - notificationCustomSoundStatusMessage = notificationCustomSoundReadyStatusMessage(for: pathSnapshot) - notificationCustomSoundStatusIsError = false - case .failure(let issue): - let message = notificationCustomSoundIssueMessage(issue) - notificationCustomSoundStatusMessage = message - notificationCustomSoundStatusIsError = true - if showAlertOnFailure { - notificationCustomSoundErrorAlertMessage = message - showNotificationCustomSoundErrorAlert = true - } - } - } - } - } - - private func chooseNotificationSoundFile() { - let panel = NSOpenPanel() - panel.canChooseFiles = true - panel.canChooseDirectories = false - panel.allowsMultipleSelection = false - panel.allowedContentTypes = [.audio] - panel.title = String( - localized: "settings.notifications.sound.custom.choose.title", - defaultValue: "Choose Notification Sound" - ) - panel.prompt = String( - localized: "settings.notifications.sound.custom.choose.prompt", - defaultValue: "Choose" - ) - guard panel.runModal() == .OK, let url = panel.url else { return } - let selectedPath = url.path - switch NotificationSoundSettings.prepareCustomFileForNotifications(path: selectedPath) { - case .success: - notificationSoundCustomFilePath = selectedPath - notificationSound = NotificationSoundSettings.customFileValue - notificationCustomSoundStatusMessage = notificationCustomSoundReadyStatusMessage(for: selectedPath) - notificationCustomSoundStatusIsError = false - previewNotificationSound() - case .failure(let issue): - let message = notificationCustomSoundIssueMessage(issue) - notificationCustomSoundErrorAlertMessage = message - showNotificationCustomSoundErrorAlert = true - refreshNotificationCustomSoundStatus() - } - } - - private func handleNotificationPermissionAction() { - let state = notificationStore.authorizationState.statusLabel -#if DEBUG - dlog("notification.ui enableTapped state=\(state)") -#endif - NSLog("notification.ui enableTapped state=%@", state) - switch notificationStore.authorizationState { - case .unknown, .notDetermined: - notificationStore.requestAuthorizationFromSettings() - case .authorized, .denied, .provisional, .ephemeral: - notificationStore.openNotificationSettings() - } - } - - private func saveSocketPassword() { - let trimmed = socketPasswordDraft.trimmingCharacters(in: .whitespacesAndNewlines) - guard !trimmed.isEmpty else { - socketPasswordStatusMessage = String(localized: "settings.automation.socketPassword.enterFirst", defaultValue: "Enter a password first.") - socketPasswordStatusIsError = true - return - } - - do { - try SocketControlPasswordStore.savePassword(trimmed) - socketPasswordDraft = "" - socketPasswordStatusMessage = String(localized: "settings.automation.socketPassword.saved", defaultValue: "Password saved.") - socketPasswordStatusIsError = false - } catch { - socketPasswordStatusMessage = String(localized: "settings.automation.socketPassword.saveFailed", defaultValue: "Failed to save password (\(error.localizedDescription)).") - socketPasswordStatusIsError = true - } - } - - private func clearSocketPassword() { - do { - try SocketControlPasswordStore.clearPassword() - socketPasswordDraft = "" - socketPasswordStatusMessage = String(localized: "settings.automation.socketPassword.cleared", defaultValue: "Password cleared.") - socketPasswordStatusIsError = false - } catch { - socketPasswordStatusMessage = String(localized: "settings.automation.socketPassword.clearFailed", defaultValue: "Failed to clear password (\(error.localizedDescription)).") - socketPasswordStatusIsError = true - } - } - - var body: some View { - let _ = keyboardShortcutSettingsObserver.revision - ScrollViewReader { proxy in - ZStack(alignment: .top) { - ScrollView { - VStack(alignment: .leading, spacing: 14) { - SettingsSectionHeader(title: String(localized: "settings.section.app", defaultValue: "App")) - SettingsCard { - ThemePickerRow( - selectedMode: appearanceMode, - onSelect: { mode in - appearanceMode = mode.rawValue - } - ) - - SettingsCardDivider() - - SettingsPickerRow( - String(localized: "settings.app.newWorkspacePlacement", defaultValue: "New Workspace Placement"), - subtitle: selectedWorkspacePlacement.description, - controlWidth: pickerColumnWidth, - selection: $newWorkspacePlacement - ) { - ForEach(NewWorkspacePlacement.allCases) { placement in - Text(placement.displayName).tag(placement.rawValue) - } - } - - SettingsCardDivider() - - SettingsCardRow( - String(localized: "settings.app.minimalMode", defaultValue: "Minimal Mode"), - subtitle: minimalModeSubtitle - ) { - Toggle("", isOn: minimalModeBinding) - .labelsHidden() - .controlSize(.small) - .accessibilityIdentifier("SettingsMinimalModeToggle") - .accessibilityLabel( - String(localized: "settings.app.minimalMode", defaultValue: "Minimal Mode") - ) - } - - SettingsCardDivider() - - SettingsCardRow( - String(localized: "settings.app.closeWorkspaceOnLastSurfaceShortcut", defaultValue: "Keep Workspace Open When Closing Last Surface"), - subtitle: closeWorkspaceOnLastSurfaceShortcutSubtitle - ) { - Toggle("", isOn: keepWorkspaceOpenOnLastSurfaceShortcutBinding) - .labelsHidden() - .controlSize(.small) - } - - SettingsCardDivider() - - SettingsCardRow( - String(localized: "settings.app.paneFirstClickFocus", defaultValue: "Focus Pane on First Click"), - subtitle: paneFirstClickFocusSubtitle - ) { - Toggle("", isOn: $paneFirstClickFocusEnabled) - .labelsHidden() - .controlSize(.small) - .accessibilityLabel( - String(localized: "settings.app.paneFirstClickFocus", defaultValue: "Focus Pane on First Click") - ) - } - - SettingsCardDivider() - - SettingsCardRow( - String(localized: "settings.app.preferredEditor", defaultValue: "Open Files With"), - subtitle: String(localized: "settings.app.preferredEditor.subtitle", defaultValue: "Command to open files on Cmd-click. Leave empty for system default.") - ) { - TextField( - String(localized: "settings.app.preferredEditor.placeholder", defaultValue: "e.g. code, zed, subl"), - text: $preferredEditorCommand - ) - .textFieldStyle(.roundedBorder) - .frame(width: 200) - } - - SettingsCardDivider() - - SettingsCardRow( - String(localized: "settings.app.reorderOnNotification", defaultValue: "Reorder on Notification"), - subtitle: String(localized: "settings.app.reorderOnNotification.subtitle", defaultValue: "Move workspaces to the top when they receive a notification. Disable for stable shortcut positions.") - ) { - Toggle("", isOn: $workspaceAutoReorder) - .labelsHidden() - .controlSize(.small) - } - - - SettingsCardDivider() - - SettingsCardRow( - String(localized: "settings.app.showInMenuBar", defaultValue: "Show in Menu Bar"), - subtitle: String(localized: "settings.app.showInMenuBar.subtitle", defaultValue: "Keep Programa in the menu bar for unread notifications and quick actions.") - ) { - Toggle("", isOn: $showMenuBarExtra) - .labelsHidden() - .controlSize(.small) - .accessibilityLabel( - String(localized: "settings.app.showInMenuBar", defaultValue: "Show in Menu Bar") - ) - } - - - SettingsCardDivider() - - SettingsCardRow( - "Desktop Notifications", - subtitle: notificationPermissionSubtitle - ) { - HStack(spacing: 6) { - Text(notificationPermissionStatusText) - .font(.system(size: 11, weight: .semibold)) - .foregroundStyle(notificationPermissionStatusColor) - .frame(width: 98, alignment: .trailing) - - Button(notificationPermissionActionTitle) { - handleNotificationPermissionAction() - } - .controlSize(.small) - } - } - - SettingsCardDivider() - - SettingsCardRow( - String(localized: "settings.notifications.sound.title", defaultValue: "Notification Sound"), - subtitle: String(localized: "settings.notifications.sound.subtitle", defaultValue: "Sound played when a notification arrives."), - controlWidth: notificationSoundControlWidth - ) { - VStack(alignment: .trailing, spacing: 6) { - HStack(spacing: 6) { - Picker("", selection: $notificationSound) { - ForEach(NotificationSoundSettings.systemSounds, id: \.value) { sound in - Text(sound.label).tag(sound.value) - } - } - .labelsHidden() - Button { - previewNotificationSound() - } label: { - Image(systemName: "play.fill") - .font(.system(size: 9)) - } - .buttonStyle(.bordered) - .controlSize(.small) - .disabled(!canPreviewNotificationSound) - } - - if notificationSound == NotificationSoundSettings.customFileValue { - HStack(spacing: 6) { - Text(notificationSoundCustomFileDisplayName) - .font(.system(size: 11)) - .foregroundStyle(.secondary) - .lineLimit(1) - .truncationMode(.middle) - .frame(width: 170, alignment: .trailing) - Button( - String( - localized: "settings.notifications.sound.custom.choose.button", - defaultValue: "Choose..." - ) - ) { - chooseNotificationSoundFile() - } - .controlSize(.small) - Button( - String( - localized: "settings.notifications.sound.custom.clear.button", - defaultValue: "Clear" - ) - ) { - notificationSoundCustomFilePath = NotificationSoundSettings.defaultCustomFilePath - refreshNotificationCustomSoundStatus() - } - .controlSize(.small) - .disabled(!hasCustomNotificationSoundFilePath) - } - if let notificationCustomSoundStatusMessage { - Text(notificationCustomSoundStatusMessage) - .font(.system(size: 11)) - .foregroundStyle(notificationCustomSoundStatusIsError ? Color.red : Color.secondary) - .lineLimit(2) - .multilineTextAlignment(.trailing) - .frame(width: 260, alignment: .trailing) - } - } - } - .frame(maxWidth: .infinity, alignment: .trailing) - } - - SettingsCardDivider() - - SettingsCardRow( - "Notification Command", - subtitle: "Run a shell command when a notification arrives. $PROGRAMA_NOTIFICATION_TITLE, $PROGRAMA_NOTIFICATION_SUBTITLE, $PROGRAMA_NOTIFICATION_BODY are set." - ) { - TextField("say \"done\"", text: $notificationCustomCommand) - .textFieldStyle(.roundedBorder) - .frame(width: 200) - } - - SettingsCardDivider() - - SettingsCardRow( - String(localized: "settings.app.warnBeforeQuit", defaultValue: "Warn Before Quit"), - subtitle: warnBeforeQuitShortcut - ? String(localized: "settings.app.warnBeforeQuit.subtitleOn", defaultValue: "Show a confirmation before quitting with Cmd+Q.") - : String(localized: "settings.app.warnBeforeQuit.subtitleOff", defaultValue: "Cmd+Q quits immediately without confirmation.") - ) { - Toggle("", isOn: $warnBeforeQuitShortcut) - .labelsHidden() - .controlSize(.small) - } - - SettingsCardDivider() - - SettingsCardRow( - String(localized: "settings.app.persistScrollback", defaultValue: "Save Scrollback on Quit"), - subtitle: sessionPersistScrollback - ? String(localized: "settings.app.persistScrollback.subtitleOn", defaultValue: "Terminal scrollback is saved and restored on next launch.") - : String(localized: "settings.app.persistScrollback.subtitleOff", defaultValue: "Terminal scrollback is not written to disk.") - ) { - Toggle("", isOn: $sessionPersistScrollback) - .labelsHidden() - .controlSize(.small) - } - - SettingsCardDivider() - - SettingsCardRow( - String(localized: "settings.app.renameSelectsName", defaultValue: "Rename Selects Existing Name"), - subtitle: commandPaletteRenameSelectAllOnFocus - ? String(localized: "settings.app.renameSelectsName.subtitleOn", defaultValue: "Command Palette rename starts with all text selected.") - : String(localized: "settings.app.renameSelectsName.subtitleOff", defaultValue: "Command Palette rename keeps the caret at the end.") - ) { - Toggle("", isOn: $commandPaletteRenameSelectAllOnFocus) - .labelsHidden() - .controlSize(.small) - } - - SettingsCardDivider() - - SettingsCardRow( - String(localized: "settings.app.commandPaletteSearchAllSurfaces", defaultValue: "Command Palette Searches All Surfaces"), - subtitle: commandPaletteSearchAllSurfaces - ? String(localized: "settings.app.commandPaletteSearchAllSurfaces.subtitleOn", defaultValue: "Cmd+P also matches terminal, browser, and markdown surfaces across workspaces.") - : String(localized: "settings.app.commandPaletteSearchAllSurfaces.subtitleOff", defaultValue: "Cmd+P matches workspace rows only.") - ) { - Toggle("", isOn: $commandPaletteSearchAllSurfaces) - .labelsHidden() - .controlSize(.small) - .accessibilityIdentifier("CommandPaletteSearchAllSurfacesToggle") - .accessibilityLabel( - String(localized: "settings.app.commandPaletteSearchAllSurfaces", defaultValue: "Command Palette Searches All Surfaces") - ) - } - - } - - SettingsSectionHeader(title: String(localized: "settings.section.workspaceColors", defaultValue: "Workspace Colors")) - SettingsCard { - SettingsPickerRow( - String(localized: "settings.workspaceColors.indicator", defaultValue: "Workspace Color Indicator"), - controlWidth: pickerColumnWidth, - selection: sidebarIndicatorStyleSelection - ) { - ForEach(SidebarActiveTabIndicatorStyle.allCases) { style in - Text(style.displayName).tag(style.rawValue) - } - } - - SettingsCardDivider() - - SettingsCardRow( - String(localized: "settings.workspaceColors.selectionColor", defaultValue: "Selection Highlight"), - subtitle: String(localized: "settings.workspaceColors.selectionColor.subtitle", defaultValue: "Background color of the selected workspace in the sidebar.") - ) { - HStack(spacing: 8) { - if sidebarSelectionColorHex != nil { - Button(String(localized: "settings.workspaceColors.selectionColor.reset", defaultValue: "Reset")) { - sidebarSelectionColorHex = nil - } - .buttonStyle(.bordered) - .controlSize(.small) - } - - HexColorPicker( - hex: sidebarSelectionColorHex, - fallback: programaAccentColor() - ) { newHex in - sidebarSelectionColorHex = newHex - } - - Text(sidebarSelectionColorHex ?? String(localized: "settings.sidebarAppearance.defaultLabel", defaultValue: "Default")) - .font(.system(size: 12, weight: .medium, design: .monospaced)) - .foregroundStyle(.secondary) - .frame(width: 76, alignment: .trailing) - } - } - - SettingsCardDivider() - - SettingsCardRow( - String(localized: "settings.workspaceColors.notificationBadgeColor", defaultValue: "Notification Badge"), - subtitle: String(localized: "settings.workspaceColors.notificationBadgeColor.subtitle", defaultValue: "Color of the unread notification badge on workspace tabs.") - ) { - HStack(spacing: 8) { - if sidebarNotificationBadgeColorHex != nil { - Button(String(localized: "settings.workspaceColors.notificationBadgeColor.reset", defaultValue: "Reset")) { - sidebarNotificationBadgeColorHex = nil - } - .buttonStyle(.bordered) - .controlSize(.small) - } - - HexColorPicker( - hex: sidebarNotificationBadgeColorHex, - fallback: programaAccentColor() - ) { newHex in - sidebarNotificationBadgeColorHex = newHex - } - - Text(sidebarNotificationBadgeColorHex ?? String(localized: "settings.sidebarAppearance.defaultLabel", defaultValue: "Default")) - .font(.system(size: 12, weight: .medium, design: .monospaced)) - .foregroundStyle(.secondary) - .frame(width: 76, alignment: .trailing) - } - } - - SettingsCardDivider() - - SettingsCardNote( - String( - localized: "settings.workspaceColors.dictionaryNote", - defaultValue: "Edit settings.json to add or remove named colors. \"Choose Custom Color...\" still adds local Custom N entries." - ) - ) - - if workspaceTabPaletteEntries.isEmpty { - SettingsCardNote( - String( - localized: "settings.workspaceColors.emptyPalette", - defaultValue: "No palette entries. Add colors in settings.json or use \"Choose Custom Color...\" from a workspace context menu." - ) - ) - } else { - ForEach(Array(workspaceTabPaletteEntries.enumerated()), id: \.element.name) { index, entry in - if index > 0 { - SettingsCardDivider() - } - SettingsCardRow( - entry.name, - subtitle: baseTabColorHex(for: entry.name).map { - String(localized: "settings.workspaceColors.base", defaultValue: "Base: \($0)") - } ?? String( - localized: "settings.workspaceColors.customEntry", - defaultValue: "Named palette entry." - ) - ) { - HStack(spacing: 8) { - HexColorPicker( - hex: entry.hex, - fallback: .blue - ) { newHex in - WorkspaceTabColorSettings.setColor(named: entry.name, hex: newHex) - reloadWorkspaceTabColorSettings() - } - - Text(entry.hex) - .font(.system(size: 12, weight: .medium, design: .monospaced)) - .foregroundStyle(.secondary) - .frame(width: 76, alignment: .trailing) - - if baseTabColorHex(for: entry.name) == nil { - Button(String(localized: "settings.workspaceColors.remove", defaultValue: "Remove")) { - removeWorkspaceColor(named: entry.name) - } - .buttonStyle(.bordered) - .controlSize(.small) - } - } - } - } - } - - SettingsCardDivider() - - SettingsCardRow( - String(localized: "settings.workspaceColors.resetPalette", defaultValue: "Reset Palette"), - subtitle: String( - localized: "settings.workspaceColors.resetPalette.subtitleV2", - defaultValue: "Restore the built-in palette and remove extra named colors." - ) - ) { - Button(String(localized: "settings.workspaceColors.resetPalette.button", defaultValue: "Reset")) { - resetWorkspaceTabColors() - } - .buttonStyle(.bordered) - .controlSize(.small) - } - } - - SettingsSectionHeader(title: String(localized: "settings.section.sidebarAppearance", defaultValue: "Sidebar Appearance")) - SettingsCard { - SettingsCardRow( - String(localized: "settings.sidebarAppearance.matchTerminalBackground", defaultValue: "Match Terminal Background"), - subtitle: String(localized: "settings.sidebarAppearance.matchTerminalBackground.subtitle", defaultValue: "Use the same background color and transparency as the terminal.") - ) { - Toggle("", isOn: $sidebarMatchTerminalBackground) - .labelsHidden() - .toggleStyle(.switch) - .controlSize(.small) - } - - SettingsCardDivider() - - SettingsCardRow( - String(localized: "settings.sidebarAppearance.tintColorLight", defaultValue: "Light Mode Tint"), - subtitle: String(localized: "settings.sidebarAppearance.tintColorLight.subtitle", defaultValue: "Sidebar tint color when using light appearance.") - ) { - HStack(spacing: 8) { - HexColorPicker( - hex: sidebarTintHexLight ?? sidebarTintHex, - fallback: .black - ) { newHex in - sidebarTintHexLight = newHex - } - - Text(sidebarTintHexLight ?? String(localized: "settings.sidebarAppearance.defaultLabel", defaultValue: "Default")) - .font(.system(size: 12, weight: .medium, design: .monospaced)) - .foregroundStyle(.secondary) - .frame(width: 76, alignment: .trailing) - } - } - - SettingsCardDivider() - - SettingsCardRow( - String(localized: "settings.sidebarAppearance.tintColorDark", defaultValue: "Dark Mode Tint"), - subtitle: String(localized: "settings.sidebarAppearance.tintColorDark.subtitle", defaultValue: "Sidebar tint color when using dark appearance.") - ) { - HStack(spacing: 8) { - HexColorPicker( - hex: sidebarTintHexDark ?? sidebarTintHex, - fallback: .black - ) { newHex in - sidebarTintHexDark = newHex - } - - Text(sidebarTintHexDark ?? String(localized: "settings.sidebarAppearance.defaultLabel", defaultValue: "Default")) - .font(.system(size: 12, weight: .medium, design: .monospaced)) - .foregroundStyle(.secondary) - .frame(width: 76, alignment: .trailing) - } - } - - SettingsCardDivider() - - SettingsCardRow( - String(localized: "settings.sidebarAppearance.tintOpacity", defaultValue: "Tint Opacity"), - subtitle: String(localized: "settings.sidebarAppearance.tintOpacity.subtitle", defaultValue: "How strongly the tint color shows over the sidebar material.") - ) { - HStack(spacing: 8) { - Slider(value: $sidebarTintOpacity, in: 0...1) - .frame(width: 140) - Text(String(format: "%.0f%%", sidebarTintOpacity * 100)) - .font(.system(size: 12, weight: .medium, design: .monospaced)) - .foregroundStyle(.secondary) - .frame(width: 36, alignment: .trailing) - } - } - - SettingsCardDivider() - - SettingsCardRow( - String(localized: "settings.sidebarAppearance.reset", defaultValue: "Reset Sidebar Tint"), - subtitle: String(localized: "settings.sidebarAppearance.reset.subtitle", defaultValue: "Restore default sidebar appearance.") - ) { - Button(String(localized: "settings.sidebarAppearance.reset.button", defaultValue: "Reset")) { - sidebarTintHexLight = nil - sidebarTintHexDark = nil - sidebarTintHex = SidebarTintDefaults.hex - sidebarTintOpacity = SidebarTintDefaults.opacity - } - .buttonStyle(.bordered) - .controlSize(.small) - } - } - - SettingsSectionHeader(title: String(localized: "settings.section.automation", defaultValue: "Automation")) - SettingsCard { - SettingsPickerRow( - String(localized: "settings.automation.socketMode", defaultValue: "Socket Control Mode"), - subtitle: selectedSocketControlMode.description, - controlWidth: pickerColumnWidth, - selection: socketModeSelection, - accessibilityId: "AutomationSocketModePicker" - ) { - ForEach(SocketControlMode.uiCases) { mode in - Text(mode.displayName).tag(mode.rawValue) - } - } - - SettingsCardDivider() - - SettingsCardNote(String(localized: "settings.automation.socketMode.note", defaultValue: "Controls access to the local Unix socket for programmatic control. Choose a mode that matches your threat model.")) - if selectedSocketControlMode == .password { - SettingsCardDivider() - SettingsCardRow( - String(localized: "settings.automation.socketPassword", defaultValue: "Socket Password"), - subtitle: hasSocketPasswordConfigured - ? String(localized: "settings.automation.socketPassword.subtitleSet", defaultValue: "Stored in Application Support.") - : String(localized: "settings.automation.socketPassword.subtitleUnset", defaultValue: "No password set. External clients will be blocked until one is configured.") - ) { - HStack(spacing: 8) { - SecureField(String(localized: "settings.automation.socketPassword.placeholder", defaultValue: "Password"), text: $socketPasswordDraft) - .textFieldStyle(.roundedBorder) - .frame(width: 170) - Button(hasSocketPasswordConfigured ? String(localized: "settings.automation.socketPassword.change", defaultValue: "Change") : String(localized: "settings.automation.socketPassword.set", defaultValue: "Set")) { - saveSocketPassword() - } - .buttonStyle(.bordered) - .controlSize(.small) - .disabled(socketPasswordDraft.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty) - if hasSocketPasswordConfigured { - Button(String(localized: "settings.automation.socketPassword.clear", defaultValue: "Clear")) { - clearSocketPassword() - } - .buttonStyle(.bordered) - .controlSize(.small) - } - } - } - if let message = socketPasswordStatusMessage { - Text(message) - .font(.caption) - .foregroundStyle(socketPasswordStatusIsError ? Color.red : Color.secondary) - .padding(.horizontal, 14) - .padding(.bottom, 8) - } - } - if selectedSocketControlMode == .allowAll { - SettingsCardDivider() - Text(String(localized: "settings.automation.openAccessWarning", defaultValue: "Warning: Full open access makes the control socket world-readable/writable on this Mac and disables auth checks. Use only for local debugging.")) - .font(.caption) - .foregroundStyle(.red) - .padding(.horizontal, 14) - .padding(.vertical, 8) - } - SettingsCardNote(String(localized: "settings.automation.socketOverrides.note", defaultValue: "Overrides: PROGRAMA_SOCKET_ENABLE, PROGRAMA_SOCKET_MODE, and PROGRAMA_SOCKET_PATH (set PROGRAMA_ALLOW_SOCKET_OVERRIDE=1 for release builds).")) - } - - SettingsCard { - SettingsCardRow( - String(localized: "settings.automation.claudeCode", defaultValue: "Claude Code Integration"), - subtitle: claudeCodeHooksEnabled - ? String(localized: "settings.automation.claudeCode.subtitleOn", defaultValue: "Sidebar shows Claude session status and notifications.") - : String(localized: "settings.automation.claudeCode.subtitleOff", defaultValue: "Claude Code runs without Programa integration.") - ) { - Toggle("", isOn: $claudeCodeHooksEnabled) - .labelsHidden() - .controlSize(.small) - .accessibilityIdentifier("SettingsClaudeCodeHooksToggle") - } - - SettingsCardDivider() - - SettingsCardNote(String(localized: "settings.automation.claudeCode.note", defaultValue: "When enabled, Programa wraps the claude command to inject session tracking and notification hooks. Disable if you prefer to manage Claude Code hooks yourself.")) - } - - SettingsCard { - SettingsCardRow( - String(localized: "settings.automation.claudeCode.customPath", defaultValue: "Claude Binary Path"), - subtitle: String(localized: "settings.automation.claudeCode.customPath.subtitle", defaultValue: "Custom path to the claude binary. Leave empty to use PATH.") - ) { - TextField( - String(localized: "settings.automation.claudeCode.customPath.placeholder", defaultValue: "e.g. /usr/local/bin/claude"), - text: $customClaudePath - ) - .textFieldStyle(.roundedBorder) - .frame(width: 200) - } - } - - SettingsCard { - SettingsCardRow(String(localized: "settings.automation.portBase", defaultValue: "Port Base"), subtitle: String(localized: "settings.automation.portBase.subtitle", defaultValue: "Starting port for PROGRAMA_PORT env var."), controlWidth: pickerColumnWidth) { - TextField("", value: $programaPortBase, format: .number) - .textFieldStyle(.roundedBorder) - .multilineTextAlignment(.trailing) - } - - SettingsCardDivider() - - SettingsCardRow(String(localized: "settings.automation.portRange", defaultValue: "Port Range Size"), subtitle: String(localized: "settings.automation.portRange.subtitle", defaultValue: "Number of ports per workspace."), controlWidth: pickerColumnWidth) { - TextField("", value: $programaPortRange, format: .number) - .textFieldStyle(.roundedBorder) - .multilineTextAlignment(.trailing) - } - - SettingsCardDivider() - - SettingsCardNote(String(localized: "settings.automation.port.note", defaultValue: "Each workspace gets PROGRAMA_PORT and PROGRAMA_PORT_END env vars with a dedicated port range. New terminals inherit these values.")) - } - - SettingsSectionHeader(title: String(localized: "settings.section.customCommands", defaultValue: "Custom Commands")) - SettingsCard { - VStack(alignment: .leading, spacing: 6) { - SettingsCardRow( - String(localized: "settings.customCommands.trustedDirectories", defaultValue: "Trusted Directories"), - subtitle: String(localized: "settings.customCommands.trustedDirectories.subtitle", defaultValue: "Commands from programa.json in these directories run without confirmation. One path per line.") - ) { - EmptyView() - } - - TextEditor(text: $trustedDirectoriesDraft) - .font(.system(.body, design: .monospaced)) - .frame(minHeight: 60, maxHeight: 120) - .scrollContentBackground(.hidden) - .padding(6) - .background(Color(nsColor: .controlBackgroundColor)) - .cornerRadius(6) - .overlay( - RoundedRectangle(cornerRadius: 6) - .stroke(Color(nsColor: .separatorColor), lineWidth: 0.5) - ) - .padding(.horizontal, 16) - .padding(.bottom, 12) - .onChange(of: trustedDirectoriesDraft) { _ in - saveTrustedDirectories() - } - } - - SettingsCardDivider() - SettingsCardNote(String(localized: "settings.customCommands.trustedDirectories.note", defaultValue: "Place a programa.json in your project root to define custom commands. Trust a directory from the confirmation dialog, or add paths here. For git repos, trusting the root covers all subdirectories.")) - } - - SettingsSectionHeader(title: String(localized: "settings.section.browser", defaultValue: "Browser")) - .id(SettingsNavigationTarget.browser) - .accessibilityIdentifier("SettingsBrowserSection") - SettingsCard { - SettingsPickerRow( - String(localized: "settings.browser.searchEngine", defaultValue: "Default Search Engine"), - subtitle: String(localized: "settings.browser.searchEngine.subtitle", defaultValue: "Used by the browser address bar when input is not a URL."), - controlWidth: pickerColumnWidth, - selection: $browserSearchEngine - ) { - ForEach(BrowserSearchEngine.allCases) { engine in - Text(engine.displayName).tag(engine.rawValue) - } - } - - SettingsCardDivider() - - SettingsCardRow(String(localized: "settings.browser.searchSuggestions", defaultValue: "Show Search Suggestions")) { - Toggle("", isOn: $browserSearchSuggestionsEnabled) - .labelsHidden() - .controlSize(.small) - } - - SettingsCardDivider() - - SettingsPickerRow( - String(localized: "settings.browser.theme", defaultValue: "Browser Theme"), - subtitle: selectedBrowserThemeMode == .system - ? String(localized: "settings.browser.theme.subtitleSystem", defaultValue: "System follows app and macOS appearance.") - : String(localized: "settings.browser.theme.subtitleForced", defaultValue: "\(selectedBrowserThemeMode.displayName) forces that color scheme for compatible pages."), - controlWidth: pickerColumnWidth, - selection: browserThemeModeSelection - ) { - ForEach(BrowserThemeMode.allCases) { mode in - Text(mode.displayName).tag(mode.rawValue) - } - } - - SettingsCardDivider() - - SettingsCardRow( - String(localized: "settings.browser.openTerminalLinks", defaultValue: "Open Terminal Links in Programa Browser"), - subtitle: String(localized: "settings.browser.openTerminalLinks.subtitle", defaultValue: "When off, links clicked in terminal output open in your default browser.") - ) { - Toggle("", isOn: $openTerminalLinksInProgramaBrowser) - .labelsHidden() - .controlSize(.small) - } - - SettingsCardDivider() - - SettingsCardRow( - String(localized: "settings.browser.interceptOpen", defaultValue: "Intercept open http(s) in Terminal"), - subtitle: String(localized: "settings.browser.interceptOpen.subtitle", defaultValue: "When off, `open https://...` and `open http://...` always use your default browser.") - ) { - Toggle("", isOn: $interceptTerminalOpenCommandInProgramaBrowser) - .labelsHidden() - .controlSize(.small) - } - - if openTerminalLinksInProgramaBrowser || interceptTerminalOpenCommandInProgramaBrowser { - SettingsCardDivider() - - VStack(alignment: .leading, spacing: 6) { - SettingsCardRow( - String(localized: "settings.browser.hostWhitelist", defaultValue: "Hosts to Open in Embedded Browser"), - subtitle: String(localized: "settings.browser.hostWhitelist.subtitle", defaultValue: "Applies to terminal link clicks and intercepted `open https://...` calls. Only these hosts open in Programa. Others open in your default browser. One host or wildcard per line (for example: example.com, *.internal.example). Leave empty to open all hosts in Programa.") - ) { - EmptyView() - } - - TextEditor(text: $browserHostWhitelist) - .font(.system(.body, design: .monospaced)) - .frame(minHeight: 60, maxHeight: 120) - .scrollContentBackground(.hidden) - .padding(6) - .background(Color(nsColor: .controlBackgroundColor)) - .cornerRadius(6) - .overlay( - RoundedRectangle(cornerRadius: 6) - .stroke(Color(nsColor: .separatorColor), lineWidth: 0.5) - ) - .padding(.horizontal, 16) - .padding(.bottom, 12) - } - - SettingsCardDivider() - - VStack(alignment: .leading, spacing: 6) { - SettingsCardRow( - String(localized: "settings.browser.externalPatterns", defaultValue: "URLs to Always Open Externally"), - subtitle: String(localized: "settings.browser.externalPatterns.subtitle", defaultValue: "Applies to terminal link clicks and intercepted `open https://...` calls. One rule per line. Plain text matches any URL substring, or prefix with `re:` for regex (for example: openai.com/usage, re:^https?://[^/]*\\.example\\.com/(billing|usage)).") - ) { - EmptyView() - } - - TextEditor(text: $browserExternalOpenPatterns) - .font(.system(.body, design: .monospaced)) - .frame(minHeight: 60, maxHeight: 120) - .scrollContentBackground(.hidden) - .padding(6) - .background(Color(nsColor: .controlBackgroundColor)) - .cornerRadius(6) - .overlay( - RoundedRectangle(cornerRadius: 6) - .stroke(Color(nsColor: .separatorColor), lineWidth: 0.5) - ) - .padding(.horizontal, 16) - .padding(.bottom, 12) - } - } - - SettingsCardDivider() - - VStack(alignment: .leading, spacing: 8) { - Text(String(localized: "settings.browser.httpAllowlist", defaultValue: "HTTP Hosts Allowed in Embedded Browser")) - .font(.system(size: 13, weight: .semibold)) - - Text(String(localized: "settings.browser.httpAllowlist.description", defaultValue: "Controls which HTTP (non-HTTPS) hosts can open in Programa without a warning prompt. Defaults include localhost, 127.0.0.1, ::1, 0.0.0.0, and *.localtest.me.")) - .font(.caption) - .foregroundStyle(.secondary) - - TextEditor(text: $browserInsecureHTTPAllowlistDraft) - .font(.system(size: 12, weight: .regular, design: .monospaced)) - .frame(minHeight: 86) - .padding(6) - .background( - RoundedRectangle(cornerRadius: 8, style: .continuous) - .fill(Color(nsColor: .textBackgroundColor)) - ) - .overlay( - RoundedRectangle(cornerRadius: 8, style: .continuous) - .stroke(Color(nsColor: .separatorColor), lineWidth: 1) - ) - .accessibilityIdentifier("SettingsBrowserHTTPAllowlistField") - - ViewThatFits(in: .horizontal) { - HStack(alignment: .center, spacing: 10) { - Text(String(localized: "settings.browser.httpAllowlist.hint", defaultValue: "One host or wildcard per line (for example: localhost, 127.0.0.1, ::1, 0.0.0.0, *.localtest.me).")) - .font(.caption) - .foregroundStyle(.secondary) - .fixedSize(horizontal: false, vertical: true) - - Spacer(minLength: 0) - - Button(String(localized: "settings.browser.httpAllowlist.save", defaultValue: "Save")) { - saveBrowserInsecureHTTPAllowlist() - } - .buttonStyle(.bordered) - .controlSize(.small) - .disabled(!browserInsecureHTTPAllowlistHasUnsavedChanges) - .accessibilityIdentifier("SettingsBrowserHTTPAllowlistSaveButton") - } - - VStack(alignment: .leading, spacing: 8) { - Text(String(localized: "settings.browser.httpAllowlist.hint", defaultValue: "One host or wildcard per line (for example: localhost, 127.0.0.1, ::1, 0.0.0.0, *.localtest.me).")) - .font(.caption) - .foregroundStyle(.secondary) - - HStack { - Spacer(minLength: 0) - Button(String(localized: "settings.browser.httpAllowlist.save", defaultValue: "Save")) { - saveBrowserInsecureHTTPAllowlist() - } - .buttonStyle(.bordered) - .controlSize(.small) - .disabled(!browserInsecureHTTPAllowlistHasUnsavedChanges) - .accessibilityIdentifier("SettingsBrowserHTTPAllowlistSaveButton") - } - } - } - } - .padding(.horizontal, 14) - .padding(.vertical, 10) - - SettingsCardDivider() - - VStack(alignment: .leading, spacing: 12) { - VStack(alignment: .leading, spacing: 8) { - Text(String(localized: "settings.browser.import", defaultValue: "Import Browser Data")) - .font(.system(size: 13, weight: .semibold)) - - VStack(alignment: .leading, spacing: 6) { - Text(String(localized: "browser.import.hint.title", defaultValue: "Import browser data")) - .font(.system(size: 12.5, weight: .semibold)) - - Text(browserImportSubtitle) - .font(.caption) - .foregroundStyle(.secondary) - .fixedSize(horizontal: false, vertical: true) - - Text(String(localized: "browser.import.hint.settingsFootnote", defaultValue: "You can always find this in Settings > Browser.")) - .font(.system(size: 10.5)) - .foregroundStyle(.tertiary) - .fixedSize(horizontal: false, vertical: true) - } - .padding(.horizontal, 12) - .padding(.vertical, 10) - .frame(maxWidth: .infinity, alignment: .leading) - .background( - RoundedRectangle(cornerRadius: 10, style: .continuous) - .fill(Color(nsColor: .controlBackgroundColor)) - ) - .overlay( - RoundedRectangle(cornerRadius: 10, style: .continuous) - .stroke(Color(nsColor: .separatorColor).opacity(0.4), lineWidth: 1) - ) - } - - HStack(spacing: 8) { - Button(String(localized: "settings.browser.import.choose", defaultValue: "Choose…")) { - DispatchQueue.main.async { - BrowserDataImportCoordinator.shared.presentImportDialog() - refreshDetectedImportBrowsers() - } - } - .buttonStyle(.bordered) - .controlSize(.small) - .accessibilityIdentifier("SettingsBrowserImportChooseButton") - - Button(String(localized: "settings.browser.import.refresh", defaultValue: "Refresh")) { - refreshDetectedImportBrowsers() - } - .buttonStyle(.bordered) - .controlSize(.small) - } - .accessibilityIdentifier("SettingsBrowserImportActions") - - Toggle( - String(localized: "settings.browser.import.hint.show", defaultValue: "Show import hint on blank browser tabs"), - isOn: browserImportHintVisibilityBinding - ) - .controlSize(.small) - .accessibilityIdentifier("SettingsBrowserImportHintToggle") - - Text(browserImportHintSettingsNote) - .font(.caption) - .foregroundStyle(.secondary) - .fixedSize(horizontal: false, vertical: true) - } - .id(SettingsNavigationTarget.browserImport) - .accessibilityIdentifier("SettingsBrowserImportSection") - .padding(.horizontal, 14) - .padding(.vertical, 10) - - SettingsCardDivider() - - SettingsCardRow(String(localized: "settings.browser.history", defaultValue: "Browsing History"), subtitle: browserHistorySubtitle) { - Button(String(localized: "settings.browser.history.clearButton", defaultValue: "Clear History…")) { - showClearBrowserHistoryConfirmation = true - } - .buttonStyle(.bordered) - .controlSize(.small) - .disabled(browserHistoryEntryCount == 0) - } - } - - SettingsSectionHeader(title: String(localized: "settings.section.keyboardShortcuts", defaultValue: "Keyboard Shortcuts")) - .id(SettingsNavigationTarget.keyboardShortcuts) - .accessibilityIdentifier("SettingsKeyboardShortcutsSection") - SettingsCard { - SettingsCardRow( - String(localized: "settings.shortcuts.chords", defaultValue: "Shortcut Chords"), - subtitle: String(localized: "settings.shortcuts.chords.subtitle", defaultValue: "Add tmux-style multi-step shortcuts in settings.json, for example [\"ctrl+b\", \"c\"].") - ) { - HStack(spacing: 8) { - Link(String(localized: "settings.shortcuts.chords.docsButton", defaultValue: "Chord docs"), destination: shortcutChordsDocsURL) - .font(.caption) - .accessibilityIdentifier("SettingsKeyboardShortcutsChordDocsLink") - - Button(String(localized: "settings.app.settingsFile.openButton", defaultValue: "Open settings.json")) { - openProgramaSettingsFileInTextEdit() - } - .buttonStyle(.bordered) - .controlSize(.small) - .accessibilityIdentifier("SettingsKeyboardShortcutsOpenSettingsFileButton") - } - } - - SettingsCardDivider() - - SettingsCardRow( - String(localized: "settings.shortcuts.showHints", defaultValue: "Show Cmd/Ctrl-Hold Shortcut Hints"), - subtitle: showShortcutHintsOnCommandHold - ? String(localized: "settings.shortcuts.showHints.subtitleOn", defaultValue: "Holding Cmd (sidebar/titlebar) or Ctrl/Cmd (pane tabs) shows shortcut hint pills.") - : String(localized: "settings.shortcuts.showHints.subtitleOff", defaultValue: "Holding Cmd or Ctrl keeps shortcut hint pills hidden.") - ) { - Toggle("", isOn: $showShortcutHintsOnCommandHold) - .labelsHidden() - .controlSize(.small) - } - - SettingsCardDivider() - - let actions = KeyboardShortcutSettings.Action.allCases - ForEach(Array(actions.enumerated()), id: \.element.id) { index, action in - ShortcutSettingRow(action: action) - .padding(.horizontal, 14) - .padding(.vertical, 9) - if index < actions.count - 1 { - SettingsCardDivider() - } - } - } - .id(shortcutResetToken) - - Text(String(localized: "settings.shortcuts.recordHint", defaultValue: "Click a shortcut value to record a new shortcut.")) - .font(.caption) - .foregroundColor(.secondary) - .padding(.leading, 2) - .accessibilityIdentifier("ShortcutRecordingHint") - - SettingsSectionHeader(title: String(localized: "settings.section.reset", defaultValue: "Reset")) - SettingsCard { - HStack { - Spacer(minLength: 0) - Button(String(localized: "settings.reset.resetAll", defaultValue: "Reset All Settings")) { - resetAllSettings() - } - .buttonStyle(.bordered) - .controlSize(.regular) - Spacer(minLength: 0) - } - .padding(.horizontal, 14) - .padding(.vertical, 10) - } - } - .padding(.horizontal, 20) - .padding(.bottom, 20) - .padding(.top, contentTopInset) - .background( - GeometryReader { proxy in - Color.clear.preference( - key: SettingsTopOffsetPreferenceKey.self, - value: proxy.frame(in: .named("SettingsScrollArea")).minY - ) - } - ) - } - .coordinateSpace(name: "SettingsScrollArea") - .onPreferenceChange(SettingsTopOffsetPreferenceKey.self) { value in - if topBlurBaselineOffset == nil { - topBlurBaselineOffset = value - } - topBlurOpacity = blurOpacity(forContentOffset: value) - } - - ZStack(alignment: .top) { - SettingsTitleLeadingInsetReader(inset: $settingsTitleLeadingInset) - .frame(width: 0, height: 0) - - AboutVisualEffectBackground(material: .underWindowBackground, blendingMode: .withinWindow) - .mask( - LinearGradient( - colors: [ - Color.black.opacity(0.9), - Color.black.opacity(0.64), - Color.black.opacity(0.36), - Color.clear - ], - startPoint: .top, - endPoint: .bottom - ) - ) - .opacity(0.52) - - AboutVisualEffectBackground(material: .underWindowBackground, blendingMode: .withinWindow) - .mask( - LinearGradient( - colors: [ - Color.black.opacity(0.98), - Color.black.opacity(0.78), - Color.black.opacity(0.42), - Color.clear - ], - startPoint: .top, - endPoint: .bottom - ) - ) - .opacity(0.14 + (topBlurOpacity * 0.86)) - - HStack { - Text(String(localized: "settings.title", defaultValue: "Settings")) - .font(.system(size: 16, weight: .semibold)) - .foregroundColor(.primary.opacity(0.92)) - Spacer(minLength: 0) - HStack(spacing: 6) { - SettingsHeaderActionButton( - title: String(localized: "settings.app.settingsFile.openButton", defaultValue: "Open settings.json"), - helpText: KeyboardShortcutSettings.settingsFileStore.settingsFileDisplayPath(), - accessibilityIdentifier: "SettingsFileOpenButton", - action: openProgramaSettingsFileInTextEdit - ) - } - } - .padding(.leading, settingsTitleLeadingInset) - .padding(.trailing, 20) - .padding(.top, 12) - } - .frame(height: 62) - .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .top) - .ignoresSafeArea(.container, edges: .top) - .overlay( - Rectangle() - .fill(Color(nsColor: .separatorColor).opacity(0.07)) - .frame(height: 1), - alignment: .bottom - ) - } - .background(Color(nsColor: .windowBackgroundColor).ignoresSafeArea()) - .toggleStyle(.switch) - .onAppear { - BrowserHistoryStore.shared.loadIfNeeded() - notificationStore.refreshAuthorizationStatus() - browserThemeMode = BrowserThemeSettings.mode(defaults: .standard).rawValue - browserHistoryEntryCount = BrowserHistoryStore.shared.entries.count - browserInsecureHTTPAllowlistDraft = browserInsecureHTTPAllowlist - refreshDetectedImportBrowsers() - reloadWorkspaceTabColorSettings() - refreshNotificationCustomSoundStatus() - } - .onChange(of: notificationSound) { _, _ in - refreshNotificationCustomSoundStatus() - } - .onChange(of: notificationSoundCustomFilePath) { _, _ in - refreshNotificationCustomSoundStatus() - } - .onChange(of: browserInsecureHTTPAllowlist) { oldValue, newValue in - // Keep draft in sync with external changes unless the user has local unsaved edits. - if browserInsecureHTTPAllowlistDraft == oldValue { - browserInsecureHTTPAllowlistDraft = newValue - } - } - .onReceive(BrowserHistoryStore.shared.$entries) { entries in - browserHistoryEntryCount = entries.count - } - .onReceive(NotificationCenter.default.publisher(for: UserDefaults.didChangeNotification)) { _ in - reloadWorkspaceTabColorSettings() - } - .onReceive(NotificationCenter.default.publisher(for: SettingsNavigationRequest.notificationName)) { notification in - guard let target = SettingsNavigationRequest.target(from: notification) else { return } - DispatchQueue.main.async { - withAnimation(.easeInOut(duration: 0.2)) { - proxy.scrollTo(target, anchor: .top) - } - } - } - .confirmationDialog( - String(localized: "settings.browser.history.clearDialog.title", defaultValue: "Clear browser history?"), - isPresented: $showClearBrowserHistoryConfirmation, - titleVisibility: .visible - ) { - Button(String(localized: "settings.browser.history.clearDialog.confirm", defaultValue: "Clear History"), role: .destructive) { - BrowserHistoryStore.shared.clearHistory() - } - Button(String(localized: "settings.browser.history.clearDialog.cancel", defaultValue: "Cancel"), role: .cancel) {} - } message: { - Text(String(localized: "settings.browser.history.clearDialog.message", defaultValue: "This removes visited-page suggestions from the browser omnibar.")) - } - .confirmationDialog( - String(localized: "settings.automation.openAccess.dialog.title", defaultValue: "Enable full open access?"), - isPresented: $showOpenAccessConfirmation, - titleVisibility: .visible - ) { - Button(String(localized: "settings.automation.openAccess.dialog.confirm", defaultValue: "Enable Full Open Access"), role: .destructive) { - socketControlMode = (pendingOpenAccessMode ?? .allowAll).rawValue - pendingOpenAccessMode = nil - } - Button(String(localized: "settings.automation.openAccess.dialog.cancel", defaultValue: "Cancel"), role: .cancel) { - pendingOpenAccessMode = nil - } - } message: { - Text(String(localized: "settings.automation.openAccess.dialog.message", defaultValue: "This disables ancestry and password checks and opens the socket to all local users. Only enable when you understand the risk.")) - } - .alert( - String( - localized: "settings.notifications.sound.custom.error.title", - defaultValue: "Custom Notification Sound Error" - ), - isPresented: $showNotificationCustomSoundErrorAlert - ) { - Button(String(localized: "common.ok", defaultValue: "OK"), role: .cancel) {} - } message: { - Text(notificationCustomSoundErrorAlertMessage) - } - } - } - - private func resetAllSettings() { - appearanceMode = AppearanceSettings.defaultMode.rawValue - socketControlMode = SocketControlSettings.defaultMode.rawValue - claudeCodeHooksEnabled = ClaudeCodeIntegrationSettings.defaultHooksEnabled - customClaudePath = "" - preferredEditorCommand = "" - browserSearchEngine = BrowserSearchSettings.defaultSearchEngine.rawValue - browserSearchSuggestionsEnabled = BrowserSearchSettings.defaultSearchSuggestionsEnabled - browserThemeMode = BrowserThemeSettings.defaultMode.rawValue - showBrowserImportHintOnBlankTabs = BrowserImportHintSettings.defaultShowOnBlankTabs - isBrowserImportHintDismissed = BrowserImportHintSettings.defaultDismissed - openTerminalLinksInProgramaBrowser = BrowserLinkOpenSettings.defaultOpenTerminalLinksInProgramaBrowser - interceptTerminalOpenCommandInProgramaBrowser = BrowserLinkOpenSettings.defaultInterceptTerminalOpenCommandInProgramaBrowser - browserHostWhitelist = BrowserLinkOpenSettings.defaultBrowserHostWhitelist - browserExternalOpenPatterns = BrowserLinkOpenSettings.defaultBrowserExternalOpenPatterns - browserInsecureHTTPAllowlist = BrowserInsecureHTTPSettings.defaultAllowlistText - browserInsecureHTTPAllowlistDraft = BrowserInsecureHTTPSettings.defaultAllowlistText - notificationSound = NotificationSoundSettings.defaultValue - notificationSoundCustomFilePath = NotificationSoundSettings.defaultCustomFilePath - notificationCustomSoundStatusMessage = nil - notificationCustomSoundStatusIsError = false - showNotificationCustomSoundErrorAlert = false - notificationCustomSoundErrorAlertMessage = "" - notificationCustomCommand = NotificationSoundSettings.defaultCustomCommand - showMenuBarExtra = MenuBarExtraSettings.defaultShowInMenuBar - warnBeforeQuitShortcut = QuitWarningSettings.defaultWarnBeforeQuit - sessionPersistScrollback = ScrollbackPersistenceSettings.defaultPersistScrollback - commandPaletteRenameSelectAllOnFocus = CommandPaletteRenameSelectionSettings.defaultSelectAllOnFocus - commandPaletteSearchAllSurfaces = CommandPaletteSwitcherSearchSettings.defaultSearchAllSurfaces - ShortcutHintDebugSettings.resetVisibilityDefaults() - alwaysShowShortcutHints = ShortcutHintDebugSettings.defaultAlwaysShowHints - newWorkspacePlacement = WorkspacePlacementSettings.defaultPlacement.rawValue - workspacePresentationMode = WorkspacePresentationModeSettings.defaultMode.rawValue - closeWorkspaceOnLastSurfaceShortcut = LastSurfaceCloseShortcutSettings.defaultValue - paneFirstClickFocusEnabled = PaneFirstClickFocusSettings.defaultEnabled - workspaceAutoReorder = WorkspaceAutoReorderSettings.defaultValue - sidebarActiveTabIndicatorStyle = SidebarActiveTabIndicatorSettings.defaultStyle.rawValue - sidebarSelectionColorHex = nil - sidebarNotificationBadgeColorHex = nil - showShortcutHintsOnCommandHold = ShortcutHintDebugSettings.defaultShowHintsOnCommandHold - sidebarTintHex = SidebarTintDefaults.hex - sidebarTintHexLight = nil - sidebarTintHexDark = nil - sidebarTintOpacity = SidebarTintDefaults.opacity - sidebarMatchTerminalBackground = false - showOpenAccessConfirmation = false - pendingOpenAccessMode = nil - socketPasswordDraft = "" - socketPasswordStatusMessage = nil - socketPasswordStatusIsError = false - refreshDetectedImportBrowsers() - KeyboardShortcutSettings.resetAll() - WorkspaceTabColorSettings.reset() - reloadWorkspaceTabColorSettings() - shortcutResetToken = UUID() - } - - private func baseTabColorHex(for name: String) -> String? { - WorkspaceTabColorSettings.defaultColorHex(named: name) - } - - private func removeWorkspaceColor(named name: String) { - WorkspaceTabColorSettings.removeColor(named: name) - reloadWorkspaceTabColorSettings() - } - - private func resetWorkspaceTabColors() { - WorkspaceTabColorSettings.reset() - reloadWorkspaceTabColorSettings() - } - - private func reloadWorkspaceTabColorSettings() { - workspaceTabPaletteEntries = WorkspaceTabColorSettings.palette() - } - - private func saveBrowserInsecureHTTPAllowlist() { - browserInsecureHTTPAllowlist = browserInsecureHTTPAllowlistDraft - } - - private func refreshDetectedImportBrowsers() { - detectedImportBrowsers = InstalledBrowserDetector.detectInstalledBrowsers() - } -} - -/// A ColorPicker that buffers the current colour in `@State` so that dragging -/// the hue wheel never forces a hex round-trip on every frame. The stored hex -/// is only written once the user commits a value (on SwiftUI's `.onChange` -/// callback), which fires once per drag-end gesture rather than per frame. -/// -/// This replaces computed `Binding` properties whose getter converted a -/// stored hex string to `Color` on every frame, causing the quantisation that -/// made the hue indicator jump (issue #8 / upstream cmux #6761). -private struct HexColorPicker: View { - /// The current hex value from `@AppStorage`, or `nil` when the slot is unset. - var hex: String? - /// Colour to show when `hex` is nil or unparseable. - var fallback: Color - /// Called with the new hex string whenever the picker value changes. - var onHexChange: (String) -> Void - - @State private var pickerColor: Color - - init(hex: String?, fallback: Color, onHexChange: @escaping (String) -> Void) { - self.hex = hex - self.fallback = fallback - self.onHexChange = onHexChange - let initial: Color - if let hex, let ns = NSColor(hex: hex) { - initial = Color(nsColor: ns) - } else { - initial = fallback - } - _pickerColor = State(initialValue: initial) - } - - var body: some View { - ColorPicker("", selection: $pickerColor, supportsOpacity: false) - .labelsHidden() - .frame(width: 38) - .onChange(of: pickerColor) { newColor in - onHexChange(NSColor(newColor).hexString()) - } - .onChange(of: hex) { newHex in - // Keep the buffer in sync when the hex is reset externally - // (e.g. the Reset button sets sidebarSelectionColorHex = nil). - if let newHex, let ns = NSColor(hex: newHex) { - pickerColor = Color(nsColor: ns) - } else { - pickerColor = fallback - } - } - } -} - -private struct SettingsTopOffsetPreferenceKey: PreferenceKey { - static var defaultValue: CGFloat = 0 - - static func reduce(value: inout CGFloat, nextValue: () -> CGFloat) { - value = nextValue() - } -} - -private struct SettingsTitleLeadingInsetReader: NSViewRepresentable { - @Binding var inset: CGFloat - - func makeNSView(context: Context) -> NSView { - let view = NSView(frame: .zero) - return view - } - - func updateNSView(_ nsView: NSView, context: Context) { - DispatchQueue.main.async { - guard let window = nsView.window else { return } - let buttons: [NSWindow.ButtonType] = [.closeButton, .miniaturizeButton, .zoomButton] - let maxX = buttons - .compactMap { window.standardWindowButton($0)?.frame.maxX } - .max() ?? 78 - let nextInset = maxX + 14 - if abs(nextInset - inset) > 0.5 { - inset = nextInset - } - } - } -} - -private struct SettingsSectionHeader: View { - let title: String - - var body: some View { - Text(title) - .font(.system(size: 13, weight: .semibold)) - .foregroundColor(.secondary) + @ViewBuilder private var textView: some View { + Text(text) + .frame(width: 140, alignment: .leading) .padding(.leading, 2) - .padding(.bottom, -2) - } -} - -private struct SettingsCard: View { - @ViewBuilder let content: Content - - init(@ViewBuilder content: () -> Content) { - self.content = content() - } - - var body: some View { - VStack(alignment: .leading, spacing: 0) { - content - } - .background( - RoundedRectangle(cornerRadius: 13, style: .continuous) - .fill(Color(nsColor: NSColor.controlBackgroundColor).opacity(0.76)) - .overlay( - RoundedRectangle(cornerRadius: 13, style: .continuous) - .stroke(Color(nsColor: NSColor.separatorColor).opacity(0.5), lineWidth: 1) - ) - ) + .tint(.secondary) + .opacity(0.8) + .monospaced() } -} - -private struct SettingsHeaderActionButton: View { - let title: String - let helpText: String - let accessibilityIdentifier: String - let action: () -> Void var body: some View { - Button(action: action) { - Text(title) - .font(.system(size: 11.5, weight: .medium)) - .foregroundStyle(.secondary) - .padding(.horizontal, 9) - .padding(.vertical, 4) - .background( - Capsule(style: .continuous) - .fill(Color(nsColor: NSColor.controlBackgroundColor).opacity(0.34)) - ) - .overlay( - Capsule(style: .continuous) - .stroke(Color(nsColor: NSColor.separatorColor).opacity(0.22), lineWidth: 1) - ) + HStack(spacing: 4) { + Text(label) + .frame(width: 126, alignment: .trailing) + .padding(.trailing, 2) + if let url { + Link(destination: url) { + textView + } + } else { + textView + } } - .buttonStyle(.plain) - .controlSize(.small) - .help(helpText) - .accessibilityIdentifier(accessibilityIdentifier) + .font(.callout) + .textSelection(.enabled) + .frame(maxWidth: .infinity) } } -private struct SettingsCardRow: View { - let title: String - let subtitle: String? - let controlWidth: CGFloat? - @ViewBuilder let trailing: Trailing +struct AboutVisualEffectBackground: NSViewRepresentable { + let material: NSVisualEffectView.Material + let blendingMode: NSVisualEffectView.BlendingMode + let isEmphasized: Bool init( - _ title: String, - subtitle: String? = nil, - controlWidth: CGFloat? = nil, - @ViewBuilder trailing: () -> Trailing + material: NSVisualEffectView.Material, + blendingMode: NSVisualEffectView.BlendingMode = .behindWindow, + isEmphasized: Bool = false ) { - self.title = title - self.subtitle = subtitle - self.controlWidth = controlWidth - self.trailing = trailing() + self.material = material + self.blendingMode = blendingMode + self.isEmphasized = isEmphasized } - var body: some View { - HStack(alignment: .center, spacing: 12) { - VStack(alignment: .leading, spacing: subtitle == nil ? 0 : 3) { - Text(title) - .font(.system(size: 13, weight: .medium)) - if let subtitle { - Text(subtitle) - .font(.caption) - .foregroundColor(.secondary) - .lineLimit(2) - } - } - .frame(maxWidth: .infinity, alignment: .leading) + func updateNSView(_ nsView: NSVisualEffectView, context: Context) { + nsView.material = material + nsView.blendingMode = blendingMode + nsView.isEmphasized = isEmphasized + } - Group { - if let controlWidth { - trailing - .frame(width: controlWidth, alignment: .trailing) - } else { - trailing - } - } - .layoutPriority(1) - } - .padding(.horizontal, 14) - .padding(.vertical, 9) - .frame(maxWidth: .infinity, alignment: .leading) + func makeNSView(context: Context) -> NSVisualEffectView { + let visualEffect = NSVisualEffectView() + visualEffect.autoresizingMask = [.width, .height] + return visualEffect } } -private struct SettingsPickerRow: View { - let title: String - let subtitle: String? - let controlWidth: CGFloat - @Binding var selection: SelectionValue - let pickerContent: PickerContent - let extraTrailing: ExtraTrailing - let accessibilityId: String? +final class AppIconAppearanceObserver: NSObject { + static let shared = AppIconAppearanceObserver() + private var observation: NSKeyValueObservation? - init( - _ title: String, - subtitle: String? = nil, - controlWidth: CGFloat, - selection: Binding, - accessibilityId: String? = nil, - @ViewBuilder content: () -> PickerContent, - @ViewBuilder extraTrailing: () -> ExtraTrailing - ) { - self.title = title - self.subtitle = subtitle - self.controlWidth = controlWidth - self._selection = selection - self.pickerContent = content() - self.extraTrailing = extraTrailing() - self.accessibilityId = accessibilityId - } + private override init() { super.init() } - var body: some View { - SettingsCardRow(title, subtitle: subtitle, controlWidth: controlWidth) { - HStack(spacing: 6) { - Picker("", selection: $selection) { - pickerContent - } - .labelsHidden() - .pickerStyle(.menu) - .applyIf(accessibilityId != nil) { $0.accessibilityIdentifier(accessibilityId!) } - extraTrailing + func startObserving() { + applyIconForCurrentAppearance() + guard observation == nil else { return } + observation = NSApp.observe(\.effectiveAppearance, options: []) { [weak self] _, _ in + DispatchQueue.main.async { + guard let self, self.observation != nil else { return } + self.applyIconForCurrentAppearance() } } } -} -extension SettingsPickerRow where ExtraTrailing == EmptyView { - init( - _ title: String, - subtitle: String? = nil, - controlWidth: CGFloat, - selection: Binding, - accessibilityId: String? = nil, - @ViewBuilder content: () -> PickerContent - ) { - self.init(title, subtitle: subtitle, controlWidth: controlWidth, selection: selection, accessibilityId: accessibilityId, content: content) { - EmptyView() - } + func stopObserving() { + observation?.invalidate() + observation = nil } -} -private extension View { - @ViewBuilder - func applyIf(_ condition: Bool, transform: (Self) -> some View) -> some View { - if condition { - transform(self) - } else { - self + private func applyIconForCurrentAppearance() { + let isDark = NSApp.effectiveAppearance.bestMatch(from: [.darkAqua, .aqua]) == .darkAqua + let imageName = isDark ? "AppIconDark" : "AppIconLight" + if let icon = NSImage(named: imageName) { + NSApplication.shared.applicationIconImage = icon } } } -private struct SettingsCardDivider: View { - var body: some View { - Rectangle() - .fill(Color(nsColor: NSColor.separatorColor).opacity(0.5)) - .frame(height: 1) +enum ProgramaUITestCapture { + static func appendLineIfConfigured(envKey: String, line: String) -> Bool { + guard let url = configuredURL(for: envKey) else { return false } + appendLine(line, to: url) + return true } -} - -private struct SettingsCardNote: View { - let text: String - init(_ text: String) { - self.text = text + static func mutateJSONObjectIfConfigured( + envKey: String, + _ update: (inout [String: Any]) -> Void + ) -> Bool { + guard let url = configuredURL(for: envKey) else { return false } + mutateJSONObject(at: url, update) + return true } - var body: some View { - Text(text) - .font(.caption) - .foregroundColor(.secondary) - .padding(.horizontal, 14) - .padding(.vertical, 8) - .frame(maxWidth: .infinity, alignment: .leading) + private static func configuredURL(for envKey: String) -> URL? { + let env = ProcessInfo.processInfo.environment + guard let rawPath = env[envKey]?.trimmingCharacters(in: .whitespacesAndNewlines), + !rawPath.isEmpty else { + return nil + } + return URL(fileURLWithPath: rawPath) } -} -private struct ThemeWindowThumbnail: View { - let isDark: Bool + private static func appendLine(_ line: String, to url: URL) { + ensureParentDirectory(for: url) + let payload = (line + "\n").data(using: .utf8) ?? Data() - var body: some View { - GeometryReader { geo in - let width = geo.size.width - let height = geo.size.height - - ZStack { - // Wallpaper background - if isDark { - LinearGradient( - colors: [Color(red: 0.1, green: 0.1, blue: 0.3), Color(red: 0.05, green: 0.05, blue: 0.1)], - startPoint: .topLeading, - endPoint: .bottomTrailing - ) - Path { path in - path.move(to: CGPoint(x: 0, y: height * 0.5)) - path.addQuadCurve(to: CGPoint(x: width, y: height), control: CGPoint(x: width * 0.5, y: height * 0.2)) - path.addLine(to: CGPoint(x: width, y: 0)) - path.addLine(to: CGPoint(x: 0, y: 0)) - } - .fill(LinearGradient(colors: [Color(red: 0.2, green: 0.2, blue: 0.6).opacity(0.5), .clear], startPoint: .topLeading, endPoint: .bottomTrailing)) + if FileManager.default.fileExists(atPath: url.path) { + do { + let handle = try FileHandle(forWritingTo: url) + defer { try? handle.close() } + try handle.seekToEnd() + try handle.write(contentsOf: payload) + } catch { + if let existing = try? Data(contentsOf: url) { + var combined = existing + combined.append(payload) + try? combined.write(to: url, options: .atomic) } else { - LinearGradient( - colors: [Color(red: 0.6, green: 0.8, blue: 0.95), Color(red: 0.2, green: 0.4, blue: 0.8)], - startPoint: .topLeading, - endPoint: .bottomTrailing - ) - Path { path in - path.move(to: CGPoint(x: 0, y: height * 0.5)) - path.addQuadCurve(to: CGPoint(x: width, y: height), control: CGPoint(x: width * 0.5, y: height * 0.2)) - path.addLine(to: CGPoint(x: width, y: 0)) - path.addLine(to: CGPoint(x: 0, y: 0)) - } - .fill(LinearGradient(colors: [Color(red: 0.8, green: 0.9, blue: 1.0).opacity(0.6), .clear], startPoint: .topLeading, endPoint: .bottomTrailing)) + try? payload.write(to: url, options: .atomic) } + } + return + } - // Menu bar - VStack(spacing: 0) { - HStack { - Image(systemName: "applelogo") - .font(.system(size: max(height * 0.08, 6))) - .foregroundColor(isDark ? .white : .black) - .opacity(0.8) - Spacer() - } - .padding(.horizontal, max(width * 0.04, 4)) - .frame(height: max(height * 0.12, 8)) - .background(.ultraThinMaterial) - Spacer() - } + try? payload.write(to: url, options: .atomic) + } - // Back window - VStack(spacing: 0) { - Rectangle() - .fill(isDark ? Color(white: 0.2) : Color(white: 0.9)) - .frame(height: max(height * 0.15, 8)) - ZStack(alignment: .top) { - Rectangle() - .fill(isDark ? Color(white: 0.15) : Color(white: 0.98)) - RoundedRectangle(cornerRadius: max(width * 0.02, 2), style: .continuous) - .fill(Color.accentColor) - .frame(height: max(height * 0.12, 6)) - .padding(max(width * 0.04, 4)) - } - } - .clipShape(RoundedRectangle(cornerRadius: max(width * 0.04, 4), style: .continuous)) - .frame(width: width * 0.65, height: height * 0.45) - .shadow(color: .black.opacity(isDark ? 0.4 : 0.15), radius: 4, x: 0, y: 2) - .offset(x: -width * 0.08, y: -height * 0.1) - - // Front window with traffic lights - VStack(spacing: 0) { - ZStack { - Rectangle() - .fill(isDark ? Color(white: 0.18) : Color(white: 0.92)) - HStack(spacing: max(width * 0.025, 2)) { - Circle().fill(Color(red: 1.0, green: 0.37, blue: 0.34)).frame(width: max(width * 0.04, 3)) - Circle().fill(Color(red: 1.0, green: 0.74, blue: 0.18)).frame(width: max(width * 0.04, 3)) - Circle().fill(Color(red: 0.15, green: 0.79, blue: 0.25)).frame(width: max(width * 0.04, 3)) - Spacer() - } - .padding(.horizontal, max(width * 0.04, 4)) - } - .frame(height: max(height * 0.18, 10)) - Rectangle() - .fill(isDark ? Color(white: 0.1) : .white) - } - .clipShape(RoundedRectangle(cornerRadius: max(width * 0.05, 5), style: .continuous)) - .shadow(color: .black.opacity(isDark ? 0.5 : 0.2), radius: 6, x: 0, y: 3) - .frame(width: width * 0.75, height: height * 0.55) - .offset(x: width * 0.12, y: height * 0.2) - } + private static func mutateJSONObject( + at url: URL, + _ update: (inout [String: Any]) -> Void + ) { + ensureParentDirectory(for: url) + var payload: [String: Any] = [:] + if let data = try? Data(contentsOf: url), + let object = try? JSONSerialization.jsonObject(with: data) as? [String: Any] { + payload = object } - .clipShape(RoundedRectangle(cornerRadius: 14, style: .continuous)) - .overlay( - RoundedRectangle(cornerRadius: 14, style: .continuous) - .stroke(Color.primary.opacity(0.1), lineWidth: 1) - ) + update(&payload) + guard let data = try? JSONSerialization.data(withJSONObject: payload, options: [.sortedKeys]) else { + return + } + try? data.write(to: url, options: .atomic) + } + + private static func ensureParentDirectory(for url: URL) { + let directory = url.deletingLastPathComponent() + guard !directory.path.isEmpty else { return } + try? FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true) } } -private struct ThemePickerRow: View { - let selectedMode: String - let onSelect: (AppearanceMode) -> Void +enum ProgramaRuntimeDebugCapture { + private struct Configuration { + let baseURL: URL + let token: String + let sessionID: String + } - private let thumbWidth: CGFloat = 76 - private let thumbHeight: CGFloat = 50 + private static let configuration: Configuration? = { + let env = ProcessInfo.processInfo.environment + guard let baseURLString = env["PROGRAMA_RUNTIME_DEBUG_BASE_URL"]?.trimmingCharacters(in: .whitespacesAndNewlines), + let baseURL = URL(string: baseURLString), + let token = env["PROGRAMA_RUNTIME_DEBUG_TOKEN"]?.trimmingCharacters(in: .whitespacesAndNewlines), + !token.isEmpty, + let sessionID = env["PROGRAMA_RUNTIME_DEBUG_SESSION_ID"]?.trimmingCharacters(in: .whitespacesAndNewlines), + !sessionID.isEmpty else { + return nil + } + return Configuration(baseURL: baseURL, token: token, sessionID: sessionID) + }() - var body: some View { - HStack(alignment: .top, spacing: 12) { - Text(String(localized: "settings.app.theme", defaultValue: "Theme")) - .font(.system(size: 13, weight: .medium)) - .frame(maxWidth: .infinity, alignment: .leading) + private static let lock = NSLock() + private static var sequence: Int = 0 - HStack(spacing: 8) { - ForEach(AppearanceMode.visibleCases) { mode in - let isSelected = selectedMode == mode.rawValue - Button { - onSelect(mode) - } label: { - VStack(spacing: 4) { - Group { - if mode == .system { - ZStack { - ThemeWindowThumbnail(isDark: false) - .mask( - GeometryReader { geo in - Rectangle() - .frame(width: geo.size.width / 2, height: geo.size.height) - .position(x: geo.size.width / 4, y: geo.size.height / 2) - } - ) - ThemeWindowThumbnail(isDark: true) - .mask( - GeometryReader { geo in - Rectangle() - .frame(width: geo.size.width / 2, height: geo.size.height) - .position(x: geo.size.width * 0.75, y: geo.size.height / 2) - } - ) - GeometryReader { geo in - Rectangle() - .fill(Color.primary.opacity(0.15)) - .frame(width: 1, height: geo.size.height) - .position(x: geo.size.width / 2, y: geo.size.height / 2) - } - } - } else { - ThemeWindowThumbnail(isDark: mode == .dark) - } - } - .frame(width: thumbWidth, height: thumbHeight) + static func logIfConfigured( + hypothesisID: String, + source: String, + name: String, + expected: String? = nil, + actual: String? = nil, + data: [String: Any] = [:] + ) { + guard let configuration else { return } - Text(mode.displayName) - .font(.system(size: 10)) - .fontWeight(isSelected ? .semibold : .regular) - .foregroundColor(isSelected ? .primary : .secondary) - } - .padding(.vertical, 8) - .padding(.horizontal, 10) - .contentShape(Rectangle()) - .background( - RoundedRectangle(cornerRadius: 8, style: .continuous) - .fill(isSelected - ? Color.accentColor.opacity(0.12) - : Color.clear) - ) - .overlay( - RoundedRectangle(cornerRadius: 8, style: .continuous) - .stroke(isSelected ? Color.accentColor : Color.clear, lineWidth: 2) - ) - } - .buttonStyle(.plain) - .focusable(false) - .accessibilityAddTraits(isSelected ? .isSelected : []) - } - } - .layoutPriority(1) + var payload: [String: Any] = [ + "session_id": configuration.sessionID, + "hypothesis_id": hypothesisID, + "service": "cmux-macos", + "source": source, + "name": name, + "ts": ISO8601DateFormatter().string(from: Date()), + "mono_ms": ProcessInfo.processInfo.systemUptime * 1000, + "seq": nextSequence(), + "data": data + ] + if let expected { + payload["expected"] = expected + } + if let actual { + payload["actual"] = actual } - .padding(.horizontal, 14) - .padding(.vertical, 9) - .frame(maxWidth: .infinity, alignment: .leading) - } -} + guard JSONSerialization.isValidJSONObject(payload), + let requestBody = try? JSONSerialization.data(withJSONObject: payload, options: []) else { + return + } -private struct ShortcutSettingRow: View { - let action: KeyboardShortcutSettings.Action - @State private var shortcut: StoredShortcut + var request = URLRequest(url: configuration.baseURL.appendingPathComponent("api/logs")) + request.httpMethod = "POST" + request.setValue("application/json", forHTTPHeaderField: "Content-Type") + request.setValue(configuration.token, forHTTPHeaderField: "X-Debug-Token") + request.httpBody = requestBody - init(action: KeyboardShortcutSettings.Action) { - self.action = action - _shortcut = State(initialValue: KeyboardShortcutSettings.shortcut(for: action)) + URLSession.shared.dataTask(with: request).resume() } - var body: some View { - KeyboardShortcutRecorder( - label: action.label, - subtitle: KeyboardShortcutSettings.settingsFileManagedSubtitle(for: action), - shortcut: $shortcut, - displayString: { action.displayedShortcutString(for: $0) }, - transformRecordedShortcut: { action.normalizedRecordedShortcut($0) }, - isDisabled: KeyboardShortcutSettings.isManagedBySettingsFile(action) - ) - .onChange(of: shortcut) { newValue in - KeyboardShortcutSettings.setShortcut(newValue, for: action) - } - .onReceive(NotificationCenter.default.publisher(for: KeyboardShortcutSettings.didChangeNotification)) { _ in - let latest = KeyboardShortcutSettings.shortcut(for: action) - if latest != shortcut { - shortcut = latest - } - } + private static func nextSequence() -> Int { + lock.lock() + defer { lock.unlock() } + sequence += 1 + return sequence } } -private struct SettingsRootView: View { - var body: some View { - SettingsView() - .background(WindowAccessor { window in - configureSettingsWindow(window) - }) - } - - private func configureSettingsWindow(_ window: NSWindow) { - window.identifier = NSUserInterfaceItemIdentifier("cmux.settings") - applyCurrentSettingsWindowStyle(to: window) - - let accessories = window.titlebarAccessoryViewControllers - for index in accessories.indices.reversed() { - guard let identifier = accessories[index].view.identifier?.rawValue else { continue } - guard identifier.hasPrefix("cmux.") else { continue } - window.removeTitlebarAccessoryViewController(at: index) - } - AppDelegate.shared?.applyWindowDecorations(to: window) - } +private func openProgramaSettingsFileInEditor() { + let url = KeyboardShortcutSettings.settingsFileStore.settingsFileURLForEditing() + PreferredEditorSettings.open(url) +} - private func applyCurrentSettingsWindowStyle(to window: NSWindow) { - SettingsAboutTitlebarDebugStore.shared.applyCurrentOptions(to: window, for: .settings) - } +func openProgramaSettingsFileInTextEdit() { + #if os(macOS) + let fileURL = KeyboardShortcutSettings.settingsFileStore.settingsFileURLForEditing() + let editorURL = URL(fileURLWithPath: "/System/Applications/TextEdit.app") + let configuration = NSWorkspace.OpenConfiguration() + NSWorkspace.shared.open([fileURL], withApplicationAt: editorURL, configuration: configuration) + #endif } + diff --git a/Sources/SettingsComponents.swift b/Sources/SettingsComponents.swift new file mode 100644 index 00000000000..add93197321 --- /dev/null +++ b/Sources/SettingsComponents.swift @@ -0,0 +1,261 @@ +import AppKit +import SwiftUI +import Darwin +import Bonsplit +import UniformTypeIdentifiers + + +/// A ColorPicker that buffers the current colour in `@State` so that dragging +/// the hue wheel never forces a hex round-trip on every frame. The stored hex +/// is only written once the user commits a value (on SwiftUI's `.onChange` +/// callback), which fires once per drag-end gesture rather than per frame. +/// +/// This replaces computed `Binding` properties whose getter converted a +/// stored hex string to `Color` on every frame, causing the quantisation that +/// made the hue indicator jump (issue #8 / upstream cmux #6761). +struct HexColorPicker: View { + /// The current hex value from `@AppStorage`, or `nil` when the slot is unset. + var hex: String? + /// Colour to show when `hex` is nil or unparseable. + var fallback: Color + /// Called with the new hex string whenever the picker value changes. + var onHexChange: (String) -> Void + + @State private var pickerColor: Color + + init(hex: String?, fallback: Color, onHexChange: @escaping (String) -> Void) { + self.hex = hex + self.fallback = fallback + self.onHexChange = onHexChange + let initial: Color + if let hex, let ns = NSColor(hex: hex) { + initial = Color(nsColor: ns) + } else { + initial = fallback + } + _pickerColor = State(initialValue: initial) + } + + var body: some View { + ColorPicker("", selection: $pickerColor, supportsOpacity: false) + .labelsHidden() + .frame(width: 38) + .onChange(of: pickerColor) { newColor in + onHexChange(NSColor(newColor).hexString()) + } + .onChange(of: hex) { newHex in + // Keep the buffer in sync when the hex is reset externally + // (e.g. the Reset button sets sidebarSelectionColorHex = nil). + if let newHex, let ns = NSColor(hex: newHex) { + pickerColor = Color(nsColor: ns) + } else { + pickerColor = fallback + } + } + } +} + +struct SettingsSectionHeader: View { + let title: String + + var body: some View { + Text(title) + .font(.system(size: 13, weight: .semibold)) + .foregroundColor(.secondary) + .padding(.leading, 2) + .padding(.bottom, -2) + } +} + +struct SettingsCard: View { + @ViewBuilder let content: Content + + init(@ViewBuilder content: () -> Content) { + self.content = content() + } + + var body: some View { + VStack(alignment: .leading, spacing: 0) { + content + } + .background( + RoundedRectangle(cornerRadius: 13, style: .continuous) + .fill(Color(nsColor: NSColor.controlBackgroundColor).opacity(0.76)) + .overlay( + RoundedRectangle(cornerRadius: 13, style: .continuous) + .stroke(Color(nsColor: NSColor.separatorColor).opacity(0.5), lineWidth: 1) + ) + ) + } +} + +struct SettingsHeaderActionButton: View { + let title: String + let helpText: String + let accessibilityIdentifier: String + let action: () -> Void + + var body: some View { + Button(action: action) { + Text(title) + .font(.system(size: 11.5, weight: .medium)) + .foregroundStyle(.secondary) + .padding(.horizontal, 9) + .padding(.vertical, 4) + .background( + Capsule(style: .continuous) + .fill(Color(nsColor: NSColor.controlBackgroundColor).opacity(0.34)) + ) + .overlay( + Capsule(style: .continuous) + .stroke(Color(nsColor: NSColor.separatorColor).opacity(0.22), lineWidth: 1) + ) + } + .buttonStyle(.plain) + .controlSize(.small) + .help(helpText) + .accessibilityIdentifier(accessibilityIdentifier) + } +} + +struct SettingsCardRow: View { + let title: String + let subtitle: String? + let controlWidth: CGFloat? + @ViewBuilder let trailing: Trailing + + init( + _ title: String, + subtitle: String? = nil, + controlWidth: CGFloat? = nil, + @ViewBuilder trailing: () -> Trailing + ) { + self.title = title + self.subtitle = subtitle + self.controlWidth = controlWidth + self.trailing = trailing() + } + + var body: some View { + HStack(alignment: .center, spacing: 12) { + VStack(alignment: .leading, spacing: subtitle == nil ? 0 : 3) { + Text(title) + .font(.system(size: 13, weight: .medium)) + if let subtitle { + Text(subtitle) + .font(.caption) + .foregroundColor(.secondary) + .lineLimit(2) + } + } + .frame(maxWidth: .infinity, alignment: .leading) + + Group { + if let controlWidth { + trailing + .frame(width: controlWidth, alignment: .trailing) + } else { + trailing + } + } + .layoutPriority(1) + } + .padding(.horizontal, 14) + .padding(.vertical, 9) + .frame(maxWidth: .infinity, alignment: .leading) + } +} + +struct SettingsPickerRow: View { + let title: String + let subtitle: String? + let controlWidth: CGFloat + @Binding var selection: SelectionValue + let pickerContent: PickerContent + let extraTrailing: ExtraTrailing + let accessibilityId: String? + + init( + _ title: String, + subtitle: String? = nil, + controlWidth: CGFloat, + selection: Binding, + accessibilityId: String? = nil, + @ViewBuilder content: () -> PickerContent, + @ViewBuilder extraTrailing: () -> ExtraTrailing + ) { + self.title = title + self.subtitle = subtitle + self.controlWidth = controlWidth + self._selection = selection + self.pickerContent = content() + self.extraTrailing = extraTrailing() + self.accessibilityId = accessibilityId + } + + var body: some View { + SettingsCardRow(title, subtitle: subtitle, controlWidth: controlWidth) { + HStack(spacing: 6) { + Picker("", selection: $selection) { + pickerContent + } + .labelsHidden() + .pickerStyle(.menu) + .applyIf(accessibilityId != nil) { $0.accessibilityIdentifier(accessibilityId!) } + extraTrailing + } + } + } +} + +extension SettingsPickerRow where ExtraTrailing == EmptyView { + init( + _ title: String, + subtitle: String? = nil, + controlWidth: CGFloat, + selection: Binding, + accessibilityId: String? = nil, + @ViewBuilder content: () -> PickerContent + ) { + self.init(title, subtitle: subtitle, controlWidth: controlWidth, selection: selection, accessibilityId: accessibilityId, content: content) { + EmptyView() + } + } +} + +private extension View { + @ViewBuilder + func applyIf(_ condition: Bool, transform: (Self) -> some View) -> some View { + if condition { + transform(self) + } else { + self + } + } +} + +struct SettingsCardDivider: View { + var body: some View { + Rectangle() + .fill(Color(nsColor: NSColor.separatorColor).opacity(0.5)) + .frame(height: 1) + } +} + +struct SettingsCardNote: View { + let text: String + + init(_ text: String) { + self.text = text + } + + var body: some View { + Text(text) + .font(.caption) + .foregroundColor(.secondary) + .padding(.horizontal, 14) + .padding(.vertical, 8) + .frame(maxWidth: .infinity, alignment: .leading) + } +} + diff --git a/Sources/SettingsModels.swift b/Sources/SettingsModels.swift new file mode 100644 index 00000000000..ff67b502d46 --- /dev/null +++ b/Sources/SettingsModels.swift @@ -0,0 +1,209 @@ +import AppKit +import SwiftUI +import Darwin +import Bonsplit +import UniformTypeIdentifiers + + +enum WorkspacePresentationModeSettings { + static let modeKey = "workspacePresentationMode" + + enum Mode: String { + case standard + case minimal + } + + static let defaultMode: Mode = .minimal + + static func mode(for rawValue: String?) -> Mode { + Mode(rawValue: rawValue ?? "") ?? defaultMode + } + + static func mode(defaults: UserDefaults = .standard) -> Mode { + mode(for: defaults.string(forKey: modeKey)) + } + + static func isMinimal(defaults: UserDefaults = .standard) -> Bool { + mode(defaults: defaults) == .minimal + } +} + +enum PaneFirstClickFocusSettings { + static let enabledKey = "paneFirstClickFocus.enabled" + static let defaultEnabled = false + private static let flag = UserDefaultsFlag(key: enabledKey, defaultValue: defaultEnabled) + + static func isEnabled(defaults: UserDefaults = .standard) -> Bool { + flag.isEnabled(defaults: defaults) + } +} + +enum AppearanceMode: String, CaseIterable, Identifiable { + case system + case light + case dark + case auto + + var id: String { rawValue } + + static var visibleCases: [AppearanceMode] { + [.system, .light, .dark] + } + + var displayName: String { + switch self { + case .system: + return String(localized: "appearance.system", defaultValue: "System") + case .light: + return String(localized: "appearance.light", defaultValue: "Light") + case .dark: + return String(localized: "appearance.dark", defaultValue: "Dark") + case .auto: + return String(localized: "appearance.auto", defaultValue: "Auto") + } + } +} + +enum AppearanceSettings { + static let appearanceModeKey = "appearanceMode" + static let defaultMode: AppearanceMode = .system + + static func mode(for rawValue: String?) -> AppearanceMode { + guard let rawValue, let mode = AppearanceMode(rawValue: rawValue) else { + return defaultMode + } + if mode == .auto { + return .system + } + return mode + } + + @discardableResult + static func resolvedMode(defaults: UserDefaults = .standard) -> AppearanceMode { + let stored = defaults.string(forKey: appearanceModeKey) + let resolved = mode(for: stored) + if stored != resolved.rawValue { + defaults.set(resolved.rawValue, forKey: appearanceModeKey) + } + return resolved + } +} + + + +enum QuitWarningSettings { + static let warnBeforeQuitKey = "warnBeforeQuitShortcut" + static let defaultWarnBeforeQuit = true + private static let flag = UserDefaultsFlag(key: warnBeforeQuitKey, defaultValue: defaultWarnBeforeQuit) + + static func isEnabled(defaults: UserDefaults = .standard) -> Bool { + flag.isEnabled(defaults: defaults) + } + + static func setEnabled(_ isEnabled: Bool, defaults: UserDefaults = .standard) { + flag.setEnabled(isEnabled, defaults: defaults) + } +} + +enum ScrollbackPersistenceSettings { + static let persistScrollbackKey = "sessionPersistScrollback" + static let defaultPersistScrollback = true + private static let flag = UserDefaultsFlag(key: persistScrollbackKey, defaultValue: defaultPersistScrollback) + + static func isEnabled(defaults: UserDefaults = .standard) -> Bool { + flag.isEnabled(defaults: defaults) + } +} + +enum CommandPaletteRenameSelectionSettings { + static let selectAllOnFocusKey = "commandPalette.renameSelectAllOnFocus" + static let defaultSelectAllOnFocus = true + private static let flag = UserDefaultsFlag(key: selectAllOnFocusKey, defaultValue: defaultSelectAllOnFocus) + + static func selectAllOnFocusEnabled(defaults: UserDefaults = .standard) -> Bool { + flag.isEnabled(defaults: defaults) + } +} + +enum CommandPaletteSwitcherSearchSettings { + static let searchAllSurfacesKey = "commandPalette.switcherSearchAllSurfaces" + static let defaultSearchAllSurfaces = false + private static let flag = UserDefaultsFlag(key: searchAllSurfacesKey, defaultValue: defaultSearchAllSurfaces) + + static func searchAllSurfacesEnabled(defaults: UserDefaults = .standard) -> Bool { + flag.isEnabled(defaults: defaults) + } +} + +enum ClaudeCodeIntegrationSettings { + static let hooksEnabledKey = "claudeCodeHooksEnabled" + static let defaultHooksEnabled = true + static let customClaudePathKey = "claudeCodeCustomClaudePath" + private static let hooksFlag = UserDefaultsFlag(key: hooksEnabledKey, defaultValue: defaultHooksEnabled) + + static func hooksEnabled(defaults: UserDefaults = .standard) -> Bool { + hooksFlag.isEnabled(defaults: defaults) + } + + static func customClaudePath(defaults: UserDefaults = .standard) -> String? { + let value = defaults.string(forKey: customClaudePathKey)? + .trimmingCharacters(in: .whitespacesAndNewlines) ?? "" + return value.isEmpty ? nil : value + } +} + +enum WelcomeSettings { + static let shownKey = "programaWelcomeShown" +} + +enum PreferredEditorSettings { + static let key = "preferredEditorCommand" + + /// Returns the configured editor command, or nil to use system default. + static func resolvedCommand(defaults: UserDefaults = .standard) -> String? { + guard let stored = defaults.string(forKey: key)?.trimmingCharacters(in: .whitespacesAndNewlines), + !stored.isEmpty else { + return nil + } + return stored + } + + /// Open a file path with the user's preferred editor, falling back to system default. + static func open(_ url: URL) { + if ProgramaUITestCapture.appendLineIfConfigured( + envKey: "PROGRAMA_UI_TEST_CAPTURE_OPEN_PATH", + line: url.path + ) { + return + } + + guard let command = resolvedCommand() else { + NSWorkspace.shared.open(url) + return + } + let path = url.path + let process = Process() + process.executableURL = URL(fileURLWithPath: "/bin/sh") + process.arguments = ["-c", "\(command) \(shellQuote(path))"] + process.standardOutput = FileHandle.nullDevice + process.standardError = FileHandle.nullDevice + do { + try process.run() + // Check exit status on a background thread; fall back on failure + // (e.g. command not found exits 127 but /bin/sh itself succeeds) + DispatchQueue.global(qos: .userInitiated).async { + process.waitUntilExit() + if process.terminationStatus != 0 { + DispatchQueue.main.async { NSWorkspace.shared.open(url) } + } + } + } catch { + NSWorkspace.shared.open(url) + } + } + + private static func shellQuote(_ s: String) -> String { + "'" + s.replacingOccurrences(of: "'", with: "'\\''") + "'" + } +} + diff --git a/Sources/SettingsView.swift b/Sources/SettingsView.swift new file mode 100644 index 00000000000..2b95546494a --- /dev/null +++ b/Sources/SettingsView.swift @@ -0,0 +1,2025 @@ +import AppKit +import SwiftUI +import Darwin +import Bonsplit +import UniformTypeIdentifiers + + +struct SettingsView: View { + private let contentTopInset: CGFloat = 8 + private let pickerColumnWidth: CGFloat = 196 + private let notificationSoundControlWidth: CGFloat = 280 + private let shortcutChordsDocsURL = URL(string: "https://github.com/darkroomengineering/programa/tree/main/docs")! + + @AppStorage(AppearanceSettings.appearanceModeKey) private var appearanceMode = AppearanceSettings.defaultMode.rawValue + @AppStorage(WorkspacePresentationModeSettings.modeKey) + private var workspacePresentationMode = WorkspacePresentationModeSettings.defaultMode.rawValue + @AppStorage(SocketControlSettings.appStorageKey) private var socketControlMode = SocketControlSettings.defaultMode.rawValue + @AppStorage(ClaudeCodeIntegrationSettings.hooksEnabledKey) + private var claudeCodeHooksEnabled = ClaudeCodeIntegrationSettings.defaultHooksEnabled + @AppStorage(ClaudeCodeIntegrationSettings.customClaudePathKey) + private var customClaudePath = "" + @AppStorage(PreferredEditorSettings.key) private var preferredEditorCommand = "" + @AppStorage("cmuxPortBase") private var programaPortBase = 9100 + @AppStorage("cmuxPortRange") private var programaPortRange = 10 + @AppStorage(BrowserSearchSettings.searchEngineKey) private var browserSearchEngine = BrowserSearchSettings.defaultSearchEngine.rawValue + @AppStorage(BrowserSearchSettings.searchSuggestionsEnabledKey) private var browserSearchSuggestionsEnabled = BrowserSearchSettings.defaultSearchSuggestionsEnabled + @AppStorage(BrowserThemeSettings.modeKey) private var browserThemeMode = BrowserThemeSettings.defaultMode.rawValue + @AppStorage(BrowserImportHintSettings.showOnBlankTabsKey) private var showBrowserImportHintOnBlankTabs = BrowserImportHintSettings.defaultShowOnBlankTabs + @AppStorage(BrowserImportHintSettings.dismissedKey) private var isBrowserImportHintDismissed = BrowserImportHintSettings.defaultDismissed + @AppStorage(BrowserLinkOpenSettings.openTerminalLinksInProgramaBrowserKey) private var openTerminalLinksInProgramaBrowser = BrowserLinkOpenSettings.defaultOpenTerminalLinksInProgramaBrowser + @AppStorage(BrowserLinkOpenSettings.interceptTerminalOpenCommandInProgramaBrowserKey) + private var interceptTerminalOpenCommandInProgramaBrowser = BrowserLinkOpenSettings.initialInterceptTerminalOpenCommandInProgramaBrowserValue() + @AppStorage(BrowserLinkOpenSettings.browserHostWhitelistKey) private var browserHostWhitelist = BrowserLinkOpenSettings.defaultBrowserHostWhitelist + @AppStorage(BrowserLinkOpenSettings.browserExternalOpenPatternsKey) + private var browserExternalOpenPatterns = BrowserLinkOpenSettings.defaultBrowserExternalOpenPatterns + @AppStorage(BrowserInsecureHTTPSettings.allowlistKey) private var browserInsecureHTTPAllowlist = BrowserInsecureHTTPSettings.defaultAllowlistText + @AppStorage(NotificationSoundSettings.key) private var notificationSound = NotificationSoundSettings.defaultValue + @AppStorage(NotificationSoundSettings.customFilePathKey) + private var notificationSoundCustomFilePath = NotificationSoundSettings.defaultCustomFilePath + @AppStorage(NotificationSoundSettings.customCommandKey) private var notificationCustomCommand = NotificationSoundSettings.defaultCustomCommand + @AppStorage(MenuBarExtraSettings.showInMenuBarKey) private var showMenuBarExtra = MenuBarExtraSettings.defaultShowInMenuBar + @AppStorage(QuitWarningSettings.warnBeforeQuitKey) private var warnBeforeQuitShortcut = QuitWarningSettings.defaultWarnBeforeQuit + @AppStorage(ScrollbackPersistenceSettings.persistScrollbackKey) private var sessionPersistScrollback = ScrollbackPersistenceSettings.defaultPersistScrollback + @AppStorage(CommandPaletteRenameSelectionSettings.selectAllOnFocusKey) + private var commandPaletteRenameSelectAllOnFocus = CommandPaletteRenameSelectionSettings.defaultSelectAllOnFocus + @AppStorage(CommandPaletteSwitcherSearchSettings.searchAllSurfacesKey) + private var commandPaletteSearchAllSurfaces = CommandPaletteSwitcherSearchSettings.defaultSearchAllSurfaces + @AppStorage(ShortcutHintDebugSettings.alwaysShowHintsKey) + private var alwaysShowShortcutHints = ShortcutHintDebugSettings.defaultAlwaysShowHints + @AppStorage(WorkspacePlacementSettings.placementKey) private var newWorkspacePlacement = WorkspacePlacementSettings.defaultPlacement.rawValue + @AppStorage(LastSurfaceCloseShortcutSettings.key) + private var closeWorkspaceOnLastSurfaceShortcut = LastSurfaceCloseShortcutSettings.defaultValue + @AppStorage(PaneFirstClickFocusSettings.enabledKey) + private var paneFirstClickFocusEnabled = PaneFirstClickFocusSettings.defaultEnabled + @AppStorage(WorkspaceAutoReorderSettings.key) private var workspaceAutoReorder = WorkspaceAutoReorderSettings.defaultValue + @AppStorage(SidebarActiveTabIndicatorSettings.styleKey) + private var sidebarActiveTabIndicatorStyle = SidebarActiveTabIndicatorSettings.defaultStyle.rawValue + @AppStorage("sidebarSelectionColorHex") private var sidebarSelectionColorHex: String? + @AppStorage("sidebarNotificationBadgeColorHex") private var sidebarNotificationBadgeColorHex: String? + @AppStorage(ShortcutHintDebugSettings.showHintsOnCommandHoldKey) + private var showShortcutHintsOnCommandHold = ShortcutHintDebugSettings.defaultShowHintsOnCommandHold + @AppStorage("sidebarTintHex") private var sidebarTintHex = SidebarTintDefaults.hex + @AppStorage("sidebarTintHexLight") private var sidebarTintHexLight: String? + @AppStorage("sidebarTintHexDark") private var sidebarTintHexDark: String? + @AppStorage("sidebarTintOpacity") private var sidebarTintOpacity = SidebarTintDefaults.opacity + @AppStorage("sidebarMatchTerminalBackground") private var sidebarMatchTerminalBackground = false + + @ObservedObject private var notificationStore = TerminalNotificationStore.shared + @StateObject private var keyboardShortcutSettingsObserver = KeyboardShortcutSettingsObserver.shared + @State private var shortcutResetToken = UUID() + @State private var topBlurOpacity: Double = 0 + @State private var topBlurBaselineOffset: CGFloat? + @State private var settingsTitleLeadingInset: CGFloat = 92 + @State private var showClearBrowserHistoryConfirmation = false + @State private var showOpenAccessConfirmation = false + @State private var pendingOpenAccessMode: SocketControlMode? + @State private var browserHistoryEntryCount: Int = 0 + @State private var detectedImportBrowsers: [InstalledBrowserCandidate] = [] + @State private var browserInsecureHTTPAllowlistDraft = BrowserInsecureHTTPSettings.defaultAllowlistText + @State private var socketPasswordDraft = "" + @State private var socketPasswordStatusMessage: String? + @State private var socketPasswordStatusIsError = false + @State private var notificationCustomSoundStatusMessage: String? + @State private var notificationCustomSoundStatusIsError = false + @State private var showNotificationCustomSoundErrorAlert = false + @State private var notificationCustomSoundErrorAlertMessage = "" + @State private var workspaceTabPaletteEntries = WorkspaceTabColorSettings.palette() + @State private var trustedDirectoriesDraft: String = ProgramaDirectoryTrust.shared.allTrustedPaths.joined(separator: "\n") + + private var selectedWorkspacePlacement: NewWorkspacePlacement { + NewWorkspacePlacement(rawValue: newWorkspacePlacement) ?? WorkspacePlacementSettings.defaultPlacement + } + + private var minimalModeEnabled: Bool { + WorkspacePresentationModeSettings.mode(for: workspacePresentationMode) == .minimal + } + + private var minimalModeSubtitle: String { + if minimalModeEnabled { + return String( + localized: "settings.app.minimalMode.subtitleOn", + defaultValue: "Hide the workspace title bar and move workspace controls into the sidebar." + ) + } + return String( + localized: "settings.app.minimalMode.subtitleOff", + defaultValue: "Use the standard workspace title bar and controls." + ) + } + + private var keepWorkspaceOpenOnLastSurfaceShortcut: Bool { + !closeWorkspaceOnLastSurfaceShortcut + } + + private var keepWorkspaceOpenOnLastSurfaceShortcutBinding: Binding { + Binding( + get: { keepWorkspaceOpenOnLastSurfaceShortcut }, + set: { closeWorkspaceOnLastSurfaceShortcut = !$0 } + ) + } + + private var closeWorkspaceOnLastSurfaceShortcutSubtitle: String { + if keepWorkspaceOpenOnLastSurfaceShortcut { + return String( + localized: "settings.app.closeWorkspaceOnLastSurfaceShortcut.subtitleOn", + defaultValue: "When the focused surface is the last one in its workspace, the close-surface shortcut closes only the surface and keeps the workspace open. Use the close-workspace shortcut to close the workspace explicitly." + ) + } + return String( + localized: "settings.app.closeWorkspaceOnLastSurfaceShortcut.subtitleOff", + defaultValue: "When the focused surface is the last one in its workspace, the close-surface shortcut also closes the workspace." + ) + } + + private var paneFirstClickFocusSubtitle: String { + if paneFirstClickFocusEnabled { + return String( + localized: "settings.app.paneFirstClickFocus.subtitleOn", + defaultValue: "When Programa is inactive, clicking a pane activates the window and focuses that pane in one click." + ) + } + return String( + localized: "settings.app.paneFirstClickFocus.subtitleOff", + defaultValue: "When Programa is inactive, the first click only activates the window. Click again to focus the pane." + ) + } + + private var selectedSidebarActiveTabIndicatorStyle: SidebarActiveTabIndicatorStyle { + SidebarActiveTabIndicatorSettings.resolvedStyle(rawValue: sidebarActiveTabIndicatorStyle) + } + + private var sidebarIndicatorStyleSelection: Binding { + Binding( + get: { selectedSidebarActiveTabIndicatorStyle.rawValue }, + set: { sidebarActiveTabIndicatorStyle = $0 } + ) + } + + private var selectedSocketControlMode: SocketControlMode { + SocketControlSettings.migrateMode(socketControlMode) + } + + private var selectedBrowserThemeMode: BrowserThemeMode { + BrowserThemeSettings.mode(for: browserThemeMode) + } + + private var browserThemeModeSelection: Binding { + Binding( + get: { browserThemeMode }, + set: { newValue in + browserThemeMode = BrowserThemeSettings.mode(for: newValue).rawValue + } + ) + } + + private var browserImportHintPresentation: BrowserImportHintPresentation { + BrowserImportHintPresentation( + showOnBlankTabs: showBrowserImportHintOnBlankTabs, + isDismissed: isBrowserImportHintDismissed + ) + } + + private var browserImportHintVisibilityBinding: Binding { + Binding( + get: { showBrowserImportHintOnBlankTabs }, + set: { newValue in + showBrowserImportHintOnBlankTabs = newValue + if newValue { + isBrowserImportHintDismissed = false + } + } + ) + } + + private var socketModeSelection: Binding { + Binding( + get: { socketControlMode }, + set: { newValue in + let normalized = SocketControlSettings.migrateMode(newValue) + if normalized == .allowAll && selectedSocketControlMode != .allowAll { + pendingOpenAccessMode = normalized + showOpenAccessConfirmation = true + return + } + socketControlMode = normalized.rawValue + if normalized != .password { + socketPasswordStatusMessage = nil + socketPasswordStatusIsError = false + } + } + ) + } + + private var minimalModeBinding: Binding { + Binding( + get: { minimalModeEnabled }, + set: { newValue in + workspacePresentationMode = newValue + ? WorkspacePresentationModeSettings.Mode.minimal.rawValue + : WorkspacePresentationModeSettings.Mode.standard.rawValue + SettingsWindowController.shared.preserveFocusAfterPreferenceMutation() + } + ) + } + + private var hasSocketPasswordConfigured: Bool { + SocketControlPasswordStore.hasConfiguredPassword() + } + + private var browserHistorySubtitle: String { + switch browserHistoryEntryCount { + case 0: + return String(localized: "settings.browser.history.subtitleEmpty", defaultValue: "No saved pages yet.") + case 1: + return String(localized: "settings.browser.history.subtitleOne", defaultValue: "1 saved page appears in omnibar suggestions.") + default: + return String(localized: "settings.browser.history.subtitleMany", defaultValue: "\(browserHistoryEntryCount) saved pages appear in omnibar suggestions.") + } + } + + private var browserImportSubtitle: String { + InstalledBrowserDetector.summaryText(for: detectedImportBrowsers) + } + + private var browserImportHintSettingsNote: String { + switch browserImportHintPresentation.settingsStatus { + case .visible: + return String(localized: "settings.browser.import.hint.note.visible", defaultValue: "Blank browser tabs can show this import suggestion. Hide or re-enable it here.") + case .hidden: + return String(localized: "settings.browser.import.hint.note.hidden", defaultValue: "The blank-tab import hint is hidden. Turn it back on here any time.") + } + } + + private var browserInsecureHTTPAllowlistHasUnsavedChanges: Bool { + browserInsecureHTTPAllowlistDraft != browserInsecureHTTPAllowlist + } + + private func saveTrustedDirectories() { + let paths = trustedDirectoriesDraft + .split(separator: "\n", omittingEmptySubsequences: true) + .map { $0.trimmingCharacters(in: .whitespaces) } + .filter { !$0.isEmpty } + ProgramaDirectoryTrust.shared.replaceAll(with: paths) + } + + private var hasCustomNotificationSoundFilePath: Bool { + !notificationSoundCustomFilePath.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty + } + + private var notificationSoundCustomFileDisplayName: String { + guard hasCustomNotificationSoundFilePath else { + return String( + localized: "settings.notifications.sound.custom.file.none", + defaultValue: "No file selected" + ) + } + return URL(fileURLWithPath: notificationSoundCustomFilePath).lastPathComponent + } + + private var canPreviewNotificationSound: Bool { + switch notificationSound { + case "none": + return false + case NotificationSoundSettings.customFileValue: + return hasCustomNotificationSoundFilePath + default: + return true + } + } + + private var notificationPermissionStatusText: String { + notificationStore.authorizationState.statusLabel + } + + private var notificationPermissionStatusColor: Color { + switch notificationStore.authorizationState { + case .authorized, .provisional, .ephemeral: + return .green + case .denied: + return .red + case .unknown, .notDetermined: + return .secondary + } + } + + private var notificationPermissionSubtitle: String { + switch notificationStore.authorizationState { + case .unknown, .notDetermined: + return "Desktop notifications are not enabled yet." + case .authorized: + return "Desktop notifications are enabled." + case .denied: + return "Desktop notifications are disabled in System Settings." + case .provisional: + return "Desktop notifications are enabled with quiet delivery." + case .ephemeral: + return "Desktop notifications are temporarily enabled." + } + } + + private var notificationPermissionActionTitle: String { + switch notificationStore.authorizationState { + case .unknown, .notDetermined: + return "Enable" + case .authorized, .denied, .provisional, .ephemeral: + return "Open Settings" + } + } + + private func blurOpacity(forContentOffset offset: CGFloat) -> Double { + guard let baseline = topBlurBaselineOffset else { return 0 } + let reveal = (baseline - offset) / 24 + return Double(min(max(reveal, 0), 1)) + } + + private func previewNotificationSound() { + if notificationSound == NotificationSoundSettings.customFileValue { + NotificationSoundSettings.playCustomFileSound(path: notificationSoundCustomFilePath) + return + } + NotificationSoundSettings.previewSound(value: notificationSound) + } + + private func notificationCustomSoundIssueMessage(_ issue: NotificationSoundSettings.CustomSoundPreparationIssue) -> String { + switch issue { + case .emptyPath: + return String( + localized: "settings.notifications.sound.custom.status.empty", + defaultValue: "Choose a custom audio file first." + ) + case .missingFile(let path): + let fileName = URL(fileURLWithPath: path).lastPathComponent + return String( + localized: "settings.notifications.sound.custom.status.missingFilePrefix", + defaultValue: "File not found: " + ) + fileName + case .missingFileExtension(let path): + let fileName = URL(fileURLWithPath: path).lastPathComponent + return String( + localized: "settings.notifications.sound.custom.status.missingExtensionPrefix", + defaultValue: "File needs an extension: " + ) + fileName + case .stagingFailed(_, let details): + let prefix = String( + localized: "settings.notifications.sound.custom.status.prepareFailed", + defaultValue: "Could not prepare this file for notifications. Try WAV, AIFF, or CAF." + ) + return "\(prefix) (\(details))" + } + } + + private func notificationCustomSoundReadyStatusMessage(for path: String) -> String { + let sourceExtension = URL(fileURLWithPath: path).pathExtension + .trimmingCharacters(in: .whitespacesAndNewlines) + .lowercased() + let stagedExtension = NotificationSoundSettings.stagedCustomSoundFileExtension(forSourceExtension: sourceExtension) + if !sourceExtension.isEmpty, stagedExtension != sourceExtension { + return String( + localized: "settings.notifications.sound.custom.status.readyConverted", + defaultValue: "Prepared for notifications (converted to CAF)." + ) + } + return String( + localized: "settings.notifications.sound.custom.status.ready", + defaultValue: "Ready for notifications." + ) + } + + private func refreshNotificationCustomSoundStatus(showAlertOnFailure: Bool = false) { + guard notificationSound == NotificationSoundSettings.customFileValue else { + notificationCustomSoundStatusMessage = nil + notificationCustomSoundStatusIsError = false + return + } + let pathSnapshot = notificationSoundCustomFilePath + DispatchQueue.global(qos: .userInitiated).async { + let result = NotificationSoundSettings.prepareCustomFileForNotifications(path: pathSnapshot) + DispatchQueue.main.async { + guard notificationSound == NotificationSoundSettings.customFileValue else { + notificationCustomSoundStatusMessage = nil + notificationCustomSoundStatusIsError = false + return + } + guard notificationSoundCustomFilePath == pathSnapshot else { return } + switch result { + case .success: + notificationCustomSoundStatusMessage = notificationCustomSoundReadyStatusMessage(for: pathSnapshot) + notificationCustomSoundStatusIsError = false + case .failure(let issue): + let message = notificationCustomSoundIssueMessage(issue) + notificationCustomSoundStatusMessage = message + notificationCustomSoundStatusIsError = true + if showAlertOnFailure { + notificationCustomSoundErrorAlertMessage = message + showNotificationCustomSoundErrorAlert = true + } + } + } + } + } + + private func chooseNotificationSoundFile() { + let panel = NSOpenPanel() + panel.canChooseFiles = true + panel.canChooseDirectories = false + panel.allowsMultipleSelection = false + panel.allowedContentTypes = [.audio] + panel.title = String( + localized: "settings.notifications.sound.custom.choose.title", + defaultValue: "Choose Notification Sound" + ) + panel.prompt = String( + localized: "settings.notifications.sound.custom.choose.prompt", + defaultValue: "Choose" + ) + guard panel.runModal() == .OK, let url = panel.url else { return } + let selectedPath = url.path + switch NotificationSoundSettings.prepareCustomFileForNotifications(path: selectedPath) { + case .success: + notificationSoundCustomFilePath = selectedPath + notificationSound = NotificationSoundSettings.customFileValue + notificationCustomSoundStatusMessage = notificationCustomSoundReadyStatusMessage(for: selectedPath) + notificationCustomSoundStatusIsError = false + previewNotificationSound() + case .failure(let issue): + let message = notificationCustomSoundIssueMessage(issue) + notificationCustomSoundErrorAlertMessage = message + showNotificationCustomSoundErrorAlert = true + refreshNotificationCustomSoundStatus() + } + } + + private func handleNotificationPermissionAction() { + let state = notificationStore.authorizationState.statusLabel +#if DEBUG + dlog("notification.ui enableTapped state=\(state)") +#endif + NSLog("notification.ui enableTapped state=%@", state) + switch notificationStore.authorizationState { + case .unknown, .notDetermined: + notificationStore.requestAuthorizationFromSettings() + case .authorized, .denied, .provisional, .ephemeral: + notificationStore.openNotificationSettings() + } + } + + private func saveSocketPassword() { + let trimmed = socketPasswordDraft.trimmingCharacters(in: .whitespacesAndNewlines) + guard !trimmed.isEmpty else { + socketPasswordStatusMessage = String(localized: "settings.automation.socketPassword.enterFirst", defaultValue: "Enter a password first.") + socketPasswordStatusIsError = true + return + } + + do { + try SocketControlPasswordStore.savePassword(trimmed) + socketPasswordDraft = "" + socketPasswordStatusMessage = String(localized: "settings.automation.socketPassword.saved", defaultValue: "Password saved.") + socketPasswordStatusIsError = false + } catch { + socketPasswordStatusMessage = String(localized: "settings.automation.socketPassword.saveFailed", defaultValue: "Failed to save password (\(error.localizedDescription)).") + socketPasswordStatusIsError = true + } + } + + private func clearSocketPassword() { + do { + try SocketControlPasswordStore.clearPassword() + socketPasswordDraft = "" + socketPasswordStatusMessage = String(localized: "settings.automation.socketPassword.cleared", defaultValue: "Password cleared.") + socketPasswordStatusIsError = false + } catch { + socketPasswordStatusMessage = String(localized: "settings.automation.socketPassword.clearFailed", defaultValue: "Failed to clear password (\(error.localizedDescription)).") + socketPasswordStatusIsError = true + } + } + + var body: some View { + let _ = keyboardShortcutSettingsObserver.revision + ScrollViewReader { proxy in + ZStack(alignment: .top) { + ScrollView { + VStack(alignment: .leading, spacing: 14) { + appSection + workspaceColorsSection + sidebarAppearanceSection + automationSection + customCommandsSection + browserSection + keyboardShortcutsSection + resetSection + } + .padding(.horizontal, 20) + .padding(.bottom, 20) + .padding(.top, contentTopInset) + .background( + GeometryReader { proxy in + Color.clear.preference( + key: SettingsTopOffsetPreferenceKey.self, + value: proxy.frame(in: .named("SettingsScrollArea")).minY + ) + } + ) + } + .coordinateSpace(name: "SettingsScrollArea") + .onPreferenceChange(SettingsTopOffsetPreferenceKey.self) { value in + if topBlurBaselineOffset == nil { + topBlurBaselineOffset = value + } + topBlurOpacity = blurOpacity(forContentOffset: value) + } + + ZStack(alignment: .top) { + SettingsTitleLeadingInsetReader(inset: $settingsTitleLeadingInset) + .frame(width: 0, height: 0) + + AboutVisualEffectBackground(material: .underWindowBackground, blendingMode: .withinWindow) + .mask( + LinearGradient( + colors: [ + Color.black.opacity(0.9), + Color.black.opacity(0.64), + Color.black.opacity(0.36), + Color.clear + ], + startPoint: .top, + endPoint: .bottom + ) + ) + .opacity(0.52) + + AboutVisualEffectBackground(material: .underWindowBackground, blendingMode: .withinWindow) + .mask( + LinearGradient( + colors: [ + Color.black.opacity(0.98), + Color.black.opacity(0.78), + Color.black.opacity(0.42), + Color.clear + ], + startPoint: .top, + endPoint: .bottom + ) + ) + .opacity(0.14 + (topBlurOpacity * 0.86)) + + HStack { + Text(String(localized: "settings.title", defaultValue: "Settings")) + .font(.system(size: 16, weight: .semibold)) + .foregroundColor(.primary.opacity(0.92)) + Spacer(minLength: 0) + HStack(spacing: 6) { + SettingsHeaderActionButton( + title: String(localized: "settings.app.settingsFile.openButton", defaultValue: "Open settings.json"), + helpText: KeyboardShortcutSettings.settingsFileStore.settingsFileDisplayPath(), + accessibilityIdentifier: "SettingsFileOpenButton", + action: openProgramaSettingsFileInTextEdit + ) + } + } + .padding(.leading, settingsTitleLeadingInset) + .padding(.trailing, 20) + .padding(.top, 12) + } + .frame(height: 62) + .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .top) + .ignoresSafeArea(.container, edges: .top) + .overlay( + Rectangle() + .fill(Color(nsColor: .separatorColor).opacity(0.07)) + .frame(height: 1), + alignment: .bottom + ) + } + .background(Color(nsColor: .windowBackgroundColor).ignoresSafeArea()) + .toggleStyle(.switch) + .onAppear { + BrowserHistoryStore.shared.loadIfNeeded() + notificationStore.refreshAuthorizationStatus() + browserThemeMode = BrowserThemeSettings.mode(defaults: .standard).rawValue + browserHistoryEntryCount = BrowserHistoryStore.shared.entries.count + browserInsecureHTTPAllowlistDraft = browserInsecureHTTPAllowlist + refreshDetectedImportBrowsers() + reloadWorkspaceTabColorSettings() + refreshNotificationCustomSoundStatus() + } + .onChange(of: notificationSound) { _, _ in + refreshNotificationCustomSoundStatus() + } + .onChange(of: notificationSoundCustomFilePath) { _, _ in + refreshNotificationCustomSoundStatus() + } + .onChange(of: browserInsecureHTTPAllowlist) { oldValue, newValue in + // Keep draft in sync with external changes unless the user has local unsaved edits. + if browserInsecureHTTPAllowlistDraft == oldValue { + browserInsecureHTTPAllowlistDraft = newValue + } + } + .onReceive(BrowserHistoryStore.shared.$entries) { entries in + browserHistoryEntryCount = entries.count + } + .onReceive(NotificationCenter.default.publisher(for: UserDefaults.didChangeNotification)) { _ in + reloadWorkspaceTabColorSettings() + } + .onReceive(NotificationCenter.default.publisher(for: SettingsNavigationRequest.notificationName)) { notification in + guard let target = SettingsNavigationRequest.target(from: notification) else { return } + DispatchQueue.main.async { + withAnimation(.easeInOut(duration: 0.2)) { + proxy.scrollTo(target, anchor: .top) + } + } + } + .confirmationDialog( + String(localized: "settings.browser.history.clearDialog.title", defaultValue: "Clear browser history?"), + isPresented: $showClearBrowserHistoryConfirmation, + titleVisibility: .visible + ) { + Button(String(localized: "settings.browser.history.clearDialog.confirm", defaultValue: "Clear History"), role: .destructive) { + BrowserHistoryStore.shared.clearHistory() + } + Button(String(localized: "settings.browser.history.clearDialog.cancel", defaultValue: "Cancel"), role: .cancel) {} + } message: { + Text(String(localized: "settings.browser.history.clearDialog.message", defaultValue: "This removes visited-page suggestions from the browser omnibar.")) + } + .confirmationDialog( + String(localized: "settings.automation.openAccess.dialog.title", defaultValue: "Enable full open access?"), + isPresented: $showOpenAccessConfirmation, + titleVisibility: .visible + ) { + Button(String(localized: "settings.automation.openAccess.dialog.confirm", defaultValue: "Enable Full Open Access"), role: .destructive) { + socketControlMode = (pendingOpenAccessMode ?? .allowAll).rawValue + pendingOpenAccessMode = nil + } + Button(String(localized: "settings.automation.openAccess.dialog.cancel", defaultValue: "Cancel"), role: .cancel) { + pendingOpenAccessMode = nil + } + } message: { + Text(String(localized: "settings.automation.openAccess.dialog.message", defaultValue: "This disables ancestry and password checks and opens the socket to all local users. Only enable when you understand the risk.")) + } + .alert( + String( + localized: "settings.notifications.sound.custom.error.title", + defaultValue: "Custom Notification Sound Error" + ), + isPresented: $showNotificationCustomSoundErrorAlert + ) { + Button(String(localized: "common.ok", defaultValue: "OK"), role: .cancel) {} + } message: { + Text(notificationCustomSoundErrorAlertMessage) + } + } + } + + @ViewBuilder + private var appSection: some View { + SettingsSectionHeader(title: String(localized: "settings.section.app", defaultValue: "App")) + SettingsCard { + ThemePickerRow( + selectedMode: appearanceMode, + onSelect: { mode in + appearanceMode = mode.rawValue + } + ) + + SettingsCardDivider() + + SettingsPickerRow( + String(localized: "settings.app.newWorkspacePlacement", defaultValue: "New Workspace Placement"), + subtitle: selectedWorkspacePlacement.description, + controlWidth: pickerColumnWidth, + selection: $newWorkspacePlacement + ) { + ForEach(NewWorkspacePlacement.allCases) { placement in + Text(placement.displayName).tag(placement.rawValue) + } + } + + SettingsCardDivider() + + SettingsCardRow( + String(localized: "settings.app.minimalMode", defaultValue: "Minimal Mode"), + subtitle: minimalModeSubtitle + ) { + Toggle("", isOn: minimalModeBinding) + .labelsHidden() + .controlSize(.small) + .accessibilityIdentifier("SettingsMinimalModeToggle") + .accessibilityLabel( + String(localized: "settings.app.minimalMode", defaultValue: "Minimal Mode") + ) + } + + SettingsCardDivider() + + SettingsCardRow( + String(localized: "settings.app.closeWorkspaceOnLastSurfaceShortcut", defaultValue: "Keep Workspace Open When Closing Last Surface"), + subtitle: closeWorkspaceOnLastSurfaceShortcutSubtitle + ) { + Toggle("", isOn: keepWorkspaceOpenOnLastSurfaceShortcutBinding) + .labelsHidden() + .controlSize(.small) + } + + SettingsCardDivider() + + SettingsCardRow( + String(localized: "settings.app.paneFirstClickFocus", defaultValue: "Focus Pane on First Click"), + subtitle: paneFirstClickFocusSubtitle + ) { + Toggle("", isOn: $paneFirstClickFocusEnabled) + .labelsHidden() + .controlSize(.small) + .accessibilityLabel( + String(localized: "settings.app.paneFirstClickFocus", defaultValue: "Focus Pane on First Click") + ) + } + + SettingsCardDivider() + + SettingsCardRow( + String(localized: "settings.app.preferredEditor", defaultValue: "Open Files With"), + subtitle: String(localized: "settings.app.preferredEditor.subtitle", defaultValue: "Command to open files on Cmd-click. Leave empty for system default.") + ) { + TextField( + String(localized: "settings.app.preferredEditor.placeholder", defaultValue: "e.g. code, zed, subl"), + text: $preferredEditorCommand + ) + .textFieldStyle(.roundedBorder) + .frame(width: 200) + } + + SettingsCardDivider() + + SettingsCardRow( + String(localized: "settings.app.reorderOnNotification", defaultValue: "Reorder on Notification"), + subtitle: String(localized: "settings.app.reorderOnNotification.subtitle", defaultValue: "Move workspaces to the top when they receive a notification. Disable for stable shortcut positions.") + ) { + Toggle("", isOn: $workspaceAutoReorder) + .labelsHidden() + .controlSize(.small) + } + + + SettingsCardDivider() + + SettingsCardRow( + String(localized: "settings.app.showInMenuBar", defaultValue: "Show in Menu Bar"), + subtitle: String(localized: "settings.app.showInMenuBar.subtitle", defaultValue: "Keep Programa in the menu bar for unread notifications and quick actions.") + ) { + Toggle("", isOn: $showMenuBarExtra) + .labelsHidden() + .controlSize(.small) + .accessibilityLabel( + String(localized: "settings.app.showInMenuBar", defaultValue: "Show in Menu Bar") + ) + } + + + SettingsCardDivider() + + SettingsCardRow( + "Desktop Notifications", + subtitle: notificationPermissionSubtitle + ) { + HStack(spacing: 6) { + Text(notificationPermissionStatusText) + .font(.system(size: 11, weight: .semibold)) + .foregroundStyle(notificationPermissionStatusColor) + .frame(width: 98, alignment: .trailing) + + Button(notificationPermissionActionTitle) { + handleNotificationPermissionAction() + } + .controlSize(.small) + } + } + + SettingsCardDivider() + + SettingsCardRow( + String(localized: "settings.notifications.sound.title", defaultValue: "Notification Sound"), + subtitle: String(localized: "settings.notifications.sound.subtitle", defaultValue: "Sound played when a notification arrives."), + controlWidth: notificationSoundControlWidth + ) { + VStack(alignment: .trailing, spacing: 6) { + HStack(spacing: 6) { + Picker("", selection: $notificationSound) { + ForEach(NotificationSoundSettings.systemSounds, id: \.value) { sound in + Text(sound.label).tag(sound.value) + } + } + .labelsHidden() + Button { + previewNotificationSound() + } label: { + Image(systemName: "play.fill") + .font(.system(size: 9)) + } + .buttonStyle(.bordered) + .controlSize(.small) + .disabled(!canPreviewNotificationSound) + } + + if notificationSound == NotificationSoundSettings.customFileValue { + HStack(spacing: 6) { + Text(notificationSoundCustomFileDisplayName) + .font(.system(size: 11)) + .foregroundStyle(.secondary) + .lineLimit(1) + .truncationMode(.middle) + .frame(width: 170, alignment: .trailing) + Button( + String( + localized: "settings.notifications.sound.custom.choose.button", + defaultValue: "Choose..." + ) + ) { + chooseNotificationSoundFile() + } + .controlSize(.small) + Button( + String( + localized: "settings.notifications.sound.custom.clear.button", + defaultValue: "Clear" + ) + ) { + notificationSoundCustomFilePath = NotificationSoundSettings.defaultCustomFilePath + refreshNotificationCustomSoundStatus() + } + .controlSize(.small) + .disabled(!hasCustomNotificationSoundFilePath) + } + if let notificationCustomSoundStatusMessage { + Text(notificationCustomSoundStatusMessage) + .font(.system(size: 11)) + .foregroundStyle(notificationCustomSoundStatusIsError ? Color.red : Color.secondary) + .lineLimit(2) + .multilineTextAlignment(.trailing) + .frame(width: 260, alignment: .trailing) + } + } + } + .frame(maxWidth: .infinity, alignment: .trailing) + } + + SettingsCardDivider() + + SettingsCardRow( + "Notification Command", + subtitle: "Run a shell command when a notification arrives. $PROGRAMA_NOTIFICATION_TITLE, $PROGRAMA_NOTIFICATION_SUBTITLE, $PROGRAMA_NOTIFICATION_BODY are set." + ) { + TextField("say \"done\"", text: $notificationCustomCommand) + .textFieldStyle(.roundedBorder) + .frame(width: 200) + } + + SettingsCardDivider() + + SettingsCardRow( + String(localized: "settings.app.warnBeforeQuit", defaultValue: "Warn Before Quit"), + subtitle: warnBeforeQuitShortcut + ? String(localized: "settings.app.warnBeforeQuit.subtitleOn", defaultValue: "Show a confirmation before quitting with Cmd+Q.") + : String(localized: "settings.app.warnBeforeQuit.subtitleOff", defaultValue: "Cmd+Q quits immediately without confirmation.") + ) { + Toggle("", isOn: $warnBeforeQuitShortcut) + .labelsHidden() + .controlSize(.small) + } + + SettingsCardDivider() + + SettingsCardRow( + String(localized: "settings.app.persistScrollback", defaultValue: "Save Scrollback on Quit"), + subtitle: sessionPersistScrollback + ? String(localized: "settings.app.persistScrollback.subtitleOn", defaultValue: "Terminal scrollback is saved and restored on next launch.") + : String(localized: "settings.app.persistScrollback.subtitleOff", defaultValue: "Terminal scrollback is not written to disk.") + ) { + Toggle("", isOn: $sessionPersistScrollback) + .labelsHidden() + .controlSize(.small) + } + + SettingsCardDivider() + + SettingsCardRow( + String(localized: "settings.app.renameSelectsName", defaultValue: "Rename Selects Existing Name"), + subtitle: commandPaletteRenameSelectAllOnFocus + ? String(localized: "settings.app.renameSelectsName.subtitleOn", defaultValue: "Command Palette rename starts with all text selected.") + : String(localized: "settings.app.renameSelectsName.subtitleOff", defaultValue: "Command Palette rename keeps the caret at the end.") + ) { + Toggle("", isOn: $commandPaletteRenameSelectAllOnFocus) + .labelsHidden() + .controlSize(.small) + } + + SettingsCardDivider() + + SettingsCardRow( + String(localized: "settings.app.commandPaletteSearchAllSurfaces", defaultValue: "Command Palette Searches All Surfaces"), + subtitle: commandPaletteSearchAllSurfaces + ? String(localized: "settings.app.commandPaletteSearchAllSurfaces.subtitleOn", defaultValue: "Cmd+P also matches terminal, browser, and markdown surfaces across workspaces.") + : String(localized: "settings.app.commandPaletteSearchAllSurfaces.subtitleOff", defaultValue: "Cmd+P matches workspace rows only.") + ) { + Toggle("", isOn: $commandPaletteSearchAllSurfaces) + .labelsHidden() + .controlSize(.small) + .accessibilityIdentifier("CommandPaletteSearchAllSurfacesToggle") + .accessibilityLabel( + String(localized: "settings.app.commandPaletteSearchAllSurfaces", defaultValue: "Command Palette Searches All Surfaces") + ) + } + + } + + } + + @ViewBuilder + private var workspaceColorsSection: some View { + SettingsSectionHeader(title: String(localized: "settings.section.workspaceColors", defaultValue: "Workspace Colors")) + SettingsCard { + SettingsPickerRow( + String(localized: "settings.workspaceColors.indicator", defaultValue: "Workspace Color Indicator"), + controlWidth: pickerColumnWidth, + selection: sidebarIndicatorStyleSelection + ) { + ForEach(SidebarActiveTabIndicatorStyle.allCases) { style in + Text(style.displayName).tag(style.rawValue) + } + } + + SettingsCardDivider() + + SettingsCardRow( + String(localized: "settings.workspaceColors.selectionColor", defaultValue: "Selection Highlight"), + subtitle: String(localized: "settings.workspaceColors.selectionColor.subtitle", defaultValue: "Background color of the selected workspace in the sidebar.") + ) { + HStack(spacing: 8) { + if sidebarSelectionColorHex != nil { + Button(String(localized: "settings.workspaceColors.selectionColor.reset", defaultValue: "Reset")) { + sidebarSelectionColorHex = nil + } + .buttonStyle(.bordered) + .controlSize(.small) + } + + HexColorPicker( + hex: sidebarSelectionColorHex, + fallback: programaAccentColor() + ) { newHex in + sidebarSelectionColorHex = newHex + } + + Text(sidebarSelectionColorHex ?? String(localized: "settings.sidebarAppearance.defaultLabel", defaultValue: "Default")) + .font(.system(size: 12, weight: .medium, design: .monospaced)) + .foregroundStyle(.secondary) + .frame(width: 76, alignment: .trailing) + } + } + + SettingsCardDivider() + + SettingsCardRow( + String(localized: "settings.workspaceColors.notificationBadgeColor", defaultValue: "Notification Badge"), + subtitle: String(localized: "settings.workspaceColors.notificationBadgeColor.subtitle", defaultValue: "Color of the unread notification badge on workspace tabs.") + ) { + HStack(spacing: 8) { + if sidebarNotificationBadgeColorHex != nil { + Button(String(localized: "settings.workspaceColors.notificationBadgeColor.reset", defaultValue: "Reset")) { + sidebarNotificationBadgeColorHex = nil + } + .buttonStyle(.bordered) + .controlSize(.small) + } + + HexColorPicker( + hex: sidebarNotificationBadgeColorHex, + fallback: programaAccentColor() + ) { newHex in + sidebarNotificationBadgeColorHex = newHex + } + + Text(sidebarNotificationBadgeColorHex ?? String(localized: "settings.sidebarAppearance.defaultLabel", defaultValue: "Default")) + .font(.system(size: 12, weight: .medium, design: .monospaced)) + .foregroundStyle(.secondary) + .frame(width: 76, alignment: .trailing) + } + } + + SettingsCardDivider() + + SettingsCardNote( + String( + localized: "settings.workspaceColors.dictionaryNote", + defaultValue: "Edit settings.json to add or remove named colors. \"Choose Custom Color...\" still adds local Custom N entries." + ) + ) + + if workspaceTabPaletteEntries.isEmpty { + SettingsCardNote( + String( + localized: "settings.workspaceColors.emptyPalette", + defaultValue: "No palette entries. Add colors in settings.json or use \"Choose Custom Color...\" from a workspace context menu." + ) + ) + } else { + ForEach(Array(workspaceTabPaletteEntries.enumerated()), id: \.element.name) { index, entry in + if index > 0 { + SettingsCardDivider() + } + SettingsCardRow( + entry.name, + subtitle: baseTabColorHex(for: entry.name).map { + String(localized: "settings.workspaceColors.base", defaultValue: "Base: \($0)") + } ?? String( + localized: "settings.workspaceColors.customEntry", + defaultValue: "Named palette entry." + ) + ) { + HStack(spacing: 8) { + HexColorPicker( + hex: entry.hex, + fallback: .blue + ) { newHex in + WorkspaceTabColorSettings.setColor(named: entry.name, hex: newHex) + reloadWorkspaceTabColorSettings() + } + + Text(entry.hex) + .font(.system(size: 12, weight: .medium, design: .monospaced)) + .foregroundStyle(.secondary) + .frame(width: 76, alignment: .trailing) + + if baseTabColorHex(for: entry.name) == nil { + Button(String(localized: "settings.workspaceColors.remove", defaultValue: "Remove")) { + removeWorkspaceColor(named: entry.name) + } + .buttonStyle(.bordered) + .controlSize(.small) + } + } + } + } + } + + SettingsCardDivider() + + SettingsCardRow( + String(localized: "settings.workspaceColors.resetPalette", defaultValue: "Reset Palette"), + subtitle: String( + localized: "settings.workspaceColors.resetPalette.subtitleV2", + defaultValue: "Restore the built-in palette and remove extra named colors." + ) + ) { + Button(String(localized: "settings.workspaceColors.resetPalette.button", defaultValue: "Reset")) { + resetWorkspaceTabColors() + } + .buttonStyle(.bordered) + .controlSize(.small) + } + } + + } + + @ViewBuilder + private var sidebarAppearanceSection: some View { + SettingsSectionHeader(title: String(localized: "settings.section.sidebarAppearance", defaultValue: "Sidebar Appearance")) + SettingsCard { + SettingsCardRow( + String(localized: "settings.sidebarAppearance.matchTerminalBackground", defaultValue: "Match Terminal Background"), + subtitle: String(localized: "settings.sidebarAppearance.matchTerminalBackground.subtitle", defaultValue: "Use the same background color and transparency as the terminal.") + ) { + Toggle("", isOn: $sidebarMatchTerminalBackground) + .labelsHidden() + .toggleStyle(.switch) + .controlSize(.small) + } + + SettingsCardDivider() + + SettingsCardRow( + String(localized: "settings.sidebarAppearance.tintColorLight", defaultValue: "Light Mode Tint"), + subtitle: String(localized: "settings.sidebarAppearance.tintColorLight.subtitle", defaultValue: "Sidebar tint color when using light appearance.") + ) { + HStack(spacing: 8) { + HexColorPicker( + hex: sidebarTintHexLight ?? sidebarTintHex, + fallback: .black + ) { newHex in + sidebarTintHexLight = newHex + } + + Text(sidebarTintHexLight ?? String(localized: "settings.sidebarAppearance.defaultLabel", defaultValue: "Default")) + .font(.system(size: 12, weight: .medium, design: .monospaced)) + .foregroundStyle(.secondary) + .frame(width: 76, alignment: .trailing) + } + } + + SettingsCardDivider() + + SettingsCardRow( + String(localized: "settings.sidebarAppearance.tintColorDark", defaultValue: "Dark Mode Tint"), + subtitle: String(localized: "settings.sidebarAppearance.tintColorDark.subtitle", defaultValue: "Sidebar tint color when using dark appearance.") + ) { + HStack(spacing: 8) { + HexColorPicker( + hex: sidebarTintHexDark ?? sidebarTintHex, + fallback: .black + ) { newHex in + sidebarTintHexDark = newHex + } + + Text(sidebarTintHexDark ?? String(localized: "settings.sidebarAppearance.defaultLabel", defaultValue: "Default")) + .font(.system(size: 12, weight: .medium, design: .monospaced)) + .foregroundStyle(.secondary) + .frame(width: 76, alignment: .trailing) + } + } + + SettingsCardDivider() + + SettingsCardRow( + String(localized: "settings.sidebarAppearance.tintOpacity", defaultValue: "Tint Opacity"), + subtitle: String(localized: "settings.sidebarAppearance.tintOpacity.subtitle", defaultValue: "How strongly the tint color shows over the sidebar material.") + ) { + HStack(spacing: 8) { + Slider(value: $sidebarTintOpacity, in: 0...1) + .frame(width: 140) + Text(String(format: "%.0f%%", sidebarTintOpacity * 100)) + .font(.system(size: 12, weight: .medium, design: .monospaced)) + .foregroundStyle(.secondary) + .frame(width: 36, alignment: .trailing) + } + } + + SettingsCardDivider() + + SettingsCardRow( + String(localized: "settings.sidebarAppearance.reset", defaultValue: "Reset Sidebar Tint"), + subtitle: String(localized: "settings.sidebarAppearance.reset.subtitle", defaultValue: "Restore default sidebar appearance.") + ) { + Button(String(localized: "settings.sidebarAppearance.reset.button", defaultValue: "Reset")) { + sidebarTintHexLight = nil + sidebarTintHexDark = nil + sidebarTintHex = SidebarTintDefaults.hex + sidebarTintOpacity = SidebarTintDefaults.opacity + } + .buttonStyle(.bordered) + .controlSize(.small) + } + } + + } + + @ViewBuilder + private var automationSection: some View { + SettingsSectionHeader(title: String(localized: "settings.section.automation", defaultValue: "Automation")) + SettingsCard { + SettingsPickerRow( + String(localized: "settings.automation.socketMode", defaultValue: "Socket Control Mode"), + subtitle: selectedSocketControlMode.description, + controlWidth: pickerColumnWidth, + selection: socketModeSelection, + accessibilityId: "AutomationSocketModePicker" + ) { + ForEach(SocketControlMode.uiCases) { mode in + Text(mode.displayName).tag(mode.rawValue) + } + } + + SettingsCardDivider() + + SettingsCardNote(String(localized: "settings.automation.socketMode.note", defaultValue: "Controls access to the local Unix socket for programmatic control. Choose a mode that matches your threat model.")) + if selectedSocketControlMode == .password { + SettingsCardDivider() + SettingsCardRow( + String(localized: "settings.automation.socketPassword", defaultValue: "Socket Password"), + subtitle: hasSocketPasswordConfigured + ? String(localized: "settings.automation.socketPassword.subtitleSet", defaultValue: "Stored in Application Support.") + : String(localized: "settings.automation.socketPassword.subtitleUnset", defaultValue: "No password set. External clients will be blocked until one is configured.") + ) { + HStack(spacing: 8) { + SecureField(String(localized: "settings.automation.socketPassword.placeholder", defaultValue: "Password"), text: $socketPasswordDraft) + .textFieldStyle(.roundedBorder) + .frame(width: 170) + Button(hasSocketPasswordConfigured ? String(localized: "settings.automation.socketPassword.change", defaultValue: "Change") : String(localized: "settings.automation.socketPassword.set", defaultValue: "Set")) { + saveSocketPassword() + } + .buttonStyle(.bordered) + .controlSize(.small) + .disabled(socketPasswordDraft.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty) + if hasSocketPasswordConfigured { + Button(String(localized: "settings.automation.socketPassword.clear", defaultValue: "Clear")) { + clearSocketPassword() + } + .buttonStyle(.bordered) + .controlSize(.small) + } + } + } + if let message = socketPasswordStatusMessage { + Text(message) + .font(.caption) + .foregroundStyle(socketPasswordStatusIsError ? Color.red : Color.secondary) + .padding(.horizontal, 14) + .padding(.bottom, 8) + } + } + if selectedSocketControlMode == .allowAll { + SettingsCardDivider() + Text(String(localized: "settings.automation.openAccessWarning", defaultValue: "Warning: Full open access makes the control socket world-readable/writable on this Mac and disables auth checks. Use only for local debugging.")) + .font(.caption) + .foregroundStyle(.red) + .padding(.horizontal, 14) + .padding(.vertical, 8) + } + SettingsCardNote(String(localized: "settings.automation.socketOverrides.note", defaultValue: "Overrides: PROGRAMA_SOCKET_ENABLE, PROGRAMA_SOCKET_MODE, and PROGRAMA_SOCKET_PATH (set PROGRAMA_ALLOW_SOCKET_OVERRIDE=1 for release builds).")) + } + + SettingsCard { + SettingsCardRow( + String(localized: "settings.automation.claudeCode", defaultValue: "Claude Code Integration"), + subtitle: claudeCodeHooksEnabled + ? String(localized: "settings.automation.claudeCode.subtitleOn", defaultValue: "Sidebar shows Claude session status and notifications.") + : String(localized: "settings.automation.claudeCode.subtitleOff", defaultValue: "Claude Code runs without Programa integration.") + ) { + Toggle("", isOn: $claudeCodeHooksEnabled) + .labelsHidden() + .controlSize(.small) + .accessibilityIdentifier("SettingsClaudeCodeHooksToggle") + } + + SettingsCardDivider() + + SettingsCardNote(String(localized: "settings.automation.claudeCode.note", defaultValue: "When enabled, Programa wraps the claude command to inject session tracking and notification hooks. Disable if you prefer to manage Claude Code hooks yourself.")) + } + + SettingsCard { + SettingsCardRow( + String(localized: "settings.automation.claudeCode.customPath", defaultValue: "Claude Binary Path"), + subtitle: String(localized: "settings.automation.claudeCode.customPath.subtitle", defaultValue: "Custom path to the claude binary. Leave empty to use PATH.") + ) { + TextField( + String(localized: "settings.automation.claudeCode.customPath.placeholder", defaultValue: "e.g. /usr/local/bin/claude"), + text: $customClaudePath + ) + .textFieldStyle(.roundedBorder) + .frame(width: 200) + } + } + + SettingsCard { + SettingsCardRow(String(localized: "settings.automation.portBase", defaultValue: "Port Base"), subtitle: String(localized: "settings.automation.portBase.subtitle", defaultValue: "Starting port for PROGRAMA_PORT env var."), controlWidth: pickerColumnWidth) { + TextField("", value: $programaPortBase, format: .number) + .textFieldStyle(.roundedBorder) + .multilineTextAlignment(.trailing) + } + + SettingsCardDivider() + + SettingsCardRow(String(localized: "settings.automation.portRange", defaultValue: "Port Range Size"), subtitle: String(localized: "settings.automation.portRange.subtitle", defaultValue: "Number of ports per workspace."), controlWidth: pickerColumnWidth) { + TextField("", value: $programaPortRange, format: .number) + .textFieldStyle(.roundedBorder) + .multilineTextAlignment(.trailing) + } + + SettingsCardDivider() + + SettingsCardNote(String(localized: "settings.automation.port.note", defaultValue: "Each workspace gets PROGRAMA_PORT and PROGRAMA_PORT_END env vars with a dedicated port range. New terminals inherit these values.")) + } + + } + + @ViewBuilder + private var customCommandsSection: some View { + SettingsSectionHeader(title: String(localized: "settings.section.customCommands", defaultValue: "Custom Commands")) + SettingsCard { + VStack(alignment: .leading, spacing: 6) { + SettingsCardRow( + String(localized: "settings.customCommands.trustedDirectories", defaultValue: "Trusted Directories"), + subtitle: String(localized: "settings.customCommands.trustedDirectories.subtitle", defaultValue: "Commands from programa.json in these directories run without confirmation. One path per line.") + ) { + EmptyView() + } + + TextEditor(text: $trustedDirectoriesDraft) + .font(.system(.body, design: .monospaced)) + .frame(minHeight: 60, maxHeight: 120) + .scrollContentBackground(.hidden) + .padding(6) + .background(Color(nsColor: .controlBackgroundColor)) + .cornerRadius(6) + .overlay( + RoundedRectangle(cornerRadius: 6) + .stroke(Color(nsColor: .separatorColor), lineWidth: 0.5) + ) + .padding(.horizontal, 16) + .padding(.bottom, 12) + .onChange(of: trustedDirectoriesDraft) { _ in + saveTrustedDirectories() + } + } + + SettingsCardDivider() + SettingsCardNote(String(localized: "settings.customCommands.trustedDirectories.note", defaultValue: "Place a programa.json in your project root to define custom commands. Trust a directory from the confirmation dialog, or add paths here. For git repos, trusting the root covers all subdirectories.")) + } + + } + + @ViewBuilder + private var browserSection: some View { + SettingsSectionHeader(title: String(localized: "settings.section.browser", defaultValue: "Browser")) + .id(SettingsNavigationTarget.browser) + .accessibilityIdentifier("SettingsBrowserSection") + SettingsCard { + SettingsPickerRow( + String(localized: "settings.browser.searchEngine", defaultValue: "Default Search Engine"), + subtitle: String(localized: "settings.browser.searchEngine.subtitle", defaultValue: "Used by the browser address bar when input is not a URL."), + controlWidth: pickerColumnWidth, + selection: $browserSearchEngine + ) { + ForEach(BrowserSearchEngine.allCases) { engine in + Text(engine.displayName).tag(engine.rawValue) + } + } + + SettingsCardDivider() + + SettingsCardRow(String(localized: "settings.browser.searchSuggestions", defaultValue: "Show Search Suggestions")) { + Toggle("", isOn: $browserSearchSuggestionsEnabled) + .labelsHidden() + .controlSize(.small) + } + + SettingsCardDivider() + + SettingsPickerRow( + String(localized: "settings.browser.theme", defaultValue: "Browser Theme"), + subtitle: selectedBrowserThemeMode == .system + ? String(localized: "settings.browser.theme.subtitleSystem", defaultValue: "System follows app and macOS appearance.") + : String(localized: "settings.browser.theme.subtitleForced", defaultValue: "\(selectedBrowserThemeMode.displayName) forces that color scheme for compatible pages."), + controlWidth: pickerColumnWidth, + selection: browserThemeModeSelection + ) { + ForEach(BrowserThemeMode.allCases) { mode in + Text(mode.displayName).tag(mode.rawValue) + } + } + + SettingsCardDivider() + + SettingsCardRow( + String(localized: "settings.browser.openTerminalLinks", defaultValue: "Open Terminal Links in Programa Browser"), + subtitle: String(localized: "settings.browser.openTerminalLinks.subtitle", defaultValue: "When off, links clicked in terminal output open in your default browser.") + ) { + Toggle("", isOn: $openTerminalLinksInProgramaBrowser) + .labelsHidden() + .controlSize(.small) + } + + SettingsCardDivider() + + SettingsCardRow( + String(localized: "settings.browser.interceptOpen", defaultValue: "Intercept open http(s) in Terminal"), + subtitle: String(localized: "settings.browser.interceptOpen.subtitle", defaultValue: "When off, `open https://...` and `open http://...` always use your default browser.") + ) { + Toggle("", isOn: $interceptTerminalOpenCommandInProgramaBrowser) + .labelsHidden() + .controlSize(.small) + } + + if openTerminalLinksInProgramaBrowser || interceptTerminalOpenCommandInProgramaBrowser { + SettingsCardDivider() + + VStack(alignment: .leading, spacing: 6) { + SettingsCardRow( + String(localized: "settings.browser.hostWhitelist", defaultValue: "Hosts to Open in Embedded Browser"), + subtitle: String(localized: "settings.browser.hostWhitelist.subtitle", defaultValue: "Applies to terminal link clicks and intercepted `open https://...` calls. Only these hosts open in Programa. Others open in your default browser. One host or wildcard per line (for example: example.com, *.internal.example). Leave empty to open all hosts in Programa.") + ) { + EmptyView() + } + + TextEditor(text: $browserHostWhitelist) + .font(.system(.body, design: .monospaced)) + .frame(minHeight: 60, maxHeight: 120) + .scrollContentBackground(.hidden) + .padding(6) + .background(Color(nsColor: .controlBackgroundColor)) + .cornerRadius(6) + .overlay( + RoundedRectangle(cornerRadius: 6) + .stroke(Color(nsColor: .separatorColor), lineWidth: 0.5) + ) + .padding(.horizontal, 16) + .padding(.bottom, 12) + } + + SettingsCardDivider() + + VStack(alignment: .leading, spacing: 6) { + SettingsCardRow( + String(localized: "settings.browser.externalPatterns", defaultValue: "URLs to Always Open Externally"), + subtitle: String(localized: "settings.browser.externalPatterns.subtitle", defaultValue: "Applies to terminal link clicks and intercepted `open https://...` calls. One rule per line. Plain text matches any URL substring, or prefix with `re:` for regex (for example: openai.com/usage, re:^https?://[^/]*\\.example\\.com/(billing|usage)).") + ) { + EmptyView() + } + + TextEditor(text: $browserExternalOpenPatterns) + .font(.system(.body, design: .monospaced)) + .frame(minHeight: 60, maxHeight: 120) + .scrollContentBackground(.hidden) + .padding(6) + .background(Color(nsColor: .controlBackgroundColor)) + .cornerRadius(6) + .overlay( + RoundedRectangle(cornerRadius: 6) + .stroke(Color(nsColor: .separatorColor), lineWidth: 0.5) + ) + .padding(.horizontal, 16) + .padding(.bottom, 12) + } + } + + SettingsCardDivider() + + VStack(alignment: .leading, spacing: 8) { + Text(String(localized: "settings.browser.httpAllowlist", defaultValue: "HTTP Hosts Allowed in Embedded Browser")) + .font(.system(size: 13, weight: .semibold)) + + Text(String(localized: "settings.browser.httpAllowlist.description", defaultValue: "Controls which HTTP (non-HTTPS) hosts can open in Programa without a warning prompt. Defaults include localhost, 127.0.0.1, ::1, 0.0.0.0, and *.localtest.me.")) + .font(.caption) + .foregroundStyle(.secondary) + + TextEditor(text: $browserInsecureHTTPAllowlistDraft) + .font(.system(size: 12, weight: .regular, design: .monospaced)) + .frame(minHeight: 86) + .padding(6) + .background( + RoundedRectangle(cornerRadius: 8, style: .continuous) + .fill(Color(nsColor: .textBackgroundColor)) + ) + .overlay( + RoundedRectangle(cornerRadius: 8, style: .continuous) + .stroke(Color(nsColor: .separatorColor), lineWidth: 1) + ) + .accessibilityIdentifier("SettingsBrowserHTTPAllowlistField") + + ViewThatFits(in: .horizontal) { + HStack(alignment: .center, spacing: 10) { + Text(String(localized: "settings.browser.httpAllowlist.hint", defaultValue: "One host or wildcard per line (for example: localhost, 127.0.0.1, ::1, 0.0.0.0, *.localtest.me).")) + .font(.caption) + .foregroundStyle(.secondary) + .fixedSize(horizontal: false, vertical: true) + + Spacer(minLength: 0) + + Button(String(localized: "settings.browser.httpAllowlist.save", defaultValue: "Save")) { + saveBrowserInsecureHTTPAllowlist() + } + .buttonStyle(.bordered) + .controlSize(.small) + .disabled(!browserInsecureHTTPAllowlistHasUnsavedChanges) + .accessibilityIdentifier("SettingsBrowserHTTPAllowlistSaveButton") + } + + VStack(alignment: .leading, spacing: 8) { + Text(String(localized: "settings.browser.httpAllowlist.hint", defaultValue: "One host or wildcard per line (for example: localhost, 127.0.0.1, ::1, 0.0.0.0, *.localtest.me).")) + .font(.caption) + .foregroundStyle(.secondary) + + HStack { + Spacer(minLength: 0) + Button(String(localized: "settings.browser.httpAllowlist.save", defaultValue: "Save")) { + saveBrowserInsecureHTTPAllowlist() + } + .buttonStyle(.bordered) + .controlSize(.small) + .disabled(!browserInsecureHTTPAllowlistHasUnsavedChanges) + .accessibilityIdentifier("SettingsBrowserHTTPAllowlistSaveButton") + } + } + } + } + .padding(.horizontal, 14) + .padding(.vertical, 10) + + SettingsCardDivider() + + VStack(alignment: .leading, spacing: 12) { + VStack(alignment: .leading, spacing: 8) { + Text(String(localized: "settings.browser.import", defaultValue: "Import Browser Data")) + .font(.system(size: 13, weight: .semibold)) + + VStack(alignment: .leading, spacing: 6) { + Text(String(localized: "browser.import.hint.title", defaultValue: "Import browser data")) + .font(.system(size: 12.5, weight: .semibold)) + + Text(browserImportSubtitle) + .font(.caption) + .foregroundStyle(.secondary) + .fixedSize(horizontal: false, vertical: true) + + Text(String(localized: "browser.import.hint.settingsFootnote", defaultValue: "You can always find this in Settings > Browser.")) + .font(.system(size: 10.5)) + .foregroundStyle(.tertiary) + .fixedSize(horizontal: false, vertical: true) + } + .padding(.horizontal, 12) + .padding(.vertical, 10) + .frame(maxWidth: .infinity, alignment: .leading) + .background( + RoundedRectangle(cornerRadius: 10, style: .continuous) + .fill(Color(nsColor: .controlBackgroundColor)) + ) + .overlay( + RoundedRectangle(cornerRadius: 10, style: .continuous) + .stroke(Color(nsColor: .separatorColor).opacity(0.4), lineWidth: 1) + ) + } + + HStack(spacing: 8) { + Button(String(localized: "settings.browser.import.choose", defaultValue: "Choose…")) { + DispatchQueue.main.async { + BrowserDataImportCoordinator.shared.presentImportDialog() + refreshDetectedImportBrowsers() + } + } + .buttonStyle(.bordered) + .controlSize(.small) + .accessibilityIdentifier("SettingsBrowserImportChooseButton") + + Button(String(localized: "settings.browser.import.refresh", defaultValue: "Refresh")) { + refreshDetectedImportBrowsers() + } + .buttonStyle(.bordered) + .controlSize(.small) + } + .accessibilityIdentifier("SettingsBrowserImportActions") + + Toggle( + String(localized: "settings.browser.import.hint.show", defaultValue: "Show import hint on blank browser tabs"), + isOn: browserImportHintVisibilityBinding + ) + .controlSize(.small) + .accessibilityIdentifier("SettingsBrowserImportHintToggle") + + Text(browserImportHintSettingsNote) + .font(.caption) + .foregroundStyle(.secondary) + .fixedSize(horizontal: false, vertical: true) + } + .id(SettingsNavigationTarget.browserImport) + .accessibilityIdentifier("SettingsBrowserImportSection") + .padding(.horizontal, 14) + .padding(.vertical, 10) + + SettingsCardDivider() + + SettingsCardRow(String(localized: "settings.browser.history", defaultValue: "Browsing History"), subtitle: browserHistorySubtitle) { + Button(String(localized: "settings.browser.history.clearButton", defaultValue: "Clear History…")) { + showClearBrowserHistoryConfirmation = true + } + .buttonStyle(.bordered) + .controlSize(.small) + .disabled(browserHistoryEntryCount == 0) + } + } + + } + + @ViewBuilder + private var keyboardShortcutsSection: some View { + SettingsSectionHeader(title: String(localized: "settings.section.keyboardShortcuts", defaultValue: "Keyboard Shortcuts")) + .id(SettingsNavigationTarget.keyboardShortcuts) + .accessibilityIdentifier("SettingsKeyboardShortcutsSection") + SettingsCard { + SettingsCardRow( + String(localized: "settings.shortcuts.chords", defaultValue: "Shortcut Chords"), + subtitle: String(localized: "settings.shortcuts.chords.subtitle", defaultValue: "Add tmux-style multi-step shortcuts in settings.json, for example [\"ctrl+b\", \"c\"].") + ) { + HStack(spacing: 8) { + Link(String(localized: "settings.shortcuts.chords.docsButton", defaultValue: "Chord docs"), destination: shortcutChordsDocsURL) + .font(.caption) + .accessibilityIdentifier("SettingsKeyboardShortcutsChordDocsLink") + + Button(String(localized: "settings.app.settingsFile.openButton", defaultValue: "Open settings.json")) { + openProgramaSettingsFileInTextEdit() + } + .buttonStyle(.bordered) + .controlSize(.small) + .accessibilityIdentifier("SettingsKeyboardShortcutsOpenSettingsFileButton") + } + } + + SettingsCardDivider() + + SettingsCardRow( + String(localized: "settings.shortcuts.showHints", defaultValue: "Show Cmd/Ctrl-Hold Shortcut Hints"), + subtitle: showShortcutHintsOnCommandHold + ? String(localized: "settings.shortcuts.showHints.subtitleOn", defaultValue: "Holding Cmd (sidebar/titlebar) or Ctrl/Cmd (pane tabs) shows shortcut hint pills.") + : String(localized: "settings.shortcuts.showHints.subtitleOff", defaultValue: "Holding Cmd or Ctrl keeps shortcut hint pills hidden.") + ) { + Toggle("", isOn: $showShortcutHintsOnCommandHold) + .labelsHidden() + .controlSize(.small) + } + + SettingsCardDivider() + + let actions = KeyboardShortcutSettings.Action.allCases + ForEach(Array(actions.enumerated()), id: \.element.id) { index, action in + ShortcutSettingRow(action: action) + .padding(.horizontal, 14) + .padding(.vertical, 9) + if index < actions.count - 1 { + SettingsCardDivider() + } + } + } + .id(shortcutResetToken) + + Text(String(localized: "settings.shortcuts.recordHint", defaultValue: "Click a shortcut value to record a new shortcut.")) + .font(.caption) + .foregroundColor(.secondary) + .padding(.leading, 2) + .accessibilityIdentifier("ShortcutRecordingHint") + + } + + @ViewBuilder + private var resetSection: some View { + SettingsSectionHeader(title: String(localized: "settings.section.reset", defaultValue: "Reset")) + SettingsCard { + HStack { + Spacer(minLength: 0) + Button(String(localized: "settings.reset.resetAll", defaultValue: "Reset All Settings")) { + resetAllSettings() + } + .buttonStyle(.bordered) + .controlSize(.regular) + Spacer(minLength: 0) + } + .padding(.horizontal, 14) + .padding(.vertical, 10) + } + } + + private func resetAllSettings() { + appearanceMode = AppearanceSettings.defaultMode.rawValue + socketControlMode = SocketControlSettings.defaultMode.rawValue + claudeCodeHooksEnabled = ClaudeCodeIntegrationSettings.defaultHooksEnabled + customClaudePath = "" + preferredEditorCommand = "" + browserSearchEngine = BrowserSearchSettings.defaultSearchEngine.rawValue + browserSearchSuggestionsEnabled = BrowserSearchSettings.defaultSearchSuggestionsEnabled + browserThemeMode = BrowserThemeSettings.defaultMode.rawValue + showBrowserImportHintOnBlankTabs = BrowserImportHintSettings.defaultShowOnBlankTabs + isBrowserImportHintDismissed = BrowserImportHintSettings.defaultDismissed + openTerminalLinksInProgramaBrowser = BrowserLinkOpenSettings.defaultOpenTerminalLinksInProgramaBrowser + interceptTerminalOpenCommandInProgramaBrowser = BrowserLinkOpenSettings.defaultInterceptTerminalOpenCommandInProgramaBrowser + browserHostWhitelist = BrowserLinkOpenSettings.defaultBrowserHostWhitelist + browserExternalOpenPatterns = BrowserLinkOpenSettings.defaultBrowserExternalOpenPatterns + browserInsecureHTTPAllowlist = BrowserInsecureHTTPSettings.defaultAllowlistText + browserInsecureHTTPAllowlistDraft = BrowserInsecureHTTPSettings.defaultAllowlistText + notificationSound = NotificationSoundSettings.defaultValue + notificationSoundCustomFilePath = NotificationSoundSettings.defaultCustomFilePath + notificationCustomSoundStatusMessage = nil + notificationCustomSoundStatusIsError = false + showNotificationCustomSoundErrorAlert = false + notificationCustomSoundErrorAlertMessage = "" + notificationCustomCommand = NotificationSoundSettings.defaultCustomCommand + showMenuBarExtra = MenuBarExtraSettings.defaultShowInMenuBar + warnBeforeQuitShortcut = QuitWarningSettings.defaultWarnBeforeQuit + sessionPersistScrollback = ScrollbackPersistenceSettings.defaultPersistScrollback + commandPaletteRenameSelectAllOnFocus = CommandPaletteRenameSelectionSettings.defaultSelectAllOnFocus + commandPaletteSearchAllSurfaces = CommandPaletteSwitcherSearchSettings.defaultSearchAllSurfaces + ShortcutHintDebugSettings.resetVisibilityDefaults() + alwaysShowShortcutHints = ShortcutHintDebugSettings.defaultAlwaysShowHints + newWorkspacePlacement = WorkspacePlacementSettings.defaultPlacement.rawValue + workspacePresentationMode = WorkspacePresentationModeSettings.defaultMode.rawValue + closeWorkspaceOnLastSurfaceShortcut = LastSurfaceCloseShortcutSettings.defaultValue + paneFirstClickFocusEnabled = PaneFirstClickFocusSettings.defaultEnabled + workspaceAutoReorder = WorkspaceAutoReorderSettings.defaultValue + sidebarActiveTabIndicatorStyle = SidebarActiveTabIndicatorSettings.defaultStyle.rawValue + sidebarSelectionColorHex = nil + sidebarNotificationBadgeColorHex = nil + showShortcutHintsOnCommandHold = ShortcutHintDebugSettings.defaultShowHintsOnCommandHold + sidebarTintHex = SidebarTintDefaults.hex + sidebarTintHexLight = nil + sidebarTintHexDark = nil + sidebarTintOpacity = SidebarTintDefaults.opacity + sidebarMatchTerminalBackground = false + showOpenAccessConfirmation = false + pendingOpenAccessMode = nil + socketPasswordDraft = "" + socketPasswordStatusMessage = nil + socketPasswordStatusIsError = false + refreshDetectedImportBrowsers() + KeyboardShortcutSettings.resetAll() + WorkspaceTabColorSettings.reset() + reloadWorkspaceTabColorSettings() + shortcutResetToken = UUID() + } + + private func baseTabColorHex(for name: String) -> String? { + WorkspaceTabColorSettings.defaultColorHex(named: name) + } + + private func removeWorkspaceColor(named name: String) { + WorkspaceTabColorSettings.removeColor(named: name) + reloadWorkspaceTabColorSettings() + } + + private func resetWorkspaceTabColors() { + WorkspaceTabColorSettings.reset() + reloadWorkspaceTabColorSettings() + } + + private func reloadWorkspaceTabColorSettings() { + workspaceTabPaletteEntries = WorkspaceTabColorSettings.palette() + } + + private func saveBrowserInsecureHTTPAllowlist() { + browserInsecureHTTPAllowlist = browserInsecureHTTPAllowlistDraft + } + + private func refreshDetectedImportBrowsers() { + detectedImportBrowsers = InstalledBrowserDetector.detectInstalledBrowsers() + } +} + +private struct SettingsTopOffsetPreferenceKey: PreferenceKey { + static var defaultValue: CGFloat = 0 + + static func reduce(value: inout CGFloat, nextValue: () -> CGFloat) { + value = nextValue() + } +} + +private struct SettingsTitleLeadingInsetReader: NSViewRepresentable { + @Binding var inset: CGFloat + + func makeNSView(context: Context) -> NSView { + let view = NSView(frame: .zero) + return view + } + + func updateNSView(_ nsView: NSView, context: Context) { + DispatchQueue.main.async { + guard let window = nsView.window else { return } + let buttons: [NSWindow.ButtonType] = [.closeButton, .miniaturizeButton, .zoomButton] + let maxX = buttons + .compactMap { window.standardWindowButton($0)?.frame.maxX } + .max() ?? 78 + let nextInset = maxX + 14 + if abs(nextInset - inset) > 0.5 { + inset = nextInset + } + } + } +} + +private struct ThemeWindowThumbnail: View { + let isDark: Bool + + var body: some View { + GeometryReader { geo in + let width = geo.size.width + let height = geo.size.height + + ZStack { + // Wallpaper background + if isDark { + LinearGradient( + colors: [Color(red: 0.1, green: 0.1, blue: 0.3), Color(red: 0.05, green: 0.05, blue: 0.1)], + startPoint: .topLeading, + endPoint: .bottomTrailing + ) + Path { path in + path.move(to: CGPoint(x: 0, y: height * 0.5)) + path.addQuadCurve(to: CGPoint(x: width, y: height), control: CGPoint(x: width * 0.5, y: height * 0.2)) + path.addLine(to: CGPoint(x: width, y: 0)) + path.addLine(to: CGPoint(x: 0, y: 0)) + } + .fill(LinearGradient(colors: [Color(red: 0.2, green: 0.2, blue: 0.6).opacity(0.5), .clear], startPoint: .topLeading, endPoint: .bottomTrailing)) + } else { + LinearGradient( + colors: [Color(red: 0.6, green: 0.8, blue: 0.95), Color(red: 0.2, green: 0.4, blue: 0.8)], + startPoint: .topLeading, + endPoint: .bottomTrailing + ) + Path { path in + path.move(to: CGPoint(x: 0, y: height * 0.5)) + path.addQuadCurve(to: CGPoint(x: width, y: height), control: CGPoint(x: width * 0.5, y: height * 0.2)) + path.addLine(to: CGPoint(x: width, y: 0)) + path.addLine(to: CGPoint(x: 0, y: 0)) + } + .fill(LinearGradient(colors: [Color(red: 0.8, green: 0.9, blue: 1.0).opacity(0.6), .clear], startPoint: .topLeading, endPoint: .bottomTrailing)) + } + + // Menu bar + VStack(spacing: 0) { + HStack { + Image(systemName: "applelogo") + .font(.system(size: max(height * 0.08, 6))) + .foregroundColor(isDark ? .white : .black) + .opacity(0.8) + Spacer() + } + .padding(.horizontal, max(width * 0.04, 4)) + .frame(height: max(height * 0.12, 8)) + .background(.ultraThinMaterial) + Spacer() + } + + // Back window + VStack(spacing: 0) { + Rectangle() + .fill(isDark ? Color(white: 0.2) : Color(white: 0.9)) + .frame(height: max(height * 0.15, 8)) + ZStack(alignment: .top) { + Rectangle() + .fill(isDark ? Color(white: 0.15) : Color(white: 0.98)) + RoundedRectangle(cornerRadius: max(width * 0.02, 2), style: .continuous) + .fill(Color.accentColor) + .frame(height: max(height * 0.12, 6)) + .padding(max(width * 0.04, 4)) + } + } + .clipShape(RoundedRectangle(cornerRadius: max(width * 0.04, 4), style: .continuous)) + .frame(width: width * 0.65, height: height * 0.45) + .shadow(color: .black.opacity(isDark ? 0.4 : 0.15), radius: 4, x: 0, y: 2) + .offset(x: -width * 0.08, y: -height * 0.1) + + // Front window with traffic lights + VStack(spacing: 0) { + ZStack { + Rectangle() + .fill(isDark ? Color(white: 0.18) : Color(white: 0.92)) + HStack(spacing: max(width * 0.025, 2)) { + Circle().fill(Color(red: 1.0, green: 0.37, blue: 0.34)).frame(width: max(width * 0.04, 3)) + Circle().fill(Color(red: 1.0, green: 0.74, blue: 0.18)).frame(width: max(width * 0.04, 3)) + Circle().fill(Color(red: 0.15, green: 0.79, blue: 0.25)).frame(width: max(width * 0.04, 3)) + Spacer() + } + .padding(.horizontal, max(width * 0.04, 4)) + } + .frame(height: max(height * 0.18, 10)) + Rectangle() + .fill(isDark ? Color(white: 0.1) : .white) + } + .clipShape(RoundedRectangle(cornerRadius: max(width * 0.05, 5), style: .continuous)) + .shadow(color: .black.opacity(isDark ? 0.5 : 0.2), radius: 6, x: 0, y: 3) + .frame(width: width * 0.75, height: height * 0.55) + .offset(x: width * 0.12, y: height * 0.2) + } + } + .clipShape(RoundedRectangle(cornerRadius: 14, style: .continuous)) + .overlay( + RoundedRectangle(cornerRadius: 14, style: .continuous) + .stroke(Color.primary.opacity(0.1), lineWidth: 1) + ) + } +} + +private struct ThemePickerRow: View { + let selectedMode: String + let onSelect: (AppearanceMode) -> Void + + private let thumbWidth: CGFloat = 76 + private let thumbHeight: CGFloat = 50 + + var body: some View { + HStack(alignment: .top, spacing: 12) { + Text(String(localized: "settings.app.theme", defaultValue: "Theme")) + .font(.system(size: 13, weight: .medium)) + .frame(maxWidth: .infinity, alignment: .leading) + + HStack(spacing: 8) { + ForEach(AppearanceMode.visibleCases) { mode in + let isSelected = selectedMode == mode.rawValue + Button { + onSelect(mode) + } label: { + VStack(spacing: 4) { + Group { + if mode == .system { + ZStack { + ThemeWindowThumbnail(isDark: false) + .mask( + GeometryReader { geo in + Rectangle() + .frame(width: geo.size.width / 2, height: geo.size.height) + .position(x: geo.size.width / 4, y: geo.size.height / 2) + } + ) + ThemeWindowThumbnail(isDark: true) + .mask( + GeometryReader { geo in + Rectangle() + .frame(width: geo.size.width / 2, height: geo.size.height) + .position(x: geo.size.width * 0.75, y: geo.size.height / 2) + } + ) + GeometryReader { geo in + Rectangle() + .fill(Color.primary.opacity(0.15)) + .frame(width: 1, height: geo.size.height) + .position(x: geo.size.width / 2, y: geo.size.height / 2) + } + } + } else { + ThemeWindowThumbnail(isDark: mode == .dark) + } + } + .frame(width: thumbWidth, height: thumbHeight) + + Text(mode.displayName) + .font(.system(size: 10)) + .fontWeight(isSelected ? .semibold : .regular) + .foregroundColor(isSelected ? .primary : .secondary) + } + .padding(.vertical, 8) + .padding(.horizontal, 10) + .contentShape(Rectangle()) + .background( + RoundedRectangle(cornerRadius: 8, style: .continuous) + .fill(isSelected + ? Color.accentColor.opacity(0.12) + : Color.clear) + ) + .overlay( + RoundedRectangle(cornerRadius: 8, style: .continuous) + .stroke(isSelected ? Color.accentColor : Color.clear, lineWidth: 2) + ) + } + .buttonStyle(.plain) + .focusable(false) + .accessibilityAddTraits(isSelected ? .isSelected : []) + } + } + .layoutPriority(1) + } + .padding(.horizontal, 14) + .padding(.vertical, 9) + .frame(maxWidth: .infinity, alignment: .leading) + } +} + + +private struct ShortcutSettingRow: View { + let action: KeyboardShortcutSettings.Action + @State private var shortcut: StoredShortcut + + init(action: KeyboardShortcutSettings.Action) { + self.action = action + _shortcut = State(initialValue: KeyboardShortcutSettings.shortcut(for: action)) + } + + var body: some View { + KeyboardShortcutRecorder( + label: action.label, + subtitle: KeyboardShortcutSettings.settingsFileManagedSubtitle(for: action), + shortcut: $shortcut, + displayString: { action.displayedShortcutString(for: $0) }, + transformRecordedShortcut: { action.normalizedRecordedShortcut($0) }, + isDisabled: KeyboardShortcutSettings.isManagedBySettingsFile(action) + ) + .onChange(of: shortcut) { newValue in + KeyboardShortcutSettings.setShortcut(newValue, for: action) + } + .onReceive(NotificationCenter.default.publisher(for: KeyboardShortcutSettings.didChangeNotification)) { _ in + let latest = KeyboardShortcutSettings.shortcut(for: action) + if latest != shortcut { + shortcut = latest + } + } + } +} + +struct SettingsRootView: View { + var body: some View { + SettingsView() + .background(WindowAccessor { window in + configureSettingsWindow(window) + }) + } + + private func configureSettingsWindow(_ window: NSWindow) { + window.identifier = NSUserInterfaceItemIdentifier("cmux.settings") + applyCurrentSettingsWindowStyle(to: window) + + let accessories = window.titlebarAccessoryViewControllers + for index in accessories.indices.reversed() { + guard let identifier = accessories[index].view.identifier?.rawValue else { continue } + guard identifier.hasPrefix("cmux.") else { continue } + window.removeTitlebarAccessoryViewController(at: index) + } + AppDelegate.shared?.applyWindowDecorations(to: window) + } + + private func applyCurrentSettingsWindowStyle(to window: NSWindow) { + SettingsAboutTitlebarDebugStore.shared.applyCurrentOptions(to: window, for: .settings) + } +} From 705d187c198b8e0fbd759321f68202f4d692664b Mon Sep 17 00:00:00 2001 From: arzafran Date: Thu, 9 Jul 2026 14:13:25 -0300 Subject: [PATCH 29/31] docs: document sidebar tint UserDefaults precedence between GhosttyConfig and settings.json MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit GhosttyConfig.applySidebarAppearanceToUserDefaults() (driven by the legacy ~/.config/ghostty/config file) and ProgramaSettingsFileStore.applyManagedSettings (driven by ~/.config/programa/settings.json) both write the sidebarTintHex, sidebarTintHexLight, sidebarTintHexDark, and sidebarTintOpacity UserDefaults keys, with no coordination between them. Traced the current effective behavior and documented it in place at both write sites rather than changing it: - At app launch, settings.json applies first (ProgramaApp.init() forces ProgramaSettingsFileStore.shared to initialize synchronously before any SwiftUI view exists); GhosttyConfig applies afterward, when the first WorkspaceContentView's @State config is constructed. - GhosttyConfig's write is sparse: it only touches a key when the parsed ghostty config file actually sets a value for it, otherwise leaving whatever settings.json (or a previous write) left in place. - After launch, both sides are independently reactive (settings.json on its file watcher and .reload() call sites; GhosttyConfig on view lifecycle events and notifications), so the effective winner per key is whichever side fired most recently, not a fixed precedence. Unifying this into one ordered apply path would mean coordinating two independently triggered reactive systems, which is a design change, not a file-reorg-safe edit — noting here instead per the escape hatch. Refs #100. --- Sources/GhosttyConfig.swift | 28 +++++++++++++++++++++++++ Sources/ProgramaSettingsFileStore.swift | 12 +++++++++++ 2 files changed, 40 insertions(+) diff --git a/Sources/GhosttyConfig.swift b/Sources/GhosttyConfig.swift index ac03572d5ec..d150435f81a 100644 --- a/Sources/GhosttyConfig.swift +++ b/Sources/GhosttyConfig.swift @@ -165,6 +165,34 @@ struct GhosttyConfig { } } + // NOTE (tint precedence, refs #100): `sidebarTintHex(Light/Dark)` and `sidebarTintOpacity` + // in UserDefaults are written from two independent sources that are never coordinated + // through a single ordered pipeline: + // 1. This method, driven by the legacy `~/.config/ghostty/config` `sidebar-background` / + // `sidebar-tint-opacity` directives (see `GhosttyConfig.load()` call sites). + // 2. `ProgramaSettingsFileStore.applyManagedSettings` (see ProgramaSettingsFileStore.swift), + // driven by `~/.config/programa/settings.json`'s `sidebarAppearance` block. + // Effective winner is "whichever wrote most recently," not a fixed precedence: + // - At app launch, settings.json applies first — `ProgramaApp.init()` forces + // `KeyboardShortcutSettings.settingsFileStore` (== ProgramaSettingsFileStore.shared) to + // initialize synchronously before any SwiftUI view exists. GhosttyConfig.load() only + // runs later, when the first WorkspaceContentView's `@State config` is constructed — + // so at launch, ghostty config (this method) wins for any key it actually sets. + // - This method is a SPARSE writer: it only overwrites a key when the parsed ghostty + // config actually specifies a value for it (see the guard above and the per-key + // `if let ... else removeObject` pairs below); if the user's ghostty config doesn't set + // `sidebar-background`, nothing here is touched and whatever settings.json (or a prior + // write) left in UserDefaults stands. + // - After launch both sides are reactive and can re-fire independently: settings.json + // re-applies on every file-watcher change to settings.json/its fallback (and on + // `.reload()` call sites such as GhosttyTerminalView.swift), while this method re-runs + // whenever `GhosttyConfig.load()` is called again (WorkspaceContentView's `onAppear`, + // `ghosttyConfigDidReload` notification, color-scheme changes). Whichever fires most + // recently wins, per key. + // Unifying this into one ordered apply path would mean coordinating two independently + // triggered reactive systems (a real design change, not a safe file-reorg edit) — flagging + // here instead of changing behavior. See also the analogous comment in + // ProgramaSettingsFileStore.swift's `applyManagedSettings`. func applySidebarAppearanceToUserDefaults() { guard rawSidebarBackground != nil else { if let opacity = sidebarTintOpacity { diff --git a/Sources/ProgramaSettingsFileStore.swift b/Sources/ProgramaSettingsFileStore.swift index b3bb3ca7c66..4b3da0a2cd7 100644 --- a/Sources/ProgramaSettingsFileStore.swift +++ b/Sources/ProgramaSettingsFileStore.swift @@ -854,6 +854,18 @@ final class ProgramaSettingsFileStore { return .some(normalized) } + // NOTE (tint precedence, refs #100): among the UserDefaults keys this writes via + // `applyManagedUserDefaultsValue` below, `sidebarTintHex(Light/Dark)` and + // `sidebarTintOpacity` are ALSO written independently by + // `GhosttyConfig.applySidebarAppearanceToUserDefaults()` (GhosttyConfig.swift), driven by + // the legacy `~/.config/ghostty/config` file rather than settings.json. There is no single + // ordered apply path between the two; the effective value for each key is whichever source + // wrote it most recently. See the detailed comment on + // `GhosttyConfig.applySidebarAppearanceToUserDefaults()` for the full load-order + // walkthrough (settings.json applies first at launch via this store's synchronous init, + // ghostty config applies afterward and again on view lifecycle events, and both re-fire + // reactively after that). Documented here rather than changed, since unifying them requires + // coordinating two independently triggered reactive systems, not a file-reorg-safe edit. private func applyManagedSettings( snapshot: ResolvedSettingsSnapshot, updateBackups: Bool = true From aac48260f5e2233570a6a2b2188a41ab84260b38 Mon Sep 17 00:00:00 2001 From: arzafran Date: Thu, 9 Jul 2026 14:20:27 -0300 Subject: [PATCH 30/31] 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 31/31] 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. }