diff --git a/GhosttyTabs.xcodeproj/project.pbxproj b/GhosttyTabs.xcodeproj/project.pbxproj index b9037f77d21..f01630c903e 100644 --- a/GhosttyTabs.xcodeproj/project.pbxproj +++ b/GhosttyTabs.xcodeproj/project.pbxproj @@ -11,6 +11,12 @@ 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 */; }; + 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 */; }; + 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 */; }; @@ -64,6 +70,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 */; }; @@ -243,6 +257,12 @@ 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 = ""; }; + 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 = ""; }; + 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 = ""; }; @@ -296,6 +316,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 = ""; }; @@ -520,6 +548,12 @@ A5FF0017 /* SettingDefinition.swift */, A5001012 /* ContentView.swift */, A5FF0015 /* ContentView+CommandPalette.swift */, + NRCV00000000000000000006 /* TabItemView.swift */, + NRCV00000000000000000004 /* FileDropOverlayView.swift */, + NRCV00000000000000000002 /* CommandPaletteSearchEngine.swift */, + NRCV00000000000000000008 /* SidebarDragDrop.swift */, + NRCV00000000000000000010 /* SidebarVisuals.swift */, + NRCV00000000000000000012 /* CommandPaletteController.swift */, 9AD52285508B1D6A9875E7B3 /* SidebarSelectionState.swift */, B9000017A1B2C3D4E5F60719 /* WindowDragHandleView.swift */, A50012F0 /* Backport.swift */, @@ -562,6 +596,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 */, @@ -858,6 +900,12 @@ A5FF0007 /* SettingDefinition.swift in Sources */, A5001002 /* ContentView.swift in Sources */, A5FF0005 /* ContentView+CommandPalette.swift in Sources */, + 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 */, + NRCV00000000000000000011 /* CommandPaletteController.swift in Sources */, E62155868BB29FEB5DAAAF25 /* SidebarSelectionState.swift in Sources */, B9000018A1B2C3D4E5F60719 /* WindowDragHandleView.swift in Sources */, A50012F1 /* Backport.swift in Sources */, @@ -899,6 +947,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..779cc7268c3 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,11802 +48,8928 @@ 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 +} - static func availableTargets(in environment: DetectionEnvironment = .live) -> Set { - Set(commandPaletteShortcutTargets.filter { $0.isAvailable(in: environment) }) +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 } - - var commandPaletteCommandId: String { - "palette.terminalOpenDirectory.\(rawValue)" + if normalizedFlags == [.shift] { + return mode != "workspace_description_input" } + 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 - } - - return nil +func commandPaletteFieldEditorHasMarkedText(in window: NSWindow) -> Bool { + if let editor = window.firstResponder as? NSTextView { + return editor.hasMarkedText() } - - 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) + if let textField = window.firstResponder as? NSTextField, + let editor = textField.currentEditor() as? NSTextView { + return editor.hasMarkedText() } + return false +} - private var applicationSearchNames: [String] { - uniquePreservingOrder( - applicationBundlePathCandidates.map { - URL(fileURLWithPath: $0).deletingPathExtension().lastPathComponent - } - ) +func shouldHandleCommandPaletteShortcutEvent( + _ event: NSEvent, + paletteWindow: NSWindow? +) -> Bool { + guard let paletteWindow else { return false } + if let eventWindow = event.window { + return eventWindow === paletteWindow } - - 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 + let eventWindowNumber = event.windowNumber + if eventWindowNumber > 0 { + return eventWindowNumber == paletteWindow.windowNumber + } + if let keyWindow = NSApp.keyWindow { + return keyWindow === paletteWindow } + return false } -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 - } +enum BrowserZoomShortcutAction: Equatable { + case zoomIn + case zoomOut + case reset +} - 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 CommandPaletteDebugResultRow { + let commandId: String + let title: String + let shortcutHint: String? + let trailingLabel: String? + let score: Int } -struct VSCodeCLILaunchConfiguration { - let executableURL: URL - let argumentsPrefix: [String] - let environment: [String: String] +struct CommandPaletteDebugSnapshot { + let query: String + let mode: String + let results: [CommandPaletteDebugResultRow] + + static let empty = CommandPaletteDebugSnapshot(query: "", mode: "commands", results: []) } -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 - ) - } +/// 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 } -final class VSCodeServeWebController { - static let shared = VSCodeServeWebController() - private static let serveWebStartupTimeoutSeconds: TimeInterval = 60 +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]) - 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 + guard hasOnlyCommandAndOptionalShift else { return nil } + let keys = browserZoomShortcutKeyCandidates( + chars: chars, + literalChars: literalChars, + keyCode: keyCode + ) - private init(launchProcessOverride: ((URL, UInt64) -> (process: Process, url: URL)?)? = nil) { - self.launchProcessOverride = launchProcessOverride + if keys.contains("=") || keys.contains("+") || keyCode == 24 || keyCode == 69 { // kVK_ANSI_Equal / kVK_ANSI_KeypadPlus + return .zoomIn } -#if DEBUG - static func makeForTesting( - launchProcessOverride: @escaping (URL, UInt64) -> (process: Process, url: URL)? - ) -> VSCodeServeWebController { - VSCodeServeWebController(launchProcessOverride: launchProcessOverride) + if keys.contains("-") || keys.contains("_") || keyCode == 27 || keyCode == 78 { // kVK_ANSI_Minus / kVK_ANSI_KeypadMinus + return .zoomOut } - 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 keys.contains("0") || keyCode == 29 || keyCode == 82 { // kVK_ANSI_0 / kVK_ANSI_Keypad0 + return .reset } -#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 + return nil +} - 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 - } +func browserZoomShortcutKeyCandidates( + chars: String, + literalChars: String?, + keyCode: UInt16 +) -> Set { + var keys: Set = [chars.lowercased()] - if let launchResult { - self.launchingProcess = nil - self.serveWebProcess = launchResult.process - self.serveWebURL = launchResult.url - } else { - self.launchingProcess = nil - self.serveWebProcess = nil - self.serveWebURL = nil - } + if let literalChars, !literalChars.isEmpty { + keys.insert(literalChars.lowercased()) + } - 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) } - } - } - } - } + if let layoutChar = KeyboardLayout.character(forKeyCode: keyCode), !layoutChar.isEmpty { + keys.insert(layoutChar) } - 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) - } + return keys +} - for tokenFileURL in tokenFileURLs where tokenFileURL.path.hasPrefix(NSTemporaryDirectory()) { - Self.removeConnectionTokenFile(at: tokenFileURL) - } +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 +} - for process in processes where process.isRunning { - process.terminate() - } +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 +} - if !completions.isEmpty { - DispatchQueue.main.async { - completions.forEach { $0(nil) } - } - } +@discardableResult +func startOrFocusTerminalSearch( + _ terminalSurface: TerminalSurface, + searchFocusNotifier: @escaping (TerminalSurface) -> Void = { + NotificationCenter.default.post(name: .ghosttySearchFocus, object: $0) } - - func restart(vscodeApplicationURL: URL, completion: @escaping (URL?) -> Void) { - stop() - ensureServeWebURL(vscodeApplicationURL: vscodeApplicationURL, completion: completion) +) -> Bool { + if terminalSurface.searchState != nil { + searchFocusNotifier(terminalSurface) + return true } - private func launchServeWebProcess( - vscodeApplicationURL: URL, - expectedGeneration: UInt64 - ) -> (process: Process, url: URL)? { - if let launchProcessOverride { - return launchProcessOverride(vscodeApplicationURL, expectedGeneration) + 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 + } - guard let launchConfiguration = VSCodeCLILaunchConfigurationBuilder.launchConfiguration( - vscodeApplicationURL: vscodeApplicationURL - ) else { return nil } + terminalSurface.searchState = TerminalSurface.SearchState() + searchFocusNotifier(terminalSurface) + return true +} - guard let connectionTokenFileURL = Self.makeConnectionTokenFile() else { - return nil - } +/// 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 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 normalizedFlags = flags.subtracting([.numericPad, .function, .capsLock]) + if event.keyCode == 50, + normalizedFlags == [.command] || normalizedFlags == [.command, .shift] { + return false + } - 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 - } + return true +} - 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 - } +private enum BrowserFindCommandEquivalent { + case find + case findNext + case findPrevious + case hideFind + case useSelection - // #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) + var keepsProgramaBrowserFindBarOwnershipWhenVisible: Bool { + switch self { + case .find, .findNext, .findPrevious, .hideFind: + return true + case .useSelection: + return false } - - 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) - } +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 } - - /// 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) + 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 randomConnectionToken() -> String { - UUID().uuidString.replacingOccurrences(of: "-", with: "") - } +private func browserFindCommandEquivalent(for event: NSEvent) -> BrowserFindCommandEquivalent? { + let flags = event.modifierFlags + .intersection(.deviceIndependentFlagsMask) + .subtracting([.numericPad, .function, .capsLock]) - /// 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 - } + 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 } - return makeEphemeralConnectionTokenFile() + return false } - 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 + switch flags { + case [.command]: + if matches("e", keyCode: 14) { // kVK_ANSI_E + return .useSelection } - 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 matches("f", keyCode: 3) { // kVK_ANSI_F + return .find } - guard wroteAllBytes else { - try? FileManager.default.removeItem(at: tokenFileURL) - return nil + if matches("g", keyCode: 5) { // kVK_ANSI_G + return .findNext } - 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 + return nil + case [.command, .shift]: + if matches("f", keyCode: 3) { // kVK_ANSI_F + return .hideFind } - guard wroteAllBytes else { - removeConnectionTokenFile(at: tokenFileURL) - return nil + if matches("g", keyCode: 5) { // kVK_ANSI_G + return .findPrevious } - return tokenFileURL + return nil + default: + return nil } +} - private static func removeConnectionTokenFile(at url: URL) { - try? FileManager.default.removeItem(at: url) +/// 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 } -} -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 - } + return true } -/// 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) +func cmuxOwningGhosttyView(for responder: NSResponder?) -> GhosttyNSView? { + guard let responder else { return nil } + if let ghosttyView = responder as? GhosttyNSView { + return ghosttyView } - /// 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) + if let view = responder as? NSView, + let ghosttyView = cmuxOwningGhosttyView(for: view) { + return ghosttyView } - /// 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 - } + if let textView = responder as? NSTextView { + if textView.isFieldEditor, + let ownerView = programaFieldEditorOwnerView(textView), + let ghosttyView = cmuxOwningGhosttyView(for: ownerView) { + 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)) - } + if !textView.isFieldEditor, + let delegateView = textView.delegate as? NSView, + let ghosttyView = cmuxOwningGhosttyView(for: delegateView) { + return ghosttyView } - 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) + 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 + } + current = next.nextResponder } + + 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 } +private func programaFieldEditorOwnerView(_ editor: NSTextView) -> NSView? { + guard editor.isFieldEditor else { return nil } - if digit == 9 { - return workspaceCount - 1 + var current = editor.nextResponder + while let next = current { + if let view = next as? NSView { + return view } - - let index = digit - 1 - return index < workspaceCount ? index : nil + current = next.nextResponder } - /// 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 - } + return editor.superview } -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 - ) - } +private func cmuxOwningGhosttyView(for view: NSView) -> GhosttyNSView? { + if let ghosttyView = view as? GhosttyNSView { + return ghosttyView } - 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 - ) + var current: NSView? = view.superview + while let candidate = current { + if let ghosttyView = candidate as? GhosttyNSView { + return ghosttyView } + current = candidate.superview } - 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) - } + return nil +} - var isDirectory: ObjCBool = false - guard fileManager.fileExists(atPath: sourceURL.path, isDirectory: &isDirectory), !isDirectory.boolValue else { - throw InstallerError.bundledCLIMissing(expectedPath: sourceURL.path) - } - return sourceURL - } +#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 } - 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) + let keys = browserZoomShortcutKeyCandidates( + chars: chars, + literalChars: literalChars, + keyCode: keyCode + ) + if keys.contains("=") || keys.contains("+") || keys.contains("-") || keys.contains("_") || keys.contains("0") { + return true } - - @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 + switch keyCode { + case 24, 27, 29, 69, 78, 82: // ANSI and keypad zoom keys + return true + default: + return false } +} - /// 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 - } +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: "+") +} - private func verifyInstalledSymlinkTarget(sourceURL: URL) throws { - guard let installedTargetURL = symlinkDestinationURL(), - installedTargetURL == sourceURL.standardizedFileURL else { - throw InstallerError.installVerificationFailed(path: destinationURL.path) - } +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 - 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 +func shouldSuppressWindowMoveForFolderDrag(hitView: NSView?) -> Bool { + var candidate = hitView + while let view = candidate { + if view is DraggableFolderNSView { + return true } - return URL( - fileURLWithPath: destinationPath, - relativeTo: destinationURL.deletingLastPathComponent() - ).standardizedFileURL + candidate = view.superview } + return false +} - 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) +func shouldSuppressWindowMoveForFolderDrag(window: NSWindow, event: NSEvent) -> Bool { + guard event.type == .leftMouseDown, + window.isMovable, + let contentView = window.contentView else { + return false } - 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) - } - } + let contentPoint = contentView.convert(event.locationInWindow, from: nil) + let hitView = contentView.hitTest(contentPoint) + return shouldSuppressWindowMoveForFolderDrag(hitView: hitView) +} - 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 - } - } +@MainActor +final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCenterDelegate, NSMenuItemValidation { + nonisolated(unsafe) static var shared: AppDelegate? - private static func defaultBundledCLIURL(bundle: Bundle = .main) -> URL? { - bundle.resourceURL?.appendingPathComponent("bin/programa", isDirectory: false) - } + private static let cachedIsRunningUnderXCTest = detectRunningUnderXCTest(ProcessInfo.processInfo.environment) - private static func defaultBundledCLIExpectedPath(bundle: Bundle = .main) -> String { - bundle.bundleURL - .appendingPathComponent("Contents/Resources/bin/programa", isDirectory: false) - .path + private var isRunningUnderXCTestCached: Bool { + Self.cachedIsRunningUnderXCTest } - 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 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 static func uninstallWithAdministratorPrivileges(destinationURL: URL) throws { - let command = "/bin/rm -f \(shellQuoted(destinationURL.path))" - try runPrivilegedShellCommand(command) + 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 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 - } + final class MainWindowContext { + let windowId: UUID + let tabManager: TabManager + let sidebarState: SidebarState + let sidebarSelectionState: SidebarSelectionState + weak var window: NSWindow? - if error.domain == NSCocoaErrorDomain { - switch error.code { - case NSFileWriteNoPermissionError, NSFileReadNoPermissionError, NSFileWriteVolumeReadOnlyError: - return true - default: - break - } + 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 } + } - if let underlying = error.userInfo[NSUnderlyingErrorKey] as? NSError { - return isPermissionDenied(underlying) - } + private final class MainWindowController: NSWindowController, NSWindowDelegate { + var onClose: (() -> Void)? - return false + func windowWillClose(_ notification: Notification) { + onClose?() + } } -} -private extension NSScreen { - var programaDisplayID: UInt32? { - let key = NSDeviceDescriptionKey("NSScreenNumber") - guard let value = deviceDescription[key] as? NSNumber else { return nil } - return value.uint32Value + struct ScriptableMainWindowState { + let windowId: UUID + let tabManager: TabManager + let window: NSWindow? } -} - -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 -} -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 + struct SessionDisplayGeometry { + let displayID: UInt32? + let frame: CGRect + let visibleFrame: CGRect } -} - -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] -} + struct PersistedWindowGeometry: Codable, Sendable { + let version: Int + let frame: SessionRectSnapshot + let display: SessionDisplaySnapshot? + } -func browserResponderHasMarkedText(_ responder: NSResponder?) -> Bool { - guard let responder else { return false } + nonisolated static let persistedWindowGeometrySchemaVersion = 2 + private nonisolated static let persistedWindowGeometryDefaultsKey = "programa.session.lastWindowGeometry.v2" + private nonisolated static let legacyPersistedWindowGeometryDefaultsKeys = [ + "programa.session.lastWindowGeometry.v1" + ] - // 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 } + 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 + } + 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 + } + 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) + }() - if let textField = responder as? NSTextField, - let editor = textField.currentEditor() as? NSTextView { - if editor.hasMarkedText() { return true } +#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 } + var debugCloseMainWindowConfirmationHandler: ((NSWindow) -> Bool)? + var debugCreateMainWindowSourceIsNativeFullScreenOverride: Bool? + // Keep debug-only windows alive when tests intentionally inject key mismatches. + private var debugDetachedContextWindows: [NSWindow] = [] - // 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 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 } - 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 + private func childExitKeyboardProbeHex(_ value: String?) -> String { + guard let value else { return "" } + return value.unicodeScalars + .map { String(format: "%04X", $0.value) } + .joined(separator: ",") } -} -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) -} - -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 + 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 - // 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" + var mainWindowContexts: [ObjectIdentifier: MainWindowContext] = [:] + private var mainWindowControllers: [MainWindowController] = [] + + /// 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) } + 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 commandPaletteStateByWindowId: [UUID: CommandPaletteWindowState] = [:] + private static let commandPaletteRequestGraceInterval: TimeInterval = 1.25 + private static let commandPalettePendingOpenMaxAge: TimeInterval = 8.0 + private static let sessionAutosaveTypingQuietPeriod: TimeInterval = 0.65 - // Keep ANSI fallback as a final safety net when layout translation is unavailable. - return keyCode == 3 -} + var updateViewModel: UpdateViewModel { + updateController.viewModel + } -func commandPaletteSelectionDeltaForKeyboardNavigation( - flags: NSEvent.ModifierFlags, - chars: String, - keyCode: UInt16 -) -> Int? { - let normalizedFlags = flags - .intersection(.deviceIndependentFlagsMask) - .subtracting([.numericPad, .function]) - let normalizedChars = chars.lowercased() +#if DEBUG + private func pointerString(_ object: AnyObject?) -> String { + guard let object else { return "nil" } + return String(describing: Unmanaged.passUnretained(object).toOpaque()) + } - 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 -} - -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 -} - -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] - - static let empty = CommandPaletteDebugSnapshot(query: "", mode: "commands", results: []) -} - -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 - ) - - if keys.contains("=") || keys.contains("+") || keyCode == 24 || keyCode == 69 { // kVK_ANSI_Equal / kVK_ANSI_KeypadPlus - return .zoomIn - } - - if keys.contains("-") || keys.contains("_") || keyCode == 27 || keyCode == 78 { // kVK_ANSI_Minus / kVK_ANSI_KeypadMinus - return .zoomOut - } - - if keys.contains("0") || keyCode == 29 || keyCode == 82 { // kVK_ANSI_0 / kVK_ANSI_Keypad0 - return .reset - } - - 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()) - } - - if let layoutChar = KeyboardLayout.character(forKeyCode: keyCode), !layoutChar.isEmpty { - keys.insert(layoutChar) - } - - 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 -} - -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 -} - -@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 - } - - 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 - } - - 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 normalizedFlags = flags.subtracting([.numericPad, .function, .capsLock]) - if event.keyCode == 50, - normalizedFlags == [.command] || normalizedFlags == [.command, .shift] { - return false - } - - return true -} - -private enum BrowserFindCommandEquivalent { - case find - case findNext - case findPrevious - case hideFind - case useSelection - - var keepsProgramaBrowserFindBarOwnershipWhenVisible: Bool { - switch self { - case .find, .findNext, .findPrevious, .hideFind: - return true - case .useSelection: - return false - } - } -} - -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 -} - -private func browserFindCommandEquivalent(for event: NSEvent) -> BrowserFindCommandEquivalent? { - let flags = event.modifierFlags - .intersection(.deviceIndependentFlagsMask) - .subtracting([.numericPad, .function, .capsLock]) - - 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 - } - return false - } - - switch flags { - case [.command]: - if matches("e", keyCode: 14) { // kVK_ANSI_E - return .useSelection - } - if matches("f", keyCode: 3) { // kVK_ANSI_F - return .find - } - if matches("g", keyCode: 5) { // kVK_ANSI_G - return .findNext - } - 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 - } - return nil - default: - return nil - } -} - -/// 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 - } - - if programaIsLikelyWebInspectorResponder(responder) { - return false - } - - if shortcut.keepsProgramaBrowserFindBarOwnershipWhenVisible, - let owningWebView { - let browserFindBarIsVisible = MainActor.assumeIsolated { - AppDelegate.shared?.browserFindBarIsVisible(for: owningWebView) == true - } - if browserFindBarIsVisible { - return false - } - } - - return true -} - -func cmuxOwningGhosttyView(for responder: NSResponder?) -> GhosttyNSView? { - guard let responder else { return nil } - if let ghosttyView = responder as? GhosttyNSView { - return ghosttyView - } - - if let view = responder as? NSView, - let ghosttyView = cmuxOwningGhosttyView(for: view) { - return ghosttyView - } - - if let textView = responder as? NSTextView { - if textView.isFieldEditor, - let ownerView = programaFieldEditorOwnerView(textView), - let ghosttyView = cmuxOwningGhosttyView(for: ownerView) { - return ghosttyView - } - - if !textView.isFieldEditor, - let delegateView = textView.delegate as? NSView, - let ghosttyView = cmuxOwningGhosttyView(for: delegateView) { - return ghosttyView - } - } - - 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 - } - current = next.nextResponder - } - - return nil -} - -private func programaFieldEditorOwnerView(_ editor: NSTextView) -> NSView? { - guard editor.isFieldEditor else { return nil } - - var current = editor.nextResponder - while let next = current { - if let view = next as? NSView { - return view - } - current = next.nextResponder - } - - return editor.superview -} - -private func cmuxOwningGhosttyView(for view: NSView) -> GhosttyNSView? { - if let ghosttyView = view as? GhosttyNSView { - return ghosttyView - } - - var current: NSView? = view.superview - while let candidate = current { - if let ghosttyView = candidate as? GhosttyNSView { - return ghosttyView - } - current = candidate.superview - } - - return nil -} - -#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" - } -} -#endif - -func shouldSuppressWindowMoveForFolderDrag(hitView: NSView?) -> Bool { - var candidate = hitView - while let view = candidate { - if view is DraggableFolderNSView { - return true - } - 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 - } - - 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 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 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) - } - - 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? - ) { - self.windowId = windowId - self.tabManager = tabManager - self.sidebarState = sidebarState - self.sidebarSelectionState = sidebarSelectionState - self.window = window - } - } - - private final class MainWindowController: NSWindowController, NSWindowDelegate { - var onClose: (() -> Void)? - - func windowWillClose(_ notification: Notification) { - onClose?() - } - } - - struct ScriptableMainWindowState { - let windowId: UUID - let tabManager: TabManager - let window: NSWindow? - } - - struct SessionDisplayGeometry { - let displayID: UInt32? - let frame: CGRect - let visibleFrame: CGRect - } - - struct PersistedWindowGeometry: Codable, Sendable { - let version: Int - let frame: SessionRectSnapshot - let display: SessionDisplaySnapshot? - } - - 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 - } - 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 - } - 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) - }() - -#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 - } - 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 - } - return path - } - - private func childExitKeyboardProbeHex(_ value: String?) -> String { - guard let value else { return "" } - return value.unicodeScalars - .map { String(format: "%04X", $0.value) } - .joined(separator: ",") - } - - 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 - - var mainWindowContexts: [ObjectIdentifier: MainWindowContext] = [:] - private var mainWindowControllers: [MainWindowController] = [] - - /// 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) - } - 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 - } - -#if DEBUG - private func pointerString(_ object: AnyObject?) -> String { - guard let object else { return "nil" } - return String(describing: Unmanaged.passUnretained(object).toOpaque()) - } - - 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))" - } - - private func summarizeAllContextsForWorkspaceRouting() -> String { - guard !mainWindowContexts.isEmpty else { return "" } - return mainWindowContexts.values - .map { summarizeContextForWorkspaceRouting($0) } - .joined(separator: " | ") - } - - 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 - - override init() { - super.init() - Self.shared = self - } - - func application(_ application: NSApplication, open urls: [URL]) { - let directories = externalOpenDirectories(from: urls) - guard !directories.isEmpty else { return } - - prepareForExplicitOpenIntentAtStartup() - for directory in directories { - openWorkspaceForExternalDirectory( - workingDirectory: directory, - debugSource: "application.openURLs" - ) - } - } - - func applicationDidFinishLaunching(_ notification: Notification) { - let env = ProcessInfo.processInfo.environment - let isRunningUnderXCTest = isRunningUnderXCTest(env) - - 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 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 - -#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") - } -#endif - - let forceDuplicateLaunchObserver = env["PROGRAMA_UI_TEST_ENABLE_DUPLICATE_LAUNCH_OBSERVER"] == "1" - - // 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) - } - } - - // 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 - } - -#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"] ?? "" - - 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) - - guard let data = try? JSONSerialization.data(withJSONObject: payload) else { return } - try? data.write(to: URL(fileURLWithPath: path), options: .atomic) - } - - 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 appendUITestSocketDiagnosticsIfNeeded( - _ payload: inout [String: String], - environment env: [String: String] - ) { - guard env["PROGRAMA_UI_TEST_SOCKET_SANITY"] == "1" else { return } - - 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 - } - - 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") - } - - 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: ",") - } - - 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 - } - - 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) - } - - private func currentUITestRenderDiagnostics() -> UITestRenderDiagnosticsSnapshot? { - guard let tabManager, - let tabId = tabManager.selectedTabId, - let workspace = tabManager.tabs.first(where: { $0.id == tabId }) else { - return nil - } - - 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 - ) - } - - 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 - } - - 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 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 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 - } - self.writeUITestDiagnosticsIfNeeded(stage: "afterMoveToTargetDisplay") - } -#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 } - - 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) - } - - 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 the user already confirmed via the Cmd+Q shortcut warning dialog - // (handleQuitShortcutWarning), skip the check to avoid a second alert. - if isQuitWarningConfirmed { - return .terminateNow - } - - // 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 - } - - // 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 .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() - _ = 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() - } - } -#endif - } - - - private func prepareStartupSessionSnapshotIfNeeded() { - guard !didPrepareStartupSessionSnapshot else { return } - didPrepareStartupSessionSnapshot = true - guard SessionRestorePolicy.shouldAttemptRestore() else { return } - Self.removeLegacyPersistedWindowGeometry() - startupSessionSnapshot = SessionPersistenceStore.load() - } - - 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 - } - - 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 - } - 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 - } - - private nonisolated static func removeLegacyPersistedWindowGeometry( - defaults: UserDefaults = .standard - ) { - legacyPersistedWindowGeometryDefaultsKeys.forEach { defaults.removeObject(forKey: $0) } - } - - private func persistWindowGeometry(from window: NSWindow?) { - guard let window else { return } - persistWindowGeometry( - frame: SessionRectSnapshot(window.frame), - display: displaySnapshot(for: window) - ) - } - - private func currentDisplayGeometries() -> ( - available: [SessionDisplayGeometry], - fallback: SessionDisplayGeometry? - ) { - let available = NSScreen.screens.map { screen in - SessionDisplayGeometry( - displayID: screen.programaDisplayID, - frame: screen.frame, - visibleFrame: screen.visibleFrame - ) - } - let fallback = (NSScreen.main ?? NSScreen.screens.first).map { screen in - SessionDisplayGeometry( - displayID: screen.programaDisplayID, - frame: screen.frame, - visibleFrame: screen.visibleFrame - ) - } - 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) - } - } - - 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() - } - } - } - - private func completeStartupSessionRestore() { - startupSessionSnapshot = nil - isApplyingStartupSessionRestore = false - _ = saveSessionSnapshot(includeScrollback: false) - } - - 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 - - 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 resolvedWindowFrame(from snapshot: SessionWindowSnapshot?) -> NSRect? { - let displays = currentDisplayGeometries() - return Self.resolvedWindowFrame( - from: snapshot?.frame, - display: snapshot?.display, - availableDisplays: displays.available, - fallbackDisplay: displays.fallback - ) - } - - 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 - } - - return resolvedWindowFrame( - from: fallbackFrame, - display: fallbackDisplaySnapshot, - availableDisplays: availableDisplays, - fallbackDisplay: fallbackDisplay - ) - } - - 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 - } - - let minWidth = CGFloat(SessionPersistencePolicy.minimumWindowWidth) - let minHeight = CGFloat(SessionPersistencePolicy.minimumWindowHeight) - guard frame.width >= minWidth, - frame.height >= minHeight else { - return nil - } - - guard !availableDisplays.isEmpty else { return frame } - - 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 - ) - } - - if let intersectingDisplay = availableDisplays.first(where: { $0.visibleFrame.intersects(frame) }) { - return clampFrame( - frame, - within: intersectingDisplay.visibleFrame, - minWidth: minWidth, - minHeight: minHeight - ) - } - - 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 - ) - } - - 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 - ) - } - - if let sourceReference = displaySnapshot?.visibleFrame?.cgRect ?? displaySnapshot?.frame?.cgRect { - return remappedFrame( - frame, - from: sourceReference, - to: targetDisplay.visibleFrame, - minWidth: minWidth, - minHeight: minHeight - ) - } - - 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 - } - - 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 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 - } - - guard let referenceRect = (snapshot.visibleFrame ?? snapshot.frame)?.cgRect else { - return nil - } - - 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 - } - - 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 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) - } - - 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 - - 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 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 - } - - 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) - } - - 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 - } - - 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) - ) - } - - 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") - } - sessionAutosaveTimer = timer - timer.resume() - } - - 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.isTerminatingApp = true - _ = self.saveSessionSnapshot(includeScrollback: true, removeWhenEmpty: 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) - } - } - } - 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()) - ) - - 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)" - ) - } -#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 - ) - } - -#if DEBUG - debugLogSessionSaveSnapshot(snapshot, includeScrollback: includeScrollback) -#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() { -#if DEBUG - dlog( - "session.save.skipped reason=typing_recent includeScrollback=0 source=\(source) " + - "retryMs=\(Int((remainingQuietPeriod * 1000).rounded()))" - ) -#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)" - ) - } -#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 -#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 - } - -#if DEBUG - let saveStart = ProcessInfo.processInfo.systemUptime -#endif - _ = saveSessionSnapshot(includeScrollback: false) -#if DEBUG - saveMs = (ProcessInfo.processInfo.systemUptime - saveStart) * 1000.0 -#endif - updateSessionAutosaveSaveState( - includeScrollback: false, - persistedAt: now, - fingerprint: autosaveFingerprint - ) - } - - fileprivate func recordTypingActivity() { - lastTypingActivityAt = ProcessInfo.processInfo.systemUptime - } - - nonisolated static func shouldWriteSessionSnapshotSynchronously( - isTerminatingApp: Bool, - includeScrollback: Bool - ) -> Bool { - isTerminatingApp && includeScrollback - } - - 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 - } - - return now.timeIntervalSince(lastPersistedAt) < maximumAutosaveSkippableInterval - } - - private func updateSessionAutosaveSaveState( - includeScrollback: Bool, - persistedAt: Date, - fingerprint: Int? - ) { - guard !isTerminatingApp, !includeScrollback else { return } - lastSessionAutosaveFingerprint = fingerprint - lastSessionAutosavePersistedAt = persistedAt - } - - 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 persistSessionSnapshot( - _ snapshot: AppSessionSnapshot?, - removeWhenEmpty: Bool, - persistedGeometryData: Data?, - synchronously: Bool - ) { - 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() - } - } - - if synchronously { - writeBlock() - } else { - sessionPersistenceQueue.async(execute: writeBlock) - } - } - - 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 + // 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() } - return lhs.windowId.uuidString < rhs.windowId.uuidString - } - - 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)) - ) - ) + } 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() } - - guard !windows.isEmpty else { return nil } - return AppSessionSnapshot( - version: SessionSnapshotSchema.currentVersion, - createdAt: Date().timeIntervalSince1970, - windows: windows - ) - } - -#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)" - ) } - } - - 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)))" - } - - 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))}" - } - - private func debugSessionNumber(_ value: Double) -> String { - String(format: "%.1f", value) - } -#endif - - private func notifyMainWindowContextsDidChange() { - NotificationCenter.default.post(name: .mainWindowContextsDidChange, object: self) - } - - /// 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 - - 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) - } + NSWindow.allowsAutomaticWindowTabbing = false + disableNativeTabbingShortcut() + ensureApplicationIcon() + if !isRunningUnderXCTest { + configureUserNotifications() + installMenuBarVisibilityObserver() + syncMenuBarExtraVisibility() + updateController.startUpdaterIfNeeded() } - commandPaletteVisibilityByWindowId[windowId] = false - commandPaletteSelectionByWindowId[windowId] = 0 - commandPaletteSnapshotByWindowId[windowId] = .empty - + titlebarAccessoryController.start() + windowDecorationsController.start() + installMainWindowKeyObserver() + refreshGhosttyGotoSplitShortcuts() + installGhosttyConfigObserver() + installWindowResponderSwizzles() + installBrowserAddressBarFocusObservers() + installShortcutMonitor() + installShortcutDefaultsObserver() + NSApp.servicesProvider = self #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) - } - - 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? - } - - 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))" - } - } - - 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 - ) + 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)") } - } - - 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 - ) + 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 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 - }) - 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 + // 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) } - targets.append( - WorkspaceMoveTarget( - windowId: summary.windowId, - workspaceId: workspace.id, - windowLabel: windowLabel, - workspaceTitle: workspaceDisplayName(workspace), - tabManager: manager, - isCurrentWindow: isCurrentWindow + 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() + } } } - - return targets +#endif } - @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 DEBUG + func writeUITestDiagnosticsIfNeeded(stage: String) { + let env = ProcessInfo.processInfo.environment + guard let path = env["PROGRAMA_UI_TEST_DIAGNOSTICS_PATH"], !path.isEmpty else { return } - if sourceManager === destinationManager { - if focus { - destinationManager.focusTab(workspaceId, suppressFlash: true) - _ = focusMainWindow(windowId: windowId) - TerminalController.shared.setActiveTabManager(destinationManager) - } - return true + 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"] ?? "" + + 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) - guard let workspace = sourceManager.detachWorkspace(tabId: workspaceId) else { return false } - destinationManager.attachWorkspace(workspace, select: focus) + guard let data = try? JSONSerialization.data(withJSONObject: payload) else { return } + try? data.write(to: URL(fileURLWithPath: path), options: .atomic) + } - if focus { - _ = focusMainWindow(windowId: windowId) - TerminalController.shared.setActiveTabManager(destinationManager) + 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 true + return object } - @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 + private func appendUITestSocketDiagnosticsIfNeeded( + _ payload: inout [String: String], + environment env: [String: String] + ) { + guard env["PROGRAMA_UI_TEST_SOCKET_SANITY"] == "1" else { return } - guard moveWorkspaceToWindow(workspaceId: workspaceId, windowId: windowId, focus: focus) else { - _ = closeMainWindow(windowId: windowId) - 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 } - // 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 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") } - 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) - } - } - } - return nil + 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: ",") } - @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 + 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 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 + 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) + } - 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 + private func currentUITestRenderDiagnostics() -> UITestRenderDiagnosticsSnapshot? { + guard let tabManager, + let tabId = tabManager.selectedTabId, + let workspace = tabManager.tabs.first(where: { $0.id == tabId }) else { + return nil } - 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 + 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 moved = sourceWorkspace.moveSurface( - panelId: panelId, - toPane: resolvedTargetPane, - atIndex: targetIndex, - focus: focus - ) -#if DEBUG - 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 - return moved - } - - let sourcePane = sourceWorkspace.paneId(forPanelId: panelId) - let sourceIndex = sourceWorkspace.indexInPane(forPanelId: panelId) -#if DEBUG - let detachStart = ProcessInfo.processInfo.systemUptime -#endif + 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 + ) + } - 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 + 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 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 + + 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 } -#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 - ) + 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) } -#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 + self.writeUITestDiagnosticsIfNeeded(stage: "targetDisplayNoWindow") + return } -#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 + 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 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 - ) + 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 } -#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))" - ) + self.writeUITestDiagnosticsIfNeeded(stage: "afterMoveToTargetDisplay") + } #endif - return true + 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 } + + 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) } - @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) + 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 } - 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 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 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 + + // 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 + } + + // 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 .terminateLater } - func tabManagerFor(windowId: UUID) -> TabManager? { - mainWindowContexts.values.first(where: { $0.windowId == windowId })?.tabManager + 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 windowId(for tabManager: TabManager) -> UUID? { - mainWindowContexts.values.first(where: { $0.tabManager === tabManager })?.windowId + func applicationWillResignActive(_ notification: Notification) { + guard !isTerminatingApp else { return } + clearConfiguredShortcutChordState() + _ = saveSessionSnapshot(includeScrollback: false) } - func mainWindow(for windowId: UUID) -> NSWindow? { - windowForMainWindowId(windowId) + func persistSessionForUpdateRelaunch() { + isTerminatingApp = true + _ = saveSessionSnapshot(includeScrollback: true, removeWhenEmpty: false) } - 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 + 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() } } - return nil +#endif } - 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) - ) - ) - } + private func prepareStartupSessionSnapshotIfNeeded() { + guard !didPrepareStartupSessionSnapshot else { return } + didPrepareStartupSessionSnapshot = true + guard SessionRestorePolicy.shouldAttemptRestore() else { return } + Self.removeLegacyPersistedWindowGeometry() + startupSessionSnapshot = SessionPersistenceStore.load() + } - let remaining = mainWindowContexts.values - .sorted { $0.windowId.uuidString < $1.windowId.uuidString } - .filter { seen.insert($0.windowId).inserted } + 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 + } - for context in remaining { - results.append( - ScriptableMainWindowState( - windowId: context.windowId, - tabManager: context.tabManager, - window: context.window ?? windowForMainWindowId(context.windowId) - ) - ) + 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 } + defaults.set(data, forKey: Self.persistedWindowGeometryDefaultsKey) + } - return results + 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) } - func scriptableMainWindow(windowId: UUID) -> ScriptableMainWindowState? { - guard let context = mainWindowContexts.values.first(where: { $0.windowId == windowId }) else { + nonisolated static func decodedPersistedWindowGeometryData(_ data: Data) -> PersistedWindowGeometry? { + guard let payload = try? JSONDecoder().decode(PersistedWindowGeometry.self, from: data), + payload.version == persistedWindowGeometrySchemaVersion else { return nil } - return ScriptableMainWindowState( - windowId: context.windowId, - tabManager: context.tabManager, - window: context.window ?? windowForMainWindowId(context.windowId) - ) + return payload } - 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 nonisolated static func removeLegacyPersistedWindowGeometry( + defaults: UserDefaults = .standard + ) { + legacyPersistedWindowGeometryDefaultsKeys.forEach { defaults.removeObject(forKey: $0) } + } + + private func persistWindowGeometry(from window: NSWindow?) { + guard let window else { return } + persistWindowGeometry( + frame: SessionRectSnapshot(window.frame), + display: displaySnapshot(for: window) ) } - @discardableResult - func focusScriptableMainWindow(windowId: UUID, bringToFront shouldBringToFront: Bool) -> Bool { - guard let state = scriptableMainWindow(windowId: windowId), - let window = state.window else { - return false + private func currentDisplayGeometries() -> ( + available: [SessionDisplayGeometry], + fallback: SessionDisplayGeometry? + ) { + let available = NSScreen.screens.map { screen in + SessionDisplayGeometry( + displayID: screen.programaDisplayID, + frame: screen.frame, + visibleFrame: screen.visibleFrame + ) } - setActiveMainWindow(window) - if shouldBringToFront { - bringToFront(window) + let fallback = (NSScreen.main ?? NSScreen.screens.first).map { screen in + SessionDisplayGeometry( + displayID: screen.programaDisplayID, + frame: screen.frame, + visibleFrame: screen.visibleFrame + ) } - return true + return (available, fallback) } - @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) + 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) + } + } + + 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() + } } - let workspace = state.tabManager.addWorkspace( - workingDirectory: workingDirectory, - select: shouldBringToFront - ) - return workspace.id } - 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 completeStartupSessionRestore() { + startupSessionSnapshot = nil + isApplyingStartupSessionRestore = false + _ = saveSessionSnapshot(includeScrollback: false) } - private func postCommandPaletteRequest( - name: Notification.Name, - preferredWindow: NSWindow?, - source: String, - markPending: Bool + private func applySessionWindowSnapshot( + _ snapshot: SessionWindowSnapshot, + to context: MainWindowContext, + window: NSWindow? ) { - let targetWindow = preferredWindow ?? NSApp.keyWindow ?? NSApp.mainWindow - if markPending { - markCommandPaletteOpenRequested(for: targetWindow) - } - NotificationCenter.default.post(name: name, object: targetWindow) #if DEBUG dlog( - "shortcut.palette.request source=\(source) " + - "target={\(debugWindowToken(targetWindow))} " + - "pendingMarked=\(markPending ? 1 : 0)" + "session.restore.apply window=\(context.windowId.uuidString.prefix(8)) " + + "liveWin=\(window?.windowNumber ?? -1) " + + "snapshotFrame={\(debugSessionRectDescription(snapshot.frame))} " + + "snapshotDisplay={\(debugSessionDisplayDescription(snapshot.display))}" ) #endif - } - - func requestCommandPaletteCommands(preferredWindow: NSWindow? = nil, source: String = "api.commandPalette") { - postCommandPaletteRequest( - name: .commandPaletteRequested, - preferredWindow: preferredWindow, - source: source, - markPending: true + 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 - func requestCommandPaletteSwitcher(preferredWindow: NSWindow? = nil, source: String = "api.commandPaletteSwitcher") { - postCommandPaletteRequest( - name: .commandPaletteSwitcherRequested, - preferredWindow: preferredWindow, - source: source, - markPending: true - ) + 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 + } } - func requestCommandPaletteRenameTab(preferredWindow: NSWindow? = nil, source: String = "api.commandPaletteRenameTab") { - postCommandPaletteRequest( - name: .commandPaletteRenameTabRequested, - preferredWindow: preferredWindow, - source: source, - markPending: true + 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 ) } - func requestCommandPaletteRenameWorkspace( - preferredWindow: NSWindow? = nil, - source: String = "api.commandPaletteRenameWorkspace" - ) { - postCommandPaletteRequest( - name: .commandPaletteRenameWorkspaceRequested, - preferredWindow: preferredWindow, - source: source, - markPending: true + 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 + } + + return resolvedWindowFrame( + from: fallbackFrame, + display: fallbackDisplaySnapshot, + availableDisplays: availableDisplays, + fallbackDisplay: fallbackDisplay ) } - func requestCommandPaletteEditWorkspaceDescription( - preferredWindow: NSWindow? = nil, - source: String = "api.commandPaletteEditWorkspaceDescription" - ) { - postCommandPaletteRequest( - name: .commandPaletteEditWorkspaceDescriptionRequested, - preferredWindow: preferredWindow, - source: source, - markPending: true + 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 + } + + let minWidth = CGFloat(SessionPersistencePolicy.minimumWindowWidth) + let minHeight = CGFloat(SessionPersistencePolicy.minimumWindowHeight) + guard frame.width >= minWidth, + frame.height >= minHeight else { + return nil + } + + guard !availableDisplays.isEmpty else { return frame } + + 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 + ) + } + + if let intersectingDisplay = availableDisplays.first(where: { $0.visibleFrame.intersects(frame) }) { + return clampFrame( + frame, + within: intersectingDisplay.visibleFrame, + minWidth: minWidth, + minHeight: minHeight + ) + } + + 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 + ) + } + + return centeredFrame( + frame, + in: fallbackDisplay.visibleFrame, + minWidth: minWidth, + minHeight: minHeight ) } - private func clearCommandPalettePendingOpen(for window: NSWindow?) { - guard let window, - let windowId = mainWindowId(for: window) else { return } - commandPalettePendingOpenByWindowId.removeValue(forKey: windowId) - commandPaletteRecentRequestAtByWindowId.removeValue(forKey: windowId) - } - - 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 + 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 } - 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))" + return clampFrame( + frame, + within: targetDisplay.visibleFrame, + minWidth: minWidth, + minHeight: minHeight ) -#endif } - } - 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 - } + if let sourceReference = displaySnapshot?.visibleFrame?.cgRect ?? displaySnapshot?.frame?.cgRect { + return remappedFrame( + frame, + from: sourceReference, + to: targetDisplay.visibleFrame, + minWidth: minWidth, + minHeight: minHeight + ) + } - private func endCommandPaletteEscapeSuppression(for window: NSWindow?) { - guard let window, - let windowId = mainWindowId(for: window) else { return } - commandPaletteEscapeSuppressionByWindowId.remove(windowId) - commandPaletteEscapeSuppressionStartedAtByWindowId.removeValue(forKey: windowId) + return centeredFrame( + frame, + in: targetDisplay.visibleFrame, + minWidth: minWidth, + minHeight: minHeight + ) } - private func shouldConsumeSuppressedEscape(event: NSEvent, window: NSWindow?) -> Bool { - guard let window, - let windowId = mainWindowId(for: window), - commandPaletteEscapeSuppressionByWindowId.contains(windowId) else { + 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 } - let startedAt = commandPaletteEscapeSuppressionStartedAtByWindowId[windowId] ?? 0 - if ProcessInfo.processInfo.systemUptime - startedAt <= 0.35 { - return true - } - // Fallback cleanup when keyUp is lost for any reason. - endCommandPaletteEscapeSuppression(for: window) - return false + + 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 recentCommandPaletteRequestAge(for window: NSWindow?) -> TimeInterval? { - guard let window, - let windowId = mainWindowId(for: window) else { - return nil + 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 } - let now = ProcessInfo.processInfo.systemUptime - pruneExpiredCommandPalettePendingOpenStates(now: now) - guard commandPalettePendingOpenByWindowId[windowId] == true else { - commandPaletteRecentRequestAtByWindowId.removeValue(forKey: windowId) + + guard let referenceRect = (snapshot.visibleFrame ?? snapshot.frame)?.cgRect else { return nil } - guard let startedAt = commandPaletteRecentRequestAtByWindowId[windowId] else { - commandPalettePendingOpenByWindowId.removeValue(forKey: windowId) - return nil + + let overlaps = displays.map { display -> (display: SessionDisplayGeometry, area: CGFloat) in + (display, intersectionArea(referenceRect, display.visibleFrame)) } - let age = now - startedAt - if age <= Self.commandPaletteRequestGraceInterval { - return age + if let bestOverlap = overlaps.max(by: { $0.area < $1.area }), bestOverlap.area > 0 { + return bestOverlap.display } - return nil - } - private func escapeSuppressionWindow(for event: NSEvent) -> NSWindow? { - commandPaletteWindowForShortcutEvent(event) ?? event.window ?? NSApp.keyWindow ?? NSApp.mainWindow + 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 + } } - @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( - "shortcut.escape suppressionClear target={\(debugWindowToken(window))} " + - "keyUpConsumed=\(didConsume ? 1 : 0)" - ) -#endif - return didConsume + 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) } - commandPaletteEscapeSuppressionByWindowId.removeAll() - commandPaletteEscapeSuppressionStartedAtByWindowId.removeAll() -#if DEBUG - dlog("shortcut.escape suppressionClear target={nil} clearedAll=1 keyUpConsumed=\(didConsume ? 1 : 0)") -#endif - return didConsume + + 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 + + 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) } - 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 + 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) } - func isCommandPaletteVisible(windowId: UUID) -> Bool { - commandPaletteVisibilityByWindowId[windowId] ?? false - } + 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) - func setCommandPaletteSelectionIndex(_ index: Int, for window: NSWindow) { - guard let windowId = mainWindowId(for: window) else { return } - commandPaletteSelectionByWindowId[windowId] = max(0, index) + return CGRect(x: x, y: y, width: width, height: height) } - func commandPaletteSelectionIndex(windowId: UUID) -> Int { - commandPaletteSelectionByWindowId[windowId] ?? 0 + 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 setCommandPaletteSnapshot(_ snapshot: CommandPaletteDebugSnapshot, for window: NSWindow) { - guard let windowId = mainWindowId(for: window) else { return } - commandPaletteSnapshotByWindowId[windowId] = snapshot + 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) } - func commandPaletteSnapshot(windowId: UUID) -> CommandPaletteDebugSnapshot { - commandPaletteSnapshotByWindowId[windowId] ?? .empty - } + 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 + } - func isCommandPaletteVisible(for window: NSWindow) -> Bool { - guard let windowId = mainWindowId(for: window) else { return false } - return commandPaletteVisibilityByWindowId[windowId] ?? 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 } - func isCommandPaletteEffectivelyVisible(for window: NSWindow) -> Bool { - isCommandPaletteEffectivelyVisible(in: window) + return frame.width.isFinite + && frame.height.isFinite + && frame.origin.x.isFinite + && frame.origin.y.isFinite } - func shouldBlockFirstResponderChangeWhileCommandPaletteVisible( - window: NSWindow, - responder: NSResponder? + private nonisolated static func rectApproximatelyEqual( + _ lhs: CGRect, + _ rhs: CGRect, + tolerance: CGFloat = 1 ) -> Bool { - guard isCommandPaletteVisible(for: window) else { return false } - guard let responder else { return false } - guard !isCommandPaletteResponder(responder) else { return false } - return isFocusStealingResponderWhileCommandPaletteVisible(responder) + 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 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) - } - return false - } + 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 } - private func isFocusStealingResponderWhileCommandPaletteVisible(_ responder: NSResponder) -> Bool { - isCommandPaletteFocusStealingTerminalOrBrowserResponder(responder) + return SessionDisplaySnapshot( + displayID: screen.programaDisplayID, + frame: SessionRectSnapshot(screen.frame), + visibleFrame: SessionRectSnapshot(screen.visibleFrame) + ) } - private func isInsideCommandPaletteOverlay(_ view: NSView) -> Bool { - var current: NSView? = view - while let candidate = current { - if candidate.identifier == commandPaletteOverlayContainerIdentifier { - return true + 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 } - current = candidate.superview + self.runSessionAutosaveTick(source: "timer") } - return false + sessionAutosaveTimer = timer + timer.resume() } - 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 responder as? NSView + private func stopSessionAutosaveTimer() { + sessionAutosaveTimer?.cancel() + sessionAutosaveTimer = nil + sessionAutosaveTickInFlight = false + sessionAutosaveDeferredRetryPending = false } - 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 + 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.isTerminatingApp = true + _ = self.saveSessionSnapshot(includeScrollback: true, removeWhenEmpty: false) } - return ghosttyView === window.contentView || ghosttyView.superview != nil } + lifecycleSnapshotObservers.append(powerOffObserver) - guard let ownerView = keyRoutingOwnerView(for: responder) else { - return false + 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) + } + } } + lifecycleSnapshotObservers.append(sessionResignObserver) - if ownerView.window !== window { - return false + 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) + } - if ownerView.isHiddenOrHasHiddenAncestor { - return false - } + 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()) + } - if ownerView !== window.contentView, ownerView.superview == nil { - return false - } + 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) + } - return true + private func disableSuddenTerminationIfNeeded() { + guard !didDisableSuddenTermination else { return } + ProcessInfo.processInfo.disableSuddenTermination() + didDisableSuddenTermination = 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 + 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 } - if hostedView.responderMatchesPreferredKeyboardFocus(responder) { - return false + 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()) + ) + + 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 true + + return hasher.finalize() } - 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 { - return + @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 } - 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)" + 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)" + ) + } #endif - terminalPanel.hostedView.ensureFocus(for: workspace.id, surfaceId: panelId) + 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 + ) + } #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)" - ) + debugLogSessionSaveSnapshot(snapshot, includeScrollback: includeScrollback) #endif + persistSessionSnapshot( + snapshot, + removeWhenEmpty: false, + persistedGeometryData: persistedGeometryData, + synchronously: writeSynchronously + ) + return 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 + nonisolated static func shouldPersistSnapshotOnWindowUnregister(isTerminatingApp: Bool) -> Bool { + !isTerminatingApp } - /// 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) - } - - 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) - } + nonisolated static func shouldRemoveSnapshotWhenNoWindowsRemainOnWindowUnregister( + isTerminatingApp: Bool + ) -> Bool { + !isTerminatingApp + } - 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) - } + nonisolated static func shouldSkipSessionSaveDuringStartupRestore( + isApplyingStartupSessionRestore: Bool, + includeScrollback: Bool + ) -> Bool { + isApplyingStartupSessionRestore && !includeScrollback + } - if let manager = tabManager, - let workspace = manager.tabs.first(where: { $0.panels[panelId] != nil }) { - return (workspace, manager) - } + nonisolated static func shouldRunSessionAutosaveTick(isTerminatingApp: Bool) -> Bool { + !isTerminatingApp + } - return nil + 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 } - 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 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") } } - return nil } - 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 runSessionAutosaveTick(source: String) { + guard Self.shouldRunSessionAutosaveTick(isTerminatingApp: isTerminatingApp) else { return } + guard !sessionAutosaveTickInFlight else { return } + if let remainingQuietPeriod = remainingSessionAutosaveTypingQuietPeriod() { #if DEBUG - dlog("reload.config.surfaceRefresh source=\(source) count=\(refreshedCount)") + dlog( + "session.save.skipped reason=typing_recent includeScrollback=0 source=\(source) " + + "retryMs=\(Int((remainingQuietPeriod * 1000).rounded()))" + ) #endif - } - - private func forEachTerminalPanel(_ body: (TerminalPanel) -> Void) { - var seenManagers: Set = [] - - 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) - } - } + scheduleDeferredSessionAutosaveRetry(after: remainingQuietPeriod) + return } - visitManager(tabManager) - for context in mainWindowContexts.values { - visitManager(context.tabManager) + 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 - func focusMainWindow(windowId: UUID) -> Bool { - guard let window = windowForMainWindowId(windowId) else { return false } - if TerminalController.shouldSuppressSocketCommandActivation(), - !TerminalController.socketCommandAllowsInAppFocusMutations() { - setActiveMainWindow(window) - return true + 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 } - bringToFront(window) - return true - } - func closeMainWindow(windowId: UUID) -> Bool { - guard let window = windowForMainWindowId(windowId) else { return false } - window.performClose(nil) - return true - } - - private func confirmCloseMainWindow(_ window: NSWindow) -> Bool { #if DEBUG - if let debugCloseMainWindowConfirmationHandler { - return debugCloseMainWindowConfirmationHandler(window) - } + let saveStart = ProcessInfo.processInfo.systemUptime #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." + _ = saveSessionSnapshot(includeScrollback: false) +#if DEBUG + saveMs = (ProcessInfo.processInfo.systemUptime - saveStart) * 1000.0 +#endif + updateSessionAutosaveSaveState( + includeScrollback: false, + persistedAt: now, + fingerprint: autosaveFingerprint ) - 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) - } - } - - return alert.runModal() == .alertFirstButtonReturn } - @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 + // 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 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 - } + nonisolated static func shouldWriteSessionSnapshotSynchronously( + isTerminatingApp: Bool, + includeScrollback: Bool + ) -> Bool { + isTerminatingApp && includeScrollback } - 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)") - } + 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 } - 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 + return now.timeIntervalSince(lastPersistedAt) < maximumAutosaveSkippableInterval } - private func rollbackDetachedSurface( - _ detached: Workspace.DetachedSurfaceTransfer, - to workspace: Workspace, - sourcePane: PaneID?, - sourceIndex: Int?, - focus: Bool + private func updateSessionAutosaveSaveState( + includeScrollback: Bool, + persistedAt: Date, + fingerprint: Int? ) { - 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 - ) + guard !isTerminatingApp, !includeScrollback else { return } + lastSessionAutosaveFingerprint = fingerprint + lastSessionAutosavePersistedAt = persistedAt } - private func cleanupEmptySourceWorkspaceAfterSurfaceMove( - sourceWorkspace: Workspace, - sourceManager: TabManager, - sourceWindowId: UUID + 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 persistSessionSnapshot( + _ snapshot: AppSessionSnapshot?, + removeWhenEmpty: Bool, + persistedGeometryData: Data?, + synchronously: Bool ) { - guard sourceWorkspace.panels.isEmpty else { return } - guard sourceManager.tabs.contains(where: { $0.id == sourceWorkspace.id }) else { return } + guard snapshot != nil || removeWhenEmpty || persistedGeometryData != nil else { return } - if sourceManager.tabs.count > 1 { - sourceManager.closeWorkspace(sourceWorkspace) + 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() + } + } + + if synchronously { + writeBlock() } else { - _ = closeMainWindow(windowId: sourceWindowId) + sessionPersistenceQueue.async(execute: writeBlock) } } - 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 + 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 } - 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 + return lhs.windowId.uuidString < rhs.windowId.uuidString + } + + 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)) + ) + ) } - self.bringToFront(destinationWindow) - destinationManager.focusTab( - destinationWorkspaceId, - surfaceId: destinationPanelId, - suppressFlash: true + guard !windows.isEmpty else { return nil } + return AppSessionSnapshot( + version: SessionSnapshotSchema.currentVersion, + createdAt: Date().timeIntervalSince1970, + windows: windows + ) + } + +#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)" ) } + } - DispatchQueue.main.asyncAfter(deadline: .now() + 0.05, execute: reassert) - DispatchQueue.main.asyncAfter(deadline: .now() + 0.16, execute: reassert) + 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)))" } - 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 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))}" } - 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 debugSessionNumber(_ value: Double) -> String { + String(format: "%.1f", value) } +#endif - 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 notifyMainWindowContextsDidChange() { + NotificationCenter.default.post(name: .mainWindowContextsDidChange, object: self) } - private func reindexMainWindowContextIfNeeded(_ context: MainWindowContext, for window: NSWindow) { - let desiredKey = ObjectIdentifier(window) - if mainWindowContexts[desiredKey] === context { - context.window = window - return - } + /// 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 - let contextKeys = mainWindowContexts.compactMap { key, value in - value === context ? key : nil + 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) + } } - for key in contextKeys { - mainWindowContexts.removeValue(forKey: key) + updateCommandPaletteState(for: windowId) { state in + state.isVisible = false + state.selectionIndex = 0 + state.snapshot = .empty } - if let conflicting = mainWindowContexts[desiredKey], conflicting !== context { - context.window = window - return +#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) } - mainWindowContexts[desiredKey] = context - context.window = window - notifyMainWindowContextsDidChange() + attemptStartupSessionRestoreIfNeeded(primaryWindow: window) + if !isTerminatingApp { + _ = saveSessionSnapshot(includeScrollback: false) + } } - private func contextForMainTerminalWindow(_ window: NSWindow, reindex: Bool = true) -> MainWindowContext? { - guard isMainTerminalWindow(window) else { return nil } + struct MainWindowSummary { + let windowId: UUID + let isKeyWindow: Bool + let isVisible: Bool + let workspaceCount: Int + let selectedWorkspaceId: UUID? + } - if let context = mainWindowContexts[ObjectIdentifier(window)] { - context.window = window - return context - } + struct WindowMoveTarget: Identifiable { + let windowId: UUID + let label: String + let tabManager: TabManager + let isCurrentWindow: Bool - 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 - } + var id: UUID { windowId } + } - 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 - } + struct WorkspaceMoveTarget: Identifiable { + let windowId: UUID + let workspaceId: UUID + let windowLabel: String + let workspaceTitle: String + let tabManager: TabManager + let isCurrentWindow: Bool - return nil + var id: String { "\(windowId.uuidString):\(workspaceId.uuidString)" } + var label: String { + isCurrentWindow ? workspaceTitle : "\(workspaceTitle) (\(windowLabel))" + } } - 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) + 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 + ) } - 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) + 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 + ) } - 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) + func workspaceMoveTargets(excludingWorkspaceId: UUID? = nil, referenceWindowId: UUID?) -> [WorkspaceMoveTarget] { + let orderedSummaries = orderedMainWindowSummaries(referenceWindowId: referenceWindowId) + let labels = windowLabelsById(orderedSummaries: orderedSummaries, referenceWindowId: referenceWindowId) - 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) - } - } + var targets: [WorkspaceMoveTarget] = [] + targets.reserveCapacity(orderedSummaries.reduce(0) { partial, summary in + partial + summary.workspaceCount + }) - if let store = notificationStore { - for tab in context.tabManager.tabs { - store.clearNotifications(forTabId: tab.id) + 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 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) + return targets } - 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 + @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 sourceManager === destinationManager { + if focus { + destinationManager.focusTab(workspaceId, suppressFlash: true) + _ = focusMainWindow(windowId: windowId) + TerminalController.shared.setActiveTabManager(destinationManager) } - stack.append(contentsOf: candidate.subviews) + return true } - 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 - } + guard let workspace = sourceManager.detachWorkspace(tabId: workspaceId) else { return false } + destinationManager.attachWorkspace(workspace, select: focus) - 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) + if focus { + _ = focusMainWindow(windowId: windowId) + TerminalController.shared.setActiveTabManager(destinationManager) } - return isCommandPaletteResponder(responder) + return true } - private func isCommandPaletteMultilineTextResponderActive(in window: NSWindow) -> Bool { - guard let textView = window.firstResponder as? NSTextView, - !textView.isFieldEditor else { - return false - } - return isCommandPaletteResponder(textView) - } + @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 - private func commandPaletteMarkedTextInput(in window: NSWindow) -> NSTextView? { - if let textView = window.firstResponder as? NSTextView, - isCommandPaletteResponder(textView), - textView.hasMarkedText() { - return textView + guard moveWorkspaceToWindow(workspaceId: workspaceId, windowId: windowId, focus: focus) else { + _ = closeMainWindow(windowId: windowId) + return nil } - if let textField = window.firstResponder as? NSTextField, - let editor = textField.currentEditor() as? NSTextView, - isCommandPaletteResponder(editor), - editor.hasMarkedText() { - return editor + // 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 nil + return windowId } - private func isCommandPaletteEffectivelyVisible(in window: NSWindow) -> Bool { - isCommandPaletteVisible(for: window) - || isCommandPalettePendingOpen(for: window) - || isCommandPaletteOverlayPresented(in: window) - || isCommandPaletteResponderActive(in: window) + 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) + } + } + } + return nil } - 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 + @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) } - if let orderedWindow = NSApp.orderedWindows.first(where: { window in - isMainTerminalWindow(window) && isCommandPaletteEffectivelyVisible(in: window) - }) { - return orderedWindow + 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 } - if let visibleWindowId = commandPaletteVisibilityByWindowId.first(where: { $0.value })?.key { - return windowForMainWindowId(visibleWindowId) + 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 } - if let pendingWindowId = commandPalettePendingOpenByWindowId.first(where: { $0.value })?.key { - return windowForMainWindowId(pendingWindowId) + 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 } - return nil - } - - private func commandPaletteWindowForShortcutEvent(_ event: NSEvent) -> NSWindow? { - if let scopedWindow = mainWindowForShortcutEvent(event) { - return scopedWindow + 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 } - return activeCommandPaletteWindow() - } +#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 contextForMainWindow(_ window: NSWindow?) -> MainWindowContext? { - guard let window, isMainTerminalWindow(window) else { return nil } - return mainWindowContexts[ObjectIdentifier(window)] - } + let resolvedTargetPane = targetPane.flatMap { pane in + destinationWorkspace.bonsplitController.allPaneIds.first(where: { $0 == pane }) + } ?? destinationWorkspace.bonsplitController.focusedPaneId + ?? destinationWorkspace.bonsplitController.allPaneIds.first + guard let resolvedTargetPane else { #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 { - guard let window else { return "nil" } - let id = mainWindowId(for: window).map { String($0.uuidString.prefix(8)) } ?? "none" - let ident = window.identifier?.rawValue ?? "nil" - let shortIdent: String - if ident.count > 120 { - shortIdent = String(ident.prefix(120)) + "..." - } else { - shortIdent = ident + dlog( + "surface.move.fail panel=\(panelId.uuidString.prefix(5)) reason=targetPaneMissing " + + "destinationWs=\(destinationWorkspace.id.uuidString.prefix(5)) elapsedMs=\(elapsedMs(since: moveStart))" + ) +#endif + return false } - return "num=\(window.windowNumber) id=\(id) ident=\(shortIdent) key=\(window.isKeyWindow ? 1 : 0) main=\(window.isMainWindow ? 1 : 0)" - } - - private func debugContextToken(_ context: MainWindowContext?) -> String { - guard let context else { return "nil" } - let selected = context.tabManager.selectedTabId.map { String($0.uuidString.prefix(5)) } ?? "nil" - let hasWindow = (context.window != nil || windowForMainWindowId(context.windowId) != nil) ? 1 : 0 - return "id=\(String(context.windowId.uuidString.prefix(8))) mgr=\(debugManagerToken(context.tabManager)) tabs=\(context.tabManager.tabs.count) selected=\(selected) hasWindow=\(hasWindow)" - } - - private func debugShortcutRouteSnapshot(event: NSEvent? = nil) -> String { - let activeManager = tabManager - let activeWindowId = activeManager.flatMap { windowId(for: $0) }.map { String($0.uuidString.prefix(8)) } ?? "nil" - let selectedWorkspace = activeManager?.selectedTabId.map { String($0.uuidString.prefix(5)) } ?? "nil" - let contexts = mainWindowContexts.values - .map { context in - let marker = (activeManager != nil && context.tabManager === activeManager) ? "*" : "-" - let window = context.window ?? windowForMainWindowId(context.windowId) - let selected = context.tabManager.selectedTabId.map { String($0.uuidString.prefix(5)) } ?? "nil" - return "\(marker)\(String(context.windowId.uuidString.prefix(8))){mgr=\(debugManagerToken(context.tabManager)),win=\(window?.windowNumber ?? -1),key=\((window?.isKeyWindow ?? false) ? 1 : 0),main=\((window?.isMainWindow ?? false) ? 1 : 0),tabs=\(context.tabManager.tabs.count),selected=\(selected)}" + 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 } - .sorted() - .joined(separator: ",") - let eventWindowNumber = event.map { String($0.windowNumber) } ?? "nil" - let eventWindow = event?.window - return "eventWinNum=\(eventWindowNumber) eventWin={\(debugWindowToken(eventWindow))} keyWin={\(debugWindowToken(NSApp.keyWindow))} mainWin={\(debugWindowToken(NSApp.mainWindow))} activeMgr=\(debugManagerToken(activeManager)) activeWinId=\(activeWindowId) activeSelected=\(selectedWorkspace) contexts=[\(contexts)]" - } + let moved = sourceWorkspace.moveSurface( + panelId: panelId, + toPane: resolvedTargetPane, + atIndex: targetIndex, + focus: focus + ) +#if DEBUG + 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 + return moved + } + + let sourcePane = sourceWorkspace.paneId(forPanelId: panelId) + let sourceIndex = sourceWorkspace.indexInPane(forPanelId: panelId) +#if DEBUG + let detachStart = ProcessInfo.processInfo.systemUptime #endif - private func mainWindowForShortcutEvent(_ event: NSEvent) -> NSWindow? { - if let window = resolvedShortcutEventWindow(event), - isMainTerminalWindow(window) { - return window - } - if let keyWindow = NSApp.keyWindow, isMainTerminalWindow(keyWindow) { - return keyWindow - } - if let mainWindow = NSApp.mainWindow, isMainTerminalWindow(mainWindow) { - return mainWindow + 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 } - return nil - } - - private func resolvedShortcutEventWindow(_ event: NSEvent) -> NSWindow? { - if let window = event.window { - return window +#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 } - let eventWindowNumber = event.windowNumber - guard eventWindowNumber > 0 else { return nil } - return NSApp.window(withWindowNumber: eventWindowNumber) - } +#if DEBUG + let attachMs = elapsedMs(since: attachStart) + var splitMs = "0.00" +#endif - /// Re-sync app-level active window pointers from the currently focused main terminal window. - /// This keeps menu/shortcut actions window-scoped even if the cached `tabManager` drifts. - @discardableResult - func synchronizeActiveMainWindowContext(preferredWindow: NSWindow? = nil) -> TabManager? { - let (context, source): (MainWindowContext?, String) = { - if let preferredWindow, - let context = contextForMainWindow(preferredWindow) { - return (context, "preferredWindow") - } - if let context = contextForMainWindow(NSApp.keyWindow) { - return (context, "keyWindow") - } - if let context = contextForMainWindow(NSApp.mainWindow) { - return (context, "mainWindow") - } - if let activeManager = tabManager, - let activeContext = mainWindowContexts.values.first(where: { $0.tabManager === activeManager }) { - return (activeContext, "activeManager") + 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 } - return (mainWindowContexts.values.first, "firstContextFallback") - }() +#if DEBUG + splitMs = elapsedMs(since: splitStart) +#endif + } #if DEBUG - let beforeManagerToken = debugManagerToken(tabManager) - dlog( - "shortcut.sync.pre source=\(source) preferred={\(debugWindowToken(preferredWindow))} chosen={\(debugContextToken(context))} \(debugShortcutRouteSnapshot())" - ) + let cleanupStart = ProcessInfo.processInfo.systemUptime #endif - guard let context else { return tabManager } - let alreadyActive = - tabManager === context.tabManager - && sidebarState === context.sidebarState - && sidebarSelectionState === context.sidebarSelectionState - if alreadyActive { + cleanupEmptySourceWorkspaceAfterSurfaceMove( + sourceWorkspace: sourceWorkspace, + sourceManager: source.tabManager, + sourceWindowId: source.windowId + ) #if DEBUG - dlog( - "shortcut.sync.post source=\(source) beforeMgr=\(beforeManagerToken) afterMgr=\(debugManagerToken(tabManager)) chosen={\(debugContextToken(context))} nochange=1 \(debugShortcutRouteSnapshot())" - ) + let cleanupMs = elapsedMs(since: cleanupStart) + let focusStart = ProcessInfo.processInfo.systemUptime #endif - return context.tabManager - } - 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 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( - "shortcut.sync.post source=\(source) beforeMgr=\(beforeManagerToken) afterMgr=\(debugManagerToken(tabManager)) chosen={\(debugContextToken(context))} \(debugShortcutRouteSnapshot())" + "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 - return context.tabManager - } - - private struct FocusedTerminalShortcutContext { - let tabManager: TabManager - let workspaceId: UUID - let panelId: UUID - } - private func resolveShortcutTabManager(for tabId: UUID, preferredWindow: NSWindow? = nil) -> TabManager? { - if let manager = tabManagerFor(tabId: tabId) { - return manager - } - if let preferredWindow, - let context = contextForMainWindow(preferredWindow), - context.tabManager.tabs.contains(where: { $0.id == tabId }) { - return context.tabManager - } - if let activeManager = tabManager, - activeManager.tabs.contains(where: { $0.id == tabId }) { - return activeManager - } - return nil + return true } - private func focusedTerminalShortcutContext(preferredWindow: NSWindow? = nil) -> FocusedTerminalShortcutContext? { - let targetWindow = preferredWindow ?? NSApp.keyWindow ?? NSApp.mainWindow - let responder = targetWindow?.firstResponder - ?? NSApp.keyWindow?.firstResponder - ?? NSApp.mainWindow?.firstResponder - guard let ghosttyView = cmuxOwningGhosttyView(for: responder), - let workspaceId = ghosttyView.tabId, - let panelId = ghosttyView.terminalSurface?.id, - let manager = resolveShortcutTabManager(for: workspaceId, preferredWindow: targetWindow) else { - return nil + @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) } - return FocusedTerminalShortcutContext( - tabManager: manager, - workspaceId: workspaceId, - panelId: panelId + 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")" ) - } - - private func preferredMainWindowContextForShortcuts(event: NSEvent) -> MainWindowContext? { - if let context = contextForMainWindow(event.window) { - return context - } - if let context = contextForMainWindow(NSApp.keyWindow) { - return context - } - if let context = contextForMainWindow(NSApp.mainWindow) { - return context - } - if let activeManager = tabManager, - let activeContext = mainWindowContexts.values.first(where: { $0.tabManager === activeManager }) { - return activeContext +#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 mainWindowContexts.values.first - } - - private func activateMainWindowContextForShortcutEvent(_ event: NSEvent) { - let preferredWindow = mainWindowForShortcutEvent(event) #if DEBUG dlog( - "shortcut.activate.pre event=\(NSWindow.keyDescription(event)) preferred={\(debugWindowToken(preferredWindow))} \(debugShortcutRouteSnapshot(event: event))" + "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 ) -#endif - _ = synchronizeActiveMainWindowContext(preferredWindow: preferredWindow) #if DEBUG dlog( - "shortcut.activate.post event=\(NSWindow.keyDescription(event)) preferred={\(debugWindowToken(preferredWindow))} \(debugShortcutRouteSnapshot(event: event))" + "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 } - @discardableResult - func toggleSidebarInActiveMainWindow() -> Bool { - if let activeManager = tabManager, - let activeContext = mainWindowContexts.values.first(where: { $0.tabManager === activeManager }) { - if let window = activeContext.window ?? windowForMainWindowId(activeContext.windowId) { - setActiveMainWindow(window) - } - activeContext.sidebarState.toggle() - return true - } - if let keyContext = contextForMainWindow(NSApp.keyWindow) { - if let window = keyContext.window ?? windowForMainWindowId(keyContext.windowId) { - setActiveMainWindow(window) - } - keyContext.sidebarState.toggle() - return true - } - if let mainContext = contextForMainWindow(NSApp.mainWindow) { - if let window = mainContext.window ?? windowForMainWindowId(mainContext.windowId) { - setActiveMainWindow(window) - } - mainContext.sidebarState.toggle() - return true - } - if let fallbackContext = mainWindowContexts.values.first { - if let window = fallbackContext.window ?? windowForMainWindowId(fallbackContext.windowId) { - setActiveMainWindow(window) - } - fallbackContext.sidebarState.toggle() - return true - } - if let sidebarState { - sidebarState.toggle() - return true - } - return false + func tabManagerFor(windowId: UUID) -> TabManager? { + mainWindowContexts.values.first(where: { $0.windowId == windowId })?.tabManager } - func sidebarVisibility(windowId: UUID) -> Bool? { - mainWindowContexts.values.first(where: { $0.windowId == windowId })?.sidebarState.isVisible + func windowId(for tabManager: TabManager) -> UUID? { + mainWindowContexts.values.first(where: { $0.tabManager === tabManager })?.windowId } - /// Toggle the sidebar for the window identified by the given NSWindow. - /// Used by titlebar accessory buttons so they affect their own window - /// rather than the currently focused/key window (#1779). - func toggleSidebarForWindow(_ window: NSWindow?) -> Bool { - if let window { - guard let context = contextForMainTerminalWindow(window) else { - return false - } - context.sidebarState.toggle() - return true - } - return toggleSidebarInActiveMainWindow() + func mainWindow(for windowId: UUID) -> NSWindow? { + windowForMainWindowId(windowId) } - func applicationDockMenu(_ sender: NSApplication) -> NSMenu? { - let menu = NSMenu(title: "") - let newWindowItem = NSMenuItem( - title: String(localized: "menu.file.newWindow", defaultValue: "New Window"), - action: #selector(openNewMainWindow(_:)), - keyEquivalent: "" - ) - newWindowItem.target = self - menu.addItem(newWindowItem) - return menu + 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 + } + } + return nil } - @objc func openNewMainWindow(_ sender: Any?) { - _ = createMainWindow() - } + func scriptableMainWindows() -> [ScriptableMainWindowState] { + var results: [ScriptableMainWindowState] = [] + var seen: Set = [] - /// Shows the "Open Folder" panel and creates a workspace for the selected directory. - /// Called from both the SwiftUI menu and `handleCustomShortcut`. - func showOpenFolderPanel() { - let panel = NSOpenPanel() - panel.canChooseFiles = false - panel.canChooseDirectories = true - panel.allowsMultipleSelection = false - panel.title = String(localized: "menu.file.openFolder.panelTitle", defaultValue: "Open Folder") - panel.prompt = String(localized: "menu.file.openFolder.panelPrompt", defaultValue: "Open") - // Seed the panel with the active workspace's directory. Use the shared - // main-window resolver so this works even when an auxiliary window is key. - if let context = preferredMainWindowContextForWorkspaceCreation(debugSource: "openFolderPanel.seed"), - let cwd = context.tabManager.selectedWorkspace?.currentDirectory, - !cwd.isEmpty { - panel.directoryURL = URL(fileURLWithPath: cwd) + 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 panel.runModal() == .OK, let url = panel.url { - openWorkspaceForExternalDirectory( - workingDirectory: url.path, - debugSource: "shortcut.openFolder" + + let remaining = mainWindowContexts.values + .sorted { $0.windowId.uuidString < $1.windowId.uuidString } + .filter { seen.insert($0.windowId).inserted } + + for context in remaining { + results.append( + ScriptableMainWindowState( + windowId: context.windowId, + tabManager: context.tabManager, + window: context.window ?? windowForMainWindowId(context.windowId) + ) ) } + + return results } - @discardableResult - func openDirectoryInInlineVSCode( - _ directoryURL: URL, - tabManager preferredTabManager: TabManager? = nil - ) -> Bool { - guard let vscodeApplicationURL = TerminalDirectoryOpenTarget.vscodeInline.applicationURL() else { - return false + 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) + ) + } - let targetTabManager = preferredTabManager - ?? preferredMainWindowContextForWorkspaceCreation(debugSource: "inlineVSCode.open.target")?.tabManager - guard let targetTabManager else { + 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) + ) + } + + @discardableResult + func focusScriptableMainWindow(windowId: UUID, bringToFront shouldBringToFront: Bool) -> Bool { + guard let state = scriptableMainWindow(windowId: windowId), + let window = state.window else { return false } - - let targetWorkspaceId = targetTabManager.selectedWorkspace?.id - ?? targetTabManager.tabs.first?.id - ?? targetTabManager.addWorkspace(select: true).id - let normalizedDirectoryURL = directoryURL.standardizedFileURL - - VSCodeServeWebController.shared.ensureServeWebURL(vscodeApplicationURL: vscodeApplicationURL) { serveWebURL in - guard let serveWebURL, - let openFolderURL = VSCodeServeWebURLBuilder.openFolderURL( - baseWebUIURL: serveWebURL, - directoryPath: normalizedDirectoryURL.path - ) else { - NSSound.beep() - return - } - - guard targetTabManager.openBrowser( - inWorkspace: targetWorkspaceId, - url: openFolderURL, - preferSplitRight: true - ) != nil else { - NSSound.beep() - return - } + setActiveMainWindow(window) + if shouldBringToFront { + bringToFront(window) } - return true } - func showOpenFolderInInlineVSCodePanel(tabManager preferredTabManager: TabManager? = nil) { - guard TerminalDirectoryOpenTarget.vscodeInline.isAvailable() else { - NSSound.beep() - return + @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 + } - let targetTabManager = preferredTabManager - ?? preferredMainWindowContextForWorkspaceCreation(debugSource: "inlineVSCode.panel.target")?.tabManager - guard let targetTabManager else { - NSSound.beep() - return - } + private func commandPaletteState(for windowId: UUID) -> CommandPaletteWindowState { + commandPaletteStateByWindowId[windowId] ?? CommandPaletteWindowState() + } - let panel = NSOpenPanel() - panel.canChooseFiles = false - panel.canChooseDirectories = true - panel.allowsMultipleSelection = false - panel.title = String( - localized: "menu.file.openFolderInVSCodeInline.panelTitle", - defaultValue: "Open Folder in VS Code (Inline)" - ) - panel.prompt = String( - localized: "menu.file.openFolderInVSCodeInline.panelPrompt", - defaultValue: "Open in VS Code" - ) - if let cwd = targetTabManager.selectedWorkspace?.currentDirectory, - !cwd.isEmpty { - panel.directoryURL = URL(fileURLWithPath: cwd) + 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 } + updateCommandPaletteState(for: windowId) { state in + state.pendingOpen = true + state.recentRequestAt = ProcessInfo.processInfo.systemUptime } + } - if panel.runModal() == .OK, - let url = panel.url, - !openDirectoryInInlineVSCode(url, tabManager: targetTabManager) { - NSSound.beep() + private func postCommandPaletteRequest( + name: Notification.Name, + preferredWindow: NSWindow?, + source: String, + markPending: Bool + ) { + let targetWindow = preferredWindow ?? NSApp.keyWindow ?? NSApp.mainWindow + if markPending { + markCommandPaletteOpenRequested(for: targetWindow) } + NotificationCenter.default.post(name: name, object: targetWindow) +#if DEBUG + dlog( + "shortcut.palette.request source=\(source) " + + "target={\(debugWindowToken(targetWindow))} " + + "pendingMarked=\(markPending ? 1 : 0)" + ) +#endif } - @objc func openWindow( - _ pasteboard: NSPasteboard, - userData: String?, - error: AutoreleasingUnsafeMutablePointer - ) { - openFromServicePasteboard(pasteboard, target: .window, error: error) + func requestCommandPaletteCommands(preferredWindow: NSWindow? = nil, source: String = "api.commandPalette") { + postCommandPaletteRequest( + name: .commandPaletteRequested, + preferredWindow: preferredWindow, + source: source, + markPending: true + ) } - @objc func openTab( - _ pasteboard: NSPasteboard, - userData: String?, - error: AutoreleasingUnsafeMutablePointer - ) { - openFromServicePasteboard(pasteboard, target: .workspace, error: error) + func requestCommandPaletteSwitcher(preferredWindow: NSWindow? = nil, source: String = "api.commandPaletteSwitcher") { + postCommandPaletteRequest( + name: .commandPaletteSwitcherRequested, + preferredWindow: preferredWindow, + source: source, + markPending: true + ) } - private enum ServiceOpenTarget { - case window - case workspace + func requestCommandPaletteRenameTab(preferredWindow: NSWindow? = nil, source: String = "api.commandPaletteRenameTab") { + postCommandPaletteRequest( + name: .commandPaletteRenameTabRequested, + preferredWindow: preferredWindow, + source: source, + markPending: true + ) } - private func openFromServicePasteboard( - _ pasteboard: NSPasteboard, - target: ServiceOpenTarget, - error: AutoreleasingUnsafeMutablePointer + func requestCommandPaletteRenameWorkspace( + preferredWindow: NSWindow? = nil, + source: String = "api.commandPaletteRenameWorkspace" ) { - let pathURLs = servicePathURLs(from: pasteboard) - guard !pathURLs.isEmpty else { - error.pointee = Self.serviceErrorNoPath - return - } - - let directories = externalOpenDirectories(from: pathURLs) - guard !directories.isEmpty else { - error.pointee = Self.serviceErrorNoPath - return - } + postCommandPaletteRequest( + name: .commandPaletteRenameWorkspaceRequested, + preferredWindow: preferredWindow, + source: source, + markPending: true + ) + } - prepareForExplicitOpenIntentAtStartup() - for directory in directories { - switch target { - case .window: - _ = createMainWindow(initialWorkingDirectory: directory) - case .workspace: - openWorkspaceFromService(workingDirectory: directory) - } - } + func requestCommandPaletteEditWorkspaceDescription( + preferredWindow: NSWindow? = nil, + source: String = "api.commandPaletteEditWorkspaceDescription" + ) { + postCommandPaletteRequest( + name: .commandPaletteEditWorkspaceDescriptionRequested, + preferredWindow: preferredWindow, + source: source, + markPending: true + ) } - private func servicePathURLs(from pasteboard: NSPasteboard) -> [URL] { - if let pathURLs = pasteboard.readObjects(forClasses: [NSURL.self]) as? [URL], !pathURLs.isEmpty { - return pathURLs + private func clearCommandPalettePendingOpen(for window: NSWindow?) { + guard let window, + let windowId = mainWindowId(for: window) else { return } + updateCommandPaletteState(for: windowId) { state in + state.pendingOpen = false + state.recentRequestAt = nil } + } - let filenamesType = NSPasteboard.PasteboardType(rawValue: "NSFilenamesPboardType") - if let paths = pasteboard.propertyList(forType: filenamesType) as? [String] { - let urls = paths.map { URL(fileURLWithPath: $0) } - if !urls.isEmpty { - return urls + private func pruneExpiredCommandPalettePendingOpenStates( + now: TimeInterval = ProcessInfo.processInfo.systemUptime + ) { + 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 + continue } + let age = now - requestedAt + guard age > Self.commandPalettePendingOpenMaxAge else { continue } + commandPaletteStateByWindowId[windowId]?.pendingOpen = false + commandPaletteStateByWindowId[windowId]?.recentRequestAt = nil +#if DEBUG + dlog( + "shortcut.palette.pendingPrune windowId=\(windowId.uuidString.prefix(8)) " + + "reason=stale ageMs=\(Int(age * 1000))" + ) +#endif } - - if let raw = pasteboard.string(forType: .string), !raw.isEmpty { - return raw - .split(whereSeparator: \.isNewline) - .map { line in - let text = String(line).trimmingCharacters(in: .whitespacesAndNewlines) - if let fileURL = URL(string: text), fileURL.isFileURL { - return fileURL - } - return URL(fileURLWithPath: text) - } - } - - return [] } - private func openWorkspaceFromService(workingDirectory: String) { - openWorkspaceForExternalDirectory( - workingDirectory: workingDirectory, - debugSource: "service.openTab" - ) + private func isCommandPalettePendingOpen(for window: NSWindow) -> Bool { + guard let windowId = mainWindowId(for: window) else { return false } + pruneExpiredCommandPalettePendingOpenStates() + return commandPaletteStateByWindowId[windowId]?.pendingOpen == true } - private func prepareForExplicitOpenIntentAtStartup() { - didHandleExplicitOpenIntentAtStartup = true - if !didAttemptStartupSessionRestore { - startupSessionSnapshot = nil - didAttemptStartupSessionRestore = true + private func beginCommandPaletteEscapeSuppression(for window: NSWindow?) { + guard let window, + let windowId = mainWindowId(for: window) else { return } + updateCommandPaletteState(for: windowId) { state in + state.escapeSuppressed = true + state.escapeSuppressionStartedAt = ProcessInfo.processInfo.systemUptime } } - private func externalOpenDirectories(from urls: [URL]) -> [String] { - // LaunchServices can surface the running app bundle on relaunch; ignore self paths so - // they do not get treated as explicit folder opens and suppress session restore. - FinderServicePathResolver.orderedUniqueDirectories( - from: urls.filter { $0.isFileURL }, - excludingDescendantsOf: [Bundle.main.bundleURL] - ) + private func endCommandPaletteEscapeSuppression(for window: NSWindow?) { + guard let window, + let windowId = mainWindowId(for: window) else { return } + updateCommandPaletteState(for: windowId) { state in + state.escapeSuppressed = false + state.escapeSuppressionStartedAt = nil + } } - private func openWorkspaceForExternalDirectory( - workingDirectory: String, - debugSource: String - ) { - if addWorkspaceInPreferredMainWindow( - workingDirectory: workingDirectory, - shouldBringToFront: true, - debugSource: debugSource - ) != nil { - return + private func shouldConsumeSuppressedEscape(event: NSEvent, window: NSWindow?) -> Bool { + guard let window, + let windowId = mainWindowId(for: window), + commandPaletteStateByWindowId[windowId]?.escapeSuppressed == true else { + return false } - _ = createMainWindow(initialWorkingDirectory: workingDirectory) + let startedAt = commandPaletteStateByWindowId[windowId]?.escapeSuppressionStartedAt ?? 0 + if ProcessInfo.processInfo.systemUptime - startedAt <= 0.35 { + return true + } + // Fallback cleanup when keyUp is lost for any reason. + endCommandPaletteEscapeSuppression(for: window) + return false } - @discardableResult - func addWorkspaceInPreferredMainWindow( - workingDirectory: String? = nil, - shouldBringToFront: Bool = false, - event: NSEvent? = nil, - debugSource: String = "unspecified" - ) -> UUID? { - #if DEBUG - logWorkspaceCreationRouting( - phase: "request", - source: debugSource, - reason: "add_workspace", - event: event, - chosenContext: nil, - workingDirectory: workingDirectory - ) - #endif - guard let context = preferredMainWindowContextForWorkspaceCreation(event: event, debugSource: debugSource) else { - #if DEBUG - logWorkspaceCreationRouting( - phase: "no_context", - source: debugSource, - reason: "context_selection_failed", - event: event, - chosenContext: nil, - workingDirectory: workingDirectory - ) - #endif + private func recentCommandPaletteRequestAge(for window: NSWindow?) -> TimeInterval? { + guard let window, + let windowId = mainWindowId(for: window) else { return nil } - guard let window = resolvedWindow(for: context) else { - #if DEBUG - logWorkspaceCreationRouting( - phase: "no_context", - source: debugSource, - reason: "context_window_missing", - event: event, - chosenContext: context, - workingDirectory: workingDirectory - ) - #endif - discardOrphanedMainWindowContext(context) + let now = ProcessInfo.processInfo.systemUptime + pruneExpiredCommandPalettePendingOpenStates(now: now) + guard commandPaletteStateByWindowId[windowId]?.pendingOpen == true else { + commandPaletteStateByWindowId[windowId]?.recentRequestAt = nil return nil } - setActiveMainWindow(window) - if shouldBringToFront { - bringToFront(window) + guard let startedAt = commandPaletteStateByWindowId[windowId]?.recentRequestAt else { + commandPaletteStateByWindowId[windowId]?.pendingOpen = false + return nil } - - let workspace: Workspace - if let workingDirectory { - workspace = context.tabManager.addWorkspace(workingDirectory: workingDirectory, select: true) - } else { - workspace = context.tabManager.addTab(select: true) + let age = now - startedAt + if age <= Self.commandPaletteRequestGraceInterval { + return age } - #if DEBUG - logWorkspaceCreationRouting( - phase: "created", - source: debugSource, - reason: "workspace_created", - event: event, - chosenContext: context, - workspaceId: workspace.id, - workingDirectory: workingDirectory - ) - #endif - return workspace.id + return nil } - private func preferredMainWindowContextForWorkspaceCreation( - event: NSEvent? = nil, - debugSource: String = "unspecified" - ) -> MainWindowContext? { - if let context = mainWindowContext(forShortcutEvent: event, debugSource: debugSource) { - return context - } + private func escapeSuppressionWindow(for event: NSEvent) -> NSWindow? { + commandPaletteWindowForShortcutEvent(event) ?? event.window ?? NSApp.keyWindow ?? NSApp.mainWindow + } - // 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) { + @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 - logWorkspaceCreationRouting( - phase: "choose", - source: debugSource, - reason: "event_context_required_no_fallback", - event: event, - chosenContext: nil + dlog( + "shortcut.escape suppressionClear target={\(debugWindowToken(window))} " + + "keyUpConsumed=\(didConsume ? 1 : 0)" ) #endif - return nil + return didConsume + } + 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 + return didConsume + } - if let keyWindow = NSApp.keyWindow, - let context = contextForMainTerminalWindow(keyWindow) { + func setCommandPaletteVisible(_ visible: Bool, for window: NSWindow) { + guard let windowId = mainWindowId(for: window) else { return } + 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 { + commandPaletteStateByWindowId[windowId]?.pendingOpen = false + commandPaletteStateByWindowId[windowId]?.recentRequestAt = nil + } #if DEBUG - logWorkspaceCreationRouting( - phase: "choose", - source: debugSource, - reason: "key_window", - event: event, - chosenContext: context + if !visible, + !wasVisible, + commandPaletteStateByWindowId[windowId]?.pendingOpen == true { + dlog( + "palette.visibility.retainPending " + + "window={\(debugWindowToken(window))} visible=0 wasVisible=0 pending=1" ) - #endif - return context } +#endif + } - 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 - } + func isCommandPaletteVisible(windowId: UUID) -> Bool { + commandPaletteStateByWindowId[windowId]?.isVisible ?? false + } - 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 - } - } + func setCommandPaletteSelectionIndex(_ index: Int, for window: NSWindow) { + guard let windowId = mainWindowId(for: window) else { return } + updateCommandPaletteState(for: windowId) { $0.selectionIndex = max(0, index) } + } - 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 + func commandPaletteSelectionIndex(windowId: UUID) -> Int { + commandPaletteStateByWindowId[windowId]?.selectionIndex ?? 0 } - 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 + func setCommandPaletteSnapshot(_ snapshot: CommandPaletteDebugSnapshot, for window: NSWindow) { + guard let windowId = mainWindowId(for: window) else { return } + updateCommandPaletteState(for: windowId) { $0.snapshot = snapshot } } - private func mainWindowContext( - forShortcutEvent event: NSEvent?, - debugSource: String = "unspecified" - ) -> MainWindowContext? { - guard let event else { return nil } + func commandPaletteSnapshot(windowId: UUID) -> CommandPaletteDebugSnapshot { + commandPaletteStateByWindowId[windowId]?.snapshot ?? .empty + } - 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 - } + func isCommandPaletteVisible(for window: NSWindow) -> Bool { + guard let windowId = mainWindowId(for: window) else { return false } + return commandPaletteStateByWindowId[windowId]?.isVisible ?? false + } - 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 - } + func isCommandPaletteEffectivelyVisible(for window: NSWindow) -> Bool { + isCommandPaletteEffectivelyVisible(in: window) + } - 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 + 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 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) + } + return false + } - #if DEBUG - logWorkspaceCreationRouting( - phase: "choose", - source: debugSource, - reason: "event_context_not_found", - event: event, - chosenContext: nil - ) - #endif - return nil + private func isFocusStealingResponderWhileCommandPaletteVisible(_ responder: NSResponder) -> Bool { + isCommandPaletteFocusStealingTerminalOrBrowserResponder(responder) } - private func preferredMainWindowContextForShortcutRouting(event: NSEvent) -> MainWindowContext? { - if let context = mainWindowContext(forShortcutEvent: event, debugSource: "shortcut.routing") { - return context + private func isInsideCommandPaletteOverlay(_ view: NSView) -> Bool { + var current: NSView? = view + while let candidate = current { + if candidate.identifier == commandPaletteOverlayContainerIdentifier { + return true + } + current = candidate.superview } + return false + } - 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 + 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 responder as? NSView + } + + 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 } - if let keyWindow = NSApp.keyWindow, - let context = contextForMainTerminalWindow(keyWindow) { - return context + guard let ownerView = keyRoutingOwnerView(for: responder) else { + return false } - if let mainWindow = NSApp.mainWindow, - let context = contextForMainTerminalWindow(mainWindow) { - return context + if ownerView.window !== window { + return false } - if let activeManager = tabManager, - let context = mainWindowContexts.values.first(where: { $0.tabManager === activeManager }) { - return context + if ownerView.isHiddenOrHasHiddenAncestor { + return false + } + + if ownerView !== window.contentView, ownerView.superview == nil { + return false } - return mainWindowContexts.values.first + return true } - @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 + 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 + } - 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) + 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 { + return } + guard responderNeedsFocusedTerminalKeyRepair( + window.firstResponder, + in: window, + hostedView: terminalPanel.hostedView + ) else { return } #if DEBUG - FocusLogStore.shared.append( - "shortcut.route reason=sync activeTM=\(pointerString(tabManager)) chosen={\(summarizeContextForWorkspaceRouting(context))}" + 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 - 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) - } + terminalPanel.hostedView.ensureFocus(for: workspace.id, surfaceId: panelId) - 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 +#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)" ) - let notificationStore = TerminalNotificationStore.shared +#endif + } - let programaConfigStore = ProgramaConfigStore() - programaConfigStore.wireDirectoryTracking(tabManager: tabManager) - programaConfigStore.loadAll() + 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 + } - let root = ContentView(updateViewModel: updateViewModel, windowId: windowId) - .environmentObject(tabManager) - .environmentObject(notificationStore) - .environmentObject(sidebarState) - .environmentObject(sidebarSelectionState) - .environmentObject(programaConfigStore) + /// 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) + } - // 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 let located = locateSurface(surfaceId: panelId), + let workspace = located.tabManager.tabs.first(where: { $0.id == located.workspaceId }), + workspace.panels[panelId] != nil { + return (workspace, located.tabManager) } - 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) + 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) } - 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)) - } + + if let manager = tabManager, + let workspace = manager.tabs.first(where: { $0.panels[panelId] != nil }) { + return (workspace, manager) } - window.contentView = MainWindowHostingView(rootView: root) - // Apply shared window styling. - attachUpdateAccessory(to: window) - applyWindowDecorations(to: window) + return nil + } - // 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 }) + 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) + } + } + } } - window.delegate = controller - mainWindowControllers.append(controller) + return nil + } - 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) + 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 forEachTerminalPanel(_ body: (TerminalPanel) -> Void) { + var seenManagers: Set = [] + + 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) + } } - } else { - window.makeKeyAndOrderFront(nil) - setActiveMainWindow(window) - NSApp.activate(ignoringOtherApps: true) } - if shouldTemporarilyDisallowFullScreenTiling { - DispatchQueue.main.async { [weak window] in - window?.collectionBehavior.remove(.fullScreenDisallowsTiling) - } + + visitManager(tabManager) + for context in mainWindowContexts.values { + visitManager(context.tabManager) } - if let restoredFrame { - window.setFrame(restoredFrame, display: true) -#if DEBUG - dlog( - "session.restore.frameApplied window=\(windowId.uuidString.prefix(8)) " + - "applied={\(debugNSRectDescription(window.frame))}" - ) -#endif + } + + func focusMainWindow(windowId: UUID) -> Bool { + guard let window = windowForMainWindowId(windowId) else { return false } + if TerminalController.shouldSuppressSocketCommandActivation(), + !TerminalController.socketCommandAllowsInAppFocusMutations() { + setActiveMainWindow(window) + return true } - return windowId + bringToFront(window) + return true } - @objc func checkForUpdates(_ sender: Any?) { - updateViewModel.overrideState = nil - updateController.checkForUpdates() + func closeMainWindow(windowId: UUID) -> Bool { + guard let window = windowForMainWindowId(windowId) else { return false } + window.performClose(nil) + return true } - func checkForUpdatesInCustomUI() { - updateViewModel.overrideState = nil - updateController.checkForUpdatesInCustomUI() + 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) + } + } + + return alert.runModal() == .alertFirstButtonReturn } - func openWelcomeWorkspace() { - guard let context = preferredMainWindowContextForWorkspaceCreation(event: nil, debugSource: "welcome") else { - return + @discardableResult + func closeWindowWithConfirmation(_ window: NSWindow) -> Bool { + guard isMainTerminalWindow(window) else { + window.performClose(nil) + return true } - if let window = context.window ?? windowForMainWindowId(context.windowId) { - setActiveMainWindow(window) - bringToFront(window) + guard confirmCloseMainWindow(window) else { return true } + window.performClose(nil) + return true + } + + 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 } - 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) + 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 } - @objc func applyUpdateIfAvailable(_ sender: Any?) { - updateViewModel.overrideState = nil - updateController.installUpdate() + private func workspaceDisplayName(_ workspace: Workspace) -> String { + let trimmed = workspace.title.trimmingCharacters(in: .whitespacesAndNewlines) + return trimmed.isEmpty ? String(localized: "workspace.displayName.fallback", defaultValue: "Workspace") : trimmed } - @objc func attemptUpdate(_ sender: Any?) { - updateViewModel.overrideState = nil - updateController.attemptUpdate() + 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 isProgramaCLIInstalledInPATH() -> Bool { - ProgramaCLIPathInstaller().isInstalled() - } + 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 } - @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 sourceManager.tabs.count > 1 { + sourceManager.closeWorkspace(sourceWorkspace) + } else { + _ = closeMainWindow(windowId: sourceWindowId) } } - @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.") + 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 } - 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 + 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 ) } + + DispatchQueue.main.asyncAfter(deadline: .now() + 0.05, execute: reassert) + DispatchQueue.main.asyncAfter(deadline: .now() + 0.16, execute: reassert) } - 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")) + 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 }) + } - if let window = NSApp.keyWindow ?? NSApp.mainWindow { - alert.beginSheetModal(for: window, completionHandler: nil) - } else { - _ = alert.runModal() + 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) } - @objc func restartSocketListener(_ sender: Any?) { - guard tabManager != nil else { - NSSound.beep() + private func reindexMainWindowContextIfNeeded(_ context: MainWindowContext, for window: NSWindow) { + let desiredKey = ObjectIdentifier(window) + if mainWindowContexts[desiredKey] === context { + context.window = window return } - guard socketListenerConfigurationIfEnabled() != nil else { - TerminalController.shared.stop() - NSSound.beep() + 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 } - 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) - } - ) + mainWindowContexts[desiredKey] = context + context.window = window + notifyMainWindowContextsDidChange() } - 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 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 } - } - private func syncMenuBarExtraVisibility(defaults: UserDefaults = .standard) { - if MenuBarExtraSettings.showsMenuBarExtra(defaults: defaults) { - setupMenuBarExtra() - return + 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 } - menuBarExtraController?.removeFromMenuBar() - menuBarExtraController = nil + return 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]) + 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 } - ) { -#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) - } + for key in removedKeys { + mainWindowContexts.removeValue(forKey: key) } -#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) + notifyMainWindowContextsDidChange() + return removed } - @objc func openPreferencesWindow() { - openPreferencesWindow(debugSource: "appDelegate") - } + 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() - func refreshMenuBarExtraForDebug() { - menuBarExtraController?.refreshForDebugControls() - } + teardownCommandPaletteState(for: context.windowId) - func showNotificationsPopoverFromMenuBar() { - let context: MainWindowContext? = { - if let keyWindow = NSApp.keyWindow, - let keyContext = contextForMainTerminalWindow(keyWindow) { - return keyContext - } - if let first = mainWindowContexts.values.first { - return first + 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) } - 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 let store = notificationStore { + for tab in context.tabManager.tabs { + store.clearNotifications(forTabId: tab.id) + } } } - #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: {})) + 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) } - @objc func hideUpdatePill(_ sender: Any?) { - updateViewModel.debugOverrideText = nil - updateViewModel.overrideState = .idle + 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 } - @objc func clearUpdatePillOverride(_ sender: Any?) { - updateViewModel.debugOverrideText = nil - updateViewModel.overrideState = nil + private func isCommandPaletteOverlayPresented(in window: NSWindow) -> Bool { + guard let container = commandPaletteOverlayContainer(in: window) else { return false } + return !container.isHidden && container.alphaValue > 0.001 } -#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())" + 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) } - let pasteboard = NSPasteboard.general - pasteboard.clearContents() - pasteboard.setString(payload, forType: .string) + return isCommandPaletteResponder(responder) } - @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 + private func isCommandPaletteMultilineTextResponderActive(in window: NSWindow) -> Bool { + guard let textView = window.firstResponder as? NSTextView, + !textView.isFieldEditor else { + return false } + return isCommandPaletteResponder(textView) + } - 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 + private func commandPaletteMarkedTextInput(in window: NSWindow) -> NSTextView? { + if let textView = window.firstResponder as? NSTextView, + isCommandPaletteResponder(textView), + textView.hasMarkedText() { + return textView } - 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 let textField = window.firstResponder as? NSTextField, + let editor = textField.currentEditor() as? NSTextView, + isCommandPaletteResponder(editor), + editor.hasMarkedText() { + return editor } -#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) + return nil } - nonisolated private static func debugShortId(_ id: UUID?) -> String { - id.map { String($0.uuidString.prefix(5)) } ?? "nil" + private func isCommandPaletteEffectivelyVisible(in window: NSWindow) -> Bool { + isCommandPaletteVisible(for: window) + || isCommandPalettePendingOpen(for: window) + || isCommandPaletteOverlayPresented(in: window) + || isCommandPaletteResponderActive(in: window) } - static func resolveTerminalPanelForTextSend(in tab: Tab, preferredPanelId: UUID? = nil) -> TerminalPanel? { - if let preferredPanelId { - return tab.terminalPanel(for: preferredPanelId) + private func activeCommandPaletteWindow() -> NSWindow? { + pruneExpiredCommandPalettePendingOpenStates() + if let keyWindow = NSApp.keyWindow, + isMainTerminalWindow(keyWindow), + isCommandPaletteEffectivelyVisible(in: keyWindow) { + return keyWindow } - return tab.focusedTerminalPanel + 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 = commandPaletteStateByWindowId.first(where: { $0.value.isVisible })?.key { + return windowForMainWindowId(visibleWindowId) + } + if let pendingWindowId = commandPaletteStateByWindowId.first(where: { $0.value.pendingOpen })?.key { + return windowForMainWindowId(pendingWindowId) + } + return nil } - 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)" - ) + private func commandPaletteWindowForShortcutEvent(_ event: NSEvent) -> NSWindow? { + if let scopedWindow = mainWindowForShortcutEvent(event) { + return scopedWindow } -#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) + return activeCommandPaletteWindow() + } + + private func contextForMainWindow(_ window: NSWindow?) -> MainWindowContext? { + guard let window, isMainTerminalWindow(window) else { return nil } + return mainWindowContexts[ObjectIdentifier(window)] + } + #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 + func debugManagerToken(_ manager: TabManager?) -> String { + guard let manager else { return "nil" } + return String(describing: Unmanaged.passUnretained(manager).toOpaque()) + } + + private func debugWindowToken(_ window: NSWindow?) -> String { + guard let window else { return "nil" } + let id = mainWindowId(for: window).map { String($0.uuidString.prefix(8)) } ?? "none" + let ident = window.identifier?.rawValue ?? "nil" + let shortIdent: String + if ident.count > 120 { + shortIdent = String(ident.prefix(120)) + "..." + } else { + shortIdent = ident } + return "num=\(window.windowNumber) id=\(id) ident=\(shortIdent) key=\(window.isKeyWindow ? 1 : 0) main=\(window.isMainWindow ? 1 : 0)" + } - var resolved = false - var readyObserver: NSObjectProtocol? - var focusObserver: NSObjectProtocol? - var firstResponderObserver: NSObjectProtocol? - var panelsCancellable: AnyCancellable? + private func debugContextToken(_ context: MainWindowContext?) -> String { + guard let context else { return "nil" } + let selected = context.tabManager.selectedTabId.map { String($0.uuidString.prefix(5)) } ?? "nil" + let hasWindow = (context.window != nil || windowForMainWindowId(context.windowId) != nil) ? 1 : 0 + return "id=\(String(context.windowId.uuidString.prefix(8))) mgr=\(debugManagerToken(context.tabManager)) tabs=\(context.tabManager.tabs.count) selected=\(selected) hasWindow=\(hasWindow)" + } - 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() - } + private func debugShortcutRouteSnapshot(event: NSEvent? = nil) -> String { + let activeManager = tabManager + let activeWindowId = activeManager.flatMap { windowId(for: $0) }.map { String($0.uuidString.prefix(8)) } ?? "nil" + let selectedWorkspace = activeManager?.selectedTabId.map { String($0.uuidString.prefix(5)) } ?? "nil" - 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)" - ) + let contexts = mainWindowContexts.values + .map { context in + let marker = (activeManager != nil && context.tabManager === activeManager) ? "*" : "-" + let window = context.window ?? windowForMainWindowId(context.windowId) + let selected = context.tabManager.selectedTabId.map { String($0.uuidString.prefix(5)) } ?? "nil" + return "\(marker)\(String(context.windowId.uuidString.prefix(8))){mgr=\(debugManagerToken(context.tabManager)),win=\(window?.windowNumber ?? -1),key=\((window?.isKeyWindow ?? false) ? 1 : 0),main=\((window?.isMainWindow ?? false) ? 1 : 0),tabs=\(context.tabManager.tabs.count),selected=\(selected)}" } + .sorted() + .joined(separator: ",") + + let eventWindowNumber = event.map { String($0.windowNumber) } ?? "nil" + let eventWindow = event?.window + return "eventWinNum=\(eventWindowNumber) eventWin={\(debugWindowToken(eventWindow))} keyWin={\(debugWindowToken(NSApp.keyWindow))} mainWin={\(debugWindowToken(NSApp.mainWindow))} activeMgr=\(debugManagerToken(activeManager)) activeWinId=\(activeWindowId) activeSelected=\(selectedWorkspace) contexts=[\(contexts)]" + } #endif + + private func mainWindowForShortcutEvent(_ event: NSEvent) -> NSWindow? { + if let window = resolvedShortcutEventWindow(event), + isMainTerminalWindow(window) { + return window } + if let keyWindow = NSApp.keyWindow, isMainTerminalWindow(keyWindow) { + return keyWindow + } + if let mainWindow = NSApp.mainWindow, isMainTerminalWindow(mainWindow) { + return mainWindow + } + return nil + } - 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() + private func resolvedShortcutEventWindow(_ event: NSEvent) -> NSWindow? { + if let window = event.window { + return window + } + let eventWindowNumber = event.windowNumber + guard eventWindowNumber > 0 else { return nil } + return NSApp.window(withWindowNumber: eventWindowNumber) + } + + /// Re-sync app-level active window pointers from the currently focused main terminal window. + /// This keeps menu/shortcut actions window-scoped even if the cached `tabManager` drifts. + @discardableResult + func synchronizeActiveMainWindowContext(preferredWindow: NSWindow? = nil) -> TabManager? { + let (context, source): (MainWindowContext?, String) = { + if let preferredWindow, + let context = contextForMainWindow(preferredWindow) { + return (context, "preferredWindow") } - 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 + if let context = contextForMainWindow(NSApp.keyWindow) { + return (context, "keyWindow") } - 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 let context = contextForMainWindow(NSApp.mainWindow) { + return (context, "mainWindow") + } + if let activeManager = tabManager, + let activeContext = mainWindowContexts.values.first(where: { $0.tabManager === activeManager }) { + return (activeContext, "activeManager") + } + return (mainWindowContexts.values.first, "firstContextFallback") + }() + #if DEBUG - dlog( - "reactGrab.pasteback h1.firstResponderEvent " + - "workspace=\(Self.debugShortId(candidateTabId)) " + - "surface=\(Self.debugShortId(candidateSurfaceId)) " + - "target=\(Self.debugShortId(preferredPanelId)) " + - "match=\(candidateSurfaceId == preferredPanelId ? 1 : 0)" - ) + let beforeManagerToken = debugManagerToken(tabManager) + dlog( + "shortcut.sync.pre source=\(source) preferred={\(debugWindowToken(preferredWindow))} chosen={\(debugContextToken(context))} \(debugShortcutRouteSnapshot())" + ) #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 + guard let context else { return tabManager } + let alreadyActive = + tabManager === context.tabManager + && sidebarState === context.sidebarState + && sidebarSelectionState === context.sidebarSelectionState + if alreadyActive { #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)" - ) - } + dlog( + "shortcut.sync.post source=\(source) beforeMgr=\(beforeManagerToken) afterMgr=\(debugManagerToken(tabManager)) chosen={\(debugContextToken(context))} nochange=1 \(debugShortcutRouteSnapshot())" + ) #endif - if let preferredPanelId, - let surfaceId, - surfaceId != preferredPanelId { - return - } - finishIfReady() + return context.tabManager + } + 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) } - 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))" - ) - } + dlog( + "shortcut.sync.post source=\(source) beforeMgr=\(beforeManagerToken) afterMgr=\(debugManagerToken(tabManager)) chosen={\(debugContextToken(context))} \(debugShortcutRouteSnapshot())" + ) #endif - cleanupObservers() - NSLog("Command send: surface not ready after 3.0s") - } + return context.tabManager + } + + private struct FocusedTerminalShortcutContext { + let tabManager: TabManager + let workspaceId: UUID + let panelId: UUID + } + + private func resolveShortcutTabManager(for tabId: UUID, preferredWindow: NSWindow? = nil) -> TabManager? { + if let manager = tabManagerFor(tabId: tabId) { + return manager + } + if let preferredWindow, + let context = contextForMainWindow(preferredWindow), + context.tabManager.tabs.contains(where: { $0.id == tabId }) { + return context.tabManager } + if let activeManager = tabManager, + activeManager.tabs.contains(where: { $0.id == tabId }) { + return activeManager + } + return nil } -#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 + private func focusedTerminalShortcutContext(preferredWindow: NSWindow? = nil) -> FocusedTerminalShortcutContext? { + let targetWindow = preferredWindow ?? NSApp.keyWindow ?? NSApp.mainWindow + let responder = targetWindow?.firstResponder + ?? NSApp.keyWindow?.firstResponder + ?? NSApp.mainWindow?.firstResponder + guard let ghosttyView = cmuxOwningGhosttyView(for: responder), + let workspaceId = ghosttyView.tabId, + let panelId = ghosttyView.terminalSurface?.id, + let manager = resolveShortcutTabManager(for: workspaceId, preferredWindow: targetWindow) else { + return nil + } + return FocusedTerminalShortcutContext( + tabManager: manager, + workspaceId: workspaceId, + panelId: panelId + ) + } - @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) + private func preferredMainWindowContextForShortcuts(event: NSEvent) -> MainWindowContext? { + if let context = contextForMainWindow(event.window) { + return context + } + if let context = contextForMainWindow(NSApp.keyWindow) { + return context + } + if let context = contextForMainWindow(NSApp.mainWindow) { + return context + } + if let activeManager = tabManager, + let activeContext = mainWindowContexts.values.first(where: { $0.tabManager === activeManager }) { + return activeContext + } + return mainWindowContexts.values.first } - @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)) + private func activateMainWindowContextForShortcutEvent(_ event: NSEvent) { + let preferredWindow = mainWindowForShortcutEvent(event) +#if DEBUG + dlog( + "shortcut.activate.pre event=\(NSWindow.keyDescription(event)) preferred={\(debugWindowToken(preferredWindow))} \(debugShortcutRouteSnapshot(event: event))" + ) +#endif + _ = synchronizeActiveMainWindowContext(preferredWindow: preferredWindow) +#if DEBUG + dlog( + "shortcut.activate.post event=\(NSWindow.keyDescription(event)) preferred={\(debugWindowToken(preferredWindow))} \(debugShortcutRouteSnapshot(event: event))" + ) +#endif + } + + @discardableResult + func toggleSidebarInActiveMainWindow() -> Bool { + if let activeManager = tabManager, + let activeContext = mainWindowContexts.values.first(where: { $0.tabManager === activeManager }) { + if let window = activeContext.window ?? windowForMainWindowId(activeContext.windowId) { + setActiveMainWindow(window) + } + activeContext.sidebarState.toggle() + return true + } + if let keyContext = contextForMainWindow(NSApp.keyWindow) { + if let window = keyContext.window ?? windowForMainWindowId(keyContext.windowId) { + setActiveMainWindow(window) + } + keyContext.sidebarState.toggle() + return true } - 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 + if let mainContext = contextForMainWindow(NSApp.mainWindow) { + if let window = mainContext.window ?? windowForMainWindowId(mainContext.windowId) { + setActiveMainWindow(window) + } + mainContext.sidebarState.toggle() + return true } - - for entry in palette { - let title = "\(debugColorWorkspaceTitlePrefix)\(entry.name)" - let targetTab: Workspace - if let existing = existingByTitle[title] { - targetTab = existing - } else { - targetTab = tabManager.addTab() + if let fallbackContext = mainWindowContexts.values.first { + if let window = fallbackContext.window ?? windowForMainWindowId(fallbackContext.windowId) { + setActiveMainWindow(window) } - tabManager.setCustomTitle(tabId: targetTab.id, title: title) - tabManager.setTabColor(tabId: targetTab.id, color: entry.hex) + fallbackContext.sidebarState.toggle() + return true + } + if let sidebarState { + sidebarState.toggle() + return true } + return false } - -#endif - - - func attachUpdateAccessory(to window: NSWindow) { - titlebarAccessoryController.start() - titlebarAccessoryController.attach(to: window) + func sidebarVisibility(windowId: UUID) -> Bool? { + mainWindowContexts.values.first(where: { $0.windowId == windowId })?.sidebarState.isVisible } - func applyWindowDecorations(to window: NSWindow) { - windowDecorationsController.apply(to: window) + /// Toggle the sidebar for the window identified by the given NSWindow. + /// Used by titlebar accessory buttons so they affect their own window + /// rather than the currently focused/key window (#1779). + func toggleSidebarForWindow(_ window: NSWindow?) -> Bool { + if let window { + guard let context = contextForMainTerminalWindow(window) else { + return false + } + context.sidebarState.toggle() + return true + } + return toggleSidebarInActiveMainWindow() } - func toggleNotificationsPopover(animated: Bool = true, anchorView: NSView? = nil) { - titlebarAccessoryController.toggleNotificationsPopover(animated: animated, anchorView: anchorView) + func applicationDockMenu(_ sender: NSApplication) -> NSMenu? { + let menu = NSMenu(title: "") + let newWindowItem = NSMenuItem( + title: String(localized: "menu.file.newWindow", defaultValue: "New Window"), + action: #selector(openNewMainWindow(_:)), + keyEquivalent: "" + ) + newWindowItem.target = self + menu.addItem(newWindowItem) + return menu } - @discardableResult - func dismissNotificationsPopoverIfShown() -> Bool { - titlebarAccessoryController.dismissNotificationsPopoverIfShown() + @objc func openNewMainWindow(_ sender: Any?) { + _ = createMainWindow() } - func isNotificationsPopoverShown() -> Bool { - titlebarAccessoryController.isNotificationsPopoverShown() + /// Shows the "Open Folder" panel and creates a workspace for the selected directory. + /// Called from both the SwiftUI menu and `handleCustomShortcut`. + func showOpenFolderPanel() { + let panel = NSOpenPanel() + panel.canChooseFiles = false + panel.canChooseDirectories = true + panel.allowsMultipleSelection = false + panel.title = String(localized: "menu.file.openFolder.panelTitle", defaultValue: "Open Folder") + panel.prompt = String(localized: "menu.file.openFolder.panelPrompt", defaultValue: "Open") + // Seed the panel with the active workspace's directory. Use the shared + // main-window resolver so this works even when an auxiliary window is key. + if let context = preferredMainWindowContextForWorkspaceCreation(debugSource: "openFolderPanel.seed"), + let cwd = context.tabManager.selectedWorkspace?.currentDirectory, + !cwd.isEmpty { + panel.directoryURL = URL(fileURLWithPath: cwd) + } + if panel.runModal() == .OK, let url = panel.url { + openWorkspaceForExternalDirectory( + workingDirectory: url.path, + debugSource: "shortcut.openFolder" + ) + } } - 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), - ]) + @discardableResult + func openDirectoryInInlineVSCode( + _ directoryURL: URL, + tabManager preferredTabManager: TabManager? = nil + ) -> Bool { + guard let vscodeApplicationURL = TerminalDirectoryOpenTarget.vscodeInline.applicationURL() else { + return false } -#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 - } + + let targetTabManager = preferredTabManager + ?? preferredMainWindowContextForWorkspaceCreation(debugSource: "inlineVSCode.open.target")?.tabManager + guard let targetTabManager else { + return false } - } - static func installWindowResponderSwizzlesForTesting() { - _ = didInstallWindowKeyEquivalentSwizzle - _ = didInstallWindowFirstResponderSwizzle - _ = didInstallWindowSendEventSwizzle - } + let targetWorkspaceId = targetTabManager.selectedWorkspace?.id + ?? targetTabManager.tabs.first?.id + ?? targetTabManager.addWorkspace(select: true).id + let normalizedDirectoryURL = directoryURL.standardizedFileURL -#if DEBUG - static func setWindowFirstResponderGuardTesting(currentEvent: NSEvent?, hitView: NSView?) { - programaFirstResponderGuardCurrentEventOverride = currentEvent - programaFirstResponderGuardHitViewOverride = hitView - } + VSCodeServeWebController.shared.ensureServeWebURL(vscodeApplicationURL: vscodeApplicationURL) { serveWebURL in + guard let serveWebURL, + let openFolderURL = VSCodeServeWebURLBuilder.openFolderURL( + baseWebUIURL: serveWebURL, + directoryPath: normalizedDirectoryURL.path + ) else { + NSSound.beep() + return + } - static func clearWindowFirstResponderGuardTesting() { - programaFirstResponderGuardCurrentEventOverride = nil - programaFirstResponderGuardHitViewOverride = nil - } -#endif + guard targetTabManager.openBrowser( + inWorkspace: targetWorkspaceId, + url: openFolderURL, + preferSplitRight: true + ) != nil else { + NSSound.beep() + return + } + } - private func installWindowResponderSwizzles() { - _ = Self.didInstallApplicationSendEventSwizzle - _ = Self.didInstallWindowKeyEquivalentSwizzle - _ = Self.didInstallWindowFirstResponderSwizzle - _ = Self.didInstallWindowSendEventSwizzle + return true } - 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 + func showOpenFolderInInlineVSCodePanel(tabManager preferredTabManager: TabManager? = nil) { + guard TerminalDirectoryOpenTarget.vscodeInline.isAvailable() else { + NSSound.beep() + return + } + + let targetTabManager = preferredTabManager + ?? preferredMainWindowContextForWorkspaceCreation(debugSource: "inlineVSCode.panel.target")?.tabManager + guard let targetTabManager else { + NSSound.beep() + return } - } - 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() + let panel = NSOpenPanel() + panel.canChooseFiles = false + panel.canChooseDirectories = true + panel.allowsMultipleSelection = false + panel.title = String( + localized: "menu.file.openFolderInVSCodeInline.panelTitle", + defaultValue: "Open Folder in VS Code (Inline)" + ) + panel.prompt = String( + localized: "menu.file.openFolderInVSCodeInline.panelPrompt", + defaultValue: "Open in VS Code" + ) + if let cwd = targetTabManager.selectedWorkspace?.currentDirectory, + !cwd.isEmpty { + panel.directoryURL = URL(fileURLWithPath: cwd) } - } - private func refreshConfiguredShortcutChordActions() { - configuredShortcutChordActions = KeyboardShortcutSettings.Action.allCases.filter { - KeyboardShortcutSettings.shortcut(for: $0).hasChord + if panel.runModal() == .OK, + let url = panel.url, + !openDirectoryInInlineVSCode(url, tabManager: targetTabManager) { + NSSound.beep() } } - private func clearConfiguredShortcutChordState() { - pendingConfiguredShortcutChord = nil - activeConfiguredShortcutChordPrefixForCurrentEvent = nil + @objc func openWindow( + _ pasteboard: NSPasteboard, + userData: String?, + error: AutoreleasingUnsafeMutablePointer + ) { + openFromServicePasteboard(pasteboard, target: .window, error: error) } - /// 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() - } + @objc func openTab( + _ pasteboard: NSPasteboard, + userData: String?, + error: AutoreleasingUnsafeMutablePointer + ) { + openFromServicePasteboard(pasteboard, target: .workspace, error: error) } - private func refreshSplitButtonTooltipsAcrossWorkspaces() { - var refreshedManagers: Set = [] - if let manager = tabManager { - manager.refreshSplitButtonTooltips() - refreshedManagers.insert(ObjectIdentifier(manager)) + private enum ServiceOpenTarget { + case window + case workspace + } + + private func openFromServicePasteboard( + _ pasteboard: NSPasteboard, + target: ServiceOpenTarget, + error: AutoreleasingUnsafeMutablePointer + ) { + let pathURLs = servicePathURLs(from: pasteboard) + guard !pathURLs.isEmpty else { + error.pointee = Self.serviceErrorNoPath + return } - for context in mainWindowContexts.values { - let manager = context.tabManager - let identifier = ObjectIdentifier(manager) - guard refreshedManagers.insert(identifier).inserted else { continue } - manager.refreshSplitButtonTooltips() + + let directories = externalOpenDirectories(from: pathURLs) + guard !directories.isEmpty else { + error.pointee = Self.serviceErrorNoPath + return } - } - private func installGhosttyConfigObserver() { - guard ghosttyConfigObserver == nil else { return } - ghosttyConfigObserver = NotificationCenter.default.addObserver( - forName: .ghosttyConfigDidReload, - object: nil, - queue: .main - ) { [weak self] _ in - self?.refreshGhosttyGotoSplitShortcuts() + prepareForExplicitOpenIntentAtStartup() + for directory in directories { + switch target { + case .window: + _ = createMainWindow(initialWorkingDirectory: directory) + case .workspace: + openWorkspaceFromService(workingDirectory: directory) + } } } - private func refreshGhosttyGotoSplitShortcuts() { - guard let config = GhosttyApp.shared.config else { - ghosttyGotoSplitLeftShortcut = nil - ghosttyGotoSplitRightShortcut = nil - ghosttyGotoSplitUpShortcut = nil - ghosttyGotoSplitDownShortcut = nil - return + private func servicePathURLs(from pasteboard: NSPasteboard) -> [URL] { + if let pathURLs = pasteboard.readObjects(forClasses: [NSURL.self]) as? [URL], !pathURLs.isEmpty { + return pathURLs } - 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)) + let filenamesType = NSPasteboard.PasteboardType(rawValue: "NSFilenamesPboardType") + if let paths = pasteboard.propertyList(forType: filenamesType) as? [String] { + let urls = paths.map { URL(fileURLWithPath: $0) } + if !urls.isEmpty { + return urls + } + } + + if let raw = pasteboard.string(forType: .string), !raw.isEmpty { + return raw + .split(whereSeparator: \.isNewline) + .map { line in + let text = String(line).trimmingCharacters(in: .whitespacesAndNewlines) + if let fileURL = URL(string: text), fileURL.isFileURL { + return fileURL + } + return URL(fileURLWithPath: text) + } + } + + return [] + } + + private func openWorkspaceFromService(workingDirectory: String) { + openWorkspaceForExternalDirectory( + workingDirectory: workingDirectory, + debugSource: "service.openTab" ) - ghosttyGotoSplitDownShortcut = storedShortcutFromGhosttyTrigger( - ghostty_config_trigger(config, "goto_split:down", UInt("goto_split:down".utf8.count)) + } + + private func prepareForExplicitOpenIntentAtStartup() { + didHandleExplicitOpenIntentAtStartup = true + if !didAttemptStartupSessionRestore { + startupSessionSnapshot = nil + didAttemptStartupSessionRestore = true + } + } + + private func externalOpenDirectories(from urls: [URL]) -> [String] { + // LaunchServices can surface the running app bundle on relaunch; ignore self paths so + // they do not get treated as explicit folder opens and suppress session restore. + FinderServicePathResolver.orderedUniqueDirectories( + from: urls.filter { $0.isFileURL }, + excludingDescendantsOf: [Bundle.main.bundleURL] ) } - 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: + private func openWorkspaceForExternalDirectory( + workingDirectory: String, + debugSource: String + ) { + if addWorkspaceInPreferredMainWindow( + workingDirectory: workingDirectory, + shouldBringToFront: true, + debugSource: debugSource + ) != nil { + return + } + _ = createMainWindow(initialWorkingDirectory: workingDirectory) + } + + @discardableResult + func addWorkspaceInPreferredMainWindow( + workingDirectory: String? = nil, + shouldBringToFront: Bool = false, + event: NSEvent? = nil, + debugSource: String = "unspecified" + ) -> UUID? { + #if DEBUG + logWorkspaceCreationRouting( + phase: "request", + source: debugSource, + reason: "add_workspace", + event: event, + chosenContext: nil, + workingDirectory: workingDirectory + ) + #endif + guard let context = preferredMainWindowContextForWorkspaceCreation(event: event, debugSource: debugSource) else { + #if DEBUG + logWorkspaceCreationRouting( + phase: "no_context", + source: debugSource, + reason: "context_selection_failed", + event: event, + chosenContext: nil, + workingDirectory: workingDirectory + ) + #endif 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) { + guard let window = resolvedWindow(for: context) else { + #if DEBUG + logWorkspaceCreationRouting( + phase: "no_context", + source: debugSource, + reason: "context_window_missing", + event: event, + chosenContext: context, + workingDirectory: workingDirectory + ) + #endif + discardOrphanedMainWindowContext(context) return nil } + setActiveMainWindow(window) + if shouldBringToFront { + bringToFront(window) + } - return StoredShortcut(key: key, command: command, shift: shift, option: option, control: control) + 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 handleQuitShortcutWarning() -> Bool { - // Tagged DEV builds are ephemeral, skip quit confirmation entirely. - if SocketControlSettings.isTaggedDevBuild() { - NSApp.terminate(nil) - return true + private func preferredMainWindowContextForWorkspaceCreation( + event: NSEvent? = nil, + debugSource: String = "unspecified" + ) -> MainWindowContext? { + if let context = mainWindowContext(forShortcutEvent: event, debugSource: debugSource) { + return context } - if !QuitWarningSettings.isEnabled() { - NSApp.terminate(nil) - return true + // 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 } - 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 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 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) + 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 } - 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 + 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 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 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 + } - let response = alert.runModal() - guard response == .alertFirstButtonReturn else { return true } - tabManager.setCustomTitle(tabId: tab.id, title: input.stringValue) - return true + 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 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 + 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 } - 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] + + 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 } -#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( + 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, - 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 + chosenContext: context + ) + #endif + return context } - 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 + logWorkspaceCreationRouting( + phase: "choose", + source: debugSource, + reason: "event_context_not_found", + event: event, + chosenContext: nil + ) + #endif + return nil + } -#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))" - ) + private func preferredMainWindowContextForShortcutRouting(event: NSEvent) -> MainWindowContext? { + if let context = mainWindowContext(forShortcutEvent: event, debugSource: "shortcut.routing") { + return context } -#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) + 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 - dlog( - "shortcut.escape requestGraceConsume consumed=1 target={\(debugWindowToken(suppressionWindow))} " + - "ageMs=\(Int(requestAge * 1000)) repeat=\(event.isARepeat ? 1 : 0)" + logWorkspaceCreationRouting( + phase: "choose", + source: "shortcut.routing", + reason: "event_context_required_no_fallback", + event: event, + chosenContext: nil ) #endif - return true + return nil } -#if DEBUG - dlog( - "shortcut.escape paletteDismiss consumed=0 target={\(debugWindowToken(commandPaletteTargetWindow))} " + - "active={\(debugWindowToken(activePaletteWindow))}" - ) -#endif } - let paletteUsesInlineTextHandling = commandPaletteShortcutWindow.map { - isCommandPaletteMultilineTextResponderActive(in: $0) - } ?? false + if let keyWindow = NSApp.keyWindow, + let context = contextForMainTerminalWindow(keyWindow) { + return context + } - let paletteSelectionDelta = commandPaletteSelectionDeltaForKeyboardNavigation( - flags: event.modifierFlags, - chars: chars, - keyCode: event.keyCode - ) + if let mainWindow = NSApp.mainWindow, + let context = contextForMainTerminalWindow(mainWindow) { + return context + } - if shouldRouteCommandPaletteSelectionNavigation( - delta: paletteSelectionDelta, - isInteractive: commandPaletteInteractiveInTargetWindow, - usesInlineTextHandling: paletteUsesInlineTextHandling - ), - let delta = paletteSelectionDelta, - let paletteWindow = commandPaletteShortcutWindow { - NotificationCenter.default.post( - name: .commandPaletteMoveSelection, - object: paletteWindow, - userInfo: ["delta": delta] - ) - return true + if let activeManager = tabManager, + let context = mainWindowContexts.values.first(where: { $0.tabManager === activeManager }) { + 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 - } + return mainWindowContexts.values.first + } - let shouldSubmitPalette = shouldSubmitCommandPaletteWithReturn( - keyCode: event.keyCode, - flags: event.modifierFlags, - mode: paletteSnapshot.mode + @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)" ) -#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 - 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 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) } - // 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.staleClear panel=\(stalePanelToken) " + - "reason=terminal_first_responder fr=\(firstResponderType)" - ) + FocusLogStore.shared.append( + "shortcut.route reason=sync activeTM=\(pointerString(tabManager)) chosen={\(summarizeContextForWorkspaceRouting(context))}" + ) #endif - browserAddressBarFocusedPanelId = nil - stopBrowserOmnibarSelectionRepeat() + 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) } - // 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 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 - 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 - } + let programaConfigStore = ProgramaConfigStore() + programaConfigStore.wireDirectoryTracking(tabManager: tabManager) + programaConfigStore.loadAll() - if !hasFocusedAddressBarInShortcutContext, - matchConfiguredShortcut(event: event, action: .goToWorkspace) { - let targetWindow = commandPaletteTargetWindow ?? event.window ?? NSApp.keyWindow ?? NSApp.mainWindow - requestCommandPaletteSwitcher(preferredWindow: targetWindow, source: "shortcut.goToWorkspace") - return true - } + let root = ContentView(updateViewModel: updateViewModel, windowId: windowId) + .environmentObject(tabManager) + .environmentObject(notificationStore) + .environmentObject(sidebarState) + .environmentObject(sidebarSelectionState) + .environmentObject(programaConfigStore) - if activeConfiguredShortcutChordPrefixForCurrentEvent == nil, - armConfiguredShortcutChordIfNeeded(event: event, actions: [.commandPalette]) { - return true + // 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 activeConfiguredShortcutChordPrefixForCurrentEvent == nil, - !hasFocusedAddressBarInShortcutContext, - armConfiguredShortcutChordIfNeeded(event: event, actions: [.goToWorkspace]) { - 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) + } + 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) - if shouldConsumeShortcutWhileCommandPaletteVisible( - isCommandPaletteVisible: commandPaletteEffectiveInTargetWindow, - normalizedFlags: normalizedFlags, - chars: chars, - keyCode: event.keyCode - ) { - return true - } + // Apply shared window styling. + attachUpdateAccessory(to: window) + applyWindowDecorations(to: window) - // 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 + // 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) - // When the notifications popover is open, Escape should dismiss it immediately. - if flags.isEmpty, event.keyCode == 53, titlebarAccessoryController.dismissNotificationsPopoverIfShown() { - return true + 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) } - - // 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 + if shouldTemporarilyDisallowFullScreenTiling { + DispatchQueue.main.async { [weak window] in + window?.collectionBehavior.remove(.fullScreenDisallowsTiling) + } } - - let hasEventWindowContext = shortcutEventHasAddressableWindow(event) - let didSynchronizeShortcutContext = synchronizeShortcutRoutingContext(event: event) - if hasEventWindowContext && !didSynchronizeShortcutContext { + 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 + } + + @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) + } - // 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 + func sendWelcomeCommandWhenReady(to workspace: Workspace, markShownOnSend: Bool = false) { + sendTextWhenReady("programa welcome\n", to: workspace) { + if markShownOnSend { + UserDefaults.standard.set(true, forKey: WelcomeSettings.shownKey) + } } + } - // 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 - } + @objc func applyUpdateIfAvailable(_ sender: Any?) { + updateViewModel.overrideState = nil + updateController.installUpdate() + } - if let delta = browserOmnibarSelectionDeltaForArrowNavigation( - hasFocusedAddressBar: browserAddressBarFocusedPanelId != nil, - flags: event.modifierFlags, - keyCode: event.keyCode - ) { - dispatchBrowserOmnibarSelectionMove(delta: delta) - return true - } + @objc func attemptUpdate(_ sender: Any?) { + updateViewModel.overrideState = nil + updateController.attemptUpdate() + } - // 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 - } + func isProgramaCLIInstalledInPATH() -> Bool { + ProgramaCLIPathInstaller().isInstalled() + } - // 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 + @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 activeConfiguredShortcutChordPrefixForCurrentEvent == nil, - armConfiguredShortcutChordIfNeeded(event: event) { - 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 matchConfiguredShortcut(event: event, action: .commandPalette) { - let targetWindow = commandPaletteTargetWindow ?? event.window ?? NSApp.keyWindow ?? NSApp.mainWindow - requestCommandPaletteCommands(preferredWindow: targetWindow, source: "shortcut.commandPalette") - 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 !hasFocusedAddressBarInShortcutContext, - matchConfiguredShortcut(event: event, action: .goToWorkspace) { - let targetWindow = commandPaletteTargetWindow ?? event.window ?? NSApp.keyWindow ?? NSApp.mainWindow - requestCommandPaletteSwitcher(preferredWindow: targetWindow, source: "shortcut.goToWorkspace") - return true + if let window = NSApp.keyWindow ?? NSApp.mainWindow { + alert.beginSheetModal(for: window, completionHandler: nil) + } else { + _ = alert.runModal() } + } - if matchConfiguredShortcut(event: event, action: .quit) { - return handleQuitShortcutWarning() - } - 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) } - return true } +#if DEBUG + dlog("settings.open.present activate=1") +#endif + } - // 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 - } + @MainActor + func openPreferencesWindow(debugSource: String, navigationTarget: SettingsNavigationTarget? = nil) { +#if DEBUG + dlog("settings.open.request source=\(debugSource)") +#endif + Self.presentPreferencesWindow(navigationTarget: navigationTarget) + } - // 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 - } + @objc func openPreferencesWindow() { + openPreferencesWindow(debugSource: "appDelegate") + } - // Check Show Notifications shortcut - if matchConfiguredShortcut(event: event, action: .showNotifications) { - toggleNotificationsPopover(animated: false, anchorView: fullscreenControlsViewModel?.notificationsAnchorView) - return true - } + func refreshMenuBarExtraForDebug() { + menuBarExtraController?.refreshForDebugControls() + } - if matchConfiguredShortcut(event: event, action: .sendFeedback) { - guard let targetContext = preferredMainWindowContextForShortcuts(event: event), - let targetWindow = targetContext.window ?? windowForMainWindowId(targetContext.windowId) else { - return false + func showNotificationsPopoverFromMenuBar() { + let context: MainWindowContext? = { + if let keyWindow = NSApp.keyWindow, + let keyContext = contextForMainTerminalWindow(keyWindow) { + return keyContext } - setActiveMainWindow(targetWindow) - bringToFront(targetWindow) - NotificationCenter.default.post(name: .feedbackComposerRequested, object: targetWindow) - return true - } - - // 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"]) + if let first = mainWindowContexts.values.first { + return first } -#endif - jumpToLatestUnread() - return true + 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) } - // Flash the currently focused panel so the user can visually confirm focus. - if matchConfiguredShortcut(event: event, action: .triggerFlash) { - tabManager?.triggerFocusFlash() - return true + DispatchQueue.main.asyncAfter(deadline: .now() + 0.05) { [weak self] in + self?.titlebarAccessoryController.showNotificationsPopover(animated: false) } + } - // Surface navigation: Cmd+Shift+] / Cmd+Shift+[ - if matchConfiguredShortcut(event: event, action: .nextSurface) { - tabManager?.selectNextSurface() - return true + #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())" } - 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) + 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)" + ) } - return true +#endif } - // 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) + 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() } - 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 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 - recordGotoSplitMoveIfNeeded(direction: .left) + 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 - } - if matchConfiguredDirectionalShortcut( - event: event, - action: .focusRight, - arrowGlyph: "→", - arrowKeyCode: 124 - ) || (ghosttyGotoSplitRightShortcut.map { matchDirectionalShortcut(event: event, shortcut: $0, arrowGlyph: "→", arrowKeyCode: 124) } ?? false) { - tabManager?.movePaneFocus(direction: .right) + } + 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 - recordGotoSplitMoveIfNeeded(direction: .right) + dlog( + "reactGrab.pasteback h1.firstResponderEvent " + + "workspace=\(Self.debugShortId(candidateTabId)) " + + "surface=\(Self.debugShortId(candidateSurfaceId)) " + + "target=\(Self.debugShortId(preferredPanelId)) " + + "match=\(candidateSurfaceId == preferredPanelId ? 1 : 0)" + ) #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) + 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 - recordGotoSplitMoveIfNeeded(direction: .up) + 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 - return true + if let preferredPanelId, + let surfaceId, + surfaceId != preferredPanelId { + return + } + finishIfReady() } - if matchConfiguredDirectionalShortcut( - event: event, - action: .focusDown, - arrowGlyph: "↓", - arrowKeyCode: 125 - ) || (ghosttyGotoSplitDownShortcut.map { matchDirectionalShortcut(event: event, shortcut: $0, arrowGlyph: "↓", arrowKeyCode: 125) } ?? false) { - tabManager?.movePaneFocus(direction: .down) + DispatchQueue.main.asyncAfter(deadline: .now() + 3.0) { + if !resolved { #if DEBUG - recordGotoSplitMoveIfNeeded(direction: .down) + 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 - return true + cleanupObservers() + NSLog("Command send: surface not ready after 3.0s") + } } + } - if matchConfiguredShortcut(event: event, action: .toggleSplitZoom) { - _ = tabManager?.toggleFocusedSplitZoom() #if DEBUG - recordGotoSplitZoomIfNeeded() -#endif - 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 + + @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) + } - // 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 + @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 } - if matchConfiguredShortcut(event: event, action: .splitDown) { -#if DEBUG - dlog("shortcut.action name=splitDown \(debugShortcutRouteSnapshot(event: event))") -#endif - if shouldSuppressSplitShortcutForTransientTerminalFocusState(direction: .down) { - return true + for entry in palette { + let title = "\(debugColorWorkspaceTitlePrefix)\(entry.name)" + let targetTab: Workspace + if let existing = existingByTitle[title] { + targetTab = existing + } else { + targetTab = tabManager.addTab() } - _ = performSplitShortcut( - direction: .down, - preferredWindow: event.window ?? NSApp.keyWindow ?? NSApp.mainWindow - ) - return true + tabManager.setCustomTitle(tabId: targetTab.id, title: title) + tabManager.setTabColor(tabId: targetTab.id, color: entry.hex) } + } - if matchConfiguredShortcut(event: event, action: .splitBrowserRight) { -#if DEBUG - dlog("shortcut.action name=splitBrowserRight \(debugShortcutRouteSnapshot(event: event))") -#endif - _ = performBrowserSplitShortcut(direction: .right) - return true - } - 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 - } - // New surface: Cmd+T - if matchConfiguredShortcut(event: event, action: .newSurface) { - tabManager?.newSurface() - return true - } + func attachUpdateAccessory(to window: NSWindow) { + titlebarAccessoryController.start() + titlebarAccessoryController.attach(to: window) + } - // Open browser: Cmd+Shift+L - if matchConfiguredShortcut(event: event, action: .openBrowser) { - _ = openBrowserAndFocusAddressBar(insertAtEnd: true) - return true - } + func applyWindowDecorations(to window: NSWindow) { + windowDecorationsController.apply(to: window) + } - if matchConfiguredShortcut(event: event, action: .focusBrowserAddressBar) { - if let focusedPanel = tabManager?.focusedBrowserPanel { - focusBrowserAddressBar(in: focusedPanel) - return true - } + func toggleNotificationsPopover(animated: Bool = true, anchorView: NSView? = nil) { + titlebarAccessoryController.toggleNotificationsPopover(animated: animated, anchorView: anchorView) + } - if let browserAddressBarFocusedPanelId, - focusBrowserAddressBar(panelId: browserAddressBarFocusedPanelId) { - return true - } + @discardableResult + func dismissNotificationsPopoverIfShown() -> Bool { + titlebarAccessoryController.dismissNotificationsPopoverIfShown() + } - if openBrowserAndFocusAddressBar(insertAtEnd: true) != nil { - return true - } - } + func isNotificationsPopoverShown() -> Bool { + titlebarAccessoryController.isNotificationsPopoverShown() + } - if matchConfiguredShortcut(event: event, action: .browserBack) { - guard let focusedBrowserPanel = tabManager?.focusedBrowserPanel else { - return false + 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.goBack() - return true } + } - if matchConfiguredShortcut(event: event, action: .browserForward) { - guard let focusedBrowserPanel = tabManager?.focusedBrowserPanel else { - return false + 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 } - focusedBrowserPanel.goForward() - return true - } - - if matchConfiguredShortcut(event: event, action: .browserReload) { - guard let focusedBrowserPanel = tabManager?.focusedBrowserPanel else { - return false + self.handleBrowserOmnibarSelectionRepeatLifecycleEvent(event) + if self.clearEscapeSuppressionForKeyUp(event: event, consumeIfSuppressed: true) { + return nil } - focusedBrowserPanel.reload() - return true + return event } + } - // 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 + 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: .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 + private func refreshConfiguredShortcutChordActions() { + configuredShortcutChordActions = KeyboardShortcutSettings.Action.allCases.filter { + KeyboardShortcutSettings.shortcut(for: $0).hasChord } + } - if matchConfiguredShortcut(event: event, action: .toggleReactGrab) { - let didHandle = tabManager?.toggleReactGrabFromCurrentFocus() ?? false - if !didHandle { NSSound.beep() } - return true + 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() } + } - if matchConfiguredShortcut(event: event, action: .browserZoomIn) { - return tabManager?.zoomInFocusedBrowser() ?? false + 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: .browserZoomOut) { - return tabManager?.zoomOutFocusedBrowser() ?? false + 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: .browserZoomReset) { - return tabManager?.resetZoomFocusedBrowser() ?? false + private func refreshGhosttyGotoSplitShortcuts() { + guard let config = GhosttyApp.shared.config else { + ghosttyGotoSplitLeftShortcut = nil + ghosttyGotoSplitRightShortcut = nil + ghosttyGotoSplitUpShortcut = nil + ghosttyGotoSplitDownShortcut = nil + return } - if matchConfiguredShortcut(event: event, action: .find) { - 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?.startSearch() - 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: .findNext) { - guard !shouldLetFocusedBrowserOwnFindShortcut(event) else { - return false - } - tabManager?.findNext() - 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 + + // Ignore bogus empty triggers. + if key.isEmpty || (!command && !shift && !option && !control) { + return nil } - if matchConfiguredShortcut(event: event, action: .findPrevious) { - guard !shouldLetFocusedBrowserOwnFindShortcut(event) else { - return false - } - tabManager?.findPrevious() + 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 matchConfiguredShortcut(event: event, action: .hideFind) { - guard !shouldLetFocusedBrowserOwnFindShortcut(event) else { - return false - } - tabManager?.hideFind() + if !QuitWarningSettings.isEnabled() { + NSApp.terminate(nil) return true } - if matchConfiguredShortcut(event: event, action: .useSelectionForFind) { - tabManager?.searchSelection() - 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 matchConfiguredShortcut(event: event, action: .reopenClosedBrowserPanel) { - _ = tabManager?.reopenMostRecentlyClosedBrowserPanel() - return true + 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 false + return true } - private func shouldSuppressSplitShortcutForTransientTerminalFocusState(direction: SplitDirection) -> Bool { + func promptRenameSelectedWorkspace() -> Bool { guard let tabManager, - let workspace = tabManager.selectedWorkspace, - let focusedPanelId = workspace.focusedPanelId, - let terminalPanel = workspace.terminalPanel(for: focusedPanelId) else { + let tabId = tabManager.selectedTabId, + let tab = tabManager.tabs.first(where: { $0.id == tabId }) else { + NSSound.beep() return false } - 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 - - 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 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 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 + + let response = alert.runModal() + guard response == .alertFirstButtonReturn else { return true } + tabManager.setCustomTitle(tabId: tab.id, title: input.stringValue) 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. + // 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 - 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 + if isControlD { + writeChildExitKeyboardProbe( + [ + "probeAppShortcutCharsHex": childExitKeyboardProbeHex(event.characters), + "probeAppShortcutCharsIgnoringHex": childExitKeyboardProbeHex(event.charactersIgnoringModifiers), + "probeAppShortcutKeyCode": String(event.keyCode), + "probeAppShortcutModsRaw": String(event.modifierFlags.rawValue), + ], + increments: ["probeAppShortcutCtrlDSeenCount": 1] + ) } +#endif - 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)" + // 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 } - 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)" - } - - private func redactedDebugURL(_ url: URL?) -> String { - guard let url else { return "nil" } - guard var components = URLComponents(url: url, resolvingAgainstBaseURL: false) else { - return "" + if NSApp.modalWindow != nil || NSApp.keyWindow?.attachedSheet != nil { + return false } - 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 { + 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( - "browser.focus.addressBar.route panel=\(panelId.uuidString.prefix(5)) " + - "result=miss \(browserFocusStateSnapshot())" + "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 - 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 - } - - @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 { + } +#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( - "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] + ) + return true + } + + 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 let workspace = context.tabManager.selectedWorkspace 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=no_workspace event=\(NSWindow.keyDescription(event))" + "browser.focus.addressBar.staleClear panel=\(stalePanelToken) " + + "reason=terminal_first_responder fr=\(firstResponderType)" ) #endif - return nil + browserAddressBarFocusedPanelId = nil + stopBrowserOmnibarSelectionRepeat() + } + + // 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 + + 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 !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 + } + + // 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 } - guard workspace.browserPanel(for: panelId) != nil else { + let hasEventWindowContext = shortcutEventHasAddressableWindow(event) + let didSynchronizeShortcutContext = synchronizeShortcutRoutingContext(event: event) + if hasEventWindowContext && !didSynchronizeShortcutContext { #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))" - ) + dlog("handleCustomShortcut: unresolved event window context; bypassing app shortcut handling") #endif - return nil + return false } + // 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.addressBar.shortcutContext panel=\(panelId.uuidString.prefix(5)) " + - "accepted=1 workspace=\(workspace.id.uuidString.prefix(5)) event=\(NSWindow.keyDescription(event))" - ) + 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 panelId - } - - @discardableResult - func requestBrowserAddressBarFocus(panelId: UUID) -> Bool { - focusBrowserAddressBar(panelId: panelId) - } + 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 + } - 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)" - ) + // 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 } -#endif - return shouldBypass - } - private func commandOmnibarSelectionDelta( - flags: NSEvent.ModifierFlags, - chars: String - ) -> Int? { - browserOmnibarSelectionDeltaForCommandNavigation( + if let delta = browserOmnibarSelectionDeltaForArrowNavigation( hasFocusedAddressBar: browserAddressBarFocusedPanelId != nil, - flags: flags, - chars: chars - ) - } - - 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] - ) - } + flags: event.modifierFlags, + keyCode: event.keyCode + ) { + dispatchBrowserOmnibarSelectionMove(delta: delta) + return true + } - 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 + // 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 } - 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 + // 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 } - 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" + return handleConfiguredAppShortcutActions( + event: event, + commandPaletteTargetWindow: commandPaletteTargetWindow, + hasFocusedAddressBarInShortcutContext: hasFocusedAddressBarInShortcutContext ) -#endif + } - let start = DispatchWorkItem { [weak self] in - self?.scheduleBrowserOmnibarSelectionRepeatTick() + // 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 } - 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 + if matchConfiguredShortcut(event: event, action: .commandPalette) { + let targetWindow = commandPaletteTargetWindow ?? event.window ?? NSApp.keyWindow ?? NSApp.mainWindow + requestCommandPaletteCommands(preferredWindow: targetWindow, source: "shortcut.commandPalette") + return true } - 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 !hasFocusedAddressBarInShortcutContext, + matchConfiguredShortcut(event: event, action: .goToWorkspace) { + let targetWindow = commandPaletteTargetWindow ?? event.window ?? NSApp.keyWindow ?? NSApp.mainWindow + requestCommandPaletteSwitcher(preferredWindow: targetWindow, source: "shortcut.goToWorkspace") + return true + } - let tick = DispatchWorkItem { [weak self] in - self?.scheduleBrowserOmnibarSelectionRepeatTick() + 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 } - 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 matchConfiguredShortcut(event: event, action: .toggleFullScreen) { + guard let targetWindow = mainWindowForShortcutEvent(event) else { + return false + } + targetWindow.toggleFullScreen(nil) + return true } -#endif - } - private func handleBrowserOmnibarSelectionRepeatLifecycleEvent(_ event: NSEvent) { - guard browserOmnibarRepeatKeyCode != nil else { return } + // Primary UI shortcuts + if matchConfiguredShortcut(event: event, action: .toggleSidebar) { + _ = toggleSidebarInActiveMainWindow() + return true + } - switch event.type { - case .keyUp: - if event.keyCode == browserOmnibarRepeatKeyCode { + if matchConfiguredShortcut(event: event, action: .newTab) { #if DEBUG - dlog( - "browser.focus.omnibar.repeat.lifecycle event=keyUp key=\(event.keyCode) " + - "action=stop" - ) + dlog("shortcut.action name=newWorkspace \(debugShortcutRouteSnapshot(event: event))") #endif - stopBrowserOmnibarSelectionRepeat() - } - case .flagsChanged: - let flags = event.modifierFlags.intersection(.deviceIndependentFlagsMask) - if !flags.contains(.command) { -#if DEBUG - dlog( - "browser.focus.omnibar.repeat.lifecycle event=flagsChanged " + - "flags=\(flags.rawValue) action=stop" + // 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 - stopBrowserOmnibarSelectionRepeat() + #endif + openNewMainWindow(nil) } - default: - break + return true } - } - private func isLikelyWebInspectorResponder(_ responder: NSResponder?) -> Bool { - programaIsLikelyWebInspectorResponder(responder) - } + // 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 DEBUG - private func developerToolsShortcutProbeKind(event: NSEvent) -> String? { - if matchShortcut(event: event, shortcut: KeyboardShortcutSettings.shortcut(for: .toggleBrowserDeveloperTools)) { - return "toggle.configured" + // 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 } - 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 + dlog("shortcut.cmdW route=browserPopup") +#endif + targetWindow.performClose(nil) + return true + } else if let targetWindow, + programaWindowShouldOwnCloseShortcut(targetWindow) { + targetWindow.performClose(nil) + } else { + 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 - 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 + dlog("shortcut.cmdW route=noManager") +#endif + return false } - 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)") - } else { - dlog("split.shortcut dir=\(directionLabel) post panel=nil \(splitContext)") } + 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 - } - - /// 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) - } - - @discardableResult - func requestRenameWorkspaceViaCommandPalette(preferredWindow: NSWindow? = nil) -> Bool { - let targetWindow = preferredWindow ?? NSApp.keyWindow ?? NSApp.mainWindow - requestCommandPaletteRenameWorkspace( - preferredWindow: targetWindow, - source: "shortcut.renameWorkspace" - ) - 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 + } - @discardableResult - func requestEditWorkspaceDescriptionViaCommandPalette(preferredWindow: NSWindow? = nil) -> Bool { - let targetWindow = preferredWindow ?? NSApp.keyWindow ?? NSApp.mainWindow + // 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.editWorkspaceDescription request target={\(debugWindowToken(targetWindow))} " + - "fr=\(targetWindow?.firstResponder.map { String(describing: type(of: $0)) } ?? "nil")" - ) + dlog( + "shortcut.action name=workspaceDigit digit=\(digit) targetIndex=\(targetIndex) manager=\(debugManagerToken(manager)) \(debugShortcutRouteSnapshot(event: event))" + ) #endif - requestCommandPaletteEditWorkspaceDescription( - preferredWindow: targetWindow, - source: "shortcut.editWorkspaceDescription" - ) - return true - } - -#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) - } - - // Debug/test hook: mirrors local monitor routing (keyDown + keyUp lifecycle). - func debugHandleShortcutMonitorEvent(event: NSEvent) -> Bool { - if event.type == .keyDown { - return handleCustomShortcut(event: event) + manager.selectTab(at: targetIndex) + } + return true } - handleBrowserOmnibarSelectionRepeatLifecycleEvent(event) - return clearEscapeSuppressionForKeyUp(event: event, consumeIfSuppressed: true) - } - - func debugMarkCommandPaletteOpenPending(window: NSWindow) { - markCommandPaletteOpenRequested(for: window) - } - @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 - } - - // 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 + // 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 } - 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 + // 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 } - for key in contextKeys { - mainWindowContexts.removeValue(forKey: key) + 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 } - mainWindowContexts[ObjectIdentifier(detachedWindow)] = context - context.window = window - 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 - - private func findButton(in view: NSView, titled title: String) -> NSButton? { - if let button = view as? NSButton, button.title == title { - return button + return true } - for subview in view.subviews { - if let found = findButton(in: subview, titled: title) { - return found - } + 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 } - return nil - } - private func findStaticText(in view: NSView, equals text: String) -> Bool { - if let field = view as? NSTextField, field.stringValue == text { + if matchConfiguredShortcut(event: event, action: .toggleSplitZoom) { + _ = tabManager?.toggleFocusedSplitZoom() +#if DEBUG + recordGotoSplitZoomIfNeeded() +#endif + return true + } + + // 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 } - for subview in view.subviews { - if findStaticText(in: subview, equals: text) { + + 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 } - 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 - } - return matchShortcutStroke(event: event, stroke: secondStroke) + if matchConfiguredShortcut(event: event, action: .splitBrowserRight) { +#if DEBUG + dlog("shortcut.action name=splitBrowserRight \(debugShortcutRouteSnapshot(event: event))") +#endif + _ = performBrowserSplitShortcut(direction: .right) + return true } - 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 - } - return numberedShortcutDigit(event: event, stroke: secondStroke) + if matchConfiguredShortcut(event: event, action: .splitBrowserDown) { +#if DEBUG + dlog("shortcut.action name=splitBrowserDown \(debugShortcutRouteSnapshot(event: event))") +#endif + _ = performBrowserSplitShortcut(direction: .down) + return true } - 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 { - return false - } - return matchDirectionalShortcut( - event: event, - stroke: secondStroke, - arrowGlyph: arrowGlyph, - arrowKeyCode: arrowKeyCode - ) + // 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 } - 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 + // New surface: Cmd+T + if matchConfiguredShortcut(event: event, action: .newSurface) { + tabManager?.newSurface() + return true } - if let window = event.window { - return window.windowNumber + + // Open browser: Cmd+Shift+L + if matchConfiguredShortcut(event: event, action: .openBrowser) { + _ = openBrowserAndFocusAddressBar(insertAtEnd: true) + 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) - ) + if matchConfiguredShortcut(event: event, action: .focusBrowserAddressBar) { + if let focusedPanel = tabManager?.focusedBrowserPanel { + focusBrowserAddressBar(in: focusedPanel) 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 } + if let browserAddressBarFocusedPanelId, + focusBrowserAddressBar(panelId: browserAddressBarFocusedPanelId) { + return true + } - let shortcutKey = stroke.key.lowercased() - if shortcutKey == "\r" { - return event.keyCode == 36 || event.keyCode == 76 + if openBrowserAndFocusAddressBar(insertAtEnd: true) != nil { + return true + } } - let eventCharsIgnoringModifiers = event.charactersIgnoringModifiers - if shortcutCharacterMatches( - eventCharacter: eventCharsIgnoringModifiers, - shortcutKey: shortcutKey, - applyShiftSymbolNormalization: flags.contains(.shift), - eventKeyCode: event.keyCode - ) { + if matchConfiguredShortcut(event: event, action: .browserBack) { + guard let focusedBrowserPanel = tabManager?.focusedBrowserPanel else { + return false + } + focusedBrowserPanel.goBack() 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: .browserForward) { + guard let focusedBrowserPanel = tabManager?.focusedBrowserPanel else { + return false + } + focusedBrowserPanel.goForward() + 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: .browserReload) { + guard let focusedBrowserPanel = tabManager?.focusedBrowserPanel else { + return false + } + focusedBrowserPanel.reload() 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 + // 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 } - return false - } - - private func matchShortcut(event: NSEvent, shortcut: StoredShortcut) -> Bool { - guard !shortcut.hasChord else { return false } - return matchShortcutStroke(event: event, stroke: shortcut.firstStroke) - } - 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: .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 + } - if let digit = numberedShortcutDigit( - eventCharacter: event.charactersIgnoringModifiers, - applyShiftSymbolNormalization: flags.contains(.shift), - eventKeyCode: event.keyCode - ) { - return digit + if matchConfiguredShortcut(event: event, action: .toggleReactGrab) { + let didHandle = tabManager?.toggleReactGrabFromCurrentFocus() ?? false + if !didHandle { NSSound.beep() } + 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: .browserZoomIn) { + return tabManager?.zoomInFocusedBrowser() ?? false } - return numberKeyDigit - } + if matchConfiguredShortcut(event: event, action: .browserZoomOut) { + return tabManager?.zoomOutFocusedBrowser() ?? false + } - 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: .browserZoomReset) { + return tabManager?.resetZoomFocusedBrowser() ?? false + } - 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: .find) { + guard !shouldLetFocusedBrowserOwnFindShortcut(event) else { + return false + } + tabManager?.startSearch() + 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: .findNext) { + guard !shouldLetFocusedBrowserOwnFindShortcut(event) else { + return false + } + tabManager?.findNext() + 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: .findPrevious) { + guard !shouldLetFocusedBrowserOwnFindShortcut(event) else { + return false + } + tabManager?.findPrevious() return true } - return false - } - private func normalizedShortcutEventCharacter( - _ eventCharacter: String, - applyShiftSymbolNormalization: Bool, - eventKeyCode: UInt16 - ) -> String { - let lowered = eventCharacter.lowercased() - guard applyShiftSymbolNormalization else { return lowered } + if matchConfiguredShortcut(event: event, action: .hideFind) { + guard !shouldLetFocusedBrowserOwnFindShortcut(event) else { + return false + } + tabManager?.hideFind() + return true + } - 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 matchConfiguredShortcut(event: event, action: .useSelectionForFind) { + tabManager?.searchSelection() + return true } - } - 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 + if matchConfiguredShortcut(event: event, action: .reopenClosedBrowserPanel) { + _ = tabManager?.reopenMostRecentlyClosedBrowserPanel() + return true } + + return false } - 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 + 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 } - } - /// 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 - } + 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 - /// 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 shouldSuppress = shouldSuppressSplitShortcutForTransientTerminalFocusInputs( + firstResponderIsWindow: firstResponderIsWindow, + hostedSize: hostedSize, + hostedHiddenInHierarchy: hostedHiddenInHierarchy, + hostedAttachedToWindow: hostedAttachedToWindow + ) + guard shouldSuppress else { return false } - private func matchTabShortcut(event: NSEvent, shortcut: StoredShortcut) -> Bool { - guard !shortcut.hasChord else { return false } - return matchTabShortcut(event: event, stroke: shortcut.firstStroke) + 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 } - /// 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( +#if DEBUG + private func logBrowserZoomShortcutTrace( + stage: String, event: NSEvent, - stroke: ShortcutStroke, - arrowGlyph: String, - arrowKeyCode: UInt16 - ) -> Bool { - if stroke.key == arrowGlyph { - return matchArrowShortcut(event: event, stroke: stroke, keyCode: arrowKeyCode) + 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 } - return matchShortcutStroke(event: event, stroke: stroke) - } - 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 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) } - func validateMenuItem(_ item: NSMenuItem) -> Bool { - updateController.validateMenuItem(item) + 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)" } + private func redactedDebugURL(_ url: URL?) -> String { + guard let url else { return "nil" } + guard var components = URLComponents(url: url, resolvingAgainstBaseURL: false) else { + return "" + } + components.user = nil + components.password = nil + components.query = nil + components.fragment = nil + return components.string ?? "" + } +#endif - private func configureUserNotifications() { - let actions = [ - UNNotificationAction( - identifier: TerminalNotificationStore.actionShowIdentifier, - title: "Show" + @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())" ) - ] - - let category = UNNotificationCategory( - identifier: TerminalNotificationStore.categoryIdentifier, - actions: actions, - intentIdentifiers: [], - options: [.customDismissAction] +#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())" ) - - let center = UNUserNotificationCenter.current() - center.setNotificationCategories([category]) - center.delegate = self +#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 } - private func disableNativeTabbingShortcut() { - guard let menu = NSApp.mainMenu else { return } - disableMenuItemShortcut(in: menu, action: #selector(NSWindow.toggleTabBar(_:))) + @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())" + ) +#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 } - 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 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 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() + func focusedBrowserAddressBarPanelId() -> UUID? { + browserAddressBarFocusedPanelId } - 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 - ]) + private func focusedBrowserAddressBarPanelIdForShortcutEvent(_ event: NSEvent) -> UUID? { + guard let panelId = browserAddressBarFocusedPanelId else { return nil } - scheduler { - let startedAt = CFAbsoluteTimeGetCurrent() - let registerStatus = register(normalizedURL as CFURL) - let durationMs = Int(((CFAbsoluteTimeGetCurrent() - startedAt) * 1000).rounded()) + 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 + } - breadcrumb("launchservices.register.complete", [ - "bundlePath": normalizedURL.path, - "status": Int(registerStatus), - "durationMs": durationMs - ]) + 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 + } - if registerStatus != noErr { - NSLog("LaunchServices registration failed (status: \(registerStatus)) for \(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 } - } #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 + 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 + } - private func enforceSingleInstance() { - guard let bundleId = Bundle.main.bundleIdentifier else { return } - let currentPid = ProcessInfo.processInfo.processIdentifier + @discardableResult + func requestBrowserAddressBarFocus(panelId: UUID) -> Bool { + focusBrowserAddressBar(panelId: panelId) + } - for app in NSRunningApplication.runningApplications(withBundleIdentifier: bundleId) { - guard app.processIdentifier != currentPid else { continue } - app.terminate() - if !app.isTerminated { - _ = app.forceTerminate() - } + 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 } - 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 func commandOmnibarSelectionDelta( + flags: NSEvent.ModifierFlags, + chars: String + ) -> Int? { + browserOmnibarSelectionDeltaForCommandNavigation( + hasFocusedAddressBar: browserAddressBarFocusedPanelId != nil, + flags: flags, + chars: chars + ) + } - 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 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] + ) + } - app.terminate() - if !app.isTerminated { - _ = app.forceTerminate() - } - NSRunningApplication.current.activate(options: [.activateAllWindows, .activateIgnoringOtherApps]) + 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 } - } - func userNotificationCenter( - _ center: UNUserNotificationCenter, - didReceive response: UNNotificationResponse, - withCompletionHandler completionHandler: @escaping () -> Void - ) { - handleNotificationResponse(response) - completionHandler() - } + 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, - willPresent notification: UNNotification, - withCompletionHandler completionHandler: @escaping (UNNotificationPresentationOptions) -> Void - ) { - var options: UNNotificationPresentationOptions = [.banner, .list] - if notification.request.content.sound != nil { - options.insert(.sound) + 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 + + 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)") } } - - // 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 - ) - ) - } + recordGotoSplitSplitIfNeeded(direction: direction) +#endif + return didCreateSplit } - private func isMainTerminalWindow(_ window: NSWindow) -> Bool { - if mainWindowContexts[ObjectIdentifier(window)] != nil { - return true + @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" } - guard let raw = window.identifier?.rawValue else { return false } - return raw == "cmux.main" || raw.hasPrefix("cmux.main.") - } + 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 - private func contextContainingTabId(_ tabId: UUID) -> MainWindowContext? { - for context in mainWindowContexts.values { - if context.tabManager.tabs.contains(where: { $0.id == tabId }) { - return context - } + guard let panelId = tabManager?.createBrowserSplit(direction: direction) else { + #if DEBUG + dlog("split.browser.shortcut failed dir=\(directionLabel)") + #endif + return false } - return nil - } - /// Returns the `TabManager` that owns `tabId`, if any. - func tabManagerFor(tabId: UUID) -> TabManager? { - contextContainingTabId(tabId)?.tabManager - } + #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 - private func workspaceForMainActor(tabId: UUID) -> Workspace? { - contextContainingTabId(tabId)?.tabManager.tabs.first(where: { $0.id == tabId }) + _ = focusBrowserAddressBar(panelId: panelId) + return true } - /// Returns the `Workspace` that owns `tabId`, if any. - @MainActor - func workspaceFor(tabId: UUID) -> Workspace? { - workspaceForMainActor(tabId: tabId) + /// 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) } - 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) + @discardableResult + func requestRenameWorkspaceViaCommandPalette(preferredWindow: NSWindow? = nil) -> Bool { + let targetWindow = preferredWindow ?? NSApp.keyWindow ?? NSApp.mainWindow + requestCommandPaletteRenameWorkspace( + preferredWindow: targetWindow, + source: "shortcut.renameWorkspace" + ) + return true } @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 ?? "", - ]) - } -#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) + func requestEditWorkspaceDescriptionViaCommandPalette(preferredWindow: NSWindow? = nil) -> Bool { + let targetWindow = preferredWindow ?? NSApp.keyWindow ?? NSApp.mainWindow #if DEBUG - if isJumpUnreadUITest { - writeJumpUnreadTestData(["jumpUnreadOpenResult": ok ? "1" : "0"]) - } + dlog( + "shortcut.editWorkspaceDescription request target={\(debugWindowToken(targetWindow))} " + + "fr=\(targetWindow?.firstResponder.map { String(describing: type(of: $0)) } ?? "nil")" + ) #endif - return ok - } + requestCommandPaletteEditWorkspaceDescription( + preferredWindow: targetWindow, + source: "shortcut.editWorkspaceDescription" + ) + return true + } + #if DEBUG - if isJumpUnreadUITest { - writeJumpUnreadTestData(["jumpUnreadOpenContextFound": "1", "jumpUnreadOpenUsedFallback": "0"]) + // 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) + } + + // Debug/test hook: mirrors local monitor routing (keyDown + keyUp lifecycle). + func debugHandleShortcutMonitorEvent(event: NSEvent) -> Bool { + if event.type == .keyDown { + return handleCustomShortcut(event: event) } -#endif - return openNotificationInContext(context, tabId: tabId, surfaceId: surfaceId, notificationId: notificationId) + handleBrowserOmnibarSelectionRepeatLifecycleEvent(event) + return clearEscapeSuppressionForKeyUp(event: event, consumeIfSuppressed: true) } - 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 + func debugMarkCommandPaletteOpenPending(window: NSWindow) { + markCommandPaletteOpenRequested(for: window) + } + + @discardableResult + func debugSetCommandPalettePendingOpenAge(window: NSWindow, age: TimeInterval) -> Bool { + guard let windowId = mainWindowId(for: window) else { return false } + updateCommandPaletteState(for: windowId) { state in + state.pendingOpen = true + state.recentRequestAt = ProcessInfo.processInfo.systemUptime - max(age, 0) } + return true + } - 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"]) - } -#endif + // 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 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 + let detachedWindow = NSWindow( + contentRect: NSRect(x: 0, y: 0, width: 16, height: 16), + styleMask: [.borderless], + backing: .buffered, + defer: false ) -#endif + debugDetachedContextWindows.append(detachedWindow) - if let notificationId, let store = notificationStore { - markReadIfFocused( - notificationId: notificationId, - tabId: tabId, - surfaceId: surfaceId, - tabManager: context.tabManager, - notificationStore: store - ) + let contextKeys = mainWindowContexts.compactMap { key, value in + value === context ? key : nil } - -#if DEBUG - 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"]) + for key in contextKeys { + mainWindowContexts.removeValue(forKey: key) } -#endif + mainWindowContexts[ObjectIdentifier(detachedWindow)] = context + context.window = window return true } +#endif - 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"]) + 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 } -#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"]) + return nil + } + + 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 + } + + 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 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"]) + 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 } -#endif - return false + return numberedShortcutDigit(event: event, stroke: secondStroke) } + guard !shortcut.hasChord else { return nil } + return numberedShortcutDigit(event: event, stroke: shortcut.firstStroke) + } - 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 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 } -#endif - return false + return matchDirectionalShortcut( + event: event, + stroke: secondStroke, + arrowGlyph: arrowGlyph, + arrowKeyCode: arrowKeyCode + ) } - -#if DEBUG - recordJumpUnreadFocusFromModelIfNeeded( - tabManager: tabManager, - tabId: tabId, - expectedSurfaceId: surfaceId + guard !shortcut.hasChord else { return false } + return matchDirectionalShortcut( + event: event, + stroke: shortcut.firstStroke, + arrowGlyph: arrowGlyph, + arrowKeyCode: arrowKeyCode ) -#endif + } - if let notificationId, let store = notificationStore { - markReadIfFocused( - notificationId: notificationId, - tabId: tabId, - surfaceId: surfaceId, - tabManager: tabManager, - notificationStore: store - ) + private func configuredShortcutChordWindowNumber(for event: NSEvent) -> Int? { + if let window = mainWindowForShortcutEvent(event) { + return window.windowNumber } -#if DEBUG - if ProcessInfo.processInfo.environment["PROGRAMA_UI_TEST_JUMP_UNREAD_SETUP"] == "1" { - writeJumpUnreadTestData(["jumpUnreadOpenInFallback": "1", "jumpUnreadOpenResult": "1"]) + if let window = event.window { + return window.windowNumber } -#endif - return true + return event.windowNumber > 0 ? event.windowNumber : nil } -#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 } + 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 + } + } + return false + } - // Ensure the expectation is armed even if the view doesn't become first responder. - armJumpUnreadFocusRecord(tabId: tabId, surfaceId: expectedSurfaceId) + /// 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 tabManager.selectedTabId == tabId, - tabManager.focusedSurfaceId(for: tabId) == expectedSurfaceId { - recordJumpUnreadFocusIfExpected(tabId: tabId, surfaceId: expectedSurfaceId) - return + let shortcutKey = stroke.key.lowercased() + if shortcutKey == "\r" { + return event.keyCode == 36 || event.keyCode == 76 } - var resolved = false - var observers: [NSObjectProtocol] = [] - var cancellables: [AnyCancellable] = [] + let eventCharsIgnoringModifiers = event.charactersIgnoringModifiers + if shortcutCharacterMatches( + eventCharacter: eventCharsIgnoringModifiers, + shortcutKey: shortcutKey, + applyShiftSymbolNormalization: flags.contains(.shift), + eventKeyCode: event.keyCode + ) { + return true + } - func cleanup() { - observers.forEach { NotificationCenter.default.removeObserver($0) } - observers.removeAll() - cancellables.forEach { $0.cancel() } - cancellables.removeAll() + // 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 } - @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) + // 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 } - 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() } - }) + // 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 } - DispatchQueue.main.asyncAfter(deadline: .now() + 2.0) { - Task { @MainActor in - guard !resolved else { return } - cleanup() + return false + } + + private func matchShortcut(event: NSEvent, shortcut: StoredShortcut) -> Bool { + guard !shortcut.hasChord else { return false } + return matchShortcutStroke(event: event, stroke: shortcut.firstStroke) + } + + 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 let digit = numberedShortcutDigit( + eventCharacter: event.charactersIgnoringModifiers, + applyShiftSymbolNormalization: flags.contains(.shift), + eventKeyCode: event.keyCode + ) { + return digit + } + + 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 } + teardownCommandPaletteState(for: 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/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/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 e17214dc0e9..cfc56e5321f 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() @@ -370,589 +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 - } - } -} - -/// 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 @@ -1018,7 +435,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") @@ -1823,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) @@ -1888,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) @@ -1925,7 +1485,7 @@ struct ContentView: View { } } - private struct CommandPaletteWorkspaceDescriptionTarget: Equatable { + struct CommandPaletteWorkspaceDescriptionTarget: Equatable { let workspaceId: UUID let currentDescription: String @@ -1944,7 +1504,7 @@ struct ContentView: View { } } - private struct CommandPaletteRestoreFocusTarget { + struct CommandPaletteRestoreFocusTarget { let workspaceId: UUID let panelId: UUID let intent: PanelFocusIntent @@ -1955,7 +1515,7 @@ struct ContentView: View { case rename } - private enum CommandPaletteTextSelectionBehavior { + enum CommandPaletteTextSelectionBehavior { case caretAtEnd case selectAll } @@ -3895,7 +3455,7 @@ struct ContentView: View { HStack(spacing: 8) { CommandPaletteSearchFieldRepresentable( placeholder: commandPaletteSearchPlaceholder, - text: $commandPaletteQuery, + text: commandPaletteQueryBinding, isFocused: Binding( get: { isCommandPaletteSearchFocused }, set: { isCommandPaletteSearchFocused = $0 } @@ -4115,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 @@ -4199,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) }, @@ -4488,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)? @@ -4616,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 = { @@ -4821,7 +4381,7 @@ struct ContentView: View { } } - private struct CommandPaletteMultilineTextEditorRepresentable: NSViewRepresentable { + struct CommandPaletteMultilineTextEditorRepresentable: NSViewRepresentable { static let defaultMinimumHeight = CommandPaletteMultilineTextEditorView.defaultMinimumHeight let placeholder: String @@ -7731,1007 +7291,20 @@ 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) +private struct SidebarResizerAccessibilityModifier: ViewModifier { + let accessibilityIdentifier: String? - 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) + @ViewBuilder + func body(content: Content) -> some View { + if let accessibilityIdentifier { + content.accessibilityIdentifier(accessibilityIdentifier) + } else { + content } - 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? - - @ViewBuilder - func body(content: Content) -> some View { - if let accessibilityIdentifier { - content.accessibilityIdentifier(accessibilityIdentifier) - } else { - content - } - } -} - -private struct SidebarTabItemSettingsSnapshot: Equatable { +struct SidebarTabItemSettingsSnapshot: Equatable { let sidebarShortcutHintXOffset: Double let sidebarShortcutHintYOffset: Double let alwaysShowShortcutHints: Bool @@ -9168,254 +7741,16 @@ 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 - ) - } +private final class SidebarShortcutHintModifierMonitor: ObservableObject { + @Published private(set) var isModifierPressed = false - 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 - - private weak var hostWindow: NSWindow? - private var hostWindowDidBecomeKeyObserver: NSObjectProtocol? - private var hostWindowDidResignKeyObserver: NSObjectProtocol? - private var flagsMonitor: Any? - private var keyDownMonitor: Any? - private var appResignObserver: NSObjectProtocol? - private var pendingShowWorkItem: DispatchWorkItem? + private weak var hostWindow: NSWindow? + private var hostWindowDidBecomeKeyObserver: NSObjectProtocol? + private var hostWindowDidResignKeyObserver: NSObjectProtocol? + private var flagsMonitor: Any? + private var keyDownMonitor: Any? + private var appResignObserver: NSObjectProtocol? + private var pendingShowWorkItem: DispatchWorkItem? func setHostWindow(_ window: NSWindow?) { guard hostWindow !== window else { return } @@ -9565,4041 +7900,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 - } -} - -// 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 -} - -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 -private 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 - } -} - -private 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 - } -} - -private 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 - } -} - -private 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 - } - } -} - -private 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")" - } -} - -private 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 - } -} - -private 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/FileDropOverlayView.swift b/Sources/FileDropOverlayView.swift new file mode 100644 index 00000000000..dcff59c5369 --- /dev/null +++ b/Sources/FileDropOverlayView.swift @@ -0,0 +1,489 @@ +// File-drop overlay NSView, extracted from ContentView.swift (nuclear-review #94.3). +// Pure move — behavior-identical relocation. + +import AppKit +import Bonsplit +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 + } +} 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/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) + } +} 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) + ) + } +} + 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 + } +}