cmux/Sources/cmuxApp.swift
Lawrence Chen 50f0dd334d
Fix frozen terminals after split churn (#12)
* Fix blank terminal after split operations and add visual tests

## Blank Terminal Fix
- Add `needsRefreshAfterWindowChange` flag in GhosttyTerminalView
- Force terminal refresh when view is added to window, even if size unchanged
- Add `ghostty_surface_refresh()` call in attachToView for same-view reattachment
- Add debug logging for surface attachment lifecycle (DEBUG builds only)

## Bonsplit Migration
- Add bonsplit as local Swift package (vendor/bonsplit submodule)
- Replace custom SplitTree with BonsplitController
- Add Panel protocol with TerminalPanel and BrowserPanel implementations
- Add SidebarTab as main tab container with BonsplitController
- Remove old Splits/ directory (SplitTree, SplitView, TerminalSplitTreeView)

## Visual Screenshot Tests
- Add test_visual_screenshots.py for automated visual regression testing
- Uses in-app screenshot API (CGWindowListCreateImage) - no screen recording needed
- Generates HTML report with before/after comparisons
- Tests: splits, browser panels, focus switching, close operations, rapid cycles
- Includes annotation fields for easy feedback

## Browser Shortcut (⌘⇧B)
- Add keyboard shortcut to open browser panel in current pane
- Add openBrowser() method to TabManager
- Add shortcut configuration in KeyboardShortcutSettings

## Screenshot Command
- Add 'screenshot' command to TerminalController for in-app window capture
- Returns OK with screenshot ID and path

## Other
- Add tests/visual_output/ and tests/visual_report.html to .gitignore

* Add browser title subscription and set tab height to 30px

- Subscribe to BrowserPanel.$pageTitle changes to update bonsplit tabs
- Update tab titles in real-time as page navigation occurs
- Clean up subscriptions when panels are removed
- Set bonsplit tab bar and tab height to 30px (in submodule)

* Fix socket API regressions in list_surfaces, list_bonsplit_tabs, focus_pane

- list_surfaces: Remove [terminal]/[browser] suffix to keep UUID-only format
  that clients and tests expect for parsing
- list_bonsplit_tabs --pane: Properly look up pane by UUID instead of
  creating a new PaneID (requires bonsplit PaneID.id to be public)
- focus_pane: Accept both UUID strings and integer indices as documented

* Fix browser panel stability and keyboard shortcuts

- Prevent WKWebView focus lifecycle crashes during split/view reshuffles
- Match bracket shortcuts via keyCode (Cmd+Shift+[ / ], Cmd+Ctrl+[ / ])
- Support Ghostty config goto_split:* keybinds when WebView is focused
- Add focus_webview/is_webview_focused socket commands and regression tests
- Rename SidebarTab to Workspace and update docs

* Make ctrl+enter keybind test skippable

Skip when the Ghostty keybind isn't configured or when osascript can't send keystrokes (no Accessibility permission), so VM runs stay green.

* Auto-focus browser omnibar when blank

When a browser surface is focused but no URL is loaded yet, focus the address bar instead of the WKWebView.

* Stabilize socket surface indexing

* Focus browser omnibar escape; add webview keybind UI tests

- Escape in omnibar now returns focus to WKWebView\n- Add UI tests for Cmd+Ctrl+H pane navigation with WebKit focused (including Ghostty config)\n- Avoid flaky element screenshots in UpdatePillUITests on the UTM VM

* Fix browser drag-to-split blanks and socket parsing

* Fix webview-focused shortcuts and stabilize browser splits

- Match ctrl/shift shortcuts by keyCode where needed (Ctrl+H, bracket keys)
- Load Ghostty goto_split triggers reliably and refresh on config load
- Add debug socket helpers: set_shortcut + simulate_shortcut for tests
- Convert browser goto_split/keybind tests to socket-based injection (no osascript)
- Bump bonsplit for drag-to-split fixes

* Fix split layout collapse and harden socket pane APIs

* Stabilize OSC 99 notification test timing

* Fix terminal focus routing after split reparent

* Support simulate_shortcut enter for focus routing test

* Stabilize terminal focus routing test

* Fix frozen new terminal tabs after many splits

* Fix frozen new terminal tabs after splits

* Fix terminal freeze on launch/new tabs

* Update ghostty submodule

* Fix terminal focus/render stalls after split churn

* Fix nested split collapsing existing pane

* Fix nested split collapse + stabilize new-surface focus

* Update bonsplit submodule

* Fix SIGINT test flake

* Remove bonsplit tab-switch crossfade

* Remove PROJECTS.md

* Remove bonsplit tab selection animation

* Ignore generated test reports

* Middle click closes tab

* Revert unintended .gitignore change

* Fix build after main merge

* Revert "Fix build after main merge"

This reverts commit 16bf9816d0856b5385d52f886aa5eb50f3c9d9a4.

* Revert "Merge remote-tracking branch 'origin/main' into fix/blank-terminal-and-visual-tests"

This reverts commit 7c20fb53fd71fea7a19a3673f2dd73e5f0c783c4, reversing
changes made to 0aff107d787bc9d8bbc28220090b4ca7af72e040.

* Remove tab close fade animation

* Use terminal.fill icon

* Make terminal tab icon smaller

* Match browser globe tab icon size

* Bonsplit: tab min width 48 and tighter close button

* Bonsplit: smaller tab title font

* Show unread notification badge in bonsplit tabs and improve UI polish

Sync unread notification state to bonsplit tab badges (blue dot).
Improve EmptyPanelView with Terminal/Browser buttons and shortcut hints.
Add tooltips to close tab button and search overlay buttons.

* Fix reload.sh single-instance safety check on macOS

Replace GNU-only `ps -o etimes=` with portable `ps -o etime=` and
parse the dd-hh:mm:ss format manually for macOS compatibility.

* Centralize keyboard shortcut definitions into Action enum

Replace per-shortcut boilerplate with a single Action enum that holds
the label, defaults key, and default binding for each shortcut. All
call sites now use shortcut(for:). Settings UI is data-driven via
ForEach(Action.allCases). Titlebar tooltips update dynamically when
shortcuts are changed. Remove duplicate .keyboardShortcut() modifiers
from menu items that are already handled by the event monitor.

* Fix WKWebView consuming app menu shortcuts and close panel confirmation

Add CmuxWebView subclass that routes key equivalents through the main
menu before WebKit, so Cmd+N/Cmd+W/tab switching work when a browser
pane is focused. Fix Cmd+W close-panel path: bypass Bonsplit delegate
gating after the user confirms the running-process dialog by tracking
forceCloseTabIds. Add unit tests (CmuxWebViewKeyEquivalentTests) and
UI test scaffolding (MenuKeyEquivalentRoutingUITests) with a new
cmux-unit Xcode scheme.

* Update CLAUDE.md and PROJECTS.md with recent changes

CLAUDE.md: enforce --tag for reload commands, add cleanup safety rules.
PROJECTS.md: log notification badge, reload.sh fix, Cmd+W fix, WebView
key equiv fix, and centralized shortcuts work.

* Keep selection index stable on close

* Add concepts page documenting terminology hierarchy

New docs page explaining Window > Workspace > Pane > Surface > Panel
hierarchy with aligned ASCII diagram. Updated tabs.mdx and splits.mdx
to use consistent terminology (workspace instead of tab, surface
instead of panel) and corrected outdated CLI command references.

* Update bonsplit submodule

* WIP: improve split close stability and UI regressions

* Close terminal panel on child exit; hide terminal dirty dot

* Fix split close/focus regressions and stabilize UI tests

* Add unread Dock/Cmd+Tab badge with settings toggle

* Fix browser-surface shortcuts and Cmd+L browser opening

* Snapshot current workspace state before regression fixes

* Update bonsplit submodule snapshot

* Stabilize split-close regression capture and sidebar resize assertions

* Change default Show Notifications shortcut from Cmd+Shift+I to Cmd+I

* Fix update check readiness race, enable release update logging, and improve checking spinner

* Restore terminal file drop, fix browser omnibar click focus, and add panel workspace ID mutation for surface moves

* Add Cmd+digit workspace hints, titlebar shortcut pills, sidebar drag-reorder, and workspace placement settings

* Add v2 browser automation API, surface move/reorder commands, and short-handle ref system to TerminalController

* Add CLI browser command surface, --id-format flag, and move/reorder commands

* Extend test clients with move/reorder APIs, ref-handle support, and increased timeouts

* Harden test runner scripts with deterministic builds, retry logic, and robust socket readiness

* Stabilize existing test suites with focus-wait helpers, increased timeouts, and API shape updates

* Add terminal file drop e2e regression test

* Add v2 browser API, CLI ref resolution, and surface move/reorder test suites

* Add unit tests for shortcut hints, workspace reorder, drop planner, and update UI test stabilization

* Add cmux-debug-windows skill with snapshot script and agent config

* Update project docs: mark browser parity and move/reorder phases complete, add parallel agent workflow guidelines

* Update bonsplit submodule: re-entrant setPosition guard, tab shortcut hints, and moveTab/reorderTab API

* Add browser agent UX improvements: snapshot refs, placement reuse, diagnostics, and skill docs

- Upgrade browser.snapshot to emit accessibility tree text with element refs (eN)
- Add right-sibling pane reuse policy for browser.open_split placement
- Add rich not_found diagnostics with retry logic for selector actions
- Support --snapshot-after for post-action verification on mutating commands
- Allow browser fill with empty text for clearing inputs
- Default CLI --id-format to refs-first (UUIDs opt-in via --id-format uuids|both)
- Format legacy new-pane/new-surface output with short surface refs
- Add skills/cmuxterm-browser/ and skills/cmuxterm/ end-user skill docs
- Add regression tests for placement policy, snapshot refs, diagnostics, and ID defaults

* Update bonsplit submodule: keep raster favicons in color when inactive
2026-02-13 16:45:31 -08:00

1838 lines
76 KiB
Swift

import AppKit
import SwiftUI
import Darwin
@main
struct cmuxApp: App {
@StateObject private var tabManager = TabManager()
@StateObject private var notificationStore = TerminalNotificationStore.shared
@StateObject private var sidebarState = SidebarState()
@StateObject private var sidebarSelectionState = SidebarSelectionState()
private let primaryWindowId = UUID()
@AppStorage("appearanceMode") private var appearanceMode = AppearanceMode.dark.rawValue
@AppStorage("titlebarControlsStyle") private var titlebarControlsStyle = TitlebarControlsStyle.classic.rawValue
@AppStorage(ShortcutHintDebugSettings.alwaysShowHintsKey) private var alwaysShowShortcutHints = ShortcutHintDebugSettings.defaultAlwaysShowHints
@AppStorage(SocketControlSettings.appStorageKey) private var socketControlMode = SocketControlSettings.defaultMode.rawValue
@AppStorage(KeyboardShortcutSettings.Action.splitRight.defaultsKey) private var splitRightShortcutData = Data()
@AppStorage(KeyboardShortcutSettings.Action.splitDown.defaultsKey) private var splitDownShortcutData = Data()
@NSApplicationDelegateAdaptor(AppDelegate.self) private var appDelegate
init() {
configureGhosttyEnvironment()
// Start the terminal controller for programmatic control
// This runs after TabManager is created via @StateObject
let defaults = UserDefaults.standard
if defaults.object(forKey: SocketControlSettings.appStorageKey) == nil,
let legacy = defaults.object(forKey: SocketControlSettings.legacyEnabledKey) as? Bool {
defaults.set(legacy ? SocketControlMode.full.rawValue : SocketControlMode.off.rawValue,
forKey: SocketControlSettings.appStorageKey)
}
migrateSidebarAppearanceDefaultsIfNeeded(defaults: defaults)
// UI tests depend on AppDelegate wiring happening even if SwiftUI view appearance
// callbacks (e.g. `.onAppear`) are delayed or skipped.
appDelegate.configure(tabManager: tabManager, notificationStore: notificationStore, sidebarState: sidebarState)
}
private func configureGhosttyEnvironment() {
let fileManager = FileManager.default
let ghosttyAppResources = "/Applications/Ghostty.app/Contents/Resources/ghostty"
let bundledGhosttyURL = Bundle.main.resourceURL?.appendingPathComponent("ghostty")
var resolvedResourcesDir: String?
if getenv("GHOSTTY_RESOURCES_DIR") == nil {
if let bundledGhosttyURL,
fileManager.fileExists(atPath: bundledGhosttyURL.path),
fileManager.fileExists(atPath: bundledGhosttyURL.appendingPathComponent("themes").path) {
resolvedResourcesDir = bundledGhosttyURL.path
} else if fileManager.fileExists(atPath: ghosttyAppResources) {
resolvedResourcesDir = ghosttyAppResources
} else if let bundledGhosttyURL, fileManager.fileExists(atPath: bundledGhosttyURL.path) {
resolvedResourcesDir = bundledGhosttyURL.path
}
if let resolvedResourcesDir {
setenv("GHOSTTY_RESOURCES_DIR", resolvedResourcesDir, 1)
}
}
if getenv("TERM") == nil {
setenv("TERM", "xterm-ghostty", 1)
}
if getenv("TERM_PROGRAM") == nil {
setenv("TERM_PROGRAM", "ghostty", 1)
}
if let resourcesDir = getenv("GHOSTTY_RESOURCES_DIR").flatMap({ String(cString: $0) }) {
let resourcesURL = URL(fileURLWithPath: resourcesDir)
let resourcesParent = resourcesURL.deletingLastPathComponent()
let dataDir = resourcesParent.path
let manDir = resourcesParent.appendingPathComponent("man").path
appendEnvPathIfMissing(
"XDG_DATA_DIRS",
path: dataDir,
defaultValue: "/usr/local/share:/usr/share"
)
appendEnvPathIfMissing("MANPATH", path: manDir)
}
}
private func appendEnvPathIfMissing(_ key: String, path: String, defaultValue: String? = nil) {
if path.isEmpty { return }
var current = getenv(key).flatMap { String(cString: $0) } ?? ""
if current.isEmpty, let defaultValue {
current = defaultValue
}
if current.split(separator: ":").contains(Substring(path)) {
return
}
let updated = current.isEmpty ? path : "\(current):\(path)"
setenv(key, updated, 1)
}
private func migrateSidebarAppearanceDefaultsIfNeeded(defaults: UserDefaults) {
let migrationKey = "sidebarAppearanceDefaultsVersion"
let targetVersion = 1
guard defaults.integer(forKey: migrationKey) < targetVersion else { return }
func normalizeHex(_ value: String) -> String {
value
.trimmingCharacters(in: .whitespacesAndNewlines)
.replacingOccurrences(of: "#", with: "")
.uppercased()
}
func approximatelyEqual(_ lhs: Double, _ rhs: Double, tolerance: Double = 0.0001) -> Bool {
abs(lhs - rhs) <= tolerance
}
let material = defaults.string(forKey: "sidebarMaterial") ?? SidebarMaterialOption.sidebar.rawValue
let blendMode = defaults.string(forKey: "sidebarBlendMode") ?? SidebarBlendModeOption.behindWindow.rawValue
let state = defaults.string(forKey: "sidebarState") ?? SidebarStateOption.followWindow.rawValue
let tintHex = defaults.string(forKey: "sidebarTintHex") ?? "#101010"
let tintOpacity = defaults.object(forKey: "sidebarTintOpacity") as? Double ?? 0.54
let blurOpacity = defaults.object(forKey: "sidebarBlurOpacity") as? Double ?? 0.79
let cornerRadius = defaults.object(forKey: "sidebarCornerRadius") as? Double ?? 0.0
let usesLegacyDefaults =
material == SidebarMaterialOption.sidebar.rawValue &&
blendMode == SidebarBlendModeOption.behindWindow.rawValue &&
state == SidebarStateOption.followWindow.rawValue &&
normalizeHex(tintHex) == "101010" &&
approximatelyEqual(tintOpacity, 0.54) &&
approximatelyEqual(blurOpacity, 0.79) &&
approximatelyEqual(cornerRadius, 0.0)
if usesLegacyDefaults {
let preset = SidebarPresetOption.nativeSidebar
defaults.set(preset.rawValue, forKey: "sidebarPreset")
defaults.set(preset.material.rawValue, forKey: "sidebarMaterial")
defaults.set(preset.blendMode.rawValue, forKey: "sidebarBlendMode")
defaults.set(preset.state.rawValue, forKey: "sidebarState")
defaults.set(preset.tintHex, forKey: "sidebarTintHex")
defaults.set(preset.tintOpacity, forKey: "sidebarTintOpacity")
defaults.set(preset.blurOpacity, forKey: "sidebarBlurOpacity")
defaults.set(preset.cornerRadius, forKey: "sidebarCornerRadius")
}
defaults.set(targetVersion, forKey: migrationKey)
}
var body: some Scene {
WindowGroup {
ContentView(updateViewModel: appDelegate.updateViewModel, windowId: primaryWindowId)
.environmentObject(tabManager)
.environmentObject(notificationStore)
.environmentObject(sidebarState)
.environmentObject(sidebarSelectionState)
.onAppear {
#if DEBUG
if ProcessInfo.processInfo.environment["CMUX_UI_TEST_MODE"] == "1" {
UpdateLogStore.shared.append("ui test: cmuxApp onAppear")
}
#endif
// Start the Unix socket controller for programmatic access
updateSocketController()
appDelegate.configure(tabManager: tabManager, notificationStore: notificationStore, sidebarState: sidebarState)
applyAppearance()
if ProcessInfo.processInfo.environment["CMUX_UI_TEST_SHOW_SETTINGS"] == "1" {
DispatchQueue.main.async {
NSApp.sendAction(Selector(("showSettingsWindow:")), to: nil, from: nil)
}
}
}
.onChange(of: appearanceMode) { _ in
applyAppearance()
}
.onChange(of: socketControlMode) { _ in
updateSocketController()
}
}
.windowStyle(.hiddenTitleBar)
Settings {
SettingsRootView()
}
.defaultSize(width: 460, height: 360)
.windowResizability(.contentMinSize)
.commands {
CommandGroup(replacing: .appInfo) {
Button("About cmux") {
showAboutPanel()
}
Button("Ghostty Settings…") {
GhosttyApp.shared.openConfigurationInTextEdit()
}
Button("Reload Configuration") {
GhosttyApp.shared.reloadConfiguration()
}
.keyboardShortcut("r", modifiers: [.command, .shift])
Divider()
Button("Check for Updates…") {
appDelegate.checkForUpdates(nil)
}
}
#if DEBUG
CommandMenu("Update Pill") {
Button("Show Update Pill") {
appDelegate.showUpdatePill(nil)
}
Button("Show Loading State") {
appDelegate.showUpdatePillLoading(nil)
}
Button("Hide Update Pill") {
appDelegate.hideUpdatePill(nil)
}
Button("Automatic Update Pill") {
appDelegate.clearUpdatePillOverride(nil)
}
}
#endif
CommandMenu("Update Logs") {
Button("Copy Update Logs") {
appDelegate.copyUpdateLogs(nil)
}
Button("Copy Focus Logs") {
appDelegate.copyFocusLogs(nil)
}
}
CommandMenu("Notifications") {
let snapshot = notificationMenuSnapshot
Button(snapshot.stateHintTitle) {}
.disabled(true)
if !snapshot.recentNotifications.isEmpty {
Divider()
ForEach(snapshot.recentNotifications) { notification in
Button(notificationMenuItemTitle(for: notification)) {
openNotificationFromMainMenu(notification)
}
}
Divider()
}
Button("Show Notifications") {
showNotificationsPopover()
}
Button("Jump to Latest Unread") {
appDelegate.jumpToLatestUnread()
}
.disabled(!snapshot.hasUnreadNotifications)
Button("Mark All Read") {
notificationStore.markAllRead()
}
.disabled(!snapshot.hasUnreadNotifications)
Button("Clear All") {
notificationStore.clearAll()
}
.disabled(!snapshot.hasNotifications)
}
#if DEBUG
CommandMenu("Debug") {
Button("New Tab With Lorem Search Text") {
appDelegate.openDebugLoremTab(nil)
}
Button("New Tab With Large Scrollback") {
appDelegate.openDebugScrollbackTab(nil)
}
Divider()
Menu("Debug Windows") {
Button("Debug Window Controls…") {
DebugWindowControlsWindowController.shared.show()
}
Divider()
Button("Sidebar Debug…") {
SidebarDebugWindowController.shared.show()
}
Button("Background Debug…") {
BackgroundDebugWindowController.shared.show()
}
Button("Menu Bar Extra Debug…") {
MenuBarExtraDebugWindowController.shared.show()
}
Divider()
Button("Open All Debug Windows") {
openAllDebugWindows()
}
}
Toggle("Always Show Shortcut Hints", isOn: $alwaysShowShortcutHints)
Divider()
Picker("Titlebar Controls Style", selection: $titlebarControlsStyle) {
ForEach(TitlebarControlsStyle.allCases) { style in
Text(style.menuTitle).tag(style.rawValue)
}
}
Divider()
Button("Trigger Sentry Test Crash") {
appDelegate.triggerSentryTestCrash(nil)
}
}
#endif
// New tab commands
CommandGroup(replacing: .newItem) {
Button("New Window") {
appDelegate.openNewMainWindow(nil)
}
.keyboardShortcut("n", modifiers: [.command, .shift])
Button("New Workspace") {
(AppDelegate.shared?.tabManager ?? tabManager).addTab()
}
}
// Close tab/workspace
CommandGroup(after: .newItem) {
// Terminal semantics:
// Cmd+W closes the focused tab (with confirmation if needed). If this is the last
// tab in the last workspace, it closes the window.
Button("Close Tab") {
closePanelOrWindow()
}
.keyboardShortcut("w", modifiers: .command)
// Cmd+Shift+W closes the current workspace (with confirmation if needed). If this
// is the last workspace, it closes the window.
Button("Close Workspace") {
closeTabOrWindow()
}
.keyboardShortcut("w", modifiers: [.command, .shift])
}
// Find
CommandGroup(after: .textEditing) {
Menu("Find") {
Button("Find…") {
(AppDelegate.shared?.tabManager ?? tabManager).startSearch()
}
.keyboardShortcut("f", modifiers: .command)
Button("Find Next") {
(AppDelegate.shared?.tabManager ?? tabManager).findNext()
}
.keyboardShortcut("g", modifiers: .command)
Button("Find Previous") {
(AppDelegate.shared?.tabManager ?? tabManager).findPrevious()
}
.keyboardShortcut("g", modifiers: [.command, .shift])
Divider()
Button("Hide Find Bar") {
(AppDelegate.shared?.tabManager ?? tabManager).hideFind()
}
.keyboardShortcut("f", modifiers: [.command, .shift])
.disabled(!((AppDelegate.shared?.tabManager ?? tabManager).isFindVisible))
Divider()
Button("Use Selection for Find") {
(AppDelegate.shared?.tabManager ?? tabManager).searchSelection()
}
.keyboardShortcut("e", modifiers: .command)
.disabled(!((AppDelegate.shared?.tabManager ?? tabManager).canUseSelectionForFind))
}
}
// Tab navigation
CommandGroup(after: .toolbar) {
Button("Toggle Sidebar") {
sidebarState.toggle()
}
Divider()
Button("Next Surface") {
(AppDelegate.shared?.tabManager ?? tabManager).selectNextSurface()
}
Button("Previous Surface") {
(AppDelegate.shared?.tabManager ?? tabManager).selectPreviousSurface()
}
Button("Back") {
(AppDelegate.shared?.tabManager ?? tabManager).navigateBack()
}
.keyboardShortcut("[", modifiers: .command)
Button("Forward") {
(AppDelegate.shared?.tabManager ?? tabManager).navigateForward()
}
.keyboardShortcut("]", modifiers: .command)
Button("Reload Page") {
(AppDelegate.shared?.tabManager ?? tabManager).focusedBrowserPanel?.reload()
}
.keyboardShortcut("r", modifiers: .command)
Button("Next Workspace") {
(AppDelegate.shared?.tabManager ?? tabManager).selectNextTab()
}
Button("Previous Workspace") {
(AppDelegate.shared?.tabManager ?? tabManager).selectPreviousTab()
}
Divider()
splitCommandButton(title: "Split Right", shortcut: splitRightMenuShortcut) {
performSplitFromMenu(direction: .right)
}
splitCommandButton(title: "Split Down", shortcut: splitDownMenuShortcut) {
performSplitFromMenu(direction: .down)
}
Divider()
// Cmd+1 through Cmd+9 for workspace selection (9 = last workspace)
ForEach(1...9, id: \.self) { number in
Button("Tab \(number)") {
let manager = (AppDelegate.shared?.tabManager ?? tabManager)
if let targetIndex = WorkspaceShortcutMapper.workspaceIndex(forCommandDigit: number, workspaceCount: manager.tabs.count) {
manager.selectTab(at: targetIndex)
}
}
.keyboardShortcut(KeyEquivalent(Character("\(number)")), modifiers: .command)
}
Divider()
Button("Jump to Latest Unread") {
AppDelegate.shared?.jumpToLatestUnread()
}
Button("Show Notifications") {
showNotificationsPopover()
}
}
}
}
private func showAboutPanel() {
AboutWindowController.shared.show()
NSApp.activate(ignoringOtherApps: true)
}
private func applyAppearance() {
guard let mode = AppearanceMode(rawValue: appearanceMode) else { return }
switch mode {
case .system:
NSApp.appearance = nil
case .light:
NSApp.appearance = NSAppearance(named: .aqua)
case .dark:
NSApp.appearance = NSAppearance(named: .darkAqua)
case .auto:
// Legacy value; treat like system and migrate.
NSApp.appearance = nil
appearanceMode = AppearanceMode.system.rawValue
}
}
private func updateSocketController() {
let mode = SocketControlSettings.effectiveMode(userMode: currentSocketMode)
if mode != .off {
TerminalController.shared.start(
tabManager: tabManager,
socketPath: SocketControlSettings.socketPath(),
accessMode: mode
)
} else {
TerminalController.shared.stop()
}
}
private var currentSocketMode: SocketControlMode {
SocketControlMode(rawValue: socketControlMode) ?? SocketControlSettings.defaultMode
}
private var splitRightMenuShortcut: StoredShortcut {
decodeShortcut(from: splitRightShortcutData, fallback: KeyboardShortcutSettings.Action.splitRight.defaultShortcut)
}
private var splitDownMenuShortcut: StoredShortcut {
decodeShortcut(from: splitDownShortcutData, fallback: KeyboardShortcutSettings.Action.splitDown.defaultShortcut)
}
private var notificationMenuSnapshot: NotificationMenuSnapshot {
NotificationMenuSnapshotBuilder.make(notifications: notificationStore.notifications)
}
private func decodeShortcut(from data: Data, fallback: StoredShortcut) -> StoredShortcut {
guard !data.isEmpty,
let shortcut = try? JSONDecoder().decode(StoredShortcut.self, from: data) else {
return fallback
}
return shortcut
}
private func notificationMenuItemTitle(for notification: TerminalNotification) -> String {
let tabTitle = appDelegate.tabTitle(for: notification.tabId)
return MenuBarNotificationLineFormatter.menuTitle(notification: notification, tabTitle: tabTitle)
}
private func openNotificationFromMainMenu(_ notification: TerminalNotification) {
_ = appDelegate.openNotification(
tabId: notification.tabId,
surfaceId: notification.surfaceId,
notificationId: notification.id
)
}
private func performSplitFromMenu(direction: SplitDirection) {
if AppDelegate.shared?.performSplitShortcut(direction: direction) == true {
return
}
tabManager.createSplit(direction: direction)
}
@ViewBuilder
private func splitCommandButton(title: String, shortcut: StoredShortcut, action: @escaping () -> Void) -> some View {
if let key = keyEquivalent(for: shortcut) {
Button(title, action: action)
.keyboardShortcut(key, modifiers: eventModifiers(for: shortcut))
} else {
Button(title, action: action)
}
}
private func keyEquivalent(for shortcut: StoredShortcut) -> KeyEquivalent? {
switch shortcut.key {
case "":
return .leftArrow
case "":
return .rightArrow
case "":
return .upArrow
case "":
return .downArrow
case "\t":
return .tab
default:
let lowered = shortcut.key.lowercased()
guard lowered.count == 1, let character = lowered.first else { return nil }
return KeyEquivalent(character)
}
}
private func eventModifiers(for shortcut: StoredShortcut) -> EventModifiers {
var modifiers: EventModifiers = []
if shortcut.command {
modifiers.insert(.command)
}
if shortcut.shift {
modifiers.insert(.shift)
}
if shortcut.option {
modifiers.insert(.option)
}
if shortcut.control {
modifiers.insert(.control)
}
return modifiers
}
private func closePanelOrWindow() {
if let window = NSApp.keyWindow,
window.identifier?.rawValue == "cmux.settings" {
window.performClose(nil)
return
}
(AppDelegate.shared?.tabManager ?? tabManager).closeCurrentPanelWithConfirmation()
}
private func closeTabOrWindow() {
(AppDelegate.shared?.tabManager ?? tabManager).closeCurrentTabWithConfirmation()
}
private func showNotificationsPopover() {
AppDelegate.shared?.toggleNotificationsPopover(animated: false)
}
private func openAllDebugWindows() {
SidebarDebugWindowController.shared.show()
BackgroundDebugWindowController.shared.show()
MenuBarExtraDebugWindowController.shared.show()
}
}
private enum DebugWindowConfigSnapshot {
static func copyCombinedToPasteboard(defaults: UserDefaults = .standard) {
let pasteboard = NSPasteboard.general
pasteboard.clearContents()
pasteboard.setString(combinedPayload(defaults: defaults), forType: .string)
}
static func combinedPayload(defaults: UserDefaults = .standard) -> String {
let sidebarPayload = """
sidebarPreset=\(stringValue(defaults, key: "sidebarPreset", fallback: SidebarPresetOption.nativeSidebar.rawValue))
sidebarMaterial=\(stringValue(defaults, key: "sidebarMaterial", fallback: SidebarMaterialOption.sidebar.rawValue))
sidebarBlendMode=\(stringValue(defaults, key: "sidebarBlendMode", fallback: SidebarBlendModeOption.withinWindow.rawValue))
sidebarState=\(stringValue(defaults, key: "sidebarState", fallback: SidebarStateOption.followWindow.rawValue))
sidebarBlurOpacity=\(String(format: "%.2f", doubleValue(defaults, key: "sidebarBlurOpacity", fallback: 1.0)))
sidebarTintHex=\(stringValue(defaults, key: "sidebarTintHex", fallback: "#000000"))
sidebarTintOpacity=\(String(format: "%.2f", doubleValue(defaults, key: "sidebarTintOpacity", fallback: 0.18)))
sidebarCornerRadius=\(String(format: "%.1f", doubleValue(defaults, key: "sidebarCornerRadius", fallback: 0.0)))
shortcutHintSidebarXOffset=\(String(format: "%.1f", doubleValue(defaults, key: ShortcutHintDebugSettings.sidebarHintXKey, fallback: ShortcutHintDebugSettings.defaultSidebarHintX)))
shortcutHintSidebarYOffset=\(String(format: "%.1f", doubleValue(defaults, key: ShortcutHintDebugSettings.sidebarHintYKey, fallback: ShortcutHintDebugSettings.defaultSidebarHintY)))
shortcutHintTitlebarXOffset=\(String(format: "%.1f", doubleValue(defaults, key: ShortcutHintDebugSettings.titlebarHintXKey, fallback: ShortcutHintDebugSettings.defaultTitlebarHintX)))
shortcutHintTitlebarYOffset=\(String(format: "%.1f", doubleValue(defaults, key: ShortcutHintDebugSettings.titlebarHintYKey, fallback: ShortcutHintDebugSettings.defaultTitlebarHintY)))
shortcutHintPaneTabXOffset=\(String(format: "%.1f", doubleValue(defaults, key: ShortcutHintDebugSettings.paneHintXKey, fallback: ShortcutHintDebugSettings.defaultPaneHintX)))
shortcutHintPaneTabYOffset=\(String(format: "%.1f", doubleValue(defaults, key: ShortcutHintDebugSettings.paneHintYKey, fallback: ShortcutHintDebugSettings.defaultPaneHintY)))
shortcutHintAlwaysShow=\(boolValue(defaults, key: ShortcutHintDebugSettings.alwaysShowHintsKey, fallback: ShortcutHintDebugSettings.defaultAlwaysShowHints))
"""
let backgroundPayload = """
bgGlassEnabled=\(boolValue(defaults, key: "bgGlassEnabled", fallback: true))
bgGlassMaterial=\(stringValue(defaults, key: "bgGlassMaterial", fallback: "hudWindow"))
bgGlassTintHex=\(stringValue(defaults, key: "bgGlassTintHex", fallback: "#000000"))
bgGlassTintOpacity=\(String(format: "%.2f", doubleValue(defaults, key: "bgGlassTintOpacity", fallback: 0.05)))
"""
let menuBarPayload = MenuBarIconDebugSettings.copyPayload(defaults: defaults)
return """
# Sidebar Debug
\(sidebarPayload)
# Background Debug
\(backgroundPayload)
# Menu Bar Extra Debug
\(menuBarPayload)
"""
}
private static func stringValue(_ defaults: UserDefaults, key: String, fallback: String) -> String {
defaults.string(forKey: key) ?? fallback
}
private static func doubleValue(_ defaults: UserDefaults, key: String, fallback: Double) -> Double {
if let value = defaults.object(forKey: key) as? NSNumber {
return value.doubleValue
}
if let text = defaults.string(forKey: key), let parsed = Double(text) {
return parsed
}
return fallback
}
private static func boolValue(_ defaults: UserDefaults, key: String, fallback: Bool) -> Bool {
guard defaults.object(forKey: key) != nil else { return fallback }
return defaults.bool(forKey: key)
}
}
private final class DebugWindowControlsWindowController: NSWindowController, NSWindowDelegate {
static let shared = DebugWindowControlsWindowController()
private init() {
let window = NSPanel(
contentRect: NSRect(x: 0, y: 0, width: 420, height: 560),
styleMask: [.titled, .closable, .utilityWindow],
backing: .buffered,
defer: false
)
window.title = "Debug Window Controls"
window.titleVisibility = .visible
window.titlebarAppearsTransparent = false
window.isMovableByWindowBackground = true
window.isReleasedWhenClosed = false
window.identifier = NSUserInterfaceItemIdentifier("cmux.debugWindowControls")
window.center()
window.contentView = NSHostingView(rootView: DebugWindowControlsView())
AppDelegate.shared?.applyWindowDecorations(to: window)
super.init(window: window)
window.delegate = self
}
@available(*, unavailable)
required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
func show() {
window?.center()
window?.makeKeyAndOrderFront(nil)
}
}
private struct DebugWindowControlsView: View {
@AppStorage(ShortcutHintDebugSettings.sidebarHintXKey) private var sidebarShortcutHintXOffset = ShortcutHintDebugSettings.defaultSidebarHintX
@AppStorage(ShortcutHintDebugSettings.sidebarHintYKey) private var sidebarShortcutHintYOffset = ShortcutHintDebugSettings.defaultSidebarHintY
@AppStorage(ShortcutHintDebugSettings.titlebarHintXKey) private var titlebarShortcutHintXOffset = ShortcutHintDebugSettings.defaultTitlebarHintX
@AppStorage(ShortcutHintDebugSettings.titlebarHintYKey) private var titlebarShortcutHintYOffset = ShortcutHintDebugSettings.defaultTitlebarHintY
@AppStorage(ShortcutHintDebugSettings.paneHintXKey) private var paneShortcutHintXOffset = ShortcutHintDebugSettings.defaultPaneHintX
@AppStorage(ShortcutHintDebugSettings.paneHintYKey) private var paneShortcutHintYOffset = ShortcutHintDebugSettings.defaultPaneHintY
@AppStorage(ShortcutHintDebugSettings.alwaysShowHintsKey) private var alwaysShowShortcutHints = ShortcutHintDebugSettings.defaultAlwaysShowHints
var body: some View {
ScrollView {
VStack(alignment: .leading, spacing: 14) {
Text("Debug Window Controls")
.font(.headline)
GroupBox("Open") {
VStack(alignment: .leading, spacing: 8) {
Button("Sidebar Debug…") {
SidebarDebugWindowController.shared.show()
}
Button("Background Debug…") {
BackgroundDebugWindowController.shared.show()
}
Button("Menu Bar Extra Debug…") {
MenuBarExtraDebugWindowController.shared.show()
}
Button("Open All Debug Windows") {
SidebarDebugWindowController.shared.show()
BackgroundDebugWindowController.shared.show()
MenuBarExtraDebugWindowController.shared.show()
}
}
.frame(maxWidth: .infinity, alignment: .leading)
.padding(.top, 2)
}
GroupBox("Shortcut Hints") {
VStack(alignment: .leading, spacing: 10) {
Toggle("Always show shortcut hints", isOn: $alwaysShowShortcutHints)
hintOffsetSection(
"Sidebar Cmd+1…9",
x: $sidebarShortcutHintXOffset,
y: $sidebarShortcutHintYOffset
)
hintOffsetSection(
"Titlebar Buttons",
x: $titlebarShortcutHintXOffset,
y: $titlebarShortcutHintYOffset
)
hintOffsetSection(
"Pane Ctrl/Cmd+1…9",
x: $paneShortcutHintXOffset,
y: $paneShortcutHintYOffset
)
HStack(spacing: 12) {
Button("Reset Hints") {
resetShortcutHintOffsets()
}
Button("Copy Hint Config") {
copyShortcutHintConfig()
}
}
}
.padding(.top, 2)
}
GroupBox("Copy") {
VStack(alignment: .leading, spacing: 8) {
Button("Copy All Debug Config") {
DebugWindowConfigSnapshot.copyCombinedToPasteboard()
}
Text("Copies sidebar, background, and menu bar debug settings as one payload.")
.font(.caption)
.foregroundColor(.secondary)
}
.frame(maxWidth: .infinity, alignment: .leading)
.padding(.top, 2)
}
Spacer(minLength: 0)
}
.padding(16)
.frame(maxWidth: .infinity, alignment: .topLeading)
}
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading)
}
private func hintOffsetSection(_ title: String, x: Binding<Double>, y: Binding<Double>) -> some View {
VStack(alignment: .leading, spacing: 6) {
Text(title)
.font(.caption)
.foregroundColor(.secondary)
sliderRow("X", value: x)
sliderRow("Y", value: y)
}
}
private func sliderRow(_ label: String, value: Binding<Double>) -> some View {
HStack(spacing: 8) {
Text(label)
Slider(value: value, in: ShortcutHintDebugSettings.offsetRange)
Text(String(format: "%.1f", ShortcutHintDebugSettings.clamped(value.wrappedValue)))
.font(.caption)
.monospacedDigit()
.frame(width: 44, alignment: .trailing)
}
}
private func resetShortcutHintOffsets() {
sidebarShortcutHintXOffset = ShortcutHintDebugSettings.defaultSidebarHintX
sidebarShortcutHintYOffset = ShortcutHintDebugSettings.defaultSidebarHintY
titlebarShortcutHintXOffset = ShortcutHintDebugSettings.defaultTitlebarHintX
titlebarShortcutHintYOffset = ShortcutHintDebugSettings.defaultTitlebarHintY
paneShortcutHintXOffset = ShortcutHintDebugSettings.defaultPaneHintX
paneShortcutHintYOffset = ShortcutHintDebugSettings.defaultPaneHintY
alwaysShowShortcutHints = ShortcutHintDebugSettings.defaultAlwaysShowHints
}
private func copyShortcutHintConfig() {
let payload = """
shortcutHintSidebarXOffset=\(String(format: "%.1f", ShortcutHintDebugSettings.clamped(sidebarShortcutHintXOffset)))
shortcutHintSidebarYOffset=\(String(format: "%.1f", ShortcutHintDebugSettings.clamped(sidebarShortcutHintYOffset)))
shortcutHintTitlebarXOffset=\(String(format: "%.1f", ShortcutHintDebugSettings.clamped(titlebarShortcutHintXOffset)))
shortcutHintTitlebarYOffset=\(String(format: "%.1f", ShortcutHintDebugSettings.clamped(titlebarShortcutHintYOffset)))
shortcutHintPaneTabXOffset=\(String(format: "%.1f", ShortcutHintDebugSettings.clamped(paneShortcutHintXOffset)))
shortcutHintPaneTabYOffset=\(String(format: "%.1f", ShortcutHintDebugSettings.clamped(paneShortcutHintYOffset)))
shortcutHintAlwaysShow=\(alwaysShowShortcutHints)
"""
let pasteboard = NSPasteboard.general
pasteboard.clearContents()
pasteboard.setString(payload, forType: .string)
}
}
private final class AboutWindowController: NSWindowController, NSWindowDelegate {
static let shared = AboutWindowController()
private init() {
let window = NSPanel(
contentRect: NSRect(x: 0, y: 0, width: 360, height: 520),
styleMask: [.titled, .closable, .utilityWindow],
backing: .buffered,
defer: false
)
window.title = ""
window.titleVisibility = .hidden
window.titlebarAppearsTransparent = true
window.isMovableByWindowBackground = true
window.isReleasedWhenClosed = false
window.identifier = NSUserInterfaceItemIdentifier("cmux.about")
window.center()
window.contentView = NSHostingView(rootView: AboutPanelView())
AppDelegate.shared?.applyWindowDecorations(to: window)
super.init(window: window)
window.delegate = self
}
@available(*, unavailable)
required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
func show() {
window?.center()
window?.makeKeyAndOrderFront(nil)
}
}
private final class SidebarDebugWindowController: NSWindowController, NSWindowDelegate {
static let shared = SidebarDebugWindowController()
private init() {
let window = NSPanel(
contentRect: NSRect(x: 0, y: 0, width: 360, height: 520),
styleMask: [.titled, .closable, .utilityWindow],
backing: .buffered,
defer: false
)
window.title = "Sidebar Debug"
window.titleVisibility = .visible
window.titlebarAppearsTransparent = false
window.isMovableByWindowBackground = true
window.isReleasedWhenClosed = false
window.identifier = NSUserInterfaceItemIdentifier("cmux.sidebarDebug")
window.center()
window.contentView = NSHostingView(rootView: SidebarDebugView())
AppDelegate.shared?.applyWindowDecorations(to: window)
super.init(window: window)
window.delegate = self
}
@available(*, unavailable)
required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
func show() {
window?.center()
window?.makeKeyAndOrderFront(nil)
}
}
private struct AboutPanelView: View {
@Environment(\.openURL) private var openURL
private let githubURL = URL(string: "https://github.com/manaflow-ai/cmux")
private let docsURL = URL(string: "https://term.cmux.dev")
private var version: String? { Bundle.main.infoDictionary?["CFBundleShortVersionString"] as? String }
private var build: String? { Bundle.main.infoDictionary?["CFBundleVersion"] as? String }
private var commit: String? {
if let value = Bundle.main.infoDictionary?["CMUXCommit"] as? String, !value.isEmpty {
return value
}
let env = ProcessInfo.processInfo.environment["CMUX_COMMIT"] ?? ""
return env.isEmpty ? nil : env
}
private var copyright: String? { Bundle.main.infoDictionary?["NSHumanReadableCopyright"] as? String }
var body: some View {
VStack(alignment: .center) {
Image(nsImage: NSApplication.shared.applicationIconImage)
.resizable()
.renderingMode(.original)
.frame(width: 96, height: 96)
.shadow(color: .black.opacity(0.18), radius: 8, x: 0, y: 3)
VStack(alignment: .center, spacing: 32) {
VStack(alignment: .center, spacing: 8) {
Text("cmux")
.bold()
.font(.title)
Text("A Ghostty-based terminal with vertical tabs\nand a notification panel for macOS.")
.multilineTextAlignment(.center)
.fixedSize(horizontal: false, vertical: true)
.font(.caption)
.tint(.secondary)
.opacity(0.8)
}
.textSelection(.enabled)
VStack(spacing: 2) {
if let version {
AboutPropertyRow(label: "Version", text: version)
}
if let build {
AboutPropertyRow(label: "Build", text: build)
}
let commitText = commit ?? ""
let commitURL = commit.flatMap { hash in
URL(string: "https://github.com/manaflow-ai/cmux/commit/\(hash)")
}
AboutPropertyRow(label: "Commit", text: commitText, url: commitURL)
}
.frame(maxWidth: .infinity)
HStack(spacing: 8) {
if let url = docsURL {
Button("Docs") {
openURL(url)
}
}
if let url = githubURL {
Button("GitHub") {
openURL(url)
}
}
}
if let copy = copyright, !copy.isEmpty {
Text(copy)
.font(.caption)
.textSelection(.enabled)
.tint(.secondary)
.opacity(0.8)
.multilineTextAlignment(.center)
.frame(maxWidth: .infinity)
}
}
.frame(maxWidth: .infinity)
}
.padding(.top, 8)
.padding(32)
.frame(minWidth: 280)
.background(AboutVisualEffectBackground(material: .underWindowBackground).ignoresSafeArea())
}
}
private struct SidebarDebugView: View {
@AppStorage("sidebarPreset") private var sidebarPreset = SidebarPresetOption.nativeSidebar.rawValue
@AppStorage("sidebarTintOpacity") private var sidebarTintOpacity = 0.18
@AppStorage("sidebarTintHex") private var sidebarTintHex = "#000000"
@AppStorage("sidebarMaterial") private var sidebarMaterial = SidebarMaterialOption.sidebar.rawValue
@AppStorage("sidebarBlendMode") private var sidebarBlendMode = SidebarBlendModeOption.withinWindow.rawValue
@AppStorage("sidebarState") private var sidebarState = SidebarStateOption.followWindow.rawValue
@AppStorage("sidebarCornerRadius") private var sidebarCornerRadius = 0.0
@AppStorage("sidebarBlurOpacity") private var sidebarBlurOpacity = 1.0
@AppStorage(ShortcutHintDebugSettings.sidebarHintXKey) private var sidebarShortcutHintXOffset = ShortcutHintDebugSettings.defaultSidebarHintX
@AppStorage(ShortcutHintDebugSettings.sidebarHintYKey) private var sidebarShortcutHintYOffset = ShortcutHintDebugSettings.defaultSidebarHintY
@AppStorage(ShortcutHintDebugSettings.titlebarHintXKey) private var titlebarShortcutHintXOffset = ShortcutHintDebugSettings.defaultTitlebarHintX
@AppStorage(ShortcutHintDebugSettings.titlebarHintYKey) private var titlebarShortcutHintYOffset = ShortcutHintDebugSettings.defaultTitlebarHintY
@AppStorage(ShortcutHintDebugSettings.paneHintXKey) private var paneShortcutHintXOffset = ShortcutHintDebugSettings.defaultPaneHintX
@AppStorage(ShortcutHintDebugSettings.paneHintYKey) private var paneShortcutHintYOffset = ShortcutHintDebugSettings.defaultPaneHintY
@AppStorage(ShortcutHintDebugSettings.alwaysShowHintsKey) private var alwaysShowShortcutHints = ShortcutHintDebugSettings.defaultAlwaysShowHints
var body: some View {
ScrollView {
VStack(alignment: .leading, spacing: 14) {
Text("Sidebar Appearance")
.font(.headline)
GroupBox("Presets") {
Picker("Preset", selection: $sidebarPreset) {
ForEach(SidebarPresetOption.allCases) { option in
Text(option.title).tag(option.rawValue)
}
}
.onChange(of: sidebarPreset) { _ in
applyPreset()
}
.padding(.top, 2)
}
GroupBox("Blur") {
VStack(alignment: .leading, spacing: 8) {
Picker("Material", selection: $sidebarMaterial) {
ForEach(SidebarMaterialOption.allCases) { option in
Text(option.title).tag(option.rawValue)
}
}
Picker("Blending", selection: $sidebarBlendMode) {
ForEach(SidebarBlendModeOption.allCases) { option in
Text(option.title).tag(option.rawValue)
}
}
Picker("State", selection: $sidebarState) {
ForEach(SidebarStateOption.allCases) { option in
Text(option.title).tag(option.rawValue)
}
}
HStack(spacing: 8) {
Text("Strength")
Slider(value: $sidebarBlurOpacity, in: 0...1)
Text(String(format: "%.0f%%", sidebarBlurOpacity * 100))
.font(.caption)
.frame(width: 44, alignment: .trailing)
}
}
.padding(.top, 2)
}
GroupBox("Tint") {
VStack(alignment: .leading, spacing: 8) {
ColorPicker("Tint Color", selection: tintColorBinding, supportsOpacity: false)
HStack(spacing: 8) {
Text("Opacity")
Slider(value: $sidebarTintOpacity, in: 0...0.7)
Text(String(format: "%.0f%%", sidebarTintOpacity * 100))
.font(.caption)
.frame(width: 44, alignment: .trailing)
}
}
.padding(.top, 2)
}
GroupBox("Shape") {
HStack(spacing: 8) {
Text("Corner Radius")
Slider(value: $sidebarCornerRadius, in: 0...20)
Text(String(format: "%.0f", sidebarCornerRadius))
.font(.caption)
.frame(width: 32, alignment: .trailing)
}
.padding(.top, 2)
}
GroupBox("Shortcut Hints") {
VStack(alignment: .leading, spacing: 10) {
Toggle("Always show shortcut hints", isOn: $alwaysShowShortcutHints)
hintOffsetSection(
"Sidebar Cmd+1…9",
x: $sidebarShortcutHintXOffset,
y: $sidebarShortcutHintYOffset
)
hintOffsetSection(
"Titlebar Buttons",
x: $titlebarShortcutHintXOffset,
y: $titlebarShortcutHintYOffset
)
hintOffsetSection(
"Pane Ctrl/Cmd+1…9",
x: $paneShortcutHintXOffset,
y: $paneShortcutHintYOffset
)
}
.padding(.top, 2)
}
HStack(spacing: 12) {
Button("Reset Tint") {
sidebarTintOpacity = 0.62
sidebarTintHex = "#000000"
}
Button("Reset Blur") {
sidebarMaterial = SidebarMaterialOption.hudWindow.rawValue
sidebarBlendMode = SidebarBlendModeOption.withinWindow.rawValue
sidebarState = SidebarStateOption.active.rawValue
sidebarBlurOpacity = 0.98
}
Button("Reset Shape") {
sidebarCornerRadius = 0.0
}
Button("Reset Hints") {
resetShortcutHintOffsets()
}
}
Button("Copy Config") {
copySidebarConfig()
}
Spacer(minLength: 0)
}
.padding(16)
.frame(maxWidth: .infinity, alignment: .topLeading)
}
}
private var tintColorBinding: Binding<Color> {
Binding(
get: {
Color(nsColor: NSColor(hex: sidebarTintHex) ?? .black)
},
set: { newColor in
let nsColor = NSColor(newColor)
sidebarTintHex = nsColor.hexString()
}
)
}
private func hintOffsetSection(_ title: String, x: Binding<Double>, y: Binding<Double>) -> some View {
VStack(alignment: .leading, spacing: 6) {
Text(title)
.font(.caption)
.foregroundColor(.secondary)
sliderRow("X", value: x)
sliderRow("Y", value: y)
}
}
private func sliderRow(_ label: String, value: Binding<Double>) -> some View {
HStack(spacing: 8) {
Text(label)
Slider(value: value, in: ShortcutHintDebugSettings.offsetRange)
Text(String(format: "%.1f", ShortcutHintDebugSettings.clamped(value.wrappedValue)))
.font(.caption)
.monospacedDigit()
.frame(width: 44, alignment: .trailing)
}
}
private func resetShortcutHintOffsets() {
sidebarShortcutHintXOffset = ShortcutHintDebugSettings.defaultSidebarHintX
sidebarShortcutHintYOffset = ShortcutHintDebugSettings.defaultSidebarHintY
titlebarShortcutHintXOffset = ShortcutHintDebugSettings.defaultTitlebarHintX
titlebarShortcutHintYOffset = ShortcutHintDebugSettings.defaultTitlebarHintY
paneShortcutHintXOffset = ShortcutHintDebugSettings.defaultPaneHintX
paneShortcutHintYOffset = ShortcutHintDebugSettings.defaultPaneHintY
alwaysShowShortcutHints = ShortcutHintDebugSettings.defaultAlwaysShowHints
}
private func copySidebarConfig() {
let payload = """
sidebarPreset=\(sidebarPreset)
sidebarMaterial=\(sidebarMaterial)
sidebarBlendMode=\(sidebarBlendMode)
sidebarState=\(sidebarState)
sidebarBlurOpacity=\(String(format: "%.2f", sidebarBlurOpacity))
sidebarTintHex=\(sidebarTintHex)
sidebarTintOpacity=\(String(format: "%.2f", sidebarTintOpacity))
sidebarCornerRadius=\(String(format: "%.1f", sidebarCornerRadius))
shortcutHintSidebarXOffset=\(String(format: "%.1f", ShortcutHintDebugSettings.clamped(sidebarShortcutHintXOffset)))
shortcutHintSidebarYOffset=\(String(format: "%.1f", ShortcutHintDebugSettings.clamped(sidebarShortcutHintYOffset)))
shortcutHintTitlebarXOffset=\(String(format: "%.1f", ShortcutHintDebugSettings.clamped(titlebarShortcutHintXOffset)))
shortcutHintTitlebarYOffset=\(String(format: "%.1f", ShortcutHintDebugSettings.clamped(titlebarShortcutHintYOffset)))
shortcutHintPaneTabXOffset=\(String(format: "%.1f", ShortcutHintDebugSettings.clamped(paneShortcutHintXOffset)))
shortcutHintPaneTabYOffset=\(String(format: "%.1f", ShortcutHintDebugSettings.clamped(paneShortcutHintYOffset)))
shortcutHintAlwaysShow=\(alwaysShowShortcutHints)
"""
let pasteboard = NSPasteboard.general
pasteboard.clearContents()
pasteboard.setString(payload, forType: .string)
}
private func applyPreset() {
guard let preset = SidebarPresetOption(rawValue: sidebarPreset) else { return }
sidebarMaterial = preset.material.rawValue
sidebarBlendMode = preset.blendMode.rawValue
sidebarState = preset.state.rawValue
sidebarTintHex = preset.tintHex
sidebarTintOpacity = preset.tintOpacity
sidebarCornerRadius = preset.cornerRadius
sidebarBlurOpacity = preset.blurOpacity
}
}
// MARK: - Menu Bar Extra Debug Window
private final class MenuBarExtraDebugWindowController: NSWindowController, NSWindowDelegate {
static let shared = MenuBarExtraDebugWindowController()
private init() {
let window = NSPanel(
contentRect: NSRect(x: 0, y: 0, width: 420, height: 430),
styleMask: [.titled, .closable, .utilityWindow],
backing: .buffered,
defer: false
)
window.title = "Menu Bar Extra Debug"
window.titleVisibility = .visible
window.titlebarAppearsTransparent = false
window.isMovableByWindowBackground = true
window.isReleasedWhenClosed = false
window.identifier = NSUserInterfaceItemIdentifier("cmux.menubarDebug")
window.center()
window.contentView = NSHostingView(rootView: MenuBarExtraDebugView())
AppDelegate.shared?.applyWindowDecorations(to: window)
super.init(window: window)
window.delegate = self
}
@available(*, unavailable)
required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
func show() {
window?.center()
window?.makeKeyAndOrderFront(nil)
}
}
private struct MenuBarExtraDebugView: View {
@AppStorage(MenuBarIconDebugSettings.previewEnabledKey) private var previewEnabled = false
@AppStorage(MenuBarIconDebugSettings.previewCountKey) private var previewCount = 1
@AppStorage(MenuBarIconDebugSettings.badgeRectXKey) private var badgeRectX = Double(MenuBarIconDebugSettings.defaultBadgeRect.origin.x)
@AppStorage(MenuBarIconDebugSettings.badgeRectYKey) private var badgeRectY = Double(MenuBarIconDebugSettings.defaultBadgeRect.origin.y)
@AppStorage(MenuBarIconDebugSettings.badgeRectWidthKey) private var badgeRectWidth = Double(MenuBarIconDebugSettings.defaultBadgeRect.width)
@AppStorage(MenuBarIconDebugSettings.badgeRectHeightKey) private var badgeRectHeight = Double(MenuBarIconDebugSettings.defaultBadgeRect.height)
@AppStorage(MenuBarIconDebugSettings.singleDigitFontSizeKey) private var singleDigitFontSize = Double(MenuBarIconDebugSettings.defaultSingleDigitFontSize)
@AppStorage(MenuBarIconDebugSettings.multiDigitFontSizeKey) private var multiDigitFontSize = Double(MenuBarIconDebugSettings.defaultMultiDigitFontSize)
@AppStorage(MenuBarIconDebugSettings.singleDigitYOffsetKey) private var singleDigitYOffset = Double(MenuBarIconDebugSettings.defaultSingleDigitYOffset)
@AppStorage(MenuBarIconDebugSettings.multiDigitYOffsetKey) private var multiDigitYOffset = Double(MenuBarIconDebugSettings.defaultMultiDigitYOffset)
@AppStorage(MenuBarIconDebugSettings.singleDigitXAdjustKey) private var singleDigitXAdjust = Double(MenuBarIconDebugSettings.defaultSingleDigitXAdjust)
@AppStorage(MenuBarIconDebugSettings.multiDigitXAdjustKey) private var multiDigitXAdjust = Double(MenuBarIconDebugSettings.defaultMultiDigitXAdjust)
@AppStorage(MenuBarIconDebugSettings.textRectWidthAdjustKey) private var textRectWidthAdjust = Double(MenuBarIconDebugSettings.defaultTextRectWidthAdjust)
var body: some View {
ScrollView {
VStack(alignment: .leading, spacing: 14) {
Text("Menu Bar Extra Icon")
.font(.headline)
GroupBox("Preview Count") {
VStack(alignment: .leading, spacing: 8) {
Toggle("Override unread count", isOn: $previewEnabled)
Stepper(value: $previewCount, in: 0...99) {
HStack {
Text("Unread Count")
Spacer()
Text("\(previewCount)")
.font(.caption)
.monospacedDigit()
}
}
.disabled(!previewEnabled)
}
.padding(.top, 2)
}
GroupBox("Badge Rect") {
VStack(alignment: .leading, spacing: 8) {
sliderRow("X", value: $badgeRectX, range: 0...20, format: "%.2f")
sliderRow("Y", value: $badgeRectY, range: 0...20, format: "%.2f")
sliderRow("Width", value: $badgeRectWidth, range: 4...14, format: "%.2f")
sliderRow("Height", value: $badgeRectHeight, range: 4...14, format: "%.2f")
}
.padding(.top, 2)
}
GroupBox("Badge Text") {
VStack(alignment: .leading, spacing: 8) {
sliderRow("1-digit size", value: $singleDigitFontSize, range: 6...14, format: "%.2f")
sliderRow("2-digit size", value: $multiDigitFontSize, range: 6...14, format: "%.2f")
sliderRow("1-digit X", value: $singleDigitXAdjust, range: -4...4, format: "%.2f")
sliderRow("2-digit X", value: $multiDigitXAdjust, range: -4...4, format: "%.2f")
sliderRow("1-digit Y", value: $singleDigitYOffset, range: -3...4, format: "%.2f")
sliderRow("2-digit Y", value: $multiDigitYOffset, range: -3...4, format: "%.2f")
sliderRow("Text width adjust", value: $textRectWidthAdjust, range: -3...5, format: "%.2f")
}
.padding(.top, 2)
}
HStack(spacing: 12) {
Button("Reset") {
previewEnabled = false
previewCount = 1
badgeRectX = Double(MenuBarIconDebugSettings.defaultBadgeRect.origin.x)
badgeRectY = Double(MenuBarIconDebugSettings.defaultBadgeRect.origin.y)
badgeRectWidth = Double(MenuBarIconDebugSettings.defaultBadgeRect.width)
badgeRectHeight = Double(MenuBarIconDebugSettings.defaultBadgeRect.height)
singleDigitFontSize = Double(MenuBarIconDebugSettings.defaultSingleDigitFontSize)
multiDigitFontSize = Double(MenuBarIconDebugSettings.defaultMultiDigitFontSize)
singleDigitYOffset = Double(MenuBarIconDebugSettings.defaultSingleDigitYOffset)
multiDigitYOffset = Double(MenuBarIconDebugSettings.defaultMultiDigitYOffset)
singleDigitXAdjust = Double(MenuBarIconDebugSettings.defaultSingleDigitXAdjust)
multiDigitXAdjust = Double(MenuBarIconDebugSettings.defaultMultiDigitXAdjust)
textRectWidthAdjust = Double(MenuBarIconDebugSettings.defaultTextRectWidthAdjust)
applyLiveUpdate()
}
Button("Copy Config") {
let payload = MenuBarIconDebugSettings.copyPayload()
let pasteboard = NSPasteboard.general
pasteboard.clearContents()
pasteboard.setString(payload, forType: .string)
}
}
Text("Tip: enable override count, then tune until the menu bar icon looks right.")
.font(.caption)
.foregroundColor(.secondary)
Spacer(minLength: 0)
}
.padding(16)
.frame(maxWidth: .infinity, alignment: .topLeading)
}
.onAppear { applyLiveUpdate() }
.onChange(of: previewEnabled) { _ in applyLiveUpdate() }
.onChange(of: previewCount) { _ in applyLiveUpdate() }
.onChange(of: badgeRectX) { _ in applyLiveUpdate() }
.onChange(of: badgeRectY) { _ in applyLiveUpdate() }
.onChange(of: badgeRectWidth) { _ in applyLiveUpdate() }
.onChange(of: badgeRectHeight) { _ in applyLiveUpdate() }
.onChange(of: singleDigitFontSize) { _ in applyLiveUpdate() }
.onChange(of: multiDigitFontSize) { _ in applyLiveUpdate() }
.onChange(of: singleDigitXAdjust) { _ in applyLiveUpdate() }
.onChange(of: multiDigitXAdjust) { _ in applyLiveUpdate() }
.onChange(of: singleDigitYOffset) { _ in applyLiveUpdate() }
.onChange(of: multiDigitYOffset) { _ in applyLiveUpdate() }
.onChange(of: textRectWidthAdjust) { _ in applyLiveUpdate() }
}
private func sliderRow(
_ label: String,
value: Binding<Double>,
range: ClosedRange<Double>,
format: String
) -> some View {
HStack(spacing: 8) {
Text(label)
Slider(value: value, in: range)
Text(String(format: format, value.wrappedValue))
.font(.caption)
.monospacedDigit()
.frame(width: 58, alignment: .trailing)
}
}
private func applyLiveUpdate() {
AppDelegate.shared?.refreshMenuBarExtraForDebug()
}
}
// MARK: - Background Debug Window
private final class BackgroundDebugWindowController: NSWindowController, NSWindowDelegate {
static let shared = BackgroundDebugWindowController()
private init() {
let window = NSPanel(
contentRect: NSRect(x: 0, y: 0, width: 360, height: 300),
styleMask: [.titled, .closable, .utilityWindow],
backing: .buffered,
defer: false
)
window.title = "Background Debug"
window.titleVisibility = .visible
window.titlebarAppearsTransparent = false
window.isMovableByWindowBackground = true
window.isReleasedWhenClosed = false
window.identifier = NSUserInterfaceItemIdentifier("cmux.backgroundDebug")
window.center()
window.contentView = NSHostingView(rootView: BackgroundDebugView())
AppDelegate.shared?.applyWindowDecorations(to: window)
super.init(window: window)
window.delegate = self
}
@available(*, unavailable)
required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
func show() {
window?.center()
window?.makeKeyAndOrderFront(nil)
}
}
private struct BackgroundDebugView: View {
@AppStorage("bgGlassTintHex") private var bgGlassTintHex = "#000000"
@AppStorage("bgGlassTintOpacity") private var bgGlassTintOpacity = 0.05
@AppStorage("bgGlassMaterial") private var bgGlassMaterial = "hudWindow"
@AppStorage("bgGlassEnabled") private var bgGlassEnabled = true
var body: some View {
ScrollView {
VStack(alignment: .leading, spacing: 14) {
Text("Window Background Glass")
.font(.headline)
GroupBox("Glass Effect") {
VStack(alignment: .leading, spacing: 8) {
Toggle("Enable Glass Effect", isOn: $bgGlassEnabled)
Picker("Material", selection: $bgGlassMaterial) {
Text("HUD Window").tag("hudWindow")
Text("Under Window").tag("underWindowBackground")
Text("Sidebar").tag("sidebar")
Text("Menu").tag("menu")
Text("Popover").tag("popover")
}
.disabled(!bgGlassEnabled)
}
.padding(.top, 2)
}
GroupBox("Tint") {
VStack(alignment: .leading, spacing: 8) {
ColorPicker("Tint Color", selection: tintColorBinding, supportsOpacity: false)
.disabled(!bgGlassEnabled)
HStack(spacing: 8) {
Text("Opacity")
Slider(value: $bgGlassTintOpacity, in: 0...0.8)
.disabled(!bgGlassEnabled)
Text(String(format: "%.0f%%", bgGlassTintOpacity * 100))
.font(.caption)
.frame(width: 44, alignment: .trailing)
}
}
.padding(.top, 2)
}
HStack(spacing: 12) {
Button("Reset") {
bgGlassTintHex = "#000000"
bgGlassTintOpacity = 0.05
bgGlassMaterial = "hudWindow"
bgGlassEnabled = true
updateWindowGlassTint()
}
Button("Copy Config") {
copyBgConfig()
}
}
Text("Tint changes apply live. Enable/disable requires reload.")
.font(.caption)
.foregroundColor(.secondary)
Spacer(minLength: 0)
}
.padding(16)
.frame(maxWidth: .infinity, alignment: .topLeading)
}
.onChange(of: bgGlassTintHex) { _ in updateWindowGlassTint() }
.onChange(of: bgGlassTintOpacity) { _ in updateWindowGlassTint() }
}
private func updateWindowGlassTint() {
let window: NSWindow? = {
if let key = NSApp.keyWindow,
let raw = key.identifier?.rawValue,
raw == "cmux.main" || raw.hasPrefix("cmux.main.") {
return key
}
return NSApp.windows.first(where: {
guard let raw = $0.identifier?.rawValue else { return false }
return raw == "cmux.main" || raw.hasPrefix("cmux.main.")
})
}()
guard let window else { return }
let tintColor = (NSColor(hex: bgGlassTintHex) ?? .black).withAlphaComponent(bgGlassTintOpacity)
WindowGlassEffect.updateTint(to: window, color: tintColor)
}
private var tintColorBinding: Binding<Color> {
Binding(
get: {
Color(nsColor: NSColor(hex: bgGlassTintHex) ?? .black)
},
set: { newColor in
let nsColor = NSColor(newColor)
bgGlassTintHex = nsColor.hexString()
}
)
}
private func copyBgConfig() {
let payload = """
bgGlassEnabled=\(bgGlassEnabled)
bgGlassMaterial=\(bgGlassMaterial)
bgGlassTintHex=\(bgGlassTintHex)
bgGlassTintOpacity=\(String(format: "%.2f", bgGlassTintOpacity))
"""
let pasteboard = NSPasteboard.general
pasteboard.clearContents()
pasteboard.setString(payload, forType: .string)
}
}
private struct AboutPropertyRow: View {
private let label: String
private let text: String
private let url: URL?
init(label: String, text: String, url: URL? = nil) {
self.label = label
self.text = text
self.url = url
}
@ViewBuilder private var textView: some View {
Text(text)
.frame(width: 140, alignment: .leading)
.padding(.leading, 2)
.tint(.secondary)
.opacity(0.8)
.monospaced()
}
var body: some View {
HStack(spacing: 4) {
Text(label)
.frame(width: 126, alignment: .trailing)
.padding(.trailing, 2)
if let url {
Link(destination: url) {
textView
}
} else {
textView
}
}
.font(.callout)
.textSelection(.enabled)
.frame(maxWidth: .infinity)
}
}
private struct AboutVisualEffectBackground: NSViewRepresentable {
let material: NSVisualEffectView.Material
let blendingMode: NSVisualEffectView.BlendingMode
let isEmphasized: Bool
init(
material: NSVisualEffectView.Material,
blendingMode: NSVisualEffectView.BlendingMode = .behindWindow,
isEmphasized: Bool = false
) {
self.material = material
self.blendingMode = blendingMode
self.isEmphasized = isEmphasized
}
func updateNSView(_ nsView: NSVisualEffectView, context: Context) {
nsView.material = material
nsView.blendingMode = blendingMode
nsView.isEmphasized = isEmphasized
}
func makeNSView(context: Context) -> NSVisualEffectView {
let visualEffect = NSVisualEffectView()
visualEffect.autoresizingMask = [.width, .height]
return visualEffect
}
}
enum AppearanceMode: String, CaseIterable, Identifiable {
case system
case light
case dark
case auto
var id: String { rawValue }
static var visibleCases: [AppearanceMode] {
[.system, .light, .dark]
}
var displayName: String {
switch self {
case .system:
return "System"
case .light:
return "Light"
case .dark:
return "Dark"
case .auto:
return "Auto"
}
}
}
struct SettingsView: View {
@AppStorage("appearanceMode") private var appearanceMode = AppearanceMode.dark.rawValue
@AppStorage(SocketControlSettings.appStorageKey) private var socketControlMode = SocketControlSettings.defaultMode.rawValue
@AppStorage(BrowserSearchSettings.searchEngineKey) private var browserSearchEngine = BrowserSearchSettings.defaultSearchEngine.rawValue
@AppStorage(BrowserSearchSettings.searchSuggestionsEnabledKey) private var browserSearchSuggestionsEnabled = BrowserSearchSettings.defaultSearchSuggestionsEnabled
@AppStorage(NotificationBadgeSettings.dockBadgeEnabledKey) private var notificationDockBadgeEnabled = NotificationBadgeSettings.defaultDockBadgeEnabled
@AppStorage(WorkspacePlacementSettings.placementKey) private var newWorkspacePlacement = WorkspacePlacementSettings.defaultPlacement.rawValue
@State private var shortcutResetToken = UUID()
var body: some View {
ScrollView {
VStack(alignment: .leading, spacing: 16) {
Text("Theme")
.font(.headline)
Picker("", selection: $appearanceMode) {
ForEach(AppearanceMode.visibleCases) { mode in
Text(mode.displayName).tag(mode.rawValue)
}
}
.pickerStyle(.radioGroup)
.labelsHidden()
Divider()
Text("Keyboard Shortcuts")
.font(.headline)
ForEach(KeyboardShortcutSettings.Action.allCases) { action in
ShortcutSettingRow(action: action)
}
.id(shortcutResetToken)
Text("Click to record a new shortcut.")
.font(.caption)
.foregroundColor(.secondary)
Divider()
Text("Workspaces")
.font(.headline)
Picker("", selection: $newWorkspacePlacement) {
ForEach(NewWorkspacePlacement.allCases) { placement in
VStack(alignment: .leading, spacing: 2) {
Text(placement.displayName)
Text(placement.description)
.font(.caption)
.foregroundColor(.secondary)
}
.tag(placement.rawValue)
}
}
.pickerStyle(.radioGroup)
.labelsHidden()
Text("Controls where new workspaces are inserted in the sidebar list.")
.font(.caption)
.foregroundColor(.secondary)
Divider()
Text("Notifications")
.font(.headline)
Toggle("Show unread count on app icon (Dock and Cmd+Tab)", isOn: $notificationDockBadgeEnabled)
Text("Displays unread notification count as a red badge on the app icon.")
.font(.caption)
.foregroundColor(.secondary)
Divider()
Text("Automation")
.font(.headline)
Picker("", selection: $socketControlMode) {
ForEach(SocketControlMode.allCases) { mode in
VStack(alignment: .leading, spacing: 2) {
Text(mode.displayName)
Text(mode.description)
.font(.caption)
.foregroundColor(.secondary)
}
.tag(mode.rawValue)
}
}
.pickerStyle(.radioGroup)
.labelsHidden()
.accessibilityIdentifier("AutomationSocketModePicker")
Text("Expose a local Unix socket for programmatic control. This can be a security risk on shared machines.")
.font(.caption)
.foregroundColor(.secondary)
Text("Overrides: CMUX_SOCKET_ENABLE, CMUX_SOCKET_MODE, and CMUX_SOCKET_PATH.")
.font(.caption)
.foregroundColor(.secondary)
Divider()
Text("Browser")
.font(.headline)
Picker("Default Search Engine", selection: $browserSearchEngine) {
ForEach(BrowserSearchEngine.allCases) { engine in
Text(engine.displayName).tag(engine.rawValue)
}
}
.pickerStyle(.segmented)
Toggle("Show Search Suggestions", isOn: $browserSearchSuggestionsEnabled)
Text("Used by the browser address bar when input is not a URL.")
.font(.caption)
.foregroundColor(.secondary)
Divider()
HStack {
Spacer()
Button("Reset All Settings") {
resetAllSettings()
}
Spacer()
}
.padding(.top, 8)
}
.padding(20)
.padding(.top, 4)
}
.frame(minWidth: 420, minHeight: 360)
}
private func resetAllSettings() {
appearanceMode = AppearanceMode.dark.rawValue
socketControlMode = SocketControlSettings.defaultMode.rawValue
browserSearchEngine = BrowserSearchSettings.defaultSearchEngine.rawValue
browserSearchSuggestionsEnabled = BrowserSearchSettings.defaultSearchSuggestionsEnabled
notificationDockBadgeEnabled = NotificationBadgeSettings.defaultDockBadgeEnabled
newWorkspacePlacement = WorkspacePlacementSettings.defaultPlacement.rawValue
KeyboardShortcutSettings.resetAll()
shortcutResetToken = UUID()
}
}
private struct ShortcutSettingRow: View {
let action: KeyboardShortcutSettings.Action
@State private var shortcut: StoredShortcut
init(action: KeyboardShortcutSettings.Action) {
self.action = action
_shortcut = State(initialValue: KeyboardShortcutSettings.shortcut(for: action))
}
var body: some View {
KeyboardShortcutRecorder(label: action.label, shortcut: $shortcut)
.onChange(of: shortcut) { newValue in
KeyboardShortcutSettings.setShortcut(newValue, for: action)
}
.onReceive(NotificationCenter.default.publisher(for: UserDefaults.didChangeNotification)) { _ in
let latest = KeyboardShortcutSettings.shortcut(for: action)
if latest != shortcut {
shortcut = latest
}
}
}
}
private struct SettingsRootView: View {
var body: some View {
SettingsView()
.background(WindowAccessor { window in
configureSettingsWindow(window)
})
}
private func configureSettingsWindow(_ window: NSWindow) {
window.identifier = NSUserInterfaceItemIdentifier("cmux.settings")
window.title = ""
window.titleVisibility = .hidden
window.titlebarAppearsTransparent = false
window.styleMask.remove(.fullSizeContentView)
window.styleMask.insert(.resizable)
window.contentMinSize = NSSize(width: 420, height: 360)
if window.toolbar == nil {
let toolbar = NSToolbar(identifier: NSToolbar.Identifier("cmux.settings.toolbar"))
toolbar.displayMode = .iconOnly
toolbar.sizeMode = .regular
toolbar.allowsUserCustomization = false
toolbar.autosavesConfiguration = false
toolbar.showsBaselineSeparator = false
window.toolbar = toolbar
window.toolbarStyle = .unified
}
let accessories = window.titlebarAccessoryViewControllers
for index in accessories.indices.reversed() {
guard let identifier = accessories[index].view.identifier?.rawValue else { continue }
guard identifier.hasPrefix("cmux.") else { continue }
window.removeTitlebarAccessoryViewController(at: index)
}
AppDelegate.shared?.applyWindowDecorations(to: window)
}
}