* Fix terminal keys (arrows, Ctrl+N/P) swallowed after opening browser After a browser panel is shown, SwiftUI's internal focus system activates and its _NSHostingView starts consuming arrow keys and other non-Command key events via performKeyEquivalent, preventing them from reaching the terminal's keyDown handler. Fix: In the NSWindow performKeyEquivalent swizzle, when GhosttyNSView is the first responder and the event has no Command modifier, route directly to the terminal's performKeyEquivalent — bypassing SwiftUI's view hierarchy walk entirely. Also clear stale browserAddressBarFocusedPanelId when a terminal surface has focus, preventing Cmd+N from being eaten by omnibar selection logic after focus transitions away from a browser. Adds DEBUG-only keyboard event ring buffer (KeyDebugLog) that dumps to /tmp/cmux-key-debug.log for diagnosing future key routing issues. * Fix split focus and Cmd+Shift+N swallowed after opening browser Split focus: capture the source terminal's hostedView before bonsplit mutates focusedPaneId, so focusPanel moves focus FROM the old pane instead of from the new pane to itself. Also retry ensureFocus when the new terminal's view has no window yet (matching the existing retry pattern for isVisibleInUI). Cmd+Shift+N: after WKWebView has been in the responder chain, SwiftUI's internal focus system can intercept Command-key events in the content view hierarchy (returning true) without firing the CommandGroup action closure. Fix by dispatching Command-key events directly to NSApp.mainMenu when the terminal is first responder, bypassing the broken SwiftUI path. Also add Cmd+Shift+N to handleCustomShortcut so it's customizable and doesn't depend on SwiftUI menu dispatch at all. * Unified debug event log: merge key/mouse/focus into /tmp/cmux-debug.log - Delete KeyDebugLog, MouseDebugLog, klog(), mlog() from AppDelegate - Replace all klog/mlog calls with dlog() (provided by bonsplit) - Remove debugLogCallback wiring from Workspace - Add focus change logging: focus.panel, focus.firstResponder, split.created, focus.moveFocus - Add import Bonsplit where needed for dlog access - Fix stale drag state on cancelled tab drags (bonsplit submodule) * Fix split focus stolen by re-entrant becomeFirstResponder during reparenting During programmatic splits (Cmd+D / Cmd+Shift+D), SwiftUI reparents the old terminal view, which fires becomeFirstResponder → onFocus → focusPanel for the OLD panel, stealing focus from the newly created pane. Add programmaticFocusTargetPanelId guard to suppress re-entrant focusPanel calls for non-target panels during split creation. Also document the unified debug event log in CLAUDE.md. * Clear stale title/favicon when browser navigation fails When a page fails to load (e.g. connection refused), the tab was still showing the previous page's title and favicon. Now didFailProvisionalNavigation resets pageTitle to the failed URL and clears faviconPNGData. * Fix Cmd+N swallowed by browser omnibar and improve split focus suppression - Only Ctrl+N/P trigger omnibar navigation, not Cmd+N/P (Cmd+N should always create new workspace regardless of address bar focus) - Move split focus suppression from workspace-level guard to source: suppress becomeFirstResponder side-effects (onFocus + ghostty_surface_set_focus) directly on the old GhosttyNSView during reparenting, preventing both model-level and libghostty-level focus divergence - Remove programmaticFocusTargetPanelId from Workspace.focusPanel * Fix omnibar hang, WebView white flash, drag-over-browser, and idle CPU spin - Omnibar: first click selects all without entering NSTextView tracking loop; subsequent clicks have 3s synthetic mouseUp safety net to prevent hang - WebView: set underPageBackgroundColor to match window so new browsers don't flash white before content loads - Drag/drop: register custom UTType (com.splittabbar.tabtransfer) in Info.plist so WKWebView doesn't intercept tab drags; override registerForDraggedTypes on CmuxWebView as belt-and-suspenders - CPU: fix infinite makeFirstResponder loop in controlTextDidEndEditing by checking both the text field and its field editor (the actual first responder)
3359 lines
133 KiB
Swift
3359 lines
133 KiB
Swift
import AppKit
|
|
import SwiftUI
|
|
import Bonsplit
|
|
import CoreServices
|
|
import UserNotifications
|
|
import Sentry
|
|
import WebKit
|
|
import Combine
|
|
import ObjectiveC.runtime
|
|
|
|
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
|
|
}
|
|
|
|
static func orderedUniqueDirectories(from pathURLs: [URL]) -> [String] {
|
|
var seen: Set<String> = []
|
|
var directories: [String] = []
|
|
|
|
for url in pathURLs {
|
|
let standardized = url.standardizedFileURL
|
|
let directoryURL = standardized.hasDirectoryPath ? standardized : standardized.deletingLastPathComponent()
|
|
let path = canonicalDirectoryPath(directoryURL.path(percentEncoded: false))
|
|
guard !path.isEmpty else { continue }
|
|
if seen.insert(path).inserted {
|
|
directories.append(path)
|
|
}
|
|
}
|
|
|
|
return directories
|
|
}
|
|
}
|
|
|
|
enum WorkspaceShortcutMapper {
|
|
/// Maps Cmd+digit workspace shortcuts to a zero-based workspace index.
|
|
/// Cmd+1...Cmd+8 target fixed indices; Cmd+9 always targets the last workspace.
|
|
static func workspaceIndex(forCommandDigit digit: Int, workspaceCount: Int) -> Int? {
|
|
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 Cmd+digit badge to display for a workspace row.
|
|
/// Picks the lowest digit that maps to that row index.
|
|
static func commandDigitForWorkspace(at index: Int, workspaceCount: Int) -> Int? {
|
|
guard index >= 0 && index < workspaceCount else { return nil }
|
|
for digit in 1...9 {
|
|
if workspaceIndex(forCommandDigit: digit, workspaceCount: workspaceCount) == index {
|
|
return digit
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
}
|
|
|
|
func browserOmnibarSelectionDeltaForCommandNavigation(
|
|
hasFocusedAddressBar: Bool,
|
|
flags: NSEvent.ModifierFlags,
|
|
chars: String
|
|
) -> Int? {
|
|
guard hasFocusedAddressBar else { return nil }
|
|
let normalizedFlags = flags
|
|
.intersection(.deviceIndependentFlagsMask)
|
|
.subtracting([.numericPad, .function])
|
|
guard normalizedFlags == [.control] 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 = flags
|
|
.intersection(.deviceIndependentFlagsMask)
|
|
.subtracting([.numericPad, .function])
|
|
guard normalizedFlags == [] else { return nil }
|
|
switch keyCode {
|
|
case 125: return 1
|
|
case 126: return -1
|
|
default: return nil
|
|
}
|
|
}
|
|
|
|
enum BrowserZoomShortcutAction: Equatable {
|
|
case zoomIn
|
|
case zoomOut
|
|
case reset
|
|
}
|
|
|
|
func browserZoomShortcutAction(
|
|
flags: NSEvent.ModifierFlags,
|
|
chars: String,
|
|
keyCode: UInt16
|
|
) -> BrowserZoomShortcutAction? {
|
|
let normalizedFlags = flags
|
|
.intersection(.deviceIndependentFlagsMask)
|
|
.subtracting([.numericPad, .function])
|
|
let key = chars.lowercased()
|
|
|
|
if normalizedFlags == [.command] {
|
|
if key == "=" || keyCode == 24 || keyCode == 69 { // kVK_ANSI_Equal / kVK_ANSI_KeypadPlus
|
|
return .zoomIn
|
|
}
|
|
if key == "-" || keyCode == 27 || keyCode == 78 { // kVK_ANSI_Minus / kVK_ANSI_KeypadMinus
|
|
return .zoomOut
|
|
}
|
|
if key == "0" || keyCode == 29 || keyCode == 82 { // kVK_ANSI_0 / kVK_ANSI_Keypad0
|
|
return .reset
|
|
}
|
|
}
|
|
|
|
if normalizedFlags == [.command, .shift] && (key == "=" || key == "+" || keyCode == 24 || keyCode == 69) {
|
|
return .zoomIn
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
@MainActor
|
|
final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCenterDelegate, NSMenuItemValidation {
|
|
static var shared: AppDelegate?
|
|
|
|
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.
|
|
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("CMUX_UI_TEST_") }) { return true }
|
|
return false
|
|
}
|
|
|
|
private 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?()
|
|
}
|
|
}
|
|
|
|
weak var tabManager: TabManager?
|
|
weak var notificationStore: TerminalNotificationStore?
|
|
weak var sidebarState: SidebarState?
|
|
weak var sidebarSelectionState: SidebarSelectionState?
|
|
private var workspaceObserver: NSObjectProtocol?
|
|
private var windowKeyObserver: NSObjectProtocol?
|
|
private var shortcutMonitor: Any?
|
|
private var ghosttyConfigObserver: NSObjectProtocol?
|
|
private var ghosttyGotoSplitLeftShortcut: StoredShortcut?
|
|
private var ghosttyGotoSplitRightShortcut: StoredShortcut?
|
|
private var ghosttyGotoSplitUpShortcut: StoredShortcut?
|
|
private 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: "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.cmux_performKeyEquivalent(with:))
|
|
guard let originalMethod = class_getInstanceMethod(targetClass, originalSelector),
|
|
let swizzledMethod = class_getInstanceMethod(targetClass, swizzledSelector) else {
|
|
return
|
|
}
|
|
method_exchangeImplementations(originalMethod, swizzledMethod)
|
|
}()
|
|
|
|
#if DEBUG
|
|
private var didSetupJumpUnreadUITest = false
|
|
private var jumpUnreadFocusExpectation: (tabId: UUID, surfaceId: UUID)?
|
|
private var jumpUnreadFocusObserver: NSObjectProtocol?
|
|
private var didSetupGotoSplitUITest = false
|
|
private var gotoSplitUITestObservers: [NSObjectProtocol] = []
|
|
private var didSetupMultiWindowNotificationsUITest = false
|
|
|
|
private func childExitKeyboardProbePath() -> String? {
|
|
let env = ProcessInfo.processInfo.environment
|
|
guard env["CMUX_UI_TEST_CHILD_EXIT_KEYBOARD_SETUP"] == "1",
|
|
let path = env["CMUX_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
|
|
|
|
private var mainWindowContexts: [ObjectIdentifier: MainWindowContext] = [:]
|
|
private var mainWindowControllers: [MainWindowController] = []
|
|
|
|
var updateViewModel: UpdateViewModel {
|
|
updateController.viewModel
|
|
}
|
|
|
|
override init() {
|
|
super.init()
|
|
Self.shared = self
|
|
}
|
|
|
|
func applicationDidFinishLaunching(_ notification: Notification) {
|
|
let env = ProcessInfo.processInfo.environment
|
|
let isRunningUnderXCTest = isRunningUnderXCTest(env)
|
|
|
|
#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")
|
|
DispatchQueue.main.asyncAfter(deadline: .now() + 1.0) { [weak self] in
|
|
self?.writeUITestDiagnosticsIfNeeded(stage: "after1s")
|
|
}
|
|
#endif
|
|
|
|
SentrySDK.start { options in
|
|
options.dsn = "https://ecba1ec90ecaee02a102fba931b6d2b3@o4507547940749312.ingest.us.sentry.io/4510796264636416"
|
|
#if DEBUG
|
|
options.environment = "development"
|
|
options.debug = true
|
|
#else
|
|
options.environment = "production"
|
|
options.debug = false
|
|
#endif
|
|
options.sendDefaultPii = true
|
|
}
|
|
|
|
if !isRunningUnderXCTest {
|
|
PostHogAnalytics.shared.startIfNeeded()
|
|
}
|
|
|
|
// 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.registerLaunchServicesBundle()
|
|
self.enforceSingleInstance()
|
|
self.observeDuplicateLaunches()
|
|
}
|
|
}
|
|
NSWindow.allowsAutomaticWindowTabbing = false
|
|
disableNativeTabbingShortcut()
|
|
ensureApplicationIcon()
|
|
if !isRunningUnderXCTest {
|
|
configureUserNotifications()
|
|
setupMenuBarExtra()
|
|
// Sparkle updater is started lazily on first manual check. This avoids any
|
|
// first-launch permission prompts and keeps cmux aligned with the update pill UI.
|
|
}
|
|
titlebarAccessoryController.start()
|
|
windowDecorationsController.start()
|
|
installMainWindowKeyObserver()
|
|
refreshGhosttyGotoSplitShortcuts()
|
|
installGhosttyConfigObserver()
|
|
installWindowKeyEquivalentSwizzle()
|
|
installBrowserAddressBarFocusObservers()
|
|
installShortcutMonitor()
|
|
NSApp.servicesProvider = self
|
|
#if DEBUG
|
|
UpdateTestSupport.applyIfNeeded(to: updateController.viewModel)
|
|
if env["CMUX_UI_TEST_MODE"] == "1" {
|
|
let trigger = env["CMUX_UI_TEST_TRIGGER_UPDATE_CHECK"] ?? "<nil>"
|
|
let feed = env["CMUX_UI_TEST_FEED_URL"] ?? "<nil>"
|
|
UpdateLogStore.shared.append("ui test env: trigger=\(trigger) feed=\(feed)")
|
|
}
|
|
if env["CMUX_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 ?? "<nil>" }
|
|
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 {
|
|
DispatchQueue.main.asyncAfter(deadline: .now() + 0.25) { [weak self] in
|
|
guard let self else { return }
|
|
if NSApp.windows.isEmpty {
|
|
self.openNewMainWindow(nil)
|
|
}
|
|
NSRunningApplication.current.activate(options: [.activateAllWindows, .activateIgnoringOtherApps])
|
|
self.writeUITestDiagnosticsIfNeeded(stage: "afterForceWindow")
|
|
}
|
|
}
|
|
#endif
|
|
}
|
|
|
|
#if DEBUG
|
|
private func writeUITestDiagnosticsIfNeeded(stage: String) {
|
|
let env = ProcessInfo.processInfo.environment
|
|
guard let path = env["CMUX_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: ",")
|
|
|
|
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
|
|
|
|
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
|
|
}
|
|
#endif
|
|
|
|
func applicationDidBecomeActive(_ notification: Notification) {
|
|
let env = ProcessInfo.processInfo.environment
|
|
if !isRunningUnderXCTest(env) {
|
|
PostHogAnalytics.shared.trackDailyActive(reason: "didBecomeActive")
|
|
}
|
|
|
|
guard let tabManager, let notificationStore 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 applicationWillTerminate(_ notification: Notification) {
|
|
BrowserHistoryStore.shared.flushPendingSaves()
|
|
PostHogAnalytics.shared.flush()
|
|
notificationStore?.clearAll()
|
|
}
|
|
|
|
func configure(tabManager: TabManager, notificationStore: TerminalNotificationStore, sidebarState: SidebarState) {
|
|
self.tabManager = tabManager
|
|
self.notificationStore = notificationStore
|
|
self.sidebarState = sidebarState
|
|
#if DEBUG
|
|
setupJumpUnreadUITestIfNeeded()
|
|
setupGotoSplitUITestIfNeeded()
|
|
setupMultiWindowNotificationsUITestIfNeeded()
|
|
|
|
// 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 = SocketControlMode(rawValue: raw) ?? SocketControlSettings.defaultMode
|
|
let mode = SocketControlSettings.effectiveMode(userMode: userMode)
|
|
if mode != .off {
|
|
TerminalController.shared.start(
|
|
tabManager: tabManager,
|
|
socketPath: SocketControlSettings.socketPath(),
|
|
accessMode: mode
|
|
)
|
|
}
|
|
}
|
|
#endif
|
|
}
|
|
|
|
/// 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
|
|
) {
|
|
let key = ObjectIdentifier(window)
|
|
if let existing = mainWindowContexts[key] {
|
|
existing.window = 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)
|
|
}
|
|
}
|
|
|
|
if window.isKeyWindow {
|
|
setActiveMainWindow(window)
|
|
}
|
|
}
|
|
|
|
struct MainWindowSummary {
|
|
let windowId: UUID
|
|
let isKeyWindow: Bool
|
|
let isVisible: Bool
|
|
let workspaceCount: Int
|
|
let selectedWorkspaceId: UUID?
|
|
}
|
|
|
|
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
|
|
)
|
|
}
|
|
}
|
|
|
|
func tabManagerFor(windowId: UUID) -> TabManager? {
|
|
mainWindowContexts.values.first(where: { $0.windowId == windowId })?.tabManager
|
|
}
|
|
|
|
func windowId(for tabManager: TabManager) -> UUID? {
|
|
mainWindowContexts.values.first(where: { $0.tabManager === tabManager })?.windowId
|
|
}
|
|
|
|
func locateSurface(surfaceId: UUID) -> (windowId: UUID, workspaceId: UUID, tabManager: TabManager)? {
|
|
for ctx in mainWindowContexts.values {
|
|
for ws in ctx.tabManager.tabs {
|
|
if ws.panels[surfaceId] != nil {
|
|
return (ctx.windowId, ws.id, ctx.tabManager)
|
|
}
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func 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)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func focusMainWindow(windowId: UUID) -> Bool {
|
|
guard let window = windowForMainWindowId(windowId) else { return false }
|
|
bringToFront(window)
|
|
return true
|
|
}
|
|
|
|
func closeMainWindow(windowId: UUID) -> Bool {
|
|
guard let window = windowForMainWindowId(windowId) else { return false }
|
|
window.performClose(nil)
|
|
return true
|
|
}
|
|
|
|
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 })
|
|
}
|
|
|
|
@objc func openNewMainWindow(_ sender: Any?) {
|
|
_ = createMainWindow()
|
|
}
|
|
|
|
@objc func openWindow(
|
|
_ pasteboard: NSPasteboard,
|
|
userData: String?,
|
|
error: AutoreleasingUnsafeMutablePointer<NSString>
|
|
) {
|
|
openFromServicePasteboard(pasteboard, target: .window, error: error)
|
|
}
|
|
|
|
@objc func openTab(
|
|
_ pasteboard: NSPasteboard,
|
|
userData: String?,
|
|
error: AutoreleasingUnsafeMutablePointer<NSString>
|
|
) {
|
|
openFromServicePasteboard(pasteboard, target: .workspace, error: error)
|
|
}
|
|
|
|
private enum ServiceOpenTarget {
|
|
case window
|
|
case workspace
|
|
}
|
|
|
|
private func openFromServicePasteboard(
|
|
_ pasteboard: NSPasteboard,
|
|
target: ServiceOpenTarget,
|
|
error: AutoreleasingUnsafeMutablePointer<NSString>
|
|
) {
|
|
let pathURLs = servicePathURLs(from: pasteboard)
|
|
guard !pathURLs.isEmpty else {
|
|
error.pointee = Self.serviceErrorNoPath
|
|
return
|
|
}
|
|
|
|
let directories = FinderServicePathResolver.orderedUniqueDirectories(from: pathURLs)
|
|
guard !directories.isEmpty else {
|
|
error.pointee = Self.serviceErrorNoPath
|
|
return
|
|
}
|
|
|
|
for directory in directories {
|
|
switch target {
|
|
case .window:
|
|
_ = createMainWindow(initialWorkingDirectory: directory)
|
|
case .workspace:
|
|
openWorkspaceFromService(workingDirectory: directory)
|
|
}
|
|
}
|
|
}
|
|
|
|
private func servicePathURLs(from pasteboard: NSPasteboard) -> [URL] {
|
|
if let pathURLs = pasteboard.readObjects(forClasses: [NSURL.self]) as? [URL], !pathURLs.isEmpty {
|
|
return pathURLs
|
|
}
|
|
|
|
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) {
|
|
if let context = preferredMainWindowContextForServiceWorkspace(),
|
|
let window = context.window ?? windowForMainWindowId(context.windowId) {
|
|
setActiveMainWindow(window)
|
|
bringToFront(window)
|
|
_ = context.tabManager.addWorkspace(workingDirectory: workingDirectory)
|
|
return
|
|
}
|
|
_ = createMainWindow(initialWorkingDirectory: workingDirectory)
|
|
}
|
|
|
|
private func preferredMainWindowContextForServiceWorkspace() -> MainWindowContext? {
|
|
if let keyWindow = NSApp.keyWindow,
|
|
isMainTerminalWindow(keyWindow),
|
|
let context = mainWindowContexts[ObjectIdentifier(keyWindow)] {
|
|
return context
|
|
}
|
|
|
|
if let mainWindow = NSApp.mainWindow,
|
|
isMainTerminalWindow(mainWindow),
|
|
let context = mainWindowContexts[ObjectIdentifier(mainWindow)] {
|
|
return context
|
|
}
|
|
|
|
return mainWindowContexts.values.first
|
|
}
|
|
|
|
@discardableResult
|
|
func createMainWindow(initialWorkingDirectory: String? = nil) -> UUID {
|
|
let windowId = UUID()
|
|
let tabManager = TabManager(initialWorkingDirectory: initialWorkingDirectory)
|
|
let sidebarState = SidebarState()
|
|
let sidebarSelectionState = SidebarSelectionState()
|
|
let notificationStore = TerminalNotificationStore.shared
|
|
|
|
let root = ContentView(updateViewModel: updateViewModel, windowId: windowId)
|
|
.environmentObject(tabManager)
|
|
.environmentObject(notificationStore)
|
|
.environmentObject(sidebarState)
|
|
.environmentObject(sidebarSelectionState)
|
|
|
|
let window = NSWindow(
|
|
contentRect: NSRect(x: 0, y: 0, width: 460, height: 360),
|
|
styleMask: [.titled, .closable, .miniaturizable, .resizable],
|
|
backing: .buffered,
|
|
defer: false
|
|
)
|
|
window.title = ""
|
|
window.titleVisibility = .hidden
|
|
window.titlebarAppearsTransparent = true
|
|
window.isMovableByWindowBackground = false
|
|
window.center()
|
|
window.contentView = NSHostingView(rootView: root)
|
|
|
|
// Apply shared window styling.
|
|
attachUpdateAccessory(to: window)
|
|
applyWindowDecorations(to: window)
|
|
|
|
// Keep a strong reference so the window isn't deallocated.
|
|
let controller = MainWindowController(window: window)
|
|
controller.onClose = { [weak self, weak controller] in
|
|
guard let self, let controller else { return }
|
|
self.mainWindowControllers.removeAll(where: { $0 === controller })
|
|
}
|
|
window.delegate = controller
|
|
mainWindowControllers.append(controller)
|
|
|
|
registerMainWindow(
|
|
window,
|
|
windowId: windowId,
|
|
tabManager: tabManager,
|
|
sidebarState: sidebarState,
|
|
sidebarSelectionState: sidebarSelectionState
|
|
)
|
|
window.makeKeyAndOrderFront(nil)
|
|
setActiveMainWindow(window)
|
|
NSApp.activate(ignoringOtherApps: true)
|
|
return windowId
|
|
}
|
|
|
|
@objc func checkForUpdates(_ sender: Any?) {
|
|
updateViewModel.overrideState = nil
|
|
updateController.checkForUpdates()
|
|
}
|
|
|
|
private func setupMenuBarExtra() {
|
|
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()
|
|
},
|
|
onQuitApp: {
|
|
NSApp.terminate(nil)
|
|
}
|
|
)
|
|
}
|
|
|
|
@objc func openPreferencesWindow() {
|
|
NSApp.sendAction(Selector(("showSettingsWindow:")), to: nil, from: nil)
|
|
NSApp.activate(ignoringOtherApps: true)
|
|
}
|
|
|
|
func refreshMenuBarExtraForDebug() {
|
|
menuBarExtraController?.refreshForDebugControls()
|
|
}
|
|
|
|
func showNotificationsPopoverFromMenuBar() {
|
|
let context: MainWindowContext? = {
|
|
if let keyWindow = NSApp.keyWindow,
|
|
isMainTerminalWindow(keyWindow),
|
|
let keyContext = mainWindowContexts[ObjectIdentifier(keyWindow)] {
|
|
return keyContext
|
|
}
|
|
if let first = mainWindowContexts.values.first {
|
|
return first
|
|
}
|
|
let windowId = createMainWindow()
|
|
return mainWindowContexts.values.first(where: { $0.windowId == windowId })
|
|
}()
|
|
|
|
if let context,
|
|
let window = context.window ?? windowForMainWindowId(context.windowId) {
|
|
setActiveMainWindow(window)
|
|
bringToFront(window)
|
|
}
|
|
|
|
DispatchQueue.main.asyncAfter(deadline: .now() + 0.05) { [weak self] in
|
|
self?.titlebarAccessoryController.showNotificationsPopover(animated: false)
|
|
}
|
|
}
|
|
|
|
#if DEBUG
|
|
@objc func showUpdatePill(_ sender: Any?) {
|
|
updateViewModel.debugOverrideText = nil
|
|
updateViewModel.overrideState = .installing(.init(isAutoUpdate: true, retryTerminatingApplication: {}, dismiss: {}))
|
|
}
|
|
|
|
@objc func showUpdatePillLongNightly(_ sender: Any?) {
|
|
updateViewModel.debugOverrideText = "Update Available: 1.32.0-nightly+20260216.abc1234"
|
|
updateViewModel.overrideState = .notFound(.init(acknowledgement: {}))
|
|
}
|
|
|
|
@objc func showUpdatePillLoading(_ sender: Any?) {
|
|
updateViewModel.debugOverrideText = nil
|
|
updateViewModel.overrideState = .checking(.init(cancel: {}))
|
|
}
|
|
|
|
@objc func hideUpdatePill(_ sender: Any?) {
|
|
updateViewModel.debugOverrideText = nil
|
|
updateViewModel.overrideState = .idle
|
|
}
|
|
|
|
@objc func clearUpdatePillOverride(_ sender: Any?) {
|
|
updateViewModel.debugOverrideText = nil
|
|
updateViewModel.overrideState = nil
|
|
}
|
|
#endif
|
|
|
|
@objc func copyUpdateLogs(_ sender: Any?) {
|
|
let logText = UpdateLogStore.shared.snapshot()
|
|
let payload: String
|
|
if logText.isEmpty {
|
|
payload = "No update logs captured.\nLog file: \(UpdateLogStore.shared.logPath())"
|
|
} else {
|
|
payload = logText + "\nLog file: \(UpdateLogStore.shared.logPath())"
|
|
}
|
|
let pasteboard = NSPasteboard.general
|
|
pasteboard.clearContents()
|
|
pasteboard.setString(payload, forType: .string)
|
|
}
|
|
@objc func copyFocusLogs(_ sender: Any?) {
|
|
let logText = FocusLogStore.shared.snapshot()
|
|
let payload: String
|
|
if logText.isEmpty {
|
|
payload = "No focus logs captured.\nLog file: \(FocusLogStore.shared.logPath())"
|
|
} else {
|
|
payload = logText + "\nLog file: \(FocusLogStore.shared.logPath())"
|
|
}
|
|
let pasteboard = NSPasteboard.general
|
|
pasteboard.clearContents()
|
|
pasteboard.setString(payload, forType: .string)
|
|
}
|
|
|
|
#if DEBUG
|
|
@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)
|
|
}
|
|
|
|
private func sendTextWhenReady(_ text: String, to tab: Tab, attempt: Int = 0) {
|
|
let maxAttempts = 60
|
|
if let terminalPanel = tab.focusedTerminalPanel, terminalPanel.surface.surface != nil {
|
|
terminalPanel.sendText(text)
|
|
return
|
|
}
|
|
guard attempt < maxAttempts else {
|
|
NSLog("Debug scrollback: surface not ready after \(maxAttempts) attempts")
|
|
return
|
|
}
|
|
DispatchQueue.main.asyncAfter(deadline: .now() + 0.05) { [weak self] in
|
|
self?.sendTextWhenReady(text, to: tab, attempt: attempt + 1)
|
|
}
|
|
}
|
|
|
|
@objc func triggerSentryTestCrash(_ sender: Any?) {
|
|
SentrySDK.crash()
|
|
}
|
|
#endif
|
|
|
|
#if DEBUG
|
|
private func setupJumpUnreadUITestIfNeeded() {
|
|
guard !didSetupJumpUnreadUITest else { return }
|
|
didSetupJumpUnreadUITest = true
|
|
let env = ProcessInfo.processInfo.environment
|
|
guard env["CMUX_UI_TEST_JUMP_UNREAD_SETUP"] == "1" else { return }
|
|
guard let notificationStore else { return }
|
|
|
|
DispatchQueue.main.asyncAfter(deadline: .now() + 0.2) { [weak self] in
|
|
guard let self else { return }
|
|
Task { @MainActor in
|
|
// In UI tests, the initial SwiftUI `WindowGroup` window can lag behind launch. Wait for a
|
|
// registered main terminal window context so notifications can be routed back correctly.
|
|
let deadline = Date().addingTimeInterval(8.0)
|
|
@MainActor func waitForContext(_ completion: @escaping (MainWindowContext) -> Void) {
|
|
if let context = self.mainWindowContexts.values.first,
|
|
context.window != nil {
|
|
completion(context)
|
|
return
|
|
}
|
|
guard Date() < deadline else { return }
|
|
DispatchQueue.main.asyncAfter(deadline: .now() + 0.05) {
|
|
Task { @MainActor in
|
|
waitForContext(completion)
|
|
}
|
|
}
|
|
}
|
|
|
|
waitForContext { context in
|
|
let tabManager = context.tabManager
|
|
let initialIndex = tabManager.tabs.firstIndex(where: { $0.id == tabManager.selectedTabId }) ?? 0
|
|
let tab = tabManager.addTab()
|
|
guard let initialPanelId = tab.focusedPanelId else { return }
|
|
|
|
_ = tabManager.newSplit(tabId: tab.id, surfaceId: initialPanelId, direction: .right)
|
|
guard let targetPanelId = tab.focusedPanelId else { return }
|
|
// Find another panel that's not the currently focused one
|
|
let otherPanelId = tab.panels.keys.first(where: { $0 != targetPanelId })
|
|
if let otherPanelId {
|
|
tab.focusPanel(otherPanelId)
|
|
}
|
|
|
|
// Avoid flakiness in the VM where focus can lag selection by a tick, which would
|
|
// cause notification suppression to incorrectly drop this UI-test notification.
|
|
let prevOverride = AppFocusState.overrideIsFocused
|
|
AppFocusState.overrideIsFocused = false
|
|
notificationStore.addNotification(
|
|
tabId: tab.id,
|
|
surfaceId: targetPanelId,
|
|
title: "JumpToUnread",
|
|
subtitle: "",
|
|
body: ""
|
|
)
|
|
AppFocusState.overrideIsFocused = prevOverride
|
|
|
|
self.writeJumpUnreadTestData([
|
|
"expectedTabId": tab.id.uuidString,
|
|
"expectedSurfaceId": targetPanelId.uuidString
|
|
])
|
|
|
|
tabManager.selectTab(at: initialIndex)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
func recordJumpToUnreadFocus(tabId: UUID, surfaceId: UUID) {
|
|
writeJumpUnreadTestData([
|
|
"focusedTabId": tabId.uuidString,
|
|
"focusedSurfaceId": surfaceId.uuidString
|
|
])
|
|
}
|
|
|
|
func armJumpUnreadFocusRecord(tabId: UUID, surfaceId: UUID) {
|
|
let env = ProcessInfo.processInfo.environment
|
|
guard let path = env["CMUX_UI_TEST_JUMP_UNREAD_PATH"], !path.isEmpty else { return }
|
|
jumpUnreadFocusExpectation = (tabId: tabId, surfaceId: surfaceId)
|
|
installJumpUnreadFocusObserverIfNeeded()
|
|
}
|
|
|
|
func recordJumpUnreadFocusIfExpected(tabId: UUID, surfaceId: UUID) {
|
|
guard let expectation = jumpUnreadFocusExpectation else { return }
|
|
guard expectation.tabId == tabId && expectation.surfaceId == surfaceId else { return }
|
|
jumpUnreadFocusExpectation = nil
|
|
recordJumpToUnreadFocus(tabId: tabId, surfaceId: surfaceId)
|
|
if let jumpUnreadFocusObserver {
|
|
NotificationCenter.default.removeObserver(jumpUnreadFocusObserver)
|
|
self.jumpUnreadFocusObserver = nil
|
|
}
|
|
}
|
|
|
|
private func installJumpUnreadFocusObserverIfNeeded() {
|
|
guard jumpUnreadFocusObserver == nil else { return }
|
|
jumpUnreadFocusObserver = NotificationCenter.default.addObserver(
|
|
forName: .ghosttyDidFocusSurface,
|
|
object: nil,
|
|
queue: .main
|
|
) { [weak self] notification in
|
|
guard let self else { return }
|
|
guard let tabId = notification.userInfo?[GhosttyNotificationKey.tabId] as? UUID else { return }
|
|
guard let surfaceId = notification.userInfo?[GhosttyNotificationKey.surfaceId] as? UUID else { return }
|
|
self.recordJumpUnreadFocusIfExpected(tabId: tabId, surfaceId: surfaceId)
|
|
}
|
|
}
|
|
|
|
private func writeJumpUnreadTestData(_ updates: [String: String]) {
|
|
let env = ProcessInfo.processInfo.environment
|
|
guard let path = env["CMUX_UI_TEST_JUMP_UNREAD_PATH"], !path.isEmpty else { return }
|
|
var payload = loadJumpUnreadTestData(at: path)
|
|
for (key, value) in updates {
|
|
payload[key] = value
|
|
}
|
|
guard let data = try? JSONSerialization.data(withJSONObject: payload) else { return }
|
|
try? data.write(to: URL(fileURLWithPath: path), options: .atomic)
|
|
}
|
|
|
|
private func loadJumpUnreadTestData(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 setupGotoSplitUITestIfNeeded() {
|
|
guard !didSetupGotoSplitUITest else { return }
|
|
didSetupGotoSplitUITest = true
|
|
let env = ProcessInfo.processInfo.environment
|
|
guard env["CMUX_UI_TEST_GOTO_SPLIT_SETUP"] == "1" else { return }
|
|
guard tabManager != nil else { return }
|
|
|
|
let useGhosttyConfig = env["CMUX_UI_TEST_GOTO_SPLIT_USE_GHOSTTY_CONFIG"] == "1"
|
|
|
|
if useGhosttyConfig {
|
|
// Keep the test hermetic: ensure the app does not accidentally pass using a persisted
|
|
// KeyboardShortcutSettings override instead of the Ghostty config-trigger path.
|
|
UserDefaults.standard.removeObject(forKey: KeyboardShortcutSettings.focusLeftKey)
|
|
} else {
|
|
// For this UI test we want a letter-based shortcut (Cmd+Ctrl+H) to drive pane navigation,
|
|
// since arrow keys can't be recorded by the shortcut recorder.
|
|
let shortcut = StoredShortcut(key: "h", command: true, shift: false, option: false, control: true)
|
|
if let data = try? JSONEncoder().encode(shortcut) {
|
|
UserDefaults.standard.set(data, forKey: KeyboardShortcutSettings.focusLeftKey)
|
|
}
|
|
}
|
|
|
|
installGotoSplitUITestFocusObserversIfNeeded()
|
|
|
|
// On the VM, launching/initializing multiple windows can occasionally take longer than a
|
|
// few seconds; keep the deadline generous so the test doesn't flake.
|
|
let deadline = Date().addingTimeInterval(20.0)
|
|
func hasMainTerminalWindow() -> Bool {
|
|
NSApp.windows.contains { window in
|
|
guard let raw = window.identifier?.rawValue else { return false }
|
|
return raw == "cmux.main" || raw.hasPrefix("cmux.main.")
|
|
}
|
|
}
|
|
|
|
func runSetupWhenWindowReady() {
|
|
guard Date() < deadline else {
|
|
writeGotoSplitTestData(["setupError": "Timed out waiting for main window"])
|
|
return
|
|
}
|
|
guard hasMainTerminalWindow() else {
|
|
DispatchQueue.main.asyncAfter(deadline: .now() + 0.05) {
|
|
runSetupWhenWindowReady()
|
|
}
|
|
return
|
|
}
|
|
guard let tabManager = self.tabManager else { return }
|
|
|
|
let tab = tabManager.addTab()
|
|
guard let initialPanelId = tab.focusedPanelId else {
|
|
self.writeGotoSplitTestData(["setupError": "Missing initial panel id"])
|
|
return
|
|
}
|
|
|
|
let url = URL(string: "https://example.com")
|
|
guard let browserPanelId = tabManager.newBrowserSplit(
|
|
tabId: tab.id,
|
|
fromPanelId: initialPanelId,
|
|
orientation: .horizontal,
|
|
url: url
|
|
) else {
|
|
self.writeGotoSplitTestData(["setupError": "Failed to create browser split"])
|
|
return
|
|
}
|
|
|
|
self.focusWebViewForGotoSplitUITest(tab: tab, browserPanelId: browserPanelId)
|
|
}
|
|
|
|
DispatchQueue.main.asyncAfter(deadline: .now() + 0.2) { [weak self] in
|
|
guard let self else { return }
|
|
runSetupWhenWindowReady()
|
|
}
|
|
}
|
|
|
|
private func focusWebViewForGotoSplitUITest(tab: Workspace, browserPanelId: UUID, attempt: Int = 0) {
|
|
let maxAttempts = 120
|
|
guard attempt < maxAttempts else {
|
|
writeGotoSplitTestData([
|
|
"webViewFocused": "false",
|
|
"setupError": "Timed out waiting for WKWebView focus"
|
|
])
|
|
return
|
|
}
|
|
|
|
guard let browserPanel = tab.browserPanel(for: browserPanelId) else {
|
|
writeGotoSplitTestData([
|
|
"webViewFocused": "false",
|
|
"setupError": "Browser panel missing"
|
|
])
|
|
return
|
|
}
|
|
|
|
// Select the browser surface and try to focus the WKWebView.
|
|
tab.focusPanel(browserPanelId)
|
|
|
|
if isWebViewFocused(browserPanel),
|
|
let (browserPaneId, terminalPaneId) = paneIdsForGotoSplitUITest(
|
|
tab: tab,
|
|
browserPanelId: browserPanelId
|
|
) {
|
|
writeGotoSplitTestData([
|
|
"browserPanelId": browserPanelId.uuidString,
|
|
"browserPaneId": browserPaneId.description,
|
|
"terminalPaneId": terminalPaneId.description,
|
|
"initialPaneCount": String(tab.bonsplitController.allPaneIds.count),
|
|
"focusedPaneId": tab.bonsplitController.focusedPaneId?.description ?? "",
|
|
"ghosttyGotoSplitLeftShortcut": ghosttyGotoSplitLeftShortcut?.displayString ?? "",
|
|
"ghosttyGotoSplitRightShortcut": ghosttyGotoSplitRightShortcut?.displayString ?? "",
|
|
"ghosttyGotoSplitUpShortcut": ghosttyGotoSplitUpShortcut?.displayString ?? "",
|
|
"ghosttyGotoSplitDownShortcut": ghosttyGotoSplitDownShortcut?.displayString ?? "",
|
|
"webViewFocused": "true"
|
|
])
|
|
return
|
|
}
|
|
|
|
DispatchQueue.main.asyncAfter(deadline: .now() + 0.05) { [weak self] in
|
|
self?.focusWebViewForGotoSplitUITest(tab: tab, browserPanelId: browserPanelId, attempt: attempt + 1)
|
|
}
|
|
}
|
|
|
|
private func isWebViewFocused(_ panel: BrowserPanel) -> Bool {
|
|
guard let window = panel.webView.window else { return false }
|
|
guard let fr = window.firstResponder as? NSView else { return false }
|
|
return fr.isDescendant(of: panel.webView)
|
|
}
|
|
|
|
private func paneIdsForGotoSplitUITest(tab: Workspace, browserPanelId: UUID) -> (browser: PaneID, terminal: PaneID)? {
|
|
let paneIds = tab.bonsplitController.allPaneIds
|
|
guard paneIds.count >= 2 else { return nil }
|
|
|
|
var browserPane: PaneID?
|
|
var terminalPane: PaneID?
|
|
for paneId in paneIds {
|
|
guard let selected = tab.bonsplitController.selectedTab(inPane: paneId),
|
|
let panelId = tab.panelIdFromSurfaceId(selected.id) else { continue }
|
|
if panelId == browserPanelId {
|
|
browserPane = paneId
|
|
} else if terminalPane == nil {
|
|
terminalPane = paneId
|
|
}
|
|
}
|
|
|
|
guard let browserPane, let terminalPane else { return nil }
|
|
return (browserPane, terminalPane)
|
|
}
|
|
|
|
private func installGotoSplitUITestFocusObserversIfNeeded() {
|
|
guard gotoSplitUITestObservers.isEmpty else { return }
|
|
|
|
gotoSplitUITestObservers.append(NotificationCenter.default.addObserver(
|
|
forName: .browserFocusAddressBar,
|
|
object: nil,
|
|
queue: .main
|
|
) { [weak self] notification in
|
|
guard let self else { return }
|
|
guard let panelId = notification.object as? UUID else { return }
|
|
self.recordGotoSplitUITestWebViewFocus(panelId: panelId, key: "webViewFocusedAfterAddressBarFocus")
|
|
})
|
|
|
|
gotoSplitUITestObservers.append(NotificationCenter.default.addObserver(
|
|
forName: .browserDidExitAddressBar,
|
|
object: nil,
|
|
queue: .main
|
|
) { [weak self] notification in
|
|
guard let self else { return }
|
|
guard let panelId = notification.object as? UUID else { return }
|
|
self.recordGotoSplitUITestWebViewFocus(panelId: panelId, key: "webViewFocusedAfterAddressBarExit")
|
|
})
|
|
}
|
|
|
|
private func recordGotoSplitUITestWebViewFocus(panelId: UUID, key: String) {
|
|
// Give the responder chain time to settle.
|
|
DispatchQueue.main.asyncAfter(deadline: .now() + 0.05) { [weak self] in
|
|
guard let self, let tabManager, let tab = tabManager.selectedWorkspace,
|
|
let panel = tab.browserPanel(for: panelId) else { return }
|
|
let focused = self.isWebViewFocused(panel)
|
|
self.writeGotoSplitTestData([
|
|
key: focused ? "true" : "false",
|
|
"\(key)PanelId": panelId.uuidString
|
|
])
|
|
}
|
|
}
|
|
|
|
private func recordGotoSplitMoveIfNeeded(direction: NavigationDirection) {
|
|
let env = ProcessInfo.processInfo.environment
|
|
guard env["CMUX_UI_TEST_GOTO_SPLIT_SETUP"] == "1" else { return }
|
|
guard let tabManager,
|
|
let focusedPaneId = tabManager.selectedWorkspace?.bonsplitController.focusedPaneId else { return }
|
|
|
|
let directionValue: String
|
|
switch direction {
|
|
case .left:
|
|
directionValue = "left"
|
|
case .right:
|
|
directionValue = "right"
|
|
case .up:
|
|
directionValue = "up"
|
|
case .down:
|
|
directionValue = "down"
|
|
}
|
|
|
|
writeGotoSplitTestData([
|
|
"lastMoveDirection": directionValue,
|
|
"focusedPaneId": focusedPaneId.description
|
|
])
|
|
}
|
|
|
|
private func recordGotoSplitSplitIfNeeded(direction: SplitDirection) {
|
|
let env = ProcessInfo.processInfo.environment
|
|
guard env["CMUX_UI_TEST_GOTO_SPLIT_SETUP"] == "1" else { return }
|
|
guard let workspace = tabManager?.selectedWorkspace else { return }
|
|
|
|
let directionValue: String
|
|
switch direction {
|
|
case .left:
|
|
directionValue = "left"
|
|
case .right:
|
|
directionValue = "right"
|
|
case .up:
|
|
directionValue = "up"
|
|
case .down:
|
|
directionValue = "down"
|
|
}
|
|
|
|
writeGotoSplitTestData([
|
|
"lastSplitDirection": directionValue,
|
|
"paneCountAfterSplit": String(workspace.bonsplitController.allPaneIds.count),
|
|
"focusedPaneId": workspace.bonsplitController.focusedPaneId?.description ?? ""
|
|
])
|
|
}
|
|
|
|
private func writeGotoSplitTestData(_ updates: [String: String]) {
|
|
let env = ProcessInfo.processInfo.environment
|
|
guard let path = env["CMUX_UI_TEST_GOTO_SPLIT_PATH"], !path.isEmpty else { return }
|
|
var payload = loadGotoSplitTestData(at: path)
|
|
for (key, value) in updates {
|
|
payload[key] = value
|
|
}
|
|
guard let data = try? JSONSerialization.data(withJSONObject: payload) else { return }
|
|
try? data.write(to: URL(fileURLWithPath: path), options: .atomic)
|
|
}
|
|
|
|
private func loadGotoSplitTestData(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 setupMultiWindowNotificationsUITestIfNeeded() {
|
|
guard !didSetupMultiWindowNotificationsUITest else { return }
|
|
didSetupMultiWindowNotificationsUITest = true
|
|
|
|
let env = ProcessInfo.processInfo.environment
|
|
guard env["CMUX_UI_TEST_MULTI_WINDOW_NOTIF_SETUP"] == "1" else { return }
|
|
guard let path = env["CMUX_UI_TEST_MULTI_WINDOW_NOTIF_PATH"], !path.isEmpty else { return }
|
|
|
|
try? FileManager.default.removeItem(atPath: path)
|
|
|
|
let deadline = Date().addingTimeInterval(8.0)
|
|
func waitForContexts(minCount: Int, _ completion: @escaping () -> Void) {
|
|
if mainWindowContexts.count >= minCount,
|
|
mainWindowContexts.values.allSatisfy({ $0.window != nil }) {
|
|
completion()
|
|
return
|
|
}
|
|
guard Date() < deadline else { return }
|
|
DispatchQueue.main.asyncAfter(deadline: .now() + 0.05) {
|
|
waitForContexts(minCount: minCount, completion)
|
|
}
|
|
}
|
|
|
|
waitForContexts(minCount: 1) { [weak self] in
|
|
guard let self else { return }
|
|
guard let window1 = self.mainWindowContexts.values.first else { return }
|
|
guard let tabId1 = window1.tabManager.selectedTabId ?? window1.tabManager.tabs.first?.id else { return }
|
|
|
|
// Create a second main terminal window.
|
|
self.openNewMainWindow(nil)
|
|
|
|
waitForContexts(minCount: 2) { [weak self] in
|
|
guard let self else { return }
|
|
let contexts = Array(self.mainWindowContexts.values)
|
|
guard let window2 = contexts.first(where: { $0.windowId != window1.windowId }) else { return }
|
|
guard let tabId2 = window2.tabManager.selectedTabId ?? window2.tabManager.tabs.first?.id else { return }
|
|
guard let store = self.notificationStore else { return }
|
|
|
|
// Ensure the target window is currently showing the Notifications overlay,
|
|
// so opening a notification must switch it back to the terminal UI.
|
|
window2.sidebarSelectionState.selection = .notifications
|
|
|
|
// Create notifications for both windows. Ensure W2 isn't suppressed just because it's focused.
|
|
let prevOverride = AppFocusState.overrideIsFocused
|
|
AppFocusState.overrideIsFocused = false
|
|
store.addNotification(tabId: tabId2, surfaceId: nil, title: "W2", subtitle: "multiwindow", body: "")
|
|
AppFocusState.overrideIsFocused = prevOverride
|
|
|
|
// Insert after W2 so it becomes "latest unread" (first in list).
|
|
store.addNotification(tabId: tabId1, surfaceId: nil, title: "W1", subtitle: "multiwindow", body: "")
|
|
|
|
let notif1 = store.notifications.first(where: { $0.tabId == tabId1 && $0.title == "W1" })
|
|
let notif2 = store.notifications.first(where: { $0.tabId == tabId2 && $0.title == "W2" })
|
|
|
|
self.writeMultiWindowNotificationTestData([
|
|
"window1Id": window1.windowId.uuidString,
|
|
"window2Id": window2.windowId.uuidString,
|
|
"window2InitialSidebarSelection": "notifications",
|
|
"tabId1": tabId1.uuidString,
|
|
"tabId2": tabId2.uuidString,
|
|
"notifId1": notif1?.id.uuidString ?? "",
|
|
"notifId2": notif2?.id.uuidString ?? "",
|
|
"expectedLatestWindowId": window1.windowId.uuidString,
|
|
"expectedLatestTabId": tabId1.uuidString,
|
|
], at: path)
|
|
}
|
|
}
|
|
}
|
|
|
|
private func writeMultiWindowNotificationTestData(_ updates: [String: String], at path: String) {
|
|
var payload = loadMultiWindowNotificationTestData(at: path)
|
|
for (key, value) in updates {
|
|
payload[key] = value
|
|
}
|
|
guard let data = try? JSONSerialization.data(withJSONObject: payload) else { return }
|
|
try? data.write(to: URL(fileURLWithPath: path), options: .atomic)
|
|
}
|
|
|
|
private func loadMultiWindowNotificationTestData(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 recordMultiWindowNotificationFocusIfNeeded(
|
|
windowId: UUID,
|
|
tabId: UUID,
|
|
surfaceId: UUID?,
|
|
sidebarSelection: SidebarSelection
|
|
) {
|
|
let env = ProcessInfo.processInfo.environment
|
|
guard let path = env["CMUX_UI_TEST_MULTI_WINDOW_NOTIF_PATH"], !path.isEmpty else { return }
|
|
let sidebarSelectionString: String = {
|
|
switch sidebarSelection {
|
|
case .tabs: return "tabs"
|
|
case .notifications: return "notifications"
|
|
}
|
|
}()
|
|
writeMultiWindowNotificationTestData([
|
|
"focusToken": UUID().uuidString,
|
|
"focusedWindowId": windowId.uuidString,
|
|
"focusedTabId": tabId.uuidString,
|
|
"focusedSurfaceId": surfaceId?.uuidString ?? "",
|
|
"focusedSidebarSelection": sidebarSelectionString,
|
|
], at: path)
|
|
}
|
|
#endif
|
|
|
|
func attachUpdateAccessory(to window: NSWindow) {
|
|
titlebarAccessoryController.start()
|
|
titlebarAccessoryController.attach(to: window)
|
|
}
|
|
|
|
func applyWindowDecorations(to window: NSWindow) {
|
|
windowDecorationsController.apply(to: window)
|
|
}
|
|
|
|
func toggleNotificationsPopover(animated: Bool = true) {
|
|
titlebarAccessoryController.toggleNotificationsPopover(animated: animated)
|
|
}
|
|
|
|
func jumpToLatestUnread() {
|
|
guard let notificationStore else { return }
|
|
#if DEBUG
|
|
if ProcessInfo.processInfo.environment["CMUX_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
|
|
}
|
|
}
|
|
}
|
|
|
|
private func installWindowKeyEquivalentSwizzle() {
|
|
_ = Self.didInstallWindowKeyEquivalentSwizzle
|
|
}
|
|
|
|
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 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")")
|
|
#endif
|
|
if self.handleCustomShortcut(event: event) {
|
|
#if DEBUG
|
|
dlog(" → consumed by handleCustomShortcut")
|
|
DebugEventLog.shared.dump()
|
|
#endif
|
|
return nil // Consume the event
|
|
}
|
|
#if DEBUG
|
|
DebugEventLog.shared.dump()
|
|
#endif
|
|
return event // Pass through
|
|
}
|
|
self.handleBrowserOmnibarSelectionRepeatLifecycleEvent(event)
|
|
return event
|
|
}
|
|
}
|
|
|
|
private func installGhosttyConfigObserver() {
|
|
guard ghosttyConfigObserver == nil else { return }
|
|
ghosttyConfigObserver = NotificationCenter.default.addObserver(
|
|
forName: .ghosttyConfigDidReload,
|
|
object: nil,
|
|
queue: .main
|
|
) { [weak self] _ in
|
|
self?.refreshGhosttyGotoSplitShortcuts()
|
|
}
|
|
}
|
|
|
|
private func refreshGhosttyGotoSplitShortcuts() {
|
|
guard let config = GhosttyApp.shared.config else {
|
|
ghosttyGotoSplitLeftShortcut = nil
|
|
ghosttyGotoSplitRightShortcut = nil
|
|
ghosttyGotoSplitUpShortcut = nil
|
|
ghosttyGotoSplitDownShortcut = nil
|
|
return
|
|
}
|
|
|
|
ghosttyGotoSplitLeftShortcut = storedShortcutFromGhosttyTrigger(
|
|
ghostty_config_trigger(config, "goto_split:left", UInt("goto_split:left".utf8.count))
|
|
)
|
|
ghosttyGotoSplitRightShortcut = storedShortcutFromGhosttyTrigger(
|
|
ghostty_config_trigger(config, "goto_split:right", UInt("goto_split:right".utf8.count))
|
|
)
|
|
ghosttyGotoSplitUpShortcut = storedShortcutFromGhosttyTrigger(
|
|
ghostty_config_trigger(config, "goto_split:up", UInt("goto_split:up".utf8.count))
|
|
)
|
|
ghosttyGotoSplitDownShortcut = storedShortcutFromGhosttyTrigger(
|
|
ghostty_config_trigger(config, "goto_split:down", UInt("goto_split:down".utf8.count))
|
|
)
|
|
}
|
|
|
|
private func storedShortcutFromGhosttyTrigger(_ trigger: ghostty_input_trigger_s) -> StoredShortcut? {
|
|
let key: String
|
|
switch trigger.tag {
|
|
case GHOSTTY_TRIGGER_PHYSICAL:
|
|
switch trigger.key.physical {
|
|
case GHOSTTY_KEY_ARROW_LEFT:
|
|
key = "←"
|
|
case GHOSTTY_KEY_ARROW_RIGHT:
|
|
key = "→"
|
|
case GHOSTTY_KEY_ARROW_UP:
|
|
key = "↑"
|
|
case GHOSTTY_KEY_ARROW_DOWN:
|
|
key = "↓"
|
|
case GHOSTTY_KEY_A: key = "a"
|
|
case GHOSTTY_KEY_B: key = "b"
|
|
case GHOSTTY_KEY_C: key = "c"
|
|
case GHOSTTY_KEY_D: key = "d"
|
|
case GHOSTTY_KEY_E: key = "e"
|
|
case GHOSTTY_KEY_F: key = "f"
|
|
case GHOSTTY_KEY_G: key = "g"
|
|
case GHOSTTY_KEY_H: key = "h"
|
|
case GHOSTTY_KEY_I: key = "i"
|
|
case GHOSTTY_KEY_J: key = "j"
|
|
case GHOSTTY_KEY_K: key = "k"
|
|
case GHOSTTY_KEY_L: key = "l"
|
|
case GHOSTTY_KEY_M: key = "m"
|
|
case GHOSTTY_KEY_N: key = "n"
|
|
case GHOSTTY_KEY_O: key = "o"
|
|
case GHOSTTY_KEY_P: key = "p"
|
|
case GHOSTTY_KEY_Q: key = "q"
|
|
case GHOSTTY_KEY_R: key = "r"
|
|
case GHOSTTY_KEY_S: key = "s"
|
|
case GHOSTTY_KEY_T: key = "t"
|
|
case GHOSTTY_KEY_U: key = "u"
|
|
case GHOSTTY_KEY_V: key = "v"
|
|
case GHOSTTY_KEY_W: key = "w"
|
|
case GHOSTTY_KEY_X: key = "x"
|
|
case GHOSTTY_KEY_Y: key = "y"
|
|
case GHOSTTY_KEY_Z: key = "z"
|
|
case GHOSTTY_KEY_DIGIT_0: key = "0"
|
|
case GHOSTTY_KEY_DIGIT_1: key = "1"
|
|
case GHOSTTY_KEY_DIGIT_2: key = "2"
|
|
case GHOSTTY_KEY_DIGIT_3: key = "3"
|
|
case GHOSTTY_KEY_DIGIT_4: key = "4"
|
|
case GHOSTTY_KEY_DIGIT_5: key = "5"
|
|
case GHOSTTY_KEY_DIGIT_6: key = "6"
|
|
case GHOSTTY_KEY_DIGIT_7: key = "7"
|
|
case GHOSTTY_KEY_DIGIT_8: key = "8"
|
|
case GHOSTTY_KEY_DIGIT_9: key = "9"
|
|
case GHOSTTY_KEY_BRACKET_LEFT: key = "["
|
|
case GHOSTTY_KEY_BRACKET_RIGHT: key = "]"
|
|
case GHOSTTY_KEY_MINUS: key = "-"
|
|
case GHOSTTY_KEY_EQUAL: key = "="
|
|
case GHOSTTY_KEY_COMMA: key = ","
|
|
case GHOSTTY_KEY_PERIOD: key = "."
|
|
case GHOSTTY_KEY_SLASH: key = "/"
|
|
case GHOSTTY_KEY_SEMICOLON: key = ";"
|
|
case GHOSTTY_KEY_QUOTE: key = "'"
|
|
case GHOSTTY_KEY_BACKQUOTE: key = "`"
|
|
case GHOSTTY_KEY_BACKSLASH: key = "\\"
|
|
default:
|
|
return nil
|
|
}
|
|
case GHOSTTY_TRIGGER_UNICODE:
|
|
guard let scalar = UnicodeScalar(trigger.key.unicode) else { return nil }
|
|
key = String(Character(scalar)).lowercased()
|
|
case GHOSTTY_TRIGGER_CATCH_ALL:
|
|
return nil
|
|
default:
|
|
return nil
|
|
}
|
|
|
|
let mods = trigger.mods.rawValue
|
|
let command = (mods & GHOSTTY_MODS_SUPER.rawValue) != 0
|
|
let shift = (mods & GHOSTTY_MODS_SHIFT.rawValue) != 0
|
|
let option = (mods & GHOSTTY_MODS_ALT.rawValue) != 0
|
|
let control = (mods & GHOSTTY_MODS_CTRL.rawValue) != 0
|
|
|
|
// Ignore bogus empty triggers.
|
|
if key.isEmpty || (!command && !shift && !option && !control) {
|
|
return nil
|
|
}
|
|
|
|
return StoredShortcut(key: key, command: command, shift: shift, option: option, control: control)
|
|
}
|
|
|
|
private func handleCustomShortcut(event: NSEvent) -> Bool {
|
|
// `charactersIgnoringModifiers` can be nil for some synthetic NSEvents and certain special keys.
|
|
// Most shortcuts below use keyCode fallbacks, so treat nil as "" rather than bailing out.
|
|
let chars = (event.charactersIgnoringModifiers ?? "").lowercased()
|
|
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)
|
|
#if DEBUG
|
|
if isControlD {
|
|
writeChildExitKeyboardProbe(
|
|
[
|
|
"probeAppShortcutCharsHex": childExitKeyboardProbeHex(event.characters),
|
|
"probeAppShortcutCharsIgnoringHex": childExitKeyboardProbeHex(event.charactersIgnoringModifiers),
|
|
"probeAppShortcutKeyCode": String(event.keyCode),
|
|
"probeAppShortcutModsRaw": String(event.modifierFlags.rawValue),
|
|
],
|
|
increments: ["probeAppShortcutCtrlDSeenCount": 1]
|
|
)
|
|
}
|
|
#endif
|
|
|
|
// Don't steal shortcuts from close-confirmation alerts. Keep standard alert key
|
|
// equivalents working and avoid surprising actions while the confirmation is up.
|
|
let closeConfirmationPanel = NSApp.windows
|
|
.compactMap { $0 as? NSPanel }
|
|
.first { panel in
|
|
guard panel.isVisible, let root = panel.contentView else { return false }
|
|
return findStaticText(in: root, equals: "Close workspace?")
|
|
|| findStaticText(in: root, equals: "Close tab?")
|
|
}
|
|
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 flags == [.command], chars == "d",
|
|
let root = closeConfirmationPanel.contentView,
|
|
let closeButton = findButton(in: root, titled: "Close") {
|
|
closeButton.performClick(nil)
|
|
return true
|
|
}
|
|
return false
|
|
}
|
|
|
|
if NSApp.modalWindow != nil || NSApp.keyWindow?.attachedSheet != nil {
|
|
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
|
|
}
|
|
|
|
// Keep keyboard routing deterministic after split close/reparent transitions:
|
|
// before processing shortcuts, converge first responder with the focused terminal panel.
|
|
if isControlD {
|
|
tabManager?.reconcileFocusedPanelFromFirstResponderForKeyboard()
|
|
#if DEBUG
|
|
writeChildExitKeyboardProbe([:], increments: ["probeAppShortcutCtrlDPassedCount": 1])
|
|
#endif
|
|
// Ctrl+D belongs to the focused terminal surface; never treat it as an app shortcut.
|
|
return false
|
|
}
|
|
|
|
// 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,
|
|
NSApp.keyWindow?.firstResponder is GhosttyNSView {
|
|
#if DEBUG
|
|
dlog("handleCustomShortcut: clearing stale browserAddressBarFocusedPanelId")
|
|
#endif
|
|
browserAddressBarFocusedPanelId = nil
|
|
stopBrowserOmnibarSelectionRepeat()
|
|
}
|
|
|
|
// 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
|
|
}
|
|
|
|
if let delta = browserOmnibarSelectionDeltaForArrowNavigation(
|
|
hasFocusedAddressBar: browserAddressBarFocusedPanelId != nil,
|
|
flags: event.modifierFlags,
|
|
keyCode: event.keyCode
|
|
) {
|
|
dispatchBrowserOmnibarSelectionMove(delta: delta)
|
|
return true
|
|
}
|
|
|
|
// 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
|
|
}
|
|
|
|
// Primary UI shortcuts
|
|
if matchShortcut(event: event, shortcut: KeyboardShortcutSettings.shortcut(for: .toggleSidebar)) {
|
|
sidebarState?.toggle()
|
|
return true
|
|
}
|
|
|
|
if matchShortcut(event: event, shortcut: KeyboardShortcutSettings.shortcut(for: .newTab)) {
|
|
// Cmd+N semantics:
|
|
// - If there are no main windows, create a new window.
|
|
// - Otherwise, create a new workspace in the active window.
|
|
if tabManager == nil || mainWindowContexts.isEmpty {
|
|
openNewMainWindow(nil)
|
|
} else {
|
|
tabManager?.addTab()
|
|
}
|
|
return true
|
|
}
|
|
|
|
// New Window: Cmd+Shift+N
|
|
// Handled here instead of relying on SwiftUI's CommandGroup menu item because
|
|
// after a browser panel has been shown, SwiftUI's menu dispatch can silently
|
|
// consume the key equivalent without firing the action closure.
|
|
if matchShortcut(event: event, shortcut: KeyboardShortcutSettings.shortcut(for: .newWindow)) {
|
|
openNewMainWindow(nil)
|
|
return true
|
|
}
|
|
|
|
// Check Show Notifications shortcut
|
|
if matchShortcut(event: event, shortcut: KeyboardShortcutSettings.shortcut(for: .showNotifications)) {
|
|
toggleNotificationsPopover(animated: false)
|
|
return true
|
|
}
|
|
|
|
// Check Jump to Unread shortcut
|
|
if matchShortcut(event: event, shortcut: KeyboardShortcutSettings.shortcut(for: .jumpToUnread)) {
|
|
#if DEBUG
|
|
if ProcessInfo.processInfo.environment["CMUX_UI_TEST_JUMP_UNREAD_SETUP"] == "1" {
|
|
writeJumpUnreadTestData(["jumpUnreadShortcutHandled": "1"])
|
|
}
|
|
#endif
|
|
jumpToLatestUnread()
|
|
return true
|
|
}
|
|
|
|
// Flash the currently focused panel so the user can visually confirm focus.
|
|
if matchShortcut(event: event, shortcut: KeyboardShortcutSettings.shortcut(for: .triggerFlash)) {
|
|
tabManager?.triggerFocusFlash()
|
|
return true
|
|
}
|
|
|
|
// Surface navigation: Cmd+Shift+] / Cmd+Shift+[
|
|
if matchShortcut(event: event, shortcut: KeyboardShortcutSettings.shortcut(for: .nextSurface)) {
|
|
tabManager?.selectNextSurface()
|
|
return true
|
|
}
|
|
if matchShortcut(event: event, shortcut: KeyboardShortcutSettings.shortcut(for: .prevSurface)) {
|
|
tabManager?.selectPreviousSurface()
|
|
return true
|
|
}
|
|
|
|
// Workspace navigation: Cmd+Ctrl+] / Cmd+Ctrl+[
|
|
if matchShortcut(event: event, shortcut: KeyboardShortcutSettings.shortcut(for: .nextSidebarTab)) {
|
|
tabManager?.selectNextTab()
|
|
return true
|
|
}
|
|
|
|
if matchShortcut(event: event, shortcut: KeyboardShortcutSettings.shortcut(for: .prevSidebarTab)) {
|
|
tabManager?.selectPreviousTab()
|
|
return true
|
|
}
|
|
|
|
// Numeric shortcuts for specific sidebar tabs: Cmd+1-9 (9 = last workspace)
|
|
if flags == [.command],
|
|
let manager = tabManager,
|
|
let num = Int(chars),
|
|
let targetIndex = WorkspaceShortcutMapper.workspaceIndex(forCommandDigit: num, workspaceCount: manager.tabs.count) {
|
|
manager.selectTab(at: targetIndex)
|
|
return true
|
|
}
|
|
|
|
// Numeric shortcuts for surfaces within pane: Ctrl+1-9 (9 = last)
|
|
if flags == [.control] {
|
|
if let num = Int(chars), num >= 1 && num <= 9 {
|
|
if num == 9 {
|
|
tabManager?.selectLastSurface()
|
|
} else {
|
|
tabManager?.selectSurface(at: num - 1)
|
|
}
|
|
return true
|
|
}
|
|
}
|
|
|
|
// Pane focus navigation (defaults to Cmd+Option+Arrow, but can be customized to letter/number keys).
|
|
if matchDirectionalShortcut(
|
|
event: event,
|
|
shortcut: KeyboardShortcutSettings.shortcut(for: .focusLeft),
|
|
arrowGlyph: "←",
|
|
arrowKeyCode: 123
|
|
) || (ghosttyGotoSplitLeftShortcut.map { matchDirectionalShortcut(event: event, shortcut: $0, arrowGlyph: "←", arrowKeyCode: 123) } ?? false) {
|
|
tabManager?.movePaneFocus(direction: .left)
|
|
#if DEBUG
|
|
recordGotoSplitMoveIfNeeded(direction: .left)
|
|
#endif
|
|
return true
|
|
}
|
|
if matchDirectionalShortcut(
|
|
event: event,
|
|
shortcut: KeyboardShortcutSettings.shortcut(for: .focusRight),
|
|
arrowGlyph: "→",
|
|
arrowKeyCode: 124
|
|
) || (ghosttyGotoSplitRightShortcut.map { matchDirectionalShortcut(event: event, shortcut: $0, arrowGlyph: "→", arrowKeyCode: 124) } ?? false) {
|
|
tabManager?.movePaneFocus(direction: .right)
|
|
#if DEBUG
|
|
recordGotoSplitMoveIfNeeded(direction: .right)
|
|
#endif
|
|
return true
|
|
}
|
|
if matchDirectionalShortcut(
|
|
event: event,
|
|
shortcut: KeyboardShortcutSettings.shortcut(for: .focusUp),
|
|
arrowGlyph: "↑",
|
|
arrowKeyCode: 126
|
|
) || (ghosttyGotoSplitUpShortcut.map { matchDirectionalShortcut(event: event, shortcut: $0, arrowGlyph: "↑", arrowKeyCode: 126) } ?? false) {
|
|
tabManager?.movePaneFocus(direction: .up)
|
|
#if DEBUG
|
|
recordGotoSplitMoveIfNeeded(direction: .up)
|
|
#endif
|
|
return true
|
|
}
|
|
if matchDirectionalShortcut(
|
|
event: event,
|
|
shortcut: KeyboardShortcutSettings.shortcut(for: .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
|
|
}
|
|
|
|
// Split actions: Cmd+D / Cmd+Shift+D
|
|
if matchShortcut(event: event, shortcut: KeyboardShortcutSettings.shortcut(for: .splitRight)) {
|
|
_ = performSplitShortcut(direction: .right)
|
|
return true
|
|
}
|
|
|
|
if matchShortcut(event: event, shortcut: KeyboardShortcutSettings.shortcut(for: .splitDown)) {
|
|
_ = performSplitShortcut(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 matchShortcut(event: event, shortcut: KeyboardShortcutSettings.shortcut(for: .newSurface)) {
|
|
tabManager?.newSurface()
|
|
return true
|
|
}
|
|
|
|
// Open browser: Cmd+Shift+L
|
|
if matchShortcut(event: event, shortcut: KeyboardShortcutSettings.shortcut(for: .openBrowser)) {
|
|
tabManager?.openBrowser(insertAtEnd: true)
|
|
return true
|
|
}
|
|
|
|
// Focus browser address bar: Cmd+L
|
|
if flags == [.command] && chars == "l" {
|
|
if let focusedPanel = tabManager?.focusedBrowserPanel {
|
|
focusBrowserAddressBar(in: focusedPanel)
|
|
return true
|
|
}
|
|
|
|
if let browserAddressBarFocusedPanelId,
|
|
let tabManager,
|
|
let workspace = tabManager.selectedWorkspace,
|
|
let panel = workspace.browserPanel(for: browserAddressBarFocusedPanelId) {
|
|
workspace.focusPanel(panel.id)
|
|
focusBrowserAddressBar(in: panel)
|
|
return true
|
|
}
|
|
|
|
tabManager?.openBrowser(insertAtEnd: true)
|
|
if let focusedPanel = tabManager?.focusedBrowserPanel {
|
|
focusBrowserAddressBar(in: focusedPanel)
|
|
return true
|
|
}
|
|
}
|
|
|
|
if let action = browserZoomShortcutAction(flags: flags, chars: chars, keyCode: event.keyCode),
|
|
let manager = tabManager {
|
|
switch action {
|
|
case .zoomIn:
|
|
return manager.zoomInFocusedBrowser()
|
|
case .zoomOut:
|
|
return manager.zoomOutFocusedBrowser()
|
|
case .reset:
|
|
return manager.resetZoomFocusedBrowser()
|
|
}
|
|
}
|
|
|
|
return false
|
|
}
|
|
|
|
private func focusBrowserAddressBar(in panel: BrowserPanel) {
|
|
panel.beginSuppressWebViewFocusForAddressBar()
|
|
browserAddressBarFocusedPanelId = panel.id
|
|
NotificationCenter.default.post(name: .browserFocusAddressBar, object: panel.id)
|
|
}
|
|
|
|
private func shouldBypassAppShortcutForFocusedBrowserAddressBar(
|
|
flags: NSEvent.ModifierFlags,
|
|
chars: String
|
|
) -> Bool {
|
|
guard browserAddressBarFocusedPanelId != nil else { return false }
|
|
let normalizedFlags = flags
|
|
.intersection(.deviceIndependentFlagsMask)
|
|
.subtracting([.numericPad, .function])
|
|
guard normalizedFlags == [.control] else { return false }
|
|
return chars == "n" || chars == "p"
|
|
}
|
|
|
|
private func commandOmnibarSelectionDelta(
|
|
flags: NSEvent.ModifierFlags,
|
|
chars: String
|
|
) -> Int? {
|
|
browserOmnibarSelectionDeltaForCommandNavigation(
|
|
hasFocusedAddressBar: browserAddressBarFocusedPanelId != nil,
|
|
flags: flags,
|
|
chars: chars
|
|
)
|
|
}
|
|
|
|
private func dispatchBrowserOmnibarSelectionMove(delta: Int) {
|
|
guard delta != 0 else { return }
|
|
guard let panelId = browserAddressBarFocusedPanelId else { return }
|
|
NotificationCenter.default.post(
|
|
name: .browserMoveOmnibarSelection,
|
|
object: panelId,
|
|
userInfo: ["delta": delta]
|
|
)
|
|
}
|
|
|
|
private func startBrowserOmnibarSelectionRepeatIfNeeded(keyCode: UInt16, delta: Int) {
|
|
guard delta != 0 else { return }
|
|
guard browserAddressBarFocusedPanelId != nil else { return }
|
|
|
|
if browserOmnibarRepeatKeyCode == keyCode, browserOmnibarRepeatDelta == delta {
|
|
return
|
|
}
|
|
|
|
stopBrowserOmnibarSelectionRepeat()
|
|
browserOmnibarRepeatKeyCode = keyCode
|
|
browserOmnibarRepeatDelta = delta
|
|
|
|
let start = DispatchWorkItem { [weak self] in
|
|
self?.scheduleBrowserOmnibarSelectionRepeatTick()
|
|
}
|
|
browserOmnibarRepeatStartWorkItem = start
|
|
DispatchQueue.main.asyncAfter(deadline: .now() + 0.25, execute: start)
|
|
}
|
|
|
|
private func scheduleBrowserOmnibarSelectionRepeatTick() {
|
|
browserOmnibarRepeatStartWorkItem = nil
|
|
guard browserAddressBarFocusedPanelId != nil else {
|
|
stopBrowserOmnibarSelectionRepeat()
|
|
return
|
|
}
|
|
guard browserOmnibarRepeatKeyCode != nil else { return }
|
|
|
|
dispatchBrowserOmnibarSelectionMove(delta: browserOmnibarRepeatDelta)
|
|
|
|
let tick = DispatchWorkItem { [weak self] in
|
|
self?.scheduleBrowserOmnibarSelectionRepeatTick()
|
|
}
|
|
browserOmnibarRepeatTickWorkItem = tick
|
|
DispatchQueue.main.asyncAfter(deadline: .now() + 0.055, execute: tick)
|
|
}
|
|
|
|
private func stopBrowserOmnibarSelectionRepeat() {
|
|
browserOmnibarRepeatStartWorkItem?.cancel()
|
|
browserOmnibarRepeatTickWorkItem?.cancel()
|
|
browserOmnibarRepeatStartWorkItem = nil
|
|
browserOmnibarRepeatTickWorkItem = nil
|
|
browserOmnibarRepeatKeyCode = nil
|
|
browserOmnibarRepeatDelta = 0
|
|
}
|
|
|
|
private func handleBrowserOmnibarSelectionRepeatLifecycleEvent(_ event: NSEvent) {
|
|
guard browserOmnibarRepeatKeyCode != nil else { return }
|
|
|
|
switch event.type {
|
|
case .keyUp:
|
|
if event.keyCode == browserOmnibarRepeatKeyCode {
|
|
stopBrowserOmnibarSelectionRepeat()
|
|
}
|
|
case .flagsChanged:
|
|
let flags = event.modifierFlags.intersection(.deviceIndependentFlagsMask)
|
|
if !flags.contains(.command) {
|
|
stopBrowserOmnibarSelectionRepeat()
|
|
}
|
|
default:
|
|
break
|
|
}
|
|
}
|
|
|
|
@discardableResult
|
|
func performSplitShortcut(direction: SplitDirection) -> Bool {
|
|
tabManager?.createSplit(direction: direction)
|
|
#if DEBUG
|
|
recordGotoSplitSplitIfNeeded(direction: direction)
|
|
#endif
|
|
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)
|
|
}
|
|
|
|
#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)
|
|
}
|
|
#endif
|
|
|
|
private func findButton(in view: NSView, titled title: String) -> NSButton? {
|
|
if let button = view as? NSButton, button.title == title {
|
|
return button
|
|
}
|
|
for subview in view.subviews {
|
|
if let found = findButton(in: subview, titled: title) {
|
|
return found
|
|
}
|
|
}
|
|
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
|
|
}
|
|
|
|
/// Match a shortcut against an event, handling normal keys
|
|
private func matchShortcut(event: NSEvent, shortcut: StoredShortcut) -> 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])
|
|
guard flags == shortcut.modifierFlags else { return false }
|
|
|
|
// NSEvent.charactersIgnoringModifiers preserves Shift for some symbol keys
|
|
// (e.g. Shift+] can yield "}" instead of "]"), so match brackets by keyCode.
|
|
let shortcutKey = shortcut.key.lowercased()
|
|
if shortcutKey == "[" || shortcutKey == "]" {
|
|
switch event.keyCode {
|
|
case 33: // kVK_ANSI_LeftBracket
|
|
return shortcutKey == "["
|
|
case 30: // kVK_ANSI_RightBracket
|
|
return shortcutKey == "]"
|
|
default:
|
|
return false
|
|
}
|
|
}
|
|
|
|
// Control-key combos can produce control characters (e.g. Ctrl+H => backspace),
|
|
// so fall back to keyCode matching for common printable keys.
|
|
if let chars = event.charactersIgnoringModifiers?.lowercased(), chars == shortcutKey {
|
|
return true
|
|
}
|
|
if let expectedKeyCode = keyCodeForShortcutKey(shortcutKey) {
|
|
return event.keyCode == expectedKeyCode
|
|
}
|
|
return false
|
|
}
|
|
|
|
private func keyCodeForShortcutKey(_ key: String) -> UInt16? {
|
|
// Matches macOS ANSI key codes. This is intentionally limited to keys we
|
|
// support in StoredShortcut/ghostty trigger translation.
|
|
switch key {
|
|
case "a": return 0 // kVK_ANSI_A
|
|
case "s": return 1 // kVK_ANSI_S
|
|
case "d": return 2 // kVK_ANSI_D
|
|
case "f": return 3 // kVK_ANSI_F
|
|
case "h": return 4 // kVK_ANSI_H
|
|
case "g": return 5 // kVK_ANSI_G
|
|
case "z": return 6 // kVK_ANSI_Z
|
|
case "x": return 7 // kVK_ANSI_X
|
|
case "c": return 8 // kVK_ANSI_C
|
|
case "v": return 9 // kVK_ANSI_V
|
|
case "b": return 11 // kVK_ANSI_B
|
|
case "q": return 12 // kVK_ANSI_Q
|
|
case "w": return 13 // kVK_ANSI_W
|
|
case "e": return 14 // kVK_ANSI_E
|
|
case "r": return 15 // kVK_ANSI_R
|
|
case "y": return 16 // kVK_ANSI_Y
|
|
case "t": return 17 // kVK_ANSI_T
|
|
case "1": return 18 // kVK_ANSI_1
|
|
case "2": return 19 // kVK_ANSI_2
|
|
case "3": return 20 // kVK_ANSI_3
|
|
case "4": return 21 // kVK_ANSI_4
|
|
case "6": return 22 // kVK_ANSI_6
|
|
case "5": return 23 // kVK_ANSI_5
|
|
case "=": return 24 // kVK_ANSI_Equal
|
|
case "9": return 25 // kVK_ANSI_9
|
|
case "7": return 26 // kVK_ANSI_7
|
|
case "-": return 27 // kVK_ANSI_Minus
|
|
case "8": return 28 // kVK_ANSI_8
|
|
case "0": return 29 // kVK_ANSI_0
|
|
case "o": return 31 // kVK_ANSI_O
|
|
case "u": return 32 // kVK_ANSI_U
|
|
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
|
|
default:
|
|
return nil
|
|
}
|
|
}
|
|
|
|
/// Match arrow key shortcuts using keyCode
|
|
/// Arrow keys include .numericPad and .function in their modifierFlags, so strip those before comparing.
|
|
private func matchArrowShortcut(event: NSEvent, shortcut: StoredShortcut, keyCode: UInt16) -> Bool {
|
|
let flags = event.modifierFlags.intersection(.deviceIndependentFlagsMask)
|
|
.subtracting([.numericPad, .function])
|
|
return event.keyCode == keyCode && flags == shortcut.modifierFlags
|
|
}
|
|
|
|
/// Match tab key shortcuts using keyCode 48
|
|
private func matchTabShortcut(event: NSEvent, shortcut: StoredShortcut) -> Bool {
|
|
let flags = event.modifierFlags.intersection(.deviceIndependentFlagsMask)
|
|
return event.keyCode == 48 && flags == shortcut.modifierFlags
|
|
}
|
|
|
|
/// 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,
|
|
shortcut: StoredShortcut,
|
|
arrowGlyph: String,
|
|
arrowKeyCode: UInt16
|
|
) -> Bool {
|
|
if shortcut.key == arrowGlyph {
|
|
return matchArrowShortcut(event: event, shortcut: shortcut, keyCode: arrowKeyCode)
|
|
}
|
|
return matchShortcut(event: event, shortcut: shortcut)
|
|
}
|
|
|
|
func validateMenuItem(_ item: NSMenuItem) -> Bool {
|
|
updateController.validateMenuItem(item)
|
|
}
|
|
|
|
|
|
private func configureUserNotifications() {
|
|
let actions = [
|
|
UNNotificationAction(
|
|
identifier: TerminalNotificationStore.actionShowIdentifier,
|
|
title: "Show"
|
|
)
|
|
]
|
|
|
|
let category = UNNotificationCategory(
|
|
identifier: TerminalNotificationStore.categoryIdentifier,
|
|
actions: actions,
|
|
intentIdentifiers: [],
|
|
options: [.customDismissAction]
|
|
)
|
|
|
|
let center = UNUserNotificationCenter.current()
|
|
center.setNotificationCategories([category])
|
|
center.delegate = self
|
|
}
|
|
|
|
private func disableNativeTabbingShortcut() {
|
|
guard let menu = NSApp.mainMenu else { return }
|
|
disableMenuItemShortcut(in: menu, action: #selector(NSWindow.toggleTabBar(_:)))
|
|
}
|
|
|
|
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 ensureApplicationIcon() {
|
|
if let icon = NSImage(named: NSImage.applicationIconName) {
|
|
NSApplication.shared.applicationIconImage = icon
|
|
}
|
|
}
|
|
|
|
private func registerLaunchServicesBundle() {
|
|
let bundleURL = Bundle.main.bundleURL.standardizedFileURL
|
|
let registerStatus = LSRegisterURL(bundleURL as CFURL, true)
|
|
if registerStatus != noErr {
|
|
NSLog("LaunchServices registration failed (status: \(registerStatus)) for \(bundleURL.path)")
|
|
}
|
|
}
|
|
|
|
private func enforceSingleInstance() {
|
|
guard let bundleId = Bundle.main.bundleIdentifier else { return }
|
|
let currentPid = ProcessInfo.processInfo.processIdentifier
|
|
|
|
for app in NSRunningApplication.runningApplications(withBundleIdentifier: bundleId) {
|
|
guard app.processIdentifier != currentPid else { continue }
|
|
app.terminate()
|
|
if !app.isTerminated {
|
|
_ = app.forceTerminate()
|
|
}
|
|
}
|
|
}
|
|
|
|
private func observeDuplicateLaunches() {
|
|
guard let bundleId = Bundle.main.bundleIdentifier else { return }
|
|
let currentPid = ProcessInfo.processInfo.processIdentifier
|
|
|
|
workspaceObserver = NSWorkspace.shared.notificationCenter.addObserver(
|
|
forName: NSWorkspace.didLaunchApplicationNotification,
|
|
object: nil,
|
|
queue: .main
|
|
) { [weak self] notification in
|
|
guard self != nil else { return }
|
|
guard let app = notification.userInfo?[NSWorkspace.applicationUserInfoKey] as? NSRunningApplication else { return }
|
|
guard app.bundleIdentifier == bundleId, app.processIdentifier != currentPid else { return }
|
|
|
|
app.terminate()
|
|
if !app.isTerminated {
|
|
_ = app.forceTerminate()
|
|
}
|
|
NSRunningApplication.current.activate(options: [.activateAllWindows, .activateIgnoringOtherApps])
|
|
}
|
|
}
|
|
|
|
func userNotificationCenter(
|
|
_ center: UNUserNotificationCenter,
|
|
didReceive response: UNNotificationResponse,
|
|
withCompletionHandler completionHandler: @escaping () -> Void
|
|
) {
|
|
handleNotificationResponse(response)
|
|
completionHandler()
|
|
}
|
|
|
|
func userNotificationCenter(
|
|
_ center: UNUserNotificationCenter,
|
|
willPresent notification: UNNotification,
|
|
withCompletionHandler completionHandler: @escaping (UNNotificationPresentationOptions) -> Void
|
|
) {
|
|
completionHandler([.banner, .sound, .list])
|
|
}
|
|
|
|
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)
|
|
}()
|
|
|
|
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
|
|
}
|
|
}
|
|
|
|
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 installBrowserAddressBarFocusObservers() {
|
|
guard browserAddressBarFocusObserver == nil, browserAddressBarBlurObserver == 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()
|
|
#if DEBUG
|
|
dlog("addressBar FOCUS panelId=\(panelId.uuidString.prefix(8))")
|
|
#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 }
|
|
if self.browserAddressBarFocusedPanelId == panelId {
|
|
self.browserAddressBarFocusedPanelId = nil
|
|
self.stopBrowserOmnibarSelectionRepeat()
|
|
#if DEBUG
|
|
dlog("addressBar BLUR panelId=\(panelId.uuidString.prefix(8))")
|
|
#endif
|
|
}
|
|
}
|
|
}
|
|
|
|
private func browserPanel(for panelId: UUID) -> BrowserPanel? {
|
|
return tabManager?.selectedWorkspace?.browserPanel(for: panelId)
|
|
}
|
|
|
|
private func setActiveMainWindow(_ window: NSWindow) {
|
|
guard isMainTerminalWindow(window) else { return }
|
|
guard let context = mainWindowContexts[ObjectIdentifier(window)] else { return }
|
|
tabManager = context.tabManager
|
|
sidebarState = context.sidebarState
|
|
sidebarSelectionState = context.sidebarSelectionState
|
|
TerminalController.shared.setActiveTabManager(context.tabManager)
|
|
}
|
|
|
|
private func unregisterMainWindow(_ window: NSWindow) {
|
|
let key = ObjectIdentifier(window)
|
|
guard let removed = mainWindowContexts.removeValue(forKey: key) else { return }
|
|
|
|
// 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 tabManager === removed.tabManager {
|
|
// Repoint "active" pointers to any remaining main terminal window.
|
|
let nextContext: MainWindowContext? = {
|
|
if let keyWindow = NSApp.keyWindow,
|
|
isMainTerminalWindow(keyWindow),
|
|
let ctx = mainWindowContexts[ObjectIdentifier(keyWindow)] {
|
|
return ctx
|
|
}
|
|
return mainWindowContexts.values.first
|
|
}()
|
|
|
|
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)
|
|
}
|
|
}
|
|
}
|
|
|
|
private func isMainTerminalWindow(_ window: NSWindow) -> Bool {
|
|
guard let raw = window.identifier?.rawValue else { return false }
|
|
return raw == "cmux.main" || raw.hasPrefix("cmux.main.")
|
|
}
|
|
|
|
private func contextContainingTabId(_ tabId: UUID) -> MainWindowContext? {
|
|
for context in mainWindowContexts.values {
|
|
if context.tabManager.tabs.contains(where: { $0.id == tabId }) {
|
|
return context
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
/// Returns the `TabManager` that owns `tabId`, if any.
|
|
func tabManagerFor(tabId: UUID) -> TabManager? {
|
|
contextContainingTabId(tabId)?.tabManager
|
|
}
|
|
|
|
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 openNotification(tabId: UUID, surfaceId: UUID?, notificationId: UUID?) -> Bool {
|
|
#if DEBUG
|
|
let isJumpUnreadUITest = ProcessInfo.processInfo.environment["CMUX_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)
|
|
#if DEBUG
|
|
if isJumpUnreadUITest {
|
|
writeJumpUnreadTestData(["jumpUnreadOpenResult": ok ? "1" : "0"])
|
|
}
|
|
#endif
|
|
return ok
|
|
}
|
|
#if DEBUG
|
|
if isJumpUnreadUITest {
|
|
writeJumpUnreadTestData(["jumpUnreadOpenContextFound": "1", "jumpUnreadOpenUsedFallback": "0"])
|
|
}
|
|
#endif
|
|
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
|
|
recordMultiWindowNotificationOpenFailureIfNeeded(
|
|
tabId: tabId,
|
|
surfaceId: surfaceId,
|
|
notificationId: notificationId,
|
|
reason: "missing_window expectedIdentifier=\(expectedIdentifier)"
|
|
)
|
|
#endif
|
|
return false
|
|
}
|
|
|
|
context.sidebarSelectionState.selection = .tabs
|
|
bringToFront(window)
|
|
context.tabManager.focusTabFromNotification(tabId, surfaceId: surfaceId)
|
|
|
|
#if DEBUG
|
|
// UI test support: Jump-to-unread asserts that the correct workspace/panel is focused.
|
|
// Recording via first-responder can be flaky on the VM, so verify focus via the model.
|
|
recordJumpUnreadFocusFromModelIfNeeded(
|
|
tabManager: context.tabManager,
|
|
tabId: tabId,
|
|
expectedSurfaceId: surfaceId
|
|
)
|
|
#endif
|
|
|
|
if let notificationId, let store = notificationStore {
|
|
markReadIfFocused(
|
|
notificationId: notificationId,
|
|
tabId: tabId,
|
|
surfaceId: surfaceId,
|
|
tabManager: context.tabManager,
|
|
notificationStore: store
|
|
)
|
|
}
|
|
|
|
#if DEBUG
|
|
recordMultiWindowNotificationFocusIfNeeded(
|
|
windowId: context.windowId,
|
|
tabId: tabId,
|
|
surfaceId: surfaceId,
|
|
sidebarSelection: context.sidebarSelectionState.selection
|
|
)
|
|
if ProcessInfo.processInfo.environment["CMUX_UI_TEST_JUMP_UNREAD_SETUP"] == "1" {
|
|
writeJumpUnreadTestData(["jumpUnreadOpenInContext": "1", "jumpUnreadOpenResult": "1"])
|
|
}
|
|
#endif
|
|
return true
|
|
}
|
|
|
|
private func openNotificationFallback(tabId: UUID, surfaceId: UUID?, notificationId: UUID?) -> Bool {
|
|
// If the owning window context hasn't been registered yet, fall back to the "active" window.
|
|
guard let tabManager else {
|
|
#if DEBUG
|
|
if ProcessInfo.processInfo.environment["CMUX_UI_TEST_JUMP_UNREAD_SETUP"] == "1" {
|
|
writeJumpUnreadTestData(["jumpUnreadFallbackFail": "missing_tabManager"])
|
|
}
|
|
#endif
|
|
return false
|
|
}
|
|
guard tabManager.tabs.contains(where: { $0.id == tabId }) else {
|
|
#if DEBUG
|
|
if ProcessInfo.processInfo.environment["CMUX_UI_TEST_JUMP_UNREAD_SETUP"] == "1" {
|
|
writeJumpUnreadTestData(["jumpUnreadFallbackFail": "tab_not_in_active_manager"])
|
|
}
|
|
#endif
|
|
return false
|
|
}
|
|
guard let window = (NSApp.keyWindow ?? NSApp.windows.first(where: { isMainTerminalWindow($0) })) else {
|
|
#if DEBUG
|
|
if ProcessInfo.processInfo.environment["CMUX_UI_TEST_JUMP_UNREAD_SETUP"] == "1" {
|
|
writeJumpUnreadTestData(["jumpUnreadFallbackFail": "missing_window"])
|
|
}
|
|
#endif
|
|
return false
|
|
}
|
|
|
|
sidebarSelectionState?.selection = .tabs
|
|
bringToFront(window)
|
|
tabManager.focusTabFromNotification(tabId, surfaceId: surfaceId)
|
|
|
|
#if DEBUG
|
|
recordJumpUnreadFocusFromModelIfNeeded(
|
|
tabManager: tabManager,
|
|
tabId: tabId,
|
|
expectedSurfaceId: surfaceId
|
|
)
|
|
#endif
|
|
|
|
if let notificationId, let store = notificationStore {
|
|
markReadIfFocused(
|
|
notificationId: notificationId,
|
|
tabId: tabId,
|
|
surfaceId: surfaceId,
|
|
tabManager: tabManager,
|
|
notificationStore: store
|
|
)
|
|
}
|
|
#if DEBUG
|
|
if ProcessInfo.processInfo.environment["CMUX_UI_TEST_JUMP_UNREAD_SETUP"] == "1" {
|
|
writeJumpUnreadTestData(["jumpUnreadOpenInFallback": "1", "jumpUnreadOpenResult": "1"])
|
|
}
|
|
#endif
|
|
return true
|
|
}
|
|
|
|
#if DEBUG
|
|
private func recordJumpUnreadFocusFromModelIfNeeded(
|
|
tabManager: TabManager,
|
|
tabId: UUID,
|
|
expectedSurfaceId: UUID?,
|
|
attempt: Int = 0
|
|
) {
|
|
let env = ProcessInfo.processInfo.environment
|
|
guard env["CMUX_UI_TEST_JUMP_UNREAD_SETUP"] == "1" else { return }
|
|
guard let expectedSurfaceId else { return }
|
|
|
|
// Ensure the expectation is armed even if the view doesn't become first responder.
|
|
armJumpUnreadFocusRecord(tabId: tabId, surfaceId: expectedSurfaceId)
|
|
|
|
let maxAttempts = 40
|
|
guard attempt < maxAttempts else { return }
|
|
|
|
let isSelected = tabManager.selectedTabId == tabId
|
|
let focused = tabManager.focusedSurfaceId(for: tabId)
|
|
if isSelected, focused == expectedSurfaceId {
|
|
recordJumpUnreadFocusIfExpected(tabId: tabId, surfaceId: expectedSurfaceId)
|
|
return
|
|
}
|
|
|
|
DispatchQueue.main.asyncAfter(deadline: .now() + 0.05) { [weak self] in
|
|
self?.recordJumpUnreadFocusFromModelIfNeeded(
|
|
tabManager: tabManager,
|
|
tabId: tabId,
|
|
expectedSurfaceId: expectedSurfaceId,
|
|
attempt: attempt + 1
|
|
)
|
|
}
|
|
}
|
|
#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 bringToFront(_ window: NSWindow) {
|
|
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 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)
|
|
}
|
|
}
|
|
|
|
#if DEBUG
|
|
private func recordMultiWindowNotificationOpenFailureIfNeeded(
|
|
tabId: UUID,
|
|
surfaceId: UUID?,
|
|
notificationId: UUID?,
|
|
reason: String
|
|
) {
|
|
let env = ProcessInfo.processInfo.environment
|
|
guard let path = env["CMUX_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)]"
|
|
}
|
|
|
|
writeMultiWindowNotificationTestData([
|
|
"focusToken": UUID().uuidString,
|
|
"openFailureTabId": tabId.uuidString,
|
|
"openFailureSurfaceId": surfaceId?.uuidString ?? "",
|
|
"openFailureNotificationId": notificationId?.uuidString ?? "",
|
|
"openFailureReason": reason,
|
|
"openFailureContexts": contextSummaries.joined(separator: "; "),
|
|
], at: path)
|
|
}
|
|
#endif
|
|
|
|
}
|
|
|
|
@MainActor
|
|
final class MenuBarExtraController: NSObject, NSMenuDelegate {
|
|
private let statusItem: NSStatusItem
|
|
private let menu = NSMenu(title: "cmux")
|
|
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: "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: "Show Notifications", action: nil, keyEquivalent: "")
|
|
private let jumpToUnreadItem = NSMenuItem(title: "Jump to Latest Unread", action: nil, keyEquivalent: "")
|
|
private let markAllReadItem = NSMenuItem(title: "Mark All Read", action: nil, keyEquivalent: "")
|
|
private let clearAllItem = NSMenuItem(title: "Clear All", action: nil, keyEquivalent: "")
|
|
private let checkForUpdatesItem = NSMenuItem(title: "Check for Updates…", action: nil, keyEquivalent: "")
|
|
private let preferencesItem = NSMenuItem(title: "Preferences…", action: nil, keyEquivalent: "")
|
|
private let quitItem = NSMenuItem(title: "Quit cmux", 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 = "cmux"
|
|
}
|
|
|
|
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()
|
|
}
|
|
|
|
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
|
|
|
|
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
|
|
? "cmux"
|
|
: "cmux: \(displayedUnreadCount) unread notification\(displayedUnreadCount == 1 ? "" : "s")"
|
|
}
|
|
}
|
|
|
|
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 {
|
|
unreadCount == 0
|
|
? "No unread notifications"
|
|
: "\(unreadCount) unread notification\(unreadCount == 1 ? "" : "s")"
|
|
}
|
|
}
|
|
|
|
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
|
|
}
|
|
}
|
|
|
|
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>
|
|
) -> 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>
|
|
) -> 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)
|
|
}
|
|
|
|
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.white.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 extension NSWindow {
|
|
@objc func cmux_performKeyEquivalent(with event: NSEvent) -> Bool {
|
|
#if DEBUG
|
|
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.
|
|
if let ghosttyView = self.firstResponder as? GhosttyNSView {
|
|
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
|
|
}
|
|
}
|
|
|
|
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 self.firstResponder is GhosttyNSView,
|
|
event.modifierFlags.intersection(.deviceIndependentFlagsMask).contains(.command),
|
|
let mainMenu = NSApp.mainMenu, mainMenu.performKeyEquivalent(with: event) {
|
|
#if DEBUG
|
|
dlog(" → consumed by mainMenu (bypassed SwiftUI)")
|
|
#endif
|
|
return true
|
|
}
|
|
|
|
let result = cmux_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: "+")
|
|
}
|
|
}
|