3749 lines
151 KiB
Swift
3749 lines
151 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
|
|
}
|
|
}
|
|
|
|
func browserOmnibarShouldSubmitOnReturn(flags: NSEvent.ModifierFlags) -> Bool {
|
|
let normalizedFlags = flags
|
|
.intersection(.deviceIndependentFlagsMask)
|
|
.subtracting([.numericPad, .function])
|
|
return normalizedFlags == [] || normalizedFlags == [.shift]
|
|
}
|
|
|
|
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 fullscreenControlsViewModel: TitlebarControlsViewModel?
|
|
weak var sidebarSelectionState: SidebarSelectionState?
|
|
private var workspaceObserver: NSObjectProtocol?
|
|
private var windowKeyObserver: NSObjectProtocol?
|
|
private var shortcutMonitor: Any?
|
|
private var shortcutDefaultsObserver: NSObjectProtocol?
|
|
private var splitButtonTooltipRefreshScheduled = false
|
|
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()
|
|
installShortcutDefaultsObserver()
|
|
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) {
|
|
TerminalController.shared.stop()
|
|
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 = SocketControlSettings.migrateMode(raw)
|
|
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 }
|
|
if TerminalController.shouldSuppressSocketCommandActivation() {
|
|
if window.isMiniaturized {
|
|
window.deminiaturize(nil)
|
|
}
|
|
if TerminalController.socketCommandAllowsInAppFocusMutations() {
|
|
window.orderFront(nil)
|
|
setActiveMainWindow(window)
|
|
}
|
|
return true
|
|
}
|
|
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, .fullSizeContentView],
|
|
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
|
|
)
|
|
installFileDropOverlay(on: window, tabManager: tabManager)
|
|
if TerminalController.shouldSuppressSocketCommandActivation() {
|
|
window.orderFront(nil)
|
|
if TerminalController.socketCommandAllowsInAppFocusMutations() {
|
|
setActiveMainWindow(window)
|
|
}
|
|
} else {
|
|
window.makeKeyAndOrderFront(nil)
|
|
setActiveMainWindow(window)
|
|
NSApp.activate(ignoringOtherApps: true)
|
|
}
|
|
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: 0.32.0-nightly+20260216.abc1234"
|
|
updateViewModel.overrideState = .notFound(.init(acknowledgement: {}))
|
|
}
|
|
|
|
@objc func showUpdatePillLoading(_ sender: Any?) {
|
|
updateViewModel.debugOverrideText = nil
|
|
updateViewModel.overrideState = .checking(.init(cancel: {}))
|
|
}
|
|
|
|
@objc func hideUpdatePill(_ sender: Any?) {
|
|
updateViewModel.debugOverrideText = nil
|
|
updateViewModel.overrideState = .idle
|
|
}
|
|
|
|
@objc func clearUpdatePillOverride(_ sender: Any?) {
|
|
updateViewModel.debugOverrideText = nil
|
|
updateViewModel.overrideState = nil
|
|
}
|
|
#endif
|
|
|
|
@objc func copyUpdateLogs(_ sender: Any?) {
|
|
let logText = UpdateLogStore.shared.snapshot()
|
|
let payload: String
|
|
if logText.isEmpty {
|
|
payload = "No update logs captured.\nLog file: \(UpdateLogStore.shared.logPath())"
|
|
} else {
|
|
payload = logText + "\nLog file: \(UpdateLogStore.shared.logPath())"
|
|
}
|
|
let pasteboard = NSPasteboard.general
|
|
pasteboard.clearContents()
|
|
pasteboard.setString(payload, forType: .string)
|
|
}
|
|
@objc func copyFocusLogs(_ sender: Any?) {
|
|
let logText = FocusLogStore.shared.snapshot()
|
|
let payload: String
|
|
if logText.isEmpty {
|
|
payload = "No focus logs captured.\nLog file: \(FocusLogStore.shared.logPath())"
|
|
} else {
|
|
payload = logText + "\nLog file: \(FocusLogStore.shared.logPath())"
|
|
}
|
|
let pasteboard = NSPasteboard.general
|
|
pasteboard.clearContents()
|
|
pasteboard.setString(payload, forType: .string)
|
|
}
|
|
|
|
#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, retrying for slow environments (e.g. VM).
|
|
recordGotoSplitUITestWebViewFocusRetry(panelId: panelId, key: key, attempt: 0)
|
|
}
|
|
|
|
private func recordGotoSplitUITestWebViewFocusRetry(panelId: UUID, key: String, attempt: Int) {
|
|
let delays: [Double] = [0.05, 0.1, 0.25, 0.5]
|
|
let delay = attempt < delays.count ? delays[attempt] : delays.last!
|
|
DispatchQueue.main.asyncAfter(deadline: .now() + delay) { [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)
|
|
// If focus hasn't settled yet and we have retries left, try again.
|
|
if !focused && key.contains("Exit") && attempt < delays.count - 1 {
|
|
self.recordGotoSplitUITestWebViewFocusRetry(panelId: panelId, key: key, attempt: attempt + 1)
|
|
return
|
|
}
|
|
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, anchorView: NSView? = nil) {
|
|
titlebarAccessoryController.toggleNotificationsPopover(animated: animated, anchorView: anchorView)
|
|
}
|
|
|
|
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
|
|
if (ProcessInfo.processInfo.environment["CMUX_KEY_LATENCY_PROBE"] == "1"
|
|
|| UserDefaults.standard.bool(forKey: "cmuxKeyLatencyProbe")),
|
|
event.timestamp > 0 {
|
|
let delayMs = max(0, (ProcessInfo.processInfo.systemUptime - event.timestamp) * 1000)
|
|
let delayText = String(format: "%.2f", delayMs)
|
|
dlog("key.latency path=appMonitor ms=\(delayText) keyCode=\(event.keyCode) mods=\(event.modifierFlags.rawValue) repeat=\(event.isARepeat ? 1 : 0)")
|
|
}
|
|
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")")
|
|
if let probeKind = self.developerToolsShortcutProbeKind(event: event) {
|
|
self.logDeveloperToolsShortcutSnapshot(phase: "monitor.pre.\(probeKind)", event: event)
|
|
}
|
|
#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 installShortcutDefaultsObserver() {
|
|
guard shortcutDefaultsObserver == nil else { return }
|
|
shortcutDefaultsObserver = NotificationCenter.default.addObserver(
|
|
forName: UserDefaults.didChangeNotification,
|
|
object: nil,
|
|
queue: .main
|
|
) { [weak self] _ in
|
|
self?.scheduleSplitButtonTooltipRefreshAcrossWorkspaces()
|
|
}
|
|
}
|
|
|
|
/// Coalesce shortcut-default changes and refresh on the next runloop turn to
|
|
/// avoid mutating Bonsplit/SwiftUI-observed state during an active update pass.
|
|
private func scheduleSplitButtonTooltipRefreshAcrossWorkspaces() {
|
|
guard !splitButtonTooltipRefreshScheduled else { return }
|
|
splitButtonTooltipRefreshScheduled = true
|
|
DispatchQueue.main.async { [weak self] in
|
|
guard let self else { return }
|
|
self.splitButtonTooltipRefreshScheduled = false
|
|
self.refreshSplitButtonTooltipsAcrossWorkspaces()
|
|
}
|
|
}
|
|
|
|
private func refreshSplitButtonTooltipsAcrossWorkspaces() {
|
|
var refreshedManagers: Set<ObjectIdentifier> = []
|
|
if let manager = tabManager {
|
|
manager.refreshSplitButtonTooltips()
|
|
refreshedManagers.insert(ObjectIdentifier(manager))
|
|
}
|
|
for context in mainWindowContexts.values {
|
|
let manager = context.tabManager
|
|
let identifier = ObjectIdentifier(manager)
|
|
guard refreshedManagers.insert(identifier).inserted else { continue }
|
|
manager.refreshSplitButtonTooltips()
|
|
}
|
|
}
|
|
|
|
private func installGhosttyConfigObserver() {
|
|
guard ghosttyConfigObserver == nil else { return }
|
|
ghosttyConfigObserver = NotificationCenter.default.addObserver(
|
|
forName: .ghosttyConfigDidReload,
|
|
object: nil,
|
|
queue: .main
|
|
) { [weak self] _ in
|
|
self?.refreshGhosttyGotoSplitShortcuts()
|
|
}
|
|
}
|
|
|
|
private func refreshGhosttyGotoSplitShortcuts() {
|
|
guard let config = GhosttyApp.shared.config else {
|
|
ghosttyGotoSplitLeftShortcut = nil
|
|
ghosttyGotoSplitRightShortcut = nil
|
|
ghosttyGotoSplitUpShortcut = nil
|
|
ghosttyGotoSplitDownShortcut = nil
|
|
return
|
|
}
|
|
|
|
ghosttyGotoSplitLeftShortcut = storedShortcutFromGhosttyTrigger(
|
|
ghostty_config_trigger(config, "goto_split:left", UInt("goto_split:left".utf8.count))
|
|
)
|
|
ghosttyGotoSplitRightShortcut = storedShortcutFromGhosttyTrigger(
|
|
ghostty_config_trigger(config, "goto_split:right", UInt("goto_split:right".utf8.count))
|
|
)
|
|
ghosttyGotoSplitUpShortcut = storedShortcutFromGhosttyTrigger(
|
|
ghostty_config_trigger(config, "goto_split:up", UInt("goto_split:up".utf8.count))
|
|
)
|
|
ghosttyGotoSplitDownShortcut = storedShortcutFromGhosttyTrigger(
|
|
ghostty_config_trigger(config, "goto_split:down", UInt("goto_split:down".utf8.count))
|
|
)
|
|
}
|
|
|
|
private func storedShortcutFromGhosttyTrigger(_ trigger: ghostty_input_trigger_s) -> StoredShortcut? {
|
|
let key: String
|
|
switch trigger.tag {
|
|
case GHOSTTY_TRIGGER_PHYSICAL:
|
|
switch trigger.key.physical {
|
|
case GHOSTTY_KEY_ARROW_LEFT:
|
|
key = "←"
|
|
case GHOSTTY_KEY_ARROW_RIGHT:
|
|
key = "→"
|
|
case GHOSTTY_KEY_ARROW_UP:
|
|
key = "↑"
|
|
case GHOSTTY_KEY_ARROW_DOWN:
|
|
key = "↓"
|
|
case GHOSTTY_KEY_A: key = "a"
|
|
case GHOSTTY_KEY_B: key = "b"
|
|
case GHOSTTY_KEY_C: key = "c"
|
|
case GHOSTTY_KEY_D: key = "d"
|
|
case GHOSTTY_KEY_E: key = "e"
|
|
case GHOSTTY_KEY_F: key = "f"
|
|
case GHOSTTY_KEY_G: key = "g"
|
|
case GHOSTTY_KEY_H: key = "h"
|
|
case GHOSTTY_KEY_I: key = "i"
|
|
case GHOSTTY_KEY_J: key = "j"
|
|
case GHOSTTY_KEY_K: key = "k"
|
|
case GHOSTTY_KEY_L: key = "l"
|
|
case GHOSTTY_KEY_M: key = "m"
|
|
case GHOSTTY_KEY_N: key = "n"
|
|
case GHOSTTY_KEY_O: key = "o"
|
|
case GHOSTTY_KEY_P: key = "p"
|
|
case GHOSTTY_KEY_Q: key = "q"
|
|
case GHOSTTY_KEY_R: key = "r"
|
|
case GHOSTTY_KEY_S: key = "s"
|
|
case GHOSTTY_KEY_T: key = "t"
|
|
case GHOSTTY_KEY_U: key = "u"
|
|
case GHOSTTY_KEY_V: key = "v"
|
|
case GHOSTTY_KEY_W: key = "w"
|
|
case GHOSTTY_KEY_X: key = "x"
|
|
case GHOSTTY_KEY_Y: key = "y"
|
|
case GHOSTTY_KEY_Z: key = "z"
|
|
case GHOSTTY_KEY_DIGIT_0: key = "0"
|
|
case GHOSTTY_KEY_DIGIT_1: key = "1"
|
|
case GHOSTTY_KEY_DIGIT_2: key = "2"
|
|
case GHOSTTY_KEY_DIGIT_3: key = "3"
|
|
case GHOSTTY_KEY_DIGIT_4: key = "4"
|
|
case GHOSTTY_KEY_DIGIT_5: key = "5"
|
|
case GHOSTTY_KEY_DIGIT_6: key = "6"
|
|
case GHOSTTY_KEY_DIGIT_7: key = "7"
|
|
case GHOSTTY_KEY_DIGIT_8: key = "8"
|
|
case GHOSTTY_KEY_DIGIT_9: key = "9"
|
|
case GHOSTTY_KEY_BRACKET_LEFT: key = "["
|
|
case GHOSTTY_KEY_BRACKET_RIGHT: key = "]"
|
|
case GHOSTTY_KEY_MINUS: key = "-"
|
|
case GHOSTTY_KEY_EQUAL: key = "="
|
|
case GHOSTTY_KEY_COMMA: key = ","
|
|
case GHOSTTY_KEY_PERIOD: key = "."
|
|
case GHOSTTY_KEY_SLASH: key = "/"
|
|
case GHOSTTY_KEY_SEMICOLON: key = ";"
|
|
case GHOSTTY_KEY_QUOTE: key = "'"
|
|
case GHOSTTY_KEY_BACKQUOTE: key = "`"
|
|
case GHOSTTY_KEY_BACKSLASH: key = "\\"
|
|
default:
|
|
return nil
|
|
}
|
|
case GHOSTTY_TRIGGER_UNICODE:
|
|
guard let scalar = UnicodeScalar(trigger.key.unicode) else { return nil }
|
|
key = String(Character(scalar)).lowercased()
|
|
case GHOSTTY_TRIGGER_CATCH_ALL:
|
|
return nil
|
|
default:
|
|
return nil
|
|
}
|
|
|
|
let mods = trigger.mods.rawValue
|
|
let command = (mods & GHOSTTY_MODS_SUPER.rawValue) != 0
|
|
let shift = (mods & GHOSTTY_MODS_SHIFT.rawValue) != 0
|
|
let option = (mods & GHOSTTY_MODS_ALT.rawValue) != 0
|
|
let control = (mods & GHOSTTY_MODS_CTRL.rawValue) != 0
|
|
|
|
// Ignore bogus empty triggers.
|
|
if key.isEmpty || (!command && !shift && !option && !control) {
|
|
return nil
|
|
}
|
|
|
|
return StoredShortcut(key: key, command: command, shift: shift, option: option, control: control)
|
|
}
|
|
|
|
private func handleQuitShortcutWarning() -> Bool {
|
|
if !QuitWarningSettings.isEnabled() {
|
|
NSApp.terminate(nil)
|
|
return true
|
|
}
|
|
|
|
let alert = NSAlert()
|
|
alert.alertStyle = .warning
|
|
alert.messageText = "Quit cmux?"
|
|
alert.informativeText = "This will close all windows and workspaces."
|
|
alert.addButton(withTitle: "Quit")
|
|
alert.addButton(withTitle: "Cancel")
|
|
alert.showsSuppressionButton = true
|
|
alert.suppressionButton?.title = "Don't warn again for Cmd+Q"
|
|
|
|
let response = alert.runModal()
|
|
if alert.suppressionButton?.state == .on {
|
|
QuitWarningSettings.setEnabled(false)
|
|
}
|
|
|
|
if response == .alertFirstButtonReturn {
|
|
NSApp.terminate(nil)
|
|
}
|
|
return true
|
|
}
|
|
|
|
func promptRenameSelectedWorkspace() -> Bool {
|
|
guard let tabManager,
|
|
let tabId = tabManager.selectedTabId,
|
|
let tab = tabManager.tabs.first(where: { $0.id == tabId }) else {
|
|
NSSound.beep()
|
|
return false
|
|
}
|
|
|
|
let alert = NSAlert()
|
|
alert.messageText = "Rename Workspace"
|
|
alert.informativeText = "Enter a custom name for this workspace."
|
|
let input = NSTextField(string: tab.customTitle ?? tab.title)
|
|
input.placeholderString = "Workspace name"
|
|
input.frame = NSRect(x: 0, y: 0, width: 240, height: 22)
|
|
alert.accessoryView = input
|
|
alert.addButton(withTitle: "Rename")
|
|
alert.addButton(withTitle: "Cancel")
|
|
let alertWindow = alert.window
|
|
alertWindow.initialFirstResponder = input
|
|
DispatchQueue.main.async {
|
|
alertWindow.makeFirstResponder(input)
|
|
input.selectText(nil)
|
|
}
|
|
|
|
let response = alert.runModal()
|
|
guard response == .alertFirstButtonReturn else { return true }
|
|
tabManager.setCustomTitle(tabId: tab.id, title: input.stringValue)
|
|
return true
|
|
}
|
|
|
|
private func handleCustomShortcut(event: NSEvent) -> Bool {
|
|
// `charactersIgnoringModifiers` can be nil for some synthetic NSEvents and certain special keys.
|
|
// 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
|
|
}
|
|
|
|
let normalizedFlags = flags.subtracting([.numericPad, .function])
|
|
if normalizedFlags == [.command], chars == "q" {
|
|
return handleQuitShortcutWarning()
|
|
}
|
|
|
|
// When the terminal has active IME composition (e.g. Korean, Japanese, Chinese
|
|
// input), don't intercept key events — let them flow through to the input method.
|
|
if let ghosttyView = NSApp.keyWindow?.firstResponder as? GhosttyNSView,
|
|
ghosttyView.hasMarkedText() {
|
|
return false
|
|
}
|
|
|
|
// When the notifications popover is open, Escape should dismiss it immediately.
|
|
if flags.isEmpty, event.keyCode == 53, titlebarAccessoryController.dismissNotificationsPopoverIfShown() {
|
|
return true
|
|
}
|
|
|
|
// When the notifications popover is showing an empty state, consume plain typing
|
|
// so key presses do not leak through into the focused terminal.
|
|
if flags.isDisjoint(with: [.command, .control, .option]),
|
|
titlebarAccessoryController.isNotificationsPopoverShown(),
|
|
(notificationStore?.notifications.isEmpty ?? false) {
|
|
return true
|
|
}
|
|
|
|
// 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, anchorView: fullscreenControlsViewModel?.notificationsAnchorView)
|
|
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)) {
|
|
#if DEBUG
|
|
let selected = tabManager?.selectedTabId.map { String($0.uuidString.prefix(5)) } ?? "nil"
|
|
dlog(
|
|
"ws.shortcut dir=next repeat=\(event.isARepeat ? 1 : 0) keyCode=\(event.keyCode) selected=\(selected)"
|
|
)
|
|
#endif
|
|
tabManager?.selectNextTab()
|
|
return true
|
|
}
|
|
|
|
if matchShortcut(event: event, shortcut: KeyboardShortcutSettings.shortcut(for: .prevSidebarTab)) {
|
|
#if DEBUG
|
|
let selected = tabManager?.selectedTabId.map { String($0.uuidString.prefix(5)) } ?? "nil"
|
|
dlog(
|
|
"ws.shortcut dir=prev repeat=\(event.isARepeat ? 1 : 0) keyCode=\(event.keyCode) selected=\(selected)"
|
|
)
|
|
#endif
|
|
tabManager?.selectPreviousTab()
|
|
return true
|
|
}
|
|
|
|
if matchShortcut(event: event, shortcut: KeyboardShortcutSettings.shortcut(for: .renameWorkspace)) {
|
|
_ = promptRenameSelectedWorkspace()
|
|
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
|
|
}
|
|
|
|
if matchShortcut(event: event, shortcut: KeyboardShortcutSettings.shortcut(for: .splitBrowserRight)) {
|
|
_ = performBrowserSplitShortcut(direction: .right)
|
|
return true
|
|
}
|
|
|
|
if matchShortcut(event: event, shortcut: KeyboardShortcutSettings.shortcut(for: .splitBrowserDown)) {
|
|
_ = performBrowserSplitShortcut(direction: .down)
|
|
return true
|
|
}
|
|
|
|
// Surface navigation (legacy Ctrl+Tab support)
|
|
if matchTabShortcut(event: event, shortcut: StoredShortcut(key: "\t", command: false, shift: false, option: false, control: true)) {
|
|
tabManager?.selectNextSurface()
|
|
return true
|
|
}
|
|
if matchTabShortcut(event: event, shortcut: StoredShortcut(key: "\t", command: false, shift: true, option: false, control: true)) {
|
|
tabManager?.selectPreviousSurface()
|
|
return true
|
|
}
|
|
|
|
// New surface: Cmd+T
|
|
if 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)) {
|
|
if let panelId = tabManager?.openBrowser(insertAtEnd: true) {
|
|
focusBrowserAddressBar(panelId: panelId)
|
|
}
|
|
return true
|
|
}
|
|
|
|
// Safari defaults:
|
|
// - Option+Command+I => Show/Toggle Web Inspector
|
|
// - Option+Command+C => Show JavaScript Console
|
|
if matchShortcut(event: event, shortcut: KeyboardShortcutSettings.shortcut(for: .toggleBrowserDeveloperTools)) {
|
|
#if DEBUG
|
|
logDeveloperToolsShortcutSnapshot(phase: "toggle.pre", event: event)
|
|
#endif
|
|
let didHandle = tabManager?.toggleDeveloperToolsFocusedBrowser() ?? false
|
|
#if DEBUG
|
|
logDeveloperToolsShortcutSnapshot(phase: "toggle.post", event: event, didHandle: didHandle)
|
|
DispatchQueue.main.async { [weak self] in
|
|
self?.logDeveloperToolsShortcutSnapshot(phase: "toggle.tick", didHandle: didHandle)
|
|
}
|
|
#endif
|
|
if !didHandle { NSSound.beep() }
|
|
return true
|
|
}
|
|
|
|
if matchShortcut(event: event, shortcut: KeyboardShortcutSettings.shortcut(for: .showBrowserJavaScriptConsole)) {
|
|
#if DEBUG
|
|
logDeveloperToolsShortcutSnapshot(phase: "console.pre", event: event)
|
|
#endif
|
|
let didHandle = tabManager?.showJavaScriptConsoleFocusedBrowser() ?? false
|
|
#if DEBUG
|
|
logDeveloperToolsShortcutSnapshot(phase: "console.post", event: event, didHandle: didHandle)
|
|
DispatchQueue.main.async { [weak self] in
|
|
self?.logDeveloperToolsShortcutSnapshot(phase: "console.tick", didHandle: didHandle)
|
|
}
|
|
#endif
|
|
if !didHandle { NSSound.beep() }
|
|
return true
|
|
}
|
|
|
|
// Focus browser address bar: Cmd+L
|
|
if flags == [.command] && chars == "l" {
|
|
if let focusedPanel = tabManager?.focusedBrowserPanel {
|
|
focusBrowserAddressBar(in: focusedPanel)
|
|
return true
|
|
}
|
|
|
|
if let browserAddressBarFocusedPanelId,
|
|
focusBrowserAddressBar(panelId: browserAddressBarFocusedPanelId) {
|
|
return true
|
|
}
|
|
|
|
if let panelId = tabManager?.openBrowser(insertAtEnd: true) {
|
|
focusBrowserAddressBar(panelId: panelId)
|
|
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
|
|
}
|
|
|
|
@discardableResult
|
|
private func focusBrowserAddressBar(panelId: UUID) -> Bool {
|
|
guard let tabManager,
|
|
let workspace = tabManager.selectedWorkspace,
|
|
let panel = workspace.browserPanel(for: panelId) else {
|
|
return false
|
|
}
|
|
workspace.focusPanel(panel.id)
|
|
focusBrowserAddressBar(in: panel)
|
|
return true
|
|
}
|
|
|
|
private func focusBrowserAddressBar(in panel: BrowserPanel) {
|
|
_ = panel.requestAddressBarFocus()
|
|
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
|
|
}
|
|
}
|
|
|
|
private func isLikelyWebInspectorResponder(_ responder: NSResponder?) -> Bool {
|
|
guard let responder else { return false }
|
|
let responderType = String(describing: type(of: responder))
|
|
if responderType.contains("WKInspector") {
|
|
return true
|
|
}
|
|
guard let view = responder as? NSView else { return false }
|
|
var node: NSView? = view
|
|
var hops = 0
|
|
while let current = node, hops < 64 {
|
|
if String(describing: type(of: current)).contains("WKInspector") {
|
|
return true
|
|
}
|
|
node = current.superview
|
|
hops += 1
|
|
}
|
|
return false
|
|
}
|
|
|
|
#if DEBUG
|
|
private func developerToolsShortcutProbeKind(event: NSEvent) -> String? {
|
|
if matchShortcut(event: event, shortcut: KeyboardShortcutSettings.shortcut(for: .toggleBrowserDeveloperTools)) {
|
|
return "toggle.configured"
|
|
}
|
|
if matchShortcut(event: event, shortcut: KeyboardShortcutSettings.shortcut(for: .showBrowserJavaScriptConsole)) {
|
|
return "console.configured"
|
|
}
|
|
|
|
let chars = (event.charactersIgnoringModifiers ?? "").lowercased()
|
|
let flags = event.modifierFlags.intersection(.deviceIndependentFlagsMask)
|
|
if flags == [.command, .option] {
|
|
if chars == "i" || event.keyCode == 34 {
|
|
return "toggle.literal"
|
|
}
|
|
if chars == "c" || event.keyCode == 8 {
|
|
return "console.literal"
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
private func logDeveloperToolsShortcutSnapshot(
|
|
phase: String,
|
|
event: NSEvent? = nil,
|
|
didHandle: Bool? = nil
|
|
) {
|
|
let keyWindow = NSApp.keyWindow
|
|
let firstResponder = keyWindow?.firstResponder
|
|
let firstResponderType = firstResponder.map { String(describing: type(of: $0)) } ?? "nil"
|
|
let firstResponderPtr = firstResponder.map { String(describing: Unmanaged.passUnretained($0).toOpaque()) } ?? "nil"
|
|
let eventDescription = event.map(NSWindow.keyDescription) ?? "none"
|
|
if let browser = tabManager?.focusedBrowserPanel {
|
|
var line =
|
|
"browser.devtools shortcut=\(phase) panel=\(browser.id.uuidString.prefix(5)) " +
|
|
"\(browser.debugDeveloperToolsStateSummary()) \(browser.debugDeveloperToolsGeometrySummary()) " +
|
|
"keyWin=\(keyWindow?.windowNumber ?? -1) fr=\(firstResponderType)@\(firstResponderPtr) event=\(eventDescription)"
|
|
if let didHandle {
|
|
line += " handled=\(didHandle ? 1 : 0)"
|
|
}
|
|
dlog(line)
|
|
return
|
|
}
|
|
var line =
|
|
"browser.devtools shortcut=\(phase) panel=nil keyWin=\(keyWindow?.windowNumber ?? -1) " +
|
|
"fr=\(firstResponderType)@\(firstResponderPtr) event=\(eventDescription)"
|
|
if let didHandle {
|
|
line += " handled=\(didHandle ? 1 : 0)"
|
|
}
|
|
dlog(line)
|
|
}
|
|
#endif
|
|
|
|
private func prepareFocusedBrowserDevToolsForSplit(directionLabel: String) {
|
|
guard let browser = tabManager?.focusedBrowserPanel else { return }
|
|
guard browser.shouldPreserveWebViewAttachmentDuringTransientHide() else { return }
|
|
guard let keyWindow = NSApp.keyWindow else { return }
|
|
guard isLikelyWebInspectorResponder(keyWindow.firstResponder) else { return }
|
|
|
|
let beforeResponder = keyWindow.firstResponder
|
|
let movedToWebView = keyWindow.makeFirstResponder(browser.webView)
|
|
let movedToNil = movedToWebView ? false : keyWindow.makeFirstResponder(nil)
|
|
|
|
#if DEBUG
|
|
let beforeType = beforeResponder.map { String(describing: type(of: $0)) } ?? "nil"
|
|
let beforePtr = beforeResponder.map { String(describing: Unmanaged.passUnretained($0).toOpaque()) } ?? "nil"
|
|
let afterResponder = keyWindow.firstResponder
|
|
let afterType = afterResponder.map { String(describing: type(of: $0)) } ?? "nil"
|
|
let afterPtr = afterResponder.map { String(describing: Unmanaged.passUnretained($0).toOpaque()) } ?? "nil"
|
|
dlog(
|
|
"split.shortcut inspector.preflight dir=\(directionLabel) panel=\(browser.id.uuidString.prefix(5)) " +
|
|
"before=\(beforeType)@\(beforePtr) after=\(afterType)@\(afterPtr) " +
|
|
"moveWeb=\(movedToWebView ? 1 : 0) moveNil=\(movedToNil ? 1 : 0) \(browser.debugDeveloperToolsStateSummary())"
|
|
)
|
|
#endif
|
|
}
|
|
|
|
@discardableResult
|
|
func performSplitShortcut(direction: SplitDirection) -> Bool {
|
|
let directionLabel: String
|
|
switch direction {
|
|
case .left: directionLabel = "left"
|
|
case .right: directionLabel = "right"
|
|
case .up: directionLabel = "up"
|
|
case .down: directionLabel = "down"
|
|
}
|
|
|
|
#if DEBUG
|
|
let keyWindow = NSApp.keyWindow
|
|
let firstResponder = keyWindow?.firstResponder
|
|
let firstResponderType = firstResponder.map { String(describing: type(of: $0)) } ?? "nil"
|
|
let firstResponderPtr = firstResponder.map { String(describing: Unmanaged.passUnretained($0).toOpaque()) } ?? "nil"
|
|
let firstResponderWindow: Int = {
|
|
if let v = firstResponder as? NSView {
|
|
return v.window?.windowNumber ?? -1
|
|
}
|
|
if let w = firstResponder as? NSWindow {
|
|
return w.windowNumber
|
|
}
|
|
return -1
|
|
}()
|
|
let splitContext = "keyWin=\(keyWindow?.windowNumber ?? -1) mainWin=\(NSApp.mainWindow?.windowNumber ?? -1) fr=\(firstResponderType)@\(firstResponderPtr) frWin=\(firstResponderWindow)"
|
|
if let browser = tabManager?.focusedBrowserPanel {
|
|
let webWindow = browser.webView.window?.windowNumber ?? -1
|
|
let webSuperview = browser.webView.superview.map { String(describing: Unmanaged.passUnretained($0).toOpaque()) } ?? "nil"
|
|
dlog("split.shortcut dir=\(directionLabel) pre panel=\(browser.id.uuidString.prefix(5)) \(browser.debugDeveloperToolsStateSummary()) webWin=\(webWindow) webSuper=\(webSuperview) \(splitContext)")
|
|
} else {
|
|
dlog("split.shortcut dir=\(directionLabel) pre panel=nil \(splitContext)")
|
|
}
|
|
#endif
|
|
|
|
prepareFocusedBrowserDevToolsForSplit(directionLabel: directionLabel)
|
|
tabManager?.createSplit(direction: direction)
|
|
#if DEBUG
|
|
DispatchQueue.main.asyncAfter(deadline: .now() + 0.15) { [weak self] in
|
|
let keyWindow = NSApp.keyWindow
|
|
let firstResponder = keyWindow?.firstResponder
|
|
let firstResponderType = firstResponder.map { String(describing: type(of: $0)) } ?? "nil"
|
|
let firstResponderPtr = firstResponder.map { String(describing: Unmanaged.passUnretained($0).toOpaque()) } ?? "nil"
|
|
let firstResponderWindow: Int = {
|
|
if let v = firstResponder as? NSView {
|
|
return v.window?.windowNumber ?? -1
|
|
}
|
|
if let w = firstResponder as? NSWindow {
|
|
return w.windowNumber
|
|
}
|
|
return -1
|
|
}()
|
|
let splitContext = "keyWin=\(keyWindow?.windowNumber ?? -1) mainWin=\(NSApp.mainWindow?.windowNumber ?? -1) fr=\(firstResponderType)@\(firstResponderPtr) frWin=\(firstResponderWindow)"
|
|
if let browser = self?.tabManager?.focusedBrowserPanel {
|
|
let webWindow = browser.webView.window?.windowNumber ?? -1
|
|
let webSuperview = browser.webView.superview.map { String(describing: Unmanaged.passUnretained($0).toOpaque()) } ?? "nil"
|
|
dlog("split.shortcut dir=\(directionLabel) post panel=\(browser.id.uuidString.prefix(5)) \(browser.debugDeveloperToolsStateSummary()) webWin=\(webWindow) webSuper=\(webSuperview) \(splitContext)")
|
|
} else {
|
|
dlog("split.shortcut dir=\(directionLabel) post panel=nil \(splitContext)")
|
|
}
|
|
}
|
|
recordGotoSplitSplitIfNeeded(direction: direction)
|
|
#endif
|
|
return true
|
|
}
|
|
|
|
@discardableResult
|
|
func performBrowserSplitShortcut(direction: SplitDirection) -> Bool {
|
|
guard let panelId = tabManager?.createBrowserSplit(direction: direction) else { return false }
|
|
_ = focusBrowserAddressBar(panelId: panelId)
|
|
return true
|
|
}
|
|
|
|
/// Allow AppKit-backed browser surfaces (WKWebView) to route non-menu shortcuts
|
|
/// through the same app-level shortcut handler used by the local key monitor.
|
|
@discardableResult
|
|
func handleBrowserSurfaceKeyEquivalent(_ event: NSEvent) -> Bool {
|
|
handleCustomShortcut(event: event)
|
|
}
|
|
|
|
#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 {
|
|
// If the IME is composing, don't intercept key events — let them flow
|
|
// through normal AppKit event dispatch so the input method can process them.
|
|
if ghosttyView.hasMarkedText() {
|
|
return cmux_performKeyEquivalent(with: event)
|
|
}
|
|
|
|
let flags = event.modifierFlags.intersection(.deviceIndependentFlagsMask)
|
|
if !flags.contains(.command) {
|
|
let result = ghosttyView.performKeyEquivalent(with: event)
|
|
#if DEBUG
|
|
dlog(" → ghostty direct: \(result)")
|
|
#endif
|
|
return result
|
|
}
|
|
}
|
|
|
|
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: "+")
|
|
}
|
|
}
|