cmux/Sources/cmuxApp.swift
Lawrence Chen 1fa0f2bcb6
Add Open Folder command (Cmd+O) (#656)
* Add "Open Folder…" command to open a workspace at a chosen directory

Adds a native folder picker (NSOpenPanel) accessible from:
- Command Palette (⌘⇧P → "Open Folder…")
- File menu with ⌘O shortcut

Selecting a folder opens a new workspace at that path.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* Rename openRepository → openFolder, add customizable shortcut

- Rename command ID from palette.openRepository to palette.openFolder
- Register openFolder in KeyboardShortcutSettings (default: Cmd+O)
- Wire menu bar shortcut through settings instead of hardcoding
- Add commandPaletteShortcutAction mapping for shortcut hint display

* Dismiss command palette before showing Open Folder panel

The NSOpenPanel.runModal() call blocked the main thread, keeping the
command palette visible behind the file picker. Wrapping in
DispatchQueue.main.async lets the palette dismiss first.

* Trigger GitHub PR refresh

---------

Co-authored-by: michalstrnadel <michal.strnadel@gmail.com>
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-27 16:28:18 -08:00

3790 lines
161 KiB
Swift

import AppKit
import SwiftUI
import Darwin
import Bonsplit
@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(AppearanceSettings.appearanceModeKey) private var appearanceMode = AppearanceSettings.defaultMode.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.toggleSidebar.defaultsKey) private var toggleSidebarShortcutData = Data()
@AppStorage(KeyboardShortcutSettings.Action.newTab.defaultsKey) private var newWorkspaceShortcutData = Data()
@AppStorage(KeyboardShortcutSettings.Action.newWindow.defaultsKey) private var newWindowShortcutData = Data()
@AppStorage(KeyboardShortcutSettings.Action.showNotifications.defaultsKey) private var showNotificationsShortcutData = Data()
@AppStorage(KeyboardShortcutSettings.Action.jumpToUnread.defaultsKey) private var jumpToUnreadShortcutData = Data()
@AppStorage(KeyboardShortcutSettings.Action.nextSurface.defaultsKey) private var nextSurfaceShortcutData = Data()
@AppStorage(KeyboardShortcutSettings.Action.prevSurface.defaultsKey) private var prevSurfaceShortcutData = Data()
@AppStorage(KeyboardShortcutSettings.Action.nextSidebarTab.defaultsKey) private var nextWorkspaceShortcutData = Data()
@AppStorage(KeyboardShortcutSettings.Action.prevSidebarTab.defaultsKey) private var prevWorkspaceShortcutData = Data()
@AppStorage(KeyboardShortcutSettings.Action.splitRight.defaultsKey) private var splitRightShortcutData = Data()
@AppStorage(KeyboardShortcutSettings.Action.splitDown.defaultsKey) private var splitDownShortcutData = Data()
@AppStorage(KeyboardShortcutSettings.Action.toggleBrowserDeveloperTools.defaultsKey)
private var toggleBrowserDeveloperToolsShortcutData = Data()
@AppStorage(KeyboardShortcutSettings.Action.showBrowserJavaScriptConsole.defaultsKey)
private var showBrowserJavaScriptConsoleShortcutData = Data()
@AppStorage(KeyboardShortcutSettings.Action.splitBrowserRight.defaultsKey) private var splitBrowserRightShortcutData = Data()
@AppStorage(KeyboardShortcutSettings.Action.splitBrowserDown.defaultsKey) private var splitBrowserDownShortcutData = Data()
@AppStorage(KeyboardShortcutSettings.Action.renameWorkspace.defaultsKey) private var renameWorkspaceShortcutData = Data()
@AppStorage(KeyboardShortcutSettings.Action.openFolder.defaultsKey) private var openFolderShortcutData = Data()
@AppStorage(KeyboardShortcutSettings.Action.closeWorkspace.defaultsKey) private var closeWorkspaceShortcutData = Data()
@NSApplicationDelegateAdaptor(AppDelegate.self) private var appDelegate
init() {
if SocketControlSettings.shouldBlockUntaggedDebugLaunch() {
Self.terminateForMissingLaunchTag()
}
Self.configureGhosttyEnvironment()
let startupAppearance = AppearanceSettings.resolvedMode()
Self.applyAppearance(startupAppearance)
_tabManager = StateObject(wrappedValue: TabManager())
// Migrate legacy and old-format socket mode values to the new enum.
let defaults = UserDefaults.standard
if let stored = defaults.string(forKey: SocketControlSettings.appStorageKey) {
let migrated = SocketControlSettings.migrateMode(stored)
if migrated.rawValue != stored {
defaults.set(migrated.rawValue, forKey: SocketControlSettings.appStorageKey)
}
} else if let legacy = defaults.object(forKey: SocketControlSettings.legacyEnabledKey) as? Bool {
defaults.set(legacy ? SocketControlMode.cmuxOnly.rawValue : SocketControlMode.off.rawValue,
forKey: SocketControlSettings.appStorageKey)
}
SocketControlPasswordStore.migrateLegacyKeychainPasswordIfNeeded(defaults: defaults)
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 static func terminateForMissingLaunchTag() -> Never {
let message = "error: refusing to launch untagged cmux DEV; start with ./scripts/reload.sh --tag <name> (or set CMUX_TAG for test harnesses)"
fputs("\(message)\n", stderr)
fflush(stderr)
NSLog("%@", message)
Darwin.exit(64)
}
private static 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 static 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 {
appDelegate.openPreferencesWindow(debugSource: "uiTestShowSettings")
}
}
}
.onChange(of: appearanceMode) { _ in
applyAppearance()
}
.onChange(of: socketControlMode) { _ in
updateSocketController()
}
}
.windowStyle(.hiddenTitleBar)
.commands {
CommandGroup(replacing: .appSettings) {
Button("Settings…") {
appDelegate.openPreferencesWindow(debugSource: "menu.cmdComma")
}
.keyboardShortcut(",", modifiers: .command)
}
CommandGroup(replacing: .appInfo) {
Button("About cmux") {
showAboutPanel()
}
Button("Ghostty Settings…") {
GhosttyApp.shared.openConfigurationInTextEdit()
}
Button("Reload Configuration") {
GhosttyApp.shared.reloadConfiguration(source: "menu.reload_configuration")
}
.keyboardShortcut(",", modifiers: [.command, .shift])
Divider()
Button("Check for Updates…") {
appDelegate.checkForUpdates(nil)
}
InstallUpdateMenuItem(model: appDelegate.updateViewModel)
}
#if DEBUG
CommandMenu("Update Pill") {
Button("Show Update Pill") {
appDelegate.showUpdatePill(nil)
}
Button("Show Long Nightly Pill") {
appDelegate.showUpdatePillLongNightly(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()
}
splitCommandButton(title: "Show Notifications", shortcut: showNotificationsMenuShortcut) {
showNotificationsPopover()
}
splitCommandButton(title: "Jump to Latest Unread", shortcut: jumpToUnreadMenuShortcut) {
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)
}
Button("Open Workspaces for All Workspace Colors") {
appDelegate.openDebugColorComparisonWorkspaces(nil)
}
Divider()
Menu("Debug Windows") {
Button("Debug Window Controls…") {
DebugWindowControlsWindowController.shared.show()
}
Button("Settings/About Titlebar Debug…") {
SettingsAboutTitlebarDebugWindowController.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) {
splitCommandButton(title: "New Window", shortcut: newWindowMenuShortcut) {
appDelegate.openNewMainWindow(nil)
}
splitCommandButton(title: "New Workspace", shortcut: newWorkspaceMenuShortcut) {
if let appDelegate = AppDelegate.shared {
if appDelegate.addWorkspaceInPreferredMainWindow(debugSource: "menu.newWorkspace") == nil {
#if DEBUG
FocusLogStore.shared.append(
"cmdn.route phase=fallback_new_window src=menu.newWorkspace reason=workspace_creation_returned_nil"
)
#endif
appDelegate.openNewMainWindow(nil)
}
} else {
activeTabManager.addTab()
}
}
splitCommandButton(title: "Open Folder…", shortcut: openFolderMenuShortcut) {
let panel = NSOpenPanel()
panel.canChooseFiles = false
panel.canChooseDirectories = true
panel.allowsMultipleSelection = false
panel.title = "Open Folder"
panel.prompt = "Open"
if panel.runModal() == .OK, let url = panel.url {
if let appDelegate = AppDelegate.shared {
if appDelegate.addWorkspaceInPreferredMainWindow(
workingDirectory: url.path,
debugSource: "menu.openFolder"
) == nil {
appDelegate.openNewMainWindow(nil)
}
} else {
activeTabManager.addWorkspace(workingDirectory: url.path)
}
}
}
}
// Close tab/workspace
CommandGroup(after: .newItem) {
Button("Go to Workspace or Tab…") {
let targetWindow = NSApp.keyWindow ?? NSApp.mainWindow
NotificationCenter.default.post(name: .commandPaletteSwitcherRequested, object: targetWindow)
}
.keyboardShortcut("p", modifiers: [.command])
Button("Command Palette…") {
let targetWindow = NSApp.keyWindow ?? NSApp.mainWindow
NotificationCenter.default.post(name: .commandPaletteRequested, object: targetWindow)
}
.keyboardShortcut("p", modifiers: [.command, .shift])
Divider()
// 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)
Button("Close Other Tabs in Pane") {
closeOtherTabsInFocusedPane()
}
.keyboardShortcut("t", modifiers: [.command, .option])
.disabled(!activeTabManager.canCloseOtherTabsInFocusedPane())
// Cmd+Shift+W closes the current workspace (with confirmation if needed). If this
// is the last workspace, it closes the window.
splitCommandButton(title: "Close Workspace", shortcut: closeWorkspaceMenuShortcut) {
closeTabOrWindow()
}
Button("Reopen Closed Browser Panel") {
_ = activeTabManager.reopenMostRecentlyClosedBrowserPanel()
}
.keyboardShortcut("t", modifiers: [.command, .shift])
}
// Find
CommandGroup(after: .textEditing) {
Menu("Find") {
Button("Find…") {
activeTabManager.startSearch()
}
.keyboardShortcut("f", modifiers: .command)
Button("Find Next") {
activeTabManager.findNext()
}
.keyboardShortcut("g", modifiers: .command)
Button("Find Previous") {
activeTabManager.findPrevious()
}
.keyboardShortcut("g", modifiers: [.command, .shift])
Divider()
Button("Hide Find Bar") {
activeTabManager.hideFind()
}
.keyboardShortcut("f", modifiers: [.command, .shift])
.disabled(!(activeTabManager.isFindVisible))
Divider()
Button("Use Selection for Find") {
activeTabManager.searchSelection()
}
.keyboardShortcut("e", modifiers: .command)
.disabled(!(activeTabManager.canUseSelectionForFind))
}
}
// Tab navigation
CommandGroup(after: .toolbar) {
splitCommandButton(title: "Toggle Sidebar", shortcut: toggleSidebarMenuShortcut) {
if AppDelegate.shared?.toggleSidebarInActiveMainWindow() != true {
sidebarState.toggle()
}
}
Divider()
splitCommandButton(title: "Next Surface", shortcut: nextSurfaceMenuShortcut) {
activeTabManager.selectNextSurface()
}
splitCommandButton(title: "Previous Surface", shortcut: prevSurfaceMenuShortcut) {
activeTabManager.selectPreviousSurface()
}
Button("Back") {
activeTabManager.focusedBrowserPanel?.goBack()
}
.keyboardShortcut("[", modifiers: .command)
Button("Forward") {
activeTabManager.focusedBrowserPanel?.goForward()
}
.keyboardShortcut("]", modifiers: .command)
Button("Reload Page") {
activeTabManager.focusedBrowserPanel?.reload()
}
.keyboardShortcut("r", modifiers: .command)
splitCommandButton(title: "Toggle Developer Tools", shortcut: toggleBrowserDeveloperToolsMenuShortcut) {
let manager = activeTabManager
if !manager.toggleDeveloperToolsFocusedBrowser() {
NSSound.beep()
}
}
splitCommandButton(title: "Show JavaScript Console", shortcut: showBrowserJavaScriptConsoleMenuShortcut) {
let manager = activeTabManager
if !manager.showJavaScriptConsoleFocusedBrowser() {
NSSound.beep()
}
}
Button("Zoom In") {
_ = activeTabManager.zoomInFocusedBrowser()
}
.keyboardShortcut("=", modifiers: .command)
Button("Zoom Out") {
_ = activeTabManager.zoomOutFocusedBrowser()
}
.keyboardShortcut("-", modifiers: .command)
Button("Actual Size") {
_ = activeTabManager.resetZoomFocusedBrowser()
}
.keyboardShortcut("0", modifiers: .command)
Button("Clear Browser History") {
BrowserHistoryStore.shared.clearHistory()
}
splitCommandButton(title: "Next Workspace", shortcut: nextWorkspaceMenuShortcut) {
activeTabManager.selectNextTab()
}
splitCommandButton(title: "Previous Workspace", shortcut: prevWorkspaceMenuShortcut) {
activeTabManager.selectPreviousTab()
}
splitCommandButton(title: "Rename Workspace…", shortcut: renameWorkspaceMenuShortcut) {
_ = AppDelegate.shared?.requestRenameWorkspaceViaCommandPalette()
}
Divider()
splitCommandButton(title: "Split Right", shortcut: splitRightMenuShortcut) {
performSplitFromMenu(direction: .right)
}
splitCommandButton(title: "Split Down", shortcut: splitDownMenuShortcut) {
performSplitFromMenu(direction: .down)
}
splitCommandButton(title: "Split Browser Right", shortcut: splitBrowserRightMenuShortcut) {
performBrowserSplitFromMenu(direction: .right)
}
splitCommandButton(title: "Split Browser Down", shortcut: splitBrowserDownMenuShortcut) {
performBrowserSplitFromMenu(direction: .down)
}
Divider()
// Cmd+1 through Cmd+9 for workspace selection (9 = last workspace)
ForEach(1...9, id: \.self) { number in
Button("Workspace \(number)") {
let manager = activeTabManager
if let targetIndex = WorkspaceShortcutMapper.workspaceIndex(forCommandDigit: number, workspaceCount: manager.tabs.count) {
manager.selectTab(at: targetIndex)
}
}
.keyboardShortcut(KeyEquivalent(Character("\(number)")), modifiers: .command)
}
Divider()
splitCommandButton(title: "Jump to Latest Unread", shortcut: jumpToUnreadMenuShortcut) {
AppDelegate.shared?.jumpToLatestUnread()
}
splitCommandButton(title: "Show Notifications", shortcut: showNotificationsMenuShortcut) {
showNotificationsPopover()
}
}
}
}
private func showAboutPanel() {
AboutWindowController.shared.show()
NSApp.activate(ignoringOtherApps: true)
}
private func applyAppearance() {
let mode = AppearanceSettings.mode(for: appearanceMode)
if appearanceMode != mode.rawValue {
appearanceMode = mode.rawValue
}
Self.applyAppearance(mode)
}
private static func applyAppearance(_ mode: AppearanceMode) {
switch mode {
case .system:
NSApplication.shared.appearance = nil
case .light:
NSApplication.shared.appearance = NSAppearance(named: .aqua)
case .dark:
NSApplication.shared.appearance = NSAppearance(named: .darkAqua)
case .auto:
NSApplication.shared.appearance = nil
}
}
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 {
SocketControlSettings.migrateMode(socketControlMode)
}
private var splitRightMenuShortcut: StoredShortcut {
decodeShortcut(from: splitRightShortcutData, fallback: KeyboardShortcutSettings.Action.splitRight.defaultShortcut)
}
private var toggleSidebarMenuShortcut: StoredShortcut {
decodeShortcut(from: toggleSidebarShortcutData, fallback: KeyboardShortcutSettings.Action.toggleSidebar.defaultShortcut)
}
private var newWorkspaceMenuShortcut: StoredShortcut {
decodeShortcut(from: newWorkspaceShortcutData, fallback: KeyboardShortcutSettings.Action.newTab.defaultShortcut)
}
private var newWindowMenuShortcut: StoredShortcut {
decodeShortcut(from: newWindowShortcutData, fallback: KeyboardShortcutSettings.Action.newWindow.defaultShortcut)
}
private var openFolderMenuShortcut: StoredShortcut {
decodeShortcut(from: openFolderShortcutData, fallback: KeyboardShortcutSettings.Action.openFolder.defaultShortcut)
}
private var showNotificationsMenuShortcut: StoredShortcut {
decodeShortcut(
from: showNotificationsShortcutData,
fallback: KeyboardShortcutSettings.Action.showNotifications.defaultShortcut
)
}
private var jumpToUnreadMenuShortcut: StoredShortcut {
decodeShortcut(
from: jumpToUnreadShortcutData,
fallback: KeyboardShortcutSettings.Action.jumpToUnread.defaultShortcut
)
}
private var nextSurfaceMenuShortcut: StoredShortcut {
decodeShortcut(from: nextSurfaceShortcutData, fallback: KeyboardShortcutSettings.Action.nextSurface.defaultShortcut)
}
private var prevSurfaceMenuShortcut: StoredShortcut {
decodeShortcut(from: prevSurfaceShortcutData, fallback: KeyboardShortcutSettings.Action.prevSurface.defaultShortcut)
}
private var nextWorkspaceMenuShortcut: StoredShortcut {
decodeShortcut(
from: nextWorkspaceShortcutData,
fallback: KeyboardShortcutSettings.Action.nextSidebarTab.defaultShortcut
)
}
private var prevWorkspaceMenuShortcut: StoredShortcut {
decodeShortcut(
from: prevWorkspaceShortcutData,
fallback: KeyboardShortcutSettings.Action.prevSidebarTab.defaultShortcut
)
}
private var splitDownMenuShortcut: StoredShortcut {
decodeShortcut(from: splitDownShortcutData, fallback: KeyboardShortcutSettings.Action.splitDown.defaultShortcut)
}
private var toggleBrowserDeveloperToolsMenuShortcut: StoredShortcut {
decodeShortcut(
from: toggleBrowserDeveloperToolsShortcutData,
fallback: KeyboardShortcutSettings.Action.toggleBrowserDeveloperTools.defaultShortcut
)
}
private var showBrowserJavaScriptConsoleMenuShortcut: StoredShortcut {
decodeShortcut(
from: showBrowserJavaScriptConsoleShortcutData,
fallback: KeyboardShortcutSettings.Action.showBrowserJavaScriptConsole.defaultShortcut
)
}
private var splitBrowserRightMenuShortcut: StoredShortcut {
decodeShortcut(
from: splitBrowserRightShortcutData,
fallback: KeyboardShortcutSettings.Action.splitBrowserRight.defaultShortcut
)
}
private var splitBrowserDownMenuShortcut: StoredShortcut {
decodeShortcut(
from: splitBrowserDownShortcutData,
fallback: KeyboardShortcutSettings.Action.splitBrowserDown.defaultShortcut
)
}
private var renameWorkspaceMenuShortcut: StoredShortcut {
decodeShortcut(
from: renameWorkspaceShortcutData,
fallback: KeyboardShortcutSettings.Action.renameWorkspace.defaultShortcut
)
}
private var closeWorkspaceMenuShortcut: StoredShortcut {
decodeShortcut(
from: closeWorkspaceShortcutData,
fallback: KeyboardShortcutSettings.Action.closeWorkspace.defaultShortcut
)
}
private var notificationMenuSnapshot: NotificationMenuSnapshot {
NotificationMenuSnapshotBuilder.make(notifications: notificationStore.notifications)
}
private var activeTabManager: TabManager {
AppDelegate.shared?.synchronizeActiveMainWindowContext(
preferredWindow: NSApp.keyWindow ?? NSApp.mainWindow
) ?? tabManager
}
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)
}
private func performBrowserSplitFromMenu(direction: SplitDirection) {
if AppDelegate.shared?.performBrowserSplitShortcut(direction: direction) == true {
return
}
_ = tabManager.createBrowserSplit(direction: direction)
}
@ViewBuilder
private func splitCommandButton(title: String, shortcut: StoredShortcut, action: @escaping () -> Void) -> some View {
if let key = shortcut.keyEquivalent {
Button(title, action: action)
.keyboardShortcut(key, modifiers: shortcut.eventModifiers)
} else {
Button(title, action: action)
}
}
private func closePanelOrWindow() {
if let window = NSApp.keyWindow,
window.identifier?.rawValue == "cmux.settings" {
window.performClose(nil)
return
}
activeTabManager.closeCurrentPanelWithConfirmation()
}
private func closeOtherTabsInFocusedPane() {
activeTabManager.closeOtherTabsInFocusedPaneWithConfirmation()
}
private func closeTabOrWindow() {
activeTabManager.closeCurrentTabWithConfirmation()
}
private func showNotificationsPopover() {
AppDelegate.shared?.toggleNotificationsPopover(animated: false)
}
private func openAllDebugWindows() {
SettingsAboutTitlebarDebugWindowController.shared.show()
SidebarDebugWindowController.shared.show()
BackgroundDebugWindowController.shared.show()
MenuBarExtraDebugWindowController.shared.show()
}
}
private enum SettingsAboutWindowKind: String, CaseIterable, Identifiable {
case settings
case about
var id: String { rawValue }
var displayTitle: String {
switch self {
case .settings:
return "Settings Window"
case .about:
return "About Window"
}
}
var windowIdentifier: String {
switch self {
case .settings:
return "cmux.settings"
case .about:
return "cmux.about"
}
}
var fallbackTitle: String {
switch self {
case .settings:
return "Settings"
case .about:
return "About cmux"
}
}
var minimumSize: NSSize {
switch self {
case .settings:
return NSSize(width: 420, height: 360)
case .about:
return NSSize(width: 360, height: 520)
}
}
}
private enum TitlebarVisibilityOption: String, CaseIterable, Identifiable {
case hidden
case visible
var id: String { rawValue }
var displayTitle: String {
switch self {
case .hidden:
return "Hidden"
case .visible:
return "Visible"
}
}
var windowValue: NSWindow.TitleVisibility {
switch self {
case .hidden:
return .hidden
case .visible:
return .visible
}
}
}
private enum TitlebarToolbarStyleOption: String, CaseIterable, Identifiable {
case automatic
case expanded
case preference
case unified
case unifiedCompact
var id: String { rawValue }
var displayTitle: String {
switch self {
case .automatic:
return "Automatic"
case .expanded:
return "Expanded"
case .preference:
return "Preference"
case .unified:
return "Unified"
case .unifiedCompact:
return "Unified Compact"
}
}
var windowValue: NSWindow.ToolbarStyle {
switch self {
case .automatic:
return .automatic
case .expanded:
return .expanded
case .preference:
return .preference
case .unified:
return .unified
case .unifiedCompact:
return .unifiedCompact
}
}
}
private struct SettingsAboutTitlebarDebugOptions: Equatable {
var overridesEnabled: Bool
var windowTitle: String
var titleVisibility: TitlebarVisibilityOption
var titlebarAppearsTransparent: Bool
var movableByWindowBackground: Bool
var titled: Bool
var closable: Bool
var miniaturizable: Bool
var resizable: Bool
var fullSizeContentView: Bool
var showToolbar: Bool
var toolbarStyle: TitlebarToolbarStyleOption
static func defaults(for kind: SettingsAboutWindowKind) -> SettingsAboutTitlebarDebugOptions {
switch kind {
case .settings:
return SettingsAboutTitlebarDebugOptions(
overridesEnabled: false,
windowTitle: "Settings",
titleVisibility: .hidden,
titlebarAppearsTransparent: true,
movableByWindowBackground: true,
titled: true,
closable: true,
miniaturizable: true,
resizable: true,
fullSizeContentView: true,
showToolbar: false,
toolbarStyle: .unifiedCompact
)
case .about:
return SettingsAboutTitlebarDebugOptions(
overridesEnabled: false,
windowTitle: "About cmux",
titleVisibility: .hidden,
titlebarAppearsTransparent: true,
movableByWindowBackground: false,
titled: true,
closable: true,
miniaturizable: true,
resizable: false,
fullSizeContentView: false,
showToolbar: false,
toolbarStyle: .automatic
)
}
}
}
@MainActor
private final class SettingsAboutTitlebarDebugStore: ObservableObject {
static let shared = SettingsAboutTitlebarDebugStore()
@Published var settingsOptions = SettingsAboutTitlebarDebugOptions.defaults(for: .settings) {
didSet { applyToOpenWindows(for: .settings) }
}
@Published var aboutOptions = SettingsAboutTitlebarDebugOptions.defaults(for: .about) {
didSet { applyToOpenWindows(for: .about) }
}
private init() {}
func options(for kind: SettingsAboutWindowKind) -> SettingsAboutTitlebarDebugOptions {
switch kind {
case .settings:
return settingsOptions
case .about:
return aboutOptions
}
}
func update(_ newValue: SettingsAboutTitlebarDebugOptions, for kind: SettingsAboutWindowKind) {
switch kind {
case .settings:
settingsOptions = newValue
case .about:
aboutOptions = newValue
}
}
func reset(_ kind: SettingsAboutWindowKind) {
update(SettingsAboutTitlebarDebugOptions.defaults(for: kind), for: kind)
}
func applyToOpenWindows(for kind: SettingsAboutWindowKind) {
for window in NSApp.windows where window.identifier?.rawValue == kind.windowIdentifier {
apply(options(for: kind), to: window, for: kind)
}
}
func applyToOpenWindows() {
applyToOpenWindows(for: .settings)
applyToOpenWindows(for: .about)
}
func applyCurrentOptions(to window: NSWindow, for kind: SettingsAboutWindowKind) {
apply(options(for: kind), to: window, for: kind)
}
func copyConfigToPasteboard() {
let settings = options(for: .settings)
let about = options(for: .about)
let payload = """
# Settings/About Titlebar Debug
settings.overridesEnabled=\(settings.overridesEnabled)
settings.title=\(settings.windowTitle)
settings.titleVisibility=\(settings.titleVisibility.rawValue)
settings.titlebarAppearsTransparent=\(settings.titlebarAppearsTransparent)
settings.movableByWindowBackground=\(settings.movableByWindowBackground)
settings.titled=\(settings.titled)
settings.closable=\(settings.closable)
settings.miniaturizable=\(settings.miniaturizable)
settings.resizable=\(settings.resizable)
settings.fullSizeContentView=\(settings.fullSizeContentView)
settings.showToolbar=\(settings.showToolbar)
settings.toolbarStyle=\(settings.toolbarStyle.rawValue)
about.overridesEnabled=\(about.overridesEnabled)
about.title=\(about.windowTitle)
about.titleVisibility=\(about.titleVisibility.rawValue)
about.titlebarAppearsTransparent=\(about.titlebarAppearsTransparent)
about.movableByWindowBackground=\(about.movableByWindowBackground)
about.titled=\(about.titled)
about.closable=\(about.closable)
about.miniaturizable=\(about.miniaturizable)
about.resizable=\(about.resizable)
about.fullSizeContentView=\(about.fullSizeContentView)
about.showToolbar=\(about.showToolbar)
about.toolbarStyle=\(about.toolbarStyle.rawValue)
"""
let pasteboard = NSPasteboard.general
pasteboard.clearContents()
pasteboard.setString(payload, forType: .string)
}
private func apply(_ options: SettingsAboutTitlebarDebugOptions, to window: NSWindow, for kind: SettingsAboutWindowKind) {
let effective = options.overridesEnabled ? options : SettingsAboutTitlebarDebugOptions.defaults(for: kind)
let resolvedTitle = effective.windowTitle.trimmingCharacters(in: .whitespacesAndNewlines)
window.title = resolvedTitle.isEmpty ? kind.fallbackTitle : resolvedTitle
window.titleVisibility = effective.titleVisibility.windowValue
window.titlebarAppearsTransparent = effective.titlebarAppearsTransparent
window.isMovableByWindowBackground = effective.movableByWindowBackground
window.toolbarStyle = effective.toolbarStyle.windowValue
if effective.showToolbar {
ensureToolbar(on: window, kind: kind)
} else if window.toolbar != nil {
window.toolbar = nil
}
var styleMask = window.styleMask
setStyleMaskBit(&styleMask, .titled, enabled: effective.titled)
setStyleMaskBit(&styleMask, .closable, enabled: effective.closable)
setStyleMaskBit(&styleMask, .miniaturizable, enabled: effective.miniaturizable)
setStyleMaskBit(&styleMask, .resizable, enabled: effective.resizable)
setStyleMaskBit(&styleMask, .fullSizeContentView, enabled: effective.fullSizeContentView)
window.styleMask = styleMask
let maxSize = effective.resizable ? NSSize(width: 8192, height: 8192) : kind.minimumSize
window.minSize = kind.minimumSize
window.maxSize = maxSize
window.contentMinSize = kind.minimumSize
window.contentMaxSize = maxSize
window.invalidateShadow()
AppDelegate.shared?.applyWindowDecorations(to: window)
}
private func ensureToolbar(on window: NSWindow, kind: SettingsAboutWindowKind) {
guard window.toolbar == nil else { return }
let identifier = NSToolbar.Identifier("cmux.debug.titlebar.\(kind.rawValue)")
let toolbar = NSToolbar(identifier: identifier)
toolbar.allowsUserCustomization = false
toolbar.autosavesConfiguration = false
toolbar.displayMode = .iconOnly
toolbar.showsBaselineSeparator = false
window.toolbar = toolbar
}
private func setStyleMaskBit(
_ styleMask: inout NSWindow.StyleMask,
_ bit: NSWindow.StyleMask,
enabled: Bool
) {
if enabled {
styleMask.insert(bit)
} else {
styleMask.remove(bit)
}
}
}
private final class SettingsAboutTitlebarDebugWindowController: NSWindowController, NSWindowDelegate {
static let shared = SettingsAboutTitlebarDebugWindowController()
private init() {
let window = NSPanel(
contentRect: NSRect(x: 0, y: 0, width: 470, height: 690),
styleMask: [.titled, .closable, .resizable, .utilityWindow],
backing: .buffered,
defer: false
)
window.title = "Settings/About Titlebar Debug"
window.titleVisibility = .visible
window.titlebarAppearsTransparent = false
window.isMovableByWindowBackground = true
window.isReleasedWhenClosed = false
window.identifier = NSUserInterfaceItemIdentifier("cmux.settingsAboutTitlebarDebug")
window.center()
window.contentView = NSHostingView(rootView: SettingsAboutTitlebarDebugView())
AppDelegate.shared?.applyWindowDecorations(to: window)
super.init(window: window)
window.delegate = self
}
@available(*, unavailable)
required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
func show() {
window?.center()
window?.makeKeyAndOrderFront(nil)
SettingsAboutTitlebarDebugStore.shared.applyToOpenWindows()
}
}
private struct SettingsAboutTitlebarDebugView: View {
@ObservedObject private var store = SettingsAboutTitlebarDebugStore.shared
var body: some View {
ScrollView {
VStack(alignment: .leading, spacing: 14) {
Text("Settings/About Titlebar Debug")
.font(.headline)
editor(for: .settings)
editor(for: .about)
GroupBox("Actions") {
HStack(spacing: 10) {
Button("Reset All") {
store.reset(.settings)
store.reset(.about)
}
Button("Reapply to Open Windows") {
store.applyToOpenWindows()
}
Button("Copy Config") {
store.copyConfigToPasteboard()
}
}
.frame(maxWidth: .infinity, alignment: .leading)
.padding(.top, 2)
}
Spacer(minLength: 0)
}
.padding(16)
.frame(maxWidth: .infinity, alignment: .topLeading)
}
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading)
}
private func editor(for kind: SettingsAboutWindowKind) -> some View {
let overridesEnabled = binding(for: kind, keyPath: \.overridesEnabled)
return GroupBox(kind.displayTitle) {
VStack(alignment: .leading, spacing: 10) {
Toggle("Enable Debug Overrides", isOn: overridesEnabled)
Text("When disabled, cmux uses normal default titlebar behavior for this window.")
.font(.caption)
.foregroundColor(.secondary)
Divider()
VStack(alignment: .leading, spacing: 10) {
HStack(spacing: 8) {
Text("Window Title")
TextField("", text: binding(for: kind, keyPath: \.windowTitle))
}
HStack(spacing: 10) {
Picker("Title Visibility", selection: binding(for: kind, keyPath: \.titleVisibility)) {
ForEach(TitlebarVisibilityOption.allCases) { option in
Text(option.displayTitle).tag(option)
}
}
Picker("Toolbar Style", selection: binding(for: kind, keyPath: \.toolbarStyle)) {
ForEach(TitlebarToolbarStyleOption.allCases) { option in
Text(option.displayTitle).tag(option)
}
}
}
Toggle("Show Toolbar", isOn: binding(for: kind, keyPath: \.showToolbar))
Toggle("Transparent Titlebar", isOn: binding(for: kind, keyPath: \.titlebarAppearsTransparent))
Toggle("Movable by Window Background", isOn: binding(for: kind, keyPath: \.movableByWindowBackground))
Divider()
Text("Style Mask")
.font(.caption)
.foregroundColor(.secondary)
Toggle("Titled", isOn: binding(for: kind, keyPath: \.titled))
Toggle("Closable", isOn: binding(for: kind, keyPath: \.closable))
Toggle("Miniaturizable", isOn: binding(for: kind, keyPath: \.miniaturizable))
Toggle("Resizable", isOn: binding(for: kind, keyPath: \.resizable))
Toggle("Full Size Content View", isOn: binding(for: kind, keyPath: \.fullSizeContentView))
HStack(spacing: 10) {
Button("Reset \(kind == .settings ? "Settings" : "About")") {
store.reset(kind)
}
Button("Apply Now") {
store.applyToOpenWindows(for: kind)
}
}
}
.disabled(!overridesEnabled.wrappedValue)
.opacity(overridesEnabled.wrappedValue ? 1 : 0.75)
}
.padding(.top, 2)
}
}
private func binding<Value>(
for kind: SettingsAboutWindowKind,
keyPath: WritableKeyPath<SettingsAboutTitlebarDebugOptions, Value>
) -> Binding<Value> {
Binding(
get: { store.options(for: kind)[keyPath: keyPath] },
set: { newValue in
var updated = store.options(for: kind)
updated[keyPath: keyPath] = newValue
store.update(updated, for: kind)
}
)
}
}
private enum DebugWindowConfigSnapshot {
static func copyCombinedToPasteboard(defaults: UserDefaults = .standard) {
let pasteboard = NSPasteboard.general
pasteboard.clearContents()
pasteboard.setString(combinedPayload(defaults: defaults), forType: .string)
}
static func combinedPayload(defaults: UserDefaults = .standard) -> String {
let sidebarPayload = """
sidebarPreset=\(stringValue(defaults, key: "sidebarPreset", fallback: SidebarPresetOption.nativeSidebar.rawValue))
sidebarMaterial=\(stringValue(defaults, key: "sidebarMaterial", fallback: SidebarMaterialOption.sidebar.rawValue))
sidebarBlendMode=\(stringValue(defaults, key: "sidebarBlendMode", fallback: SidebarBlendModeOption.withinWindow.rawValue))
sidebarState=\(stringValue(defaults, key: "sidebarState", fallback: SidebarStateOption.followWindow.rawValue))
sidebarBlurOpacity=\(String(format: "%.2f", doubleValue(defaults, key: "sidebarBlurOpacity", fallback: 1.0)))
sidebarTintHex=\(stringValue(defaults, key: "sidebarTintHex", fallback: "#000000"))
sidebarTintOpacity=\(String(format: "%.2f", doubleValue(defaults, key: "sidebarTintOpacity", fallback: 0.18)))
sidebarCornerRadius=\(String(format: "%.1f", doubleValue(defaults, key: "sidebarCornerRadius", fallback: 0.0)))
sidebarBranchVerticalLayout=\(boolValue(defaults, key: SidebarBranchLayoutSettings.key, fallback: SidebarBranchLayoutSettings.defaultVerticalLayout))
sidebarActiveTabIndicatorStyle=\(stringValue(defaults, key: SidebarActiveTabIndicatorSettings.styleKey, fallback: SidebarActiveTabIndicatorSettings.defaultStyle.rawValue))
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.03)))
"""
let menuBarPayload = MenuBarIconDebugSettings.copyPayload(defaults: defaults)
let browserDevToolsPayload = BrowserDevToolsButtonDebugSettings.copyPayload(defaults: defaults)
return """
# Sidebar Debug
\(sidebarPayload)
# Background Debug
\(backgroundPayload)
# Menu Bar Extra Debug
\(menuBarPayload)
# Browser DevTools Button
\(browserDevToolsPayload)
"""
}
private static func stringValue(_ defaults: UserDefaults, key: String, fallback: String) -> String {
defaults.string(forKey: key) ?? fallback
}
private static func doubleValue(_ defaults: UserDefaults, key: String, fallback: Double) -> Double {
if let value = defaults.object(forKey: key) as? NSNumber {
return value.doubleValue
}
if let text = defaults.string(forKey: key), let parsed = Double(text) {
return parsed
}
return fallback
}
private static func boolValue(_ defaults: UserDefaults, key: String, fallback: Bool) -> Bool {
guard defaults.object(forKey: key) != nil else { return fallback }
return defaults.bool(forKey: key)
}
}
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
@AppStorage(SidebarActiveTabIndicatorSettings.styleKey)
private var sidebarActiveTabIndicatorStyle = SidebarActiveTabIndicatorSettings.defaultStyle.rawValue
@AppStorage("debugTitlebarLeadingExtra") private var titlebarLeadingExtra: Double = 0
@AppStorage(BrowserDevToolsButtonDebugSettings.iconNameKey) private var browserDevToolsIconNameRaw = BrowserDevToolsButtonDebugSettings.defaultIcon.rawValue
@AppStorage(BrowserDevToolsButtonDebugSettings.iconColorKey) private var browserDevToolsIconColorRaw = BrowserDevToolsButtonDebugSettings.defaultColor.rawValue
private var selectedDevToolsIconOption: BrowserDevToolsIconOption {
BrowserDevToolsIconOption(rawValue: browserDevToolsIconNameRaw) ?? BrowserDevToolsButtonDebugSettings.defaultIcon
}
private var selectedDevToolsColorOption: BrowserDevToolsIconColorOption {
BrowserDevToolsIconColorOption(rawValue: browserDevToolsIconColorRaw) ?? BrowserDevToolsButtonDebugSettings.defaultColor
}
private var selectedSidebarActiveTabIndicatorStyle: SidebarActiveTabIndicatorStyle {
SidebarActiveTabIndicatorSettings.resolvedStyle(rawValue: sidebarActiveTabIndicatorStyle)
}
private var sidebarIndicatorStyleSelection: Binding<String> {
Binding(
get: { selectedSidebarActiveTabIndicatorStyle.rawValue },
set: { sidebarActiveTabIndicatorStyle = $0 }
)
}
var body: some View {
ScrollView {
VStack(alignment: .leading, spacing: 14) {
Text("Debug Window Controls")
.font(.headline)
GroupBox("Open") {
VStack(alignment: .leading, spacing: 8) {
Button("Settings/About Titlebar Debug…") {
SettingsAboutTitlebarDebugWindowController.shared.show()
}
Button("Sidebar Debug…") {
SidebarDebugWindowController.shared.show()
}
Button("Background Debug…") {
BackgroundDebugWindowController.shared.show()
}
Button("Menu Bar Extra Debug…") {
MenuBarExtraDebugWindowController.shared.show()
}
Button("Open All Debug Windows") {
SettingsAboutTitlebarDebugWindowController.shared.show()
SidebarDebugWindowController.shared.show()
BackgroundDebugWindowController.shared.show()
MenuBarExtraDebugWindowController.shared.show()
}
}
.frame(maxWidth: .infinity, alignment: .leading)
.padding(.top, 2)
}
GroupBox("Shortcut Hints") {
VStack(alignment: .leading, spacing: 10) {
Toggle("Always show shortcut hints", isOn: $alwaysShowShortcutHints)
hintOffsetSection(
"Sidebar Cmd+1…9",
x: $sidebarShortcutHintXOffset,
y: $sidebarShortcutHintYOffset
)
hintOffsetSection(
"Titlebar Buttons",
x: $titlebarShortcutHintXOffset,
y: $titlebarShortcutHintYOffset
)
hintOffsetSection(
"Pane Ctrl/Cmd+1…9",
x: $paneShortcutHintXOffset,
y: $paneShortcutHintYOffset
)
HStack(spacing: 12) {
Button("Reset Hints") {
resetShortcutHintOffsets()
}
Button("Copy Hint Config") {
copyShortcutHintConfig()
}
}
}
.padding(.top, 2)
}
GroupBox("Active Workspace Indicator") {
VStack(alignment: .leading, spacing: 8) {
Picker("Style", selection: sidebarIndicatorStyleSelection) {
ForEach(SidebarActiveTabIndicatorStyle.allCases) { style in
Text(style.displayName).tag(style.rawValue)
}
}
.pickerStyle(.menu)
Button("Reset Indicator Style") {
sidebarActiveTabIndicatorStyle = SidebarActiveTabIndicatorSettings.defaultStyle.rawValue
}
}
.padding(.top, 2)
}
GroupBox("Titlebar Spacing") {
VStack(alignment: .leading, spacing: 6) {
HStack(spacing: 8) {
Text("Leading extra")
Slider(value: $titlebarLeadingExtra, in: 0...40)
Text(String(format: "%.0f", titlebarLeadingExtra))
.font(.caption)
.monospacedDigit()
.frame(width: 30, alignment: .trailing)
}
Button("Reset (0)") {
titlebarLeadingExtra = 0
}
}
.padding(.top, 2)
}
GroupBox("Browser DevTools Button") {
VStack(alignment: .leading, spacing: 10) {
HStack(spacing: 8) {
Text("Icon")
Picker("Icon", selection: $browserDevToolsIconNameRaw) {
ForEach(BrowserDevToolsIconOption.allCases) { option in
Text(option.title).tag(option.rawValue)
}
}
.labelsHidden()
.pickerStyle(.menu)
Spacer()
}
HStack(spacing: 8) {
Text("Color")
Picker("Color", selection: $browserDevToolsIconColorRaw) {
ForEach(BrowserDevToolsIconColorOption.allCases) { option in
Text(option.title).tag(option.rawValue)
}
}
.labelsHidden()
.pickerStyle(.menu)
Spacer()
}
HStack(spacing: 8) {
Text("Preview")
Spacer()
Image(systemName: selectedDevToolsIconOption.rawValue)
.font(.system(size: 12, weight: .medium))
.foregroundStyle(selectedDevToolsColorOption.color)
}
HStack(spacing: 12) {
Button("Reset Button") {
resetBrowserDevToolsButton()
}
Button("Copy Button Config") {
copyBrowserDevToolsButtonConfig()
}
}
}
.padding(.top, 2)
}
GroupBox("Copy") {
VStack(alignment: .leading, spacing: 8) {
Button("Copy All Debug Config") {
DebugWindowConfigSnapshot.copyCombinedToPasteboard()
}
Text("Copies sidebar, background, menu bar, and browser devtools settings as one payload.")
.font(.caption)
.foregroundColor(.secondary)
}
.frame(maxWidth: .infinity, alignment: .leading)
.padding(.top, 2)
}
Spacer(minLength: 0)
}
.padding(16)
.frame(maxWidth: .infinity, alignment: .topLeading)
}
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading)
}
private func hintOffsetSection(_ title: String, x: Binding<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 func resetBrowserDevToolsButton() {
browserDevToolsIconNameRaw = BrowserDevToolsButtonDebugSettings.defaultIcon.rawValue
browserDevToolsIconColorRaw = BrowserDevToolsButtonDebugSettings.defaultColor.rawValue
}
private func copyBrowserDevToolsButtonConfig() {
let payload = BrowserDevToolsButtonDebugSettings.copyPayload(defaults: .standard)
let pasteboard = NSPasteboard.general
pasteboard.clearContents()
pasteboard.setString(payload, forType: .string)
}
}
private final class AboutWindowController: NSWindowController, NSWindowDelegate {
static let shared = AboutWindowController()
private init() {
let window = NSWindow(
contentRect: NSRect(x: 0, y: 0, width: 360, height: 520),
styleMask: [.titled, .closable, .miniaturizable],
backing: .buffered,
defer: false
)
window.isReleasedWhenClosed = false
window.identifier = NSUserInterfaceItemIdentifier("cmux.about")
window.center()
window.contentView = NSHostingView(rootView: AboutPanelView())
SettingsAboutTitlebarDebugStore.shared.applyCurrentOptions(to: window, for: .about)
AppDelegate.shared?.applyWindowDecorations(to: window)
super.init(window: window)
window.delegate = self
}
@available(*, unavailable)
required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
func show() {
guard let window else { return }
SettingsAboutTitlebarDebugStore.shared.applyCurrentOptions(to: window, for: .about)
window.center()
window.makeKeyAndOrderFront(nil)
}
}
private final class AcknowledgmentsWindowController: NSWindowController, NSWindowDelegate {
static let shared = AcknowledgmentsWindowController()
private init() {
let window = NSWindow(
contentRect: NSRect(x: 0, y: 0, width: 500, height: 480),
styleMask: [.titled, .closable, .miniaturizable, .resizable],
backing: .buffered,
defer: false
)
window.isReleasedWhenClosed = false
window.title = "Third-Party Licenses"
window.identifier = NSUserInterfaceItemIdentifier("cmux.licenses")
window.center()
window.contentView = NSHostingView(rootView: AcknowledgmentsView())
super.init(window: window)
window.delegate = self
}
@available(*, unavailable)
required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
func show() {
guard let window else { return }
window.makeKeyAndOrderFront(nil)
}
}
private struct AcknowledgmentsView: View {
private let content: String = {
if let url = Bundle.main.url(forResource: "THIRD_PARTY_LICENSES", withExtension: "md"),
let text = try? String(contentsOf: url) {
return text
}
return "Licenses file not found."
}()
var body: some View {
ScrollView {
Text(content)
.font(.system(.body, design: .monospaced))
.textSelection(.enabled)
.frame(maxWidth: .infinity, alignment: .leading)
.padding()
}
}
}
final class SettingsWindowController: NSWindowController, NSWindowDelegate {
static let shared = SettingsWindowController()
private init() {
let window = NSWindow(
contentRect: NSRect(x: 0, y: 0, width: 640, height: 520),
styleMask: [.titled, .closable, .miniaturizable, .resizable, .fullSizeContentView],
backing: .buffered,
defer: false
)
window.isReleasedWhenClosed = false
window.identifier = NSUserInterfaceItemIdentifier("cmux.settings")
window.center()
window.contentView = NSHostingView(rootView: SettingsRootView())
SettingsAboutTitlebarDebugStore.shared.applyCurrentOptions(to: window, for: .settings)
AppDelegate.shared?.applyWindowDecorations(to: window)
super.init(window: window)
window.delegate = self
}
@available(*, unavailable)
required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
func show() {
guard let window else { return }
#if DEBUG
dlog("settings.window.show requested isVisible=\(window.isVisible ? 1 : 0) isKey=\(window.isKeyWindow ? 1 : 0)")
#endif
SettingsAboutTitlebarDebugStore.shared.applyCurrentOptions(to: window, for: .settings)
if !window.isVisible {
window.center()
}
window.makeKeyAndOrderFront(nil)
#if DEBUG
dlog("settings.window.show completed isVisible=\(window.isVisible ? 1 : 0) isKey=\(window.isKeyWindow ? 1 : 0)")
#endif
}
}
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://cmux.dev/docs")
private var version: String? { Bundle.main.infoDictionary?["CFBundleShortVersionString"] as? String }
private var build: String? { Bundle.main.infoDictionary?["CFBundleVersion"] as? String }
private var commit: String? {
if let value = Bundle.main.infoDictionary?["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)
}
}
Button("Licenses") {
AcknowledgmentsWindowController.shared.show()
}
}
if let copy = copyright, !copy.isEmpty {
Text(copy)
.font(.caption)
.textSelection(.enabled)
.tint(.secondary)
.opacity(0.8)
.multilineTextAlignment(.center)
.frame(maxWidth: .infinity)
}
}
.frame(maxWidth: .infinity)
}
.padding(.top, 8)
.padding(32)
.frame(minWidth: 280)
.background(AboutVisualEffectBackground(material: .underWindowBackground).ignoresSafeArea())
}
}
private struct SidebarDebugView: View {
@AppStorage("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(SidebarBranchLayoutSettings.key) private var sidebarBranchVerticalLayout = SidebarBranchLayoutSettings.defaultVerticalLayout
@AppStorage(ShortcutHintDebugSettings.sidebarHintXKey) private var sidebarShortcutHintXOffset = ShortcutHintDebugSettings.defaultSidebarHintX
@AppStorage(ShortcutHintDebugSettings.sidebarHintYKey) private var sidebarShortcutHintYOffset = ShortcutHintDebugSettings.defaultSidebarHintY
@AppStorage(ShortcutHintDebugSettings.titlebarHintXKey) private var titlebarShortcutHintXOffset = ShortcutHintDebugSettings.defaultTitlebarHintX
@AppStorage(ShortcutHintDebugSettings.titlebarHintYKey) private var titlebarShortcutHintYOffset = ShortcutHintDebugSettings.defaultTitlebarHintY
@AppStorage(ShortcutHintDebugSettings.paneHintXKey) private var paneShortcutHintXOffset = ShortcutHintDebugSettings.defaultPaneHintX
@AppStorage(ShortcutHintDebugSettings.paneHintYKey) private var paneShortcutHintYOffset = ShortcutHintDebugSettings.defaultPaneHintY
@AppStorage(ShortcutHintDebugSettings.alwaysShowHintsKey) private var alwaysShowShortcutHints = ShortcutHintDebugSettings.defaultAlwaysShowHints
@AppStorage(SidebarActiveTabIndicatorSettings.styleKey)
private var sidebarActiveTabIndicatorStyle = SidebarActiveTabIndicatorSettings.defaultStyle.rawValue
private var selectedSidebarIndicatorStyle: SidebarActiveTabIndicatorStyle {
SidebarActiveTabIndicatorSettings.resolvedStyle(rawValue: sidebarActiveTabIndicatorStyle)
}
private var sidebarIndicatorStyleSelection: Binding<String> {
Binding(
get: { selectedSidebarIndicatorStyle.rawValue },
set: { sidebarActiveTabIndicatorStyle = $0 }
)
}
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)
}
GroupBox("Active Workspace Indicator") {
VStack(alignment: .leading, spacing: 8) {
Picker("Style", selection: sidebarIndicatorStyleSelection) {
ForEach(SidebarActiveTabIndicatorStyle.allCases) { style in
Text(style.displayName).tag(style.rawValue)
}
}
}
.padding(.top, 2)
}
GroupBox("Workspace Metadata") {
VStack(alignment: .leading, spacing: 8) {
Toggle("Render branch list vertically", isOn: $sidebarBranchVerticalLayout)
Text("When enabled, each branch appears on its own line in the sidebar.")
.font(.caption)
.foregroundColor(.secondary)
}
.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("Reset Active Indicator") {
sidebarActiveTabIndicatorStyle = SidebarActiveTabIndicatorSettings.defaultStyle.rawValue
}
}
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))
sidebarBranchVerticalLayout=\(sidebarBranchVerticalLayout)
sidebarActiveTabIndicatorStyle=\(sidebarActiveTabIndicatorStyle)
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.03
@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.03
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"
}
}
}
enum AppearanceSettings {
static let appearanceModeKey = "appearanceMode"
static let defaultMode: AppearanceMode = .system
static func mode(for rawValue: String?) -> AppearanceMode {
guard let rawValue, let mode = AppearanceMode(rawValue: rawValue) else {
return defaultMode
}
if mode == .auto {
return .system
}
return mode
}
@discardableResult
static func resolvedMode(defaults: UserDefaults = .standard) -> AppearanceMode {
let stored = defaults.string(forKey: appearanceModeKey)
let resolved = mode(for: stored)
if stored != resolved.rawValue {
defaults.set(resolved.rawValue, forKey: appearanceModeKey)
}
return resolved
}
}
enum QuitWarningSettings {
static let warnBeforeQuitKey = "warnBeforeQuitShortcut"
static let defaultWarnBeforeQuit = true
static func isEnabled(defaults: UserDefaults = .standard) -> Bool {
if defaults.object(forKey: warnBeforeQuitKey) == nil {
return defaultWarnBeforeQuit
}
return defaults.bool(forKey: warnBeforeQuitKey)
}
static func setEnabled(_ isEnabled: Bool, defaults: UserDefaults = .standard) {
defaults.set(isEnabled, forKey: warnBeforeQuitKey)
}
}
enum CommandPaletteRenameSelectionSettings {
static let selectAllOnFocusKey = "commandPalette.renameSelectAllOnFocus"
static let defaultSelectAllOnFocus = true
static func selectAllOnFocusEnabled(defaults: UserDefaults = .standard) -> Bool {
if defaults.object(forKey: selectAllOnFocusKey) == nil {
return defaultSelectAllOnFocus
}
return defaults.bool(forKey: selectAllOnFocusKey)
}
}
enum ClaudeCodeIntegrationSettings {
static let hooksEnabledKey = "claudeCodeHooksEnabled"
static let defaultHooksEnabled = true
static func hooksEnabled(defaults: UserDefaults = .standard) -> Bool {
if defaults.object(forKey: hooksEnabledKey) == nil {
return defaultHooksEnabled
}
return defaults.bool(forKey: hooksEnabledKey)
}
}
enum TelemetrySettings {
static let sendAnonymousTelemetryKey = "sendAnonymousTelemetry"
static let defaultSendAnonymousTelemetry = true
static func isEnabled(defaults: UserDefaults = .standard) -> Bool {
if defaults.object(forKey: sendAnonymousTelemetryKey) == nil {
return defaultSendAnonymousTelemetry
}
return defaults.bool(forKey: sendAnonymousTelemetryKey)
}
// Freeze telemetry enablement once per launch. Settings changes apply on next restart.
static let enabledForCurrentLaunch = isEnabled()
}
struct SettingsView: View {
private let contentTopInset: CGFloat = 8
private let pickerColumnWidth: CGFloat = 196
@AppStorage(AppearanceSettings.appearanceModeKey) private var appearanceMode = AppearanceSettings.defaultMode.rawValue
@AppStorage(SocketControlSettings.appStorageKey) private var socketControlMode = SocketControlSettings.defaultMode.rawValue
@AppStorage(ClaudeCodeIntegrationSettings.hooksEnabledKey)
private var claudeCodeHooksEnabled = ClaudeCodeIntegrationSettings.defaultHooksEnabled
@AppStorage(TelemetrySettings.sendAnonymousTelemetryKey)
private var sendAnonymousTelemetry = TelemetrySettings.defaultSendAnonymousTelemetry
@AppStorage("cmuxPortBase") private var cmuxPortBase = 9100
@AppStorage("cmuxPortRange") private var cmuxPortRange = 10
@AppStorage(BrowserSearchSettings.searchEngineKey) private var browserSearchEngine = BrowserSearchSettings.defaultSearchEngine.rawValue
@AppStorage(BrowserSearchSettings.searchSuggestionsEnabledKey) private var browserSearchSuggestionsEnabled = BrowserSearchSettings.defaultSearchSuggestionsEnabled
@AppStorage(BrowserThemeSettings.modeKey) private var browserThemeMode = BrowserThemeSettings.defaultMode.rawValue
@AppStorage(BrowserLinkOpenSettings.openTerminalLinksInCmuxBrowserKey) private var openTerminalLinksInCmuxBrowser = BrowserLinkOpenSettings.defaultOpenTerminalLinksInCmuxBrowser
@AppStorage(BrowserLinkOpenSettings.interceptTerminalOpenCommandInCmuxBrowserKey)
private var interceptTerminalOpenCommandInCmuxBrowser = BrowserLinkOpenSettings.initialInterceptTerminalOpenCommandInCmuxBrowserValue()
@AppStorage(BrowserLinkOpenSettings.browserHostWhitelistKey) private var browserHostWhitelist = BrowserLinkOpenSettings.defaultBrowserHostWhitelist
@AppStorage(BrowserInsecureHTTPSettings.allowlistKey) private var browserInsecureHTTPAllowlist = BrowserInsecureHTTPSettings.defaultAllowlistText
@AppStorage(NotificationBadgeSettings.dockBadgeEnabledKey) private var notificationDockBadgeEnabled = NotificationBadgeSettings.defaultDockBadgeEnabled
@AppStorage(QuitWarningSettings.warnBeforeQuitKey) private var warnBeforeQuitShortcut = QuitWarningSettings.defaultWarnBeforeQuit
@AppStorage(CommandPaletteRenameSelectionSettings.selectAllOnFocusKey)
private var commandPaletteRenameSelectAllOnFocus = CommandPaletteRenameSelectionSettings.defaultSelectAllOnFocus
@AppStorage(WorkspacePlacementSettings.placementKey) private var newWorkspacePlacement = WorkspacePlacementSettings.defaultPlacement.rawValue
@AppStorage(WorkspaceAutoReorderSettings.key) private var workspaceAutoReorder = WorkspaceAutoReorderSettings.defaultValue
@AppStorage(SidebarBranchLayoutSettings.key) private var sidebarBranchVerticalLayout = SidebarBranchLayoutSettings.defaultVerticalLayout
@AppStorage(SidebarActiveTabIndicatorSettings.styleKey)
private var sidebarActiveTabIndicatorStyle = SidebarActiveTabIndicatorSettings.defaultStyle.rawValue
@AppStorage("sidebarShowBranchDirectory") private var sidebarShowBranchDirectory = true
@AppStorage("sidebarShowPullRequest") private var sidebarShowPullRequest = true
@AppStorage(BrowserLinkOpenSettings.openSidebarPullRequestLinksInCmuxBrowserKey)
private var openSidebarPullRequestLinksInCmuxBrowser = BrowserLinkOpenSettings.defaultOpenSidebarPullRequestLinksInCmuxBrowser
@AppStorage("sidebarShowPorts") private var sidebarShowPorts = true
@AppStorage("sidebarShowLog") private var sidebarShowLog = true
@AppStorage("sidebarShowProgress") private var sidebarShowProgress = true
@AppStorage("sidebarShowStatusPills") private var sidebarShowMetadata = true
@State private var shortcutResetToken = UUID()
@State private var topBlurOpacity: Double = 0
@State private var topBlurBaselineOffset: CGFloat?
@State private var settingsTitleLeadingInset: CGFloat = 92
@State private var showClearBrowserHistoryConfirmation = false
@State private var showOpenAccessConfirmation = false
@State private var pendingOpenAccessMode: SocketControlMode?
@State private var browserHistoryEntryCount: Int = 0
@State private var browserInsecureHTTPAllowlistDraft = BrowserInsecureHTTPSettings.defaultAllowlistText
@State private var socketPasswordDraft = ""
@State private var socketPasswordStatusMessage: String?
@State private var socketPasswordStatusIsError = false
@State private var telemetryValueAtLaunch = TelemetrySettings.enabledForCurrentLaunch
@State private var workspaceTabDefaultEntries = WorkspaceTabColorSettings.defaultPaletteWithOverrides()
@State private var workspaceTabCustomColors = WorkspaceTabColorSettings.customColors()
private var selectedWorkspacePlacement: NewWorkspacePlacement {
NewWorkspacePlacement(rawValue: newWorkspacePlacement) ?? WorkspacePlacementSettings.defaultPlacement
}
private var selectedSidebarActiveTabIndicatorStyle: SidebarActiveTabIndicatorStyle {
SidebarActiveTabIndicatorSettings.resolvedStyle(rawValue: sidebarActiveTabIndicatorStyle)
}
private var sidebarIndicatorStyleSelection: Binding<String> {
Binding(
get: { selectedSidebarActiveTabIndicatorStyle.rawValue },
set: { sidebarActiveTabIndicatorStyle = $0 }
)
}
private var selectedSocketControlMode: SocketControlMode {
SocketControlSettings.migrateMode(socketControlMode)
}
private var selectedBrowserThemeMode: BrowserThemeMode {
BrowserThemeSettings.mode(for: browserThemeMode)
}
private var browserThemeModeSelection: Binding<String> {
Binding(
get: { browserThemeMode },
set: { newValue in
browserThemeMode = BrowserThemeSettings.mode(for: newValue).rawValue
}
)
}
private var socketModeSelection: Binding<String> {
Binding(
get: { socketControlMode },
set: { newValue in
let normalized = SocketControlSettings.migrateMode(newValue)
if normalized == .allowAll && selectedSocketControlMode != .allowAll {
pendingOpenAccessMode = normalized
showOpenAccessConfirmation = true
return
}
socketControlMode = normalized.rawValue
if normalized != .password {
socketPasswordStatusMessage = nil
socketPasswordStatusIsError = false
}
}
)
}
private var hasSocketPasswordConfigured: Bool {
SocketControlPasswordStore.hasConfiguredPassword()
}
private var browserHistorySubtitle: String {
switch browserHistoryEntryCount {
case 0:
return "No saved pages yet."
case 1:
return "1 saved page appears in omnibar suggestions."
default:
return "\(browserHistoryEntryCount) saved pages appear in omnibar suggestions."
}
}
private var browserInsecureHTTPAllowlistHasUnsavedChanges: Bool {
browserInsecureHTTPAllowlistDraft != browserInsecureHTTPAllowlist
}
private func blurOpacity(forContentOffset offset: CGFloat) -> Double {
guard let baseline = topBlurBaselineOffset else { return 0 }
let reveal = (baseline - offset) / 24
return Double(min(max(reveal, 0), 1))
}
private func saveSocketPassword() {
let trimmed = socketPasswordDraft.trimmingCharacters(in: .whitespacesAndNewlines)
guard !trimmed.isEmpty else {
socketPasswordStatusMessage = "Enter a password first."
socketPasswordStatusIsError = true
return
}
do {
try SocketControlPasswordStore.savePassword(trimmed)
socketPasswordDraft = ""
socketPasswordStatusMessage = "Password saved."
socketPasswordStatusIsError = false
} catch {
socketPasswordStatusMessage = "Failed to save password (\(error.localizedDescription))."
socketPasswordStatusIsError = true
}
}
private func clearSocketPassword() {
do {
try SocketControlPasswordStore.clearPassword()
socketPasswordDraft = ""
socketPasswordStatusMessage = "Password cleared."
socketPasswordStatusIsError = false
} catch {
socketPasswordStatusMessage = "Failed to clear password (\(error.localizedDescription))."
socketPasswordStatusIsError = true
}
}
var body: some View {
ZStack(alignment: .top) {
ScrollView {
VStack(alignment: .leading, spacing: 14) {
SettingsSectionHeader(title: "App")
SettingsCard {
SettingsCardRow("Theme", controlWidth: pickerColumnWidth) {
Picker("", selection: $appearanceMode) {
ForEach(AppearanceMode.visibleCases) { mode in
Text(mode.displayName).tag(mode.rawValue)
}
}
.labelsHidden()
.pickerStyle(.menu)
}
SettingsCardDivider()
SettingsCardRow(
"New Workspace Placement",
subtitle: selectedWorkspacePlacement.description,
controlWidth: pickerColumnWidth
) {
Picker("", selection: $newWorkspacePlacement) {
ForEach(NewWorkspacePlacement.allCases) { placement in
Text(placement.displayName).tag(placement.rawValue)
}
}
.labelsHidden()
.pickerStyle(.menu)
}
SettingsCardDivider()
SettingsCardRow(
"Reorder on Notification",
subtitle: "Move workspaces to the top when they receive a notification. Disable for stable shortcut positions."
) {
Toggle("", isOn: $workspaceAutoReorder)
.labelsHidden()
.controlSize(.small)
}
SettingsCardDivider()
SettingsCardRow(
"Dock Badge",
subtitle: "Show unread count on app icon (Dock and Cmd+Tab)."
) {
Toggle("", isOn: $notificationDockBadgeEnabled)
.labelsHidden()
.controlSize(.small)
}
SettingsCardDivider()
SettingsCardRow(
"Send anonymous telemetry",
subtitle: sendAnonymousTelemetry != telemetryValueAtLaunch
? "Change takes effect on next launch."
: "Share anonymized crash and usage data to help improve cmux."
) {
Toggle("", isOn: $sendAnonymousTelemetry)
.labelsHidden()
.controlSize(.small)
}
SettingsCardDivider()
SettingsCardRow(
"Warn Before Quit",
subtitle: warnBeforeQuitShortcut
? "Show a confirmation before quitting with Cmd+Q."
: "Cmd+Q quits immediately without confirmation."
) {
Toggle("", isOn: $warnBeforeQuitShortcut)
.labelsHidden()
.controlSize(.small)
}
SettingsCardDivider()
SettingsCardRow(
"Rename Selects Existing Name",
subtitle: commandPaletteRenameSelectAllOnFocus
? "Command Palette rename starts with all text selected."
: "Command Palette rename keeps the caret at the end."
) {
Toggle("", isOn: $commandPaletteRenameSelectAllOnFocus)
.labelsHidden()
.controlSize(.small)
}
SettingsCardDivider()
SettingsCardRow(
"Sidebar Branch Layout",
subtitle: sidebarBranchVerticalLayout
? "Vertical: each branch appears on its own line."
: "Inline: all branches share one line."
) {
Picker("", selection: $sidebarBranchVerticalLayout) {
Text("Vertical").tag(true)
Text("Inline").tag(false)
}
.labelsHidden()
.pickerStyle(.menu)
}
SettingsCardDivider()
SettingsCardRow(
"Show Branch + Directory in Sidebar",
subtitle: "Display the built-in git branch and working-directory row."
) {
Toggle("", isOn: $sidebarShowBranchDirectory)
.labelsHidden()
.controlSize(.small)
}
SettingsCardDivider()
SettingsCardRow(
"Show Pull Requests in Sidebar",
subtitle: "Display review items (PR/MR/etc.) with status, number, and clickable link."
) {
Toggle("", isOn: $sidebarShowPullRequest)
.labelsHidden()
.controlSize(.small)
}
SettingsCardDivider()
SettingsCardRow(
"Open Sidebar PR Links in cmux Browser",
subtitle: openSidebarPullRequestLinksInCmuxBrowser
? "Clicks open inside cmux browser."
: "Clicks open in your default browser."
) {
Toggle("", isOn: $openSidebarPullRequestLinksInCmuxBrowser)
.labelsHidden()
.controlSize(.small)
}
SettingsCardDivider()
SettingsCardRow(
"Show Listening Ports in Sidebar",
subtitle: "Display detected listening ports for the active workspace."
) {
Toggle("", isOn: $sidebarShowPorts)
.labelsHidden()
.controlSize(.small)
}
SettingsCardDivider()
SettingsCardRow(
"Show Latest Log in Sidebar",
subtitle: "Display the latest imperative log/status message."
) {
Toggle("", isOn: $sidebarShowLog)
.labelsHidden()
.controlSize(.small)
}
SettingsCardDivider()
SettingsCardRow(
"Show Progress in Sidebar",
subtitle: "Display the built-in progress bar from set_progress."
) {
Toggle("", isOn: $sidebarShowProgress)
.labelsHidden()
.controlSize(.small)
}
SettingsCardDivider()
SettingsCardRow(
"Show Custom Metadata in Sidebar",
subtitle: "Display custom metadata from report_meta/set_status and report_meta_block."
) {
Toggle("", isOn: $sidebarShowMetadata)
.labelsHidden()
.controlSize(.small)
}
}
SettingsSectionHeader(title: "Workspace Colors")
SettingsCard {
SettingsCardRow(
"Workspace Color Indicator",
controlWidth: pickerColumnWidth
) {
Picker("", selection: sidebarIndicatorStyleSelection) {
ForEach(SidebarActiveTabIndicatorStyle.allCases) { style in
Text(style.displayName).tag(style.rawValue)
}
}
.labelsHidden()
.pickerStyle(.menu)
}
SettingsCardDivider()
SettingsCardNote("Customize the workspace color palette used by Sidebar > Workspace Color. \"Choose Custom Color...\" entries are persisted below.")
ForEach(Array(workspaceTabDefaultEntries.enumerated()), id: \.element.name) { index, entry in
if index > 0 {
SettingsCardDivider()
}
SettingsCardRow(
entry.name,
subtitle: "Base: \(baseTabColorHex(for: entry.name))"
) {
HStack(spacing: 8) {
ColorPicker(
"",
selection: defaultTabColorBinding(for: entry.name),
supportsOpacity: false
)
.labelsHidden()
.frame(width: 38)
Text(entry.hex)
.font(.system(size: 12, weight: .medium, design: .monospaced))
.foregroundStyle(.secondary)
.frame(width: 76, alignment: .trailing)
}
}
}
SettingsCardDivider()
if workspaceTabCustomColors.isEmpty {
SettingsCardNote("Custom colors: none yet. Use \"Choose Custom Color...\" from a workspace context menu.")
} else {
VStack(alignment: .leading, spacing: 8) {
Text("Custom Colors")
.font(.system(size: 13, weight: .semibold))
ForEach(workspaceTabCustomColors, id: \.self) { hex in
HStack(spacing: 8) {
Circle()
.fill(Color(nsColor: NSColor(hex: hex) ?? .gray))
.frame(width: 11, height: 11)
Text(hex)
.font(.system(size: 12, weight: .medium, design: .monospaced))
.foregroundStyle(.secondary)
Spacer(minLength: 8)
Button("Remove") {
removeWorkspaceCustomColor(hex)
}
.buttonStyle(.bordered)
.controlSize(.small)
}
}
}
.padding(.horizontal, 14)
.padding(.vertical, 10)
}
SettingsCardDivider()
SettingsCardRow(
"Reset Palette",
subtitle: "Restore built-in defaults and clear all custom colors."
) {
Button("Reset") {
resetWorkspaceTabColors()
}
.buttonStyle(.bordered)
.controlSize(.small)
}
}
SettingsSectionHeader(title: "Automation")
SettingsCard {
SettingsCardRow(
"Socket Control Mode",
subtitle: selectedSocketControlMode.description,
controlWidth: pickerColumnWidth
) {
Picker("", selection: socketModeSelection) {
ForEach(SocketControlMode.uiCases) { mode in
Text(mode.displayName).tag(mode.rawValue)
}
}
.labelsHidden()
.pickerStyle(.menu)
.accessibilityIdentifier("AutomationSocketModePicker")
}
SettingsCardDivider()
SettingsCardNote("Controls access to the local Unix socket for programmatic control. Choose a mode that matches your threat model.")
if selectedSocketControlMode == .password {
SettingsCardDivider()
SettingsCardRow(
"Socket Password",
subtitle: hasSocketPasswordConfigured
? "Stored in Application Support."
: "No password set. External clients will be blocked until one is configured."
) {
HStack(spacing: 8) {
SecureField("Password", text: $socketPasswordDraft)
.textFieldStyle(.roundedBorder)
.frame(width: 170)
Button(hasSocketPasswordConfigured ? "Change" : "Set") {
saveSocketPassword()
}
.buttonStyle(.bordered)
.controlSize(.small)
.disabled(socketPasswordDraft.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty)
if hasSocketPasswordConfigured {
Button("Clear") {
clearSocketPassword()
}
.buttonStyle(.bordered)
.controlSize(.small)
}
}
}
if let message = socketPasswordStatusMessage {
Text(message)
.font(.caption)
.foregroundStyle(socketPasswordStatusIsError ? Color.red : Color.secondary)
.padding(.horizontal, 14)
.padding(.bottom, 8)
}
}
if selectedSocketControlMode == .allowAll {
SettingsCardDivider()
Text("Warning: Full open access makes the control socket world-readable/writable on this Mac and disables auth checks. Use only for local debugging.")
.font(.caption)
.foregroundStyle(.red)
.padding(.horizontal, 14)
.padding(.vertical, 8)
}
SettingsCardNote("Overrides: CMUX_SOCKET_ENABLE, CMUX_SOCKET_MODE, and CMUX_SOCKET_PATH (set CMUX_ALLOW_SOCKET_OVERRIDE=1 for stable/nightly builds).")
}
SettingsCard {
SettingsCardRow(
"Claude Code Integration",
subtitle: claudeCodeHooksEnabled
? "Sidebar shows Claude session status and notifications."
: "Claude Code runs without cmux integration."
) {
Toggle("", isOn: $claudeCodeHooksEnabled)
.labelsHidden()
.controlSize(.small)
.accessibilityIdentifier("SettingsClaudeCodeHooksToggle")
}
SettingsCardDivider()
SettingsCardNote("When enabled, cmux wraps the claude command to inject session tracking and notification hooks. Disable if you prefer to manage Claude Code hooks yourself.")
}
SettingsCard {
SettingsCardRow("Port Base", subtitle: "Starting port for CMUX_PORT env var.", controlWidth: pickerColumnWidth) {
TextField("", value: $cmuxPortBase, format: .number)
.textFieldStyle(.roundedBorder)
.multilineTextAlignment(.trailing)
}
SettingsCardDivider()
SettingsCardRow("Port Range Size", subtitle: "Number of ports per workspace.", controlWidth: pickerColumnWidth) {
TextField("", value: $cmuxPortRange, format: .number)
.textFieldStyle(.roundedBorder)
.multilineTextAlignment(.trailing)
}
SettingsCardDivider()
SettingsCardNote("Each workspace gets CMUX_PORT and CMUX_PORT_END env vars with a dedicated port range. New terminals inherit these values.")
}
SettingsSectionHeader(title: "Browser")
SettingsCard {
SettingsCardRow(
"Default Search Engine",
subtitle: "Used by the browser address bar when input is not a URL.",
controlWidth: pickerColumnWidth
) {
Picker("", selection: $browserSearchEngine) {
ForEach(BrowserSearchEngine.allCases) { engine in
Text(engine.displayName).tag(engine.rawValue)
}
}
.labelsHidden()
.pickerStyle(.menu)
}
SettingsCardDivider()
SettingsCardRow("Show Search Suggestions") {
Toggle("", isOn: $browserSearchSuggestionsEnabled)
.labelsHidden()
.controlSize(.small)
}
SettingsCardDivider()
SettingsCardRow(
"Browser Theme",
subtitle: selectedBrowserThemeMode == .system
? "System follows app and macOS appearance."
: "\(selectedBrowserThemeMode.displayName) forces that color scheme for compatible pages.",
controlWidth: pickerColumnWidth
) {
Picker("", selection: browserThemeModeSelection) {
ForEach(BrowserThemeMode.allCases) { mode in
Text(mode.displayName).tag(mode.rawValue)
}
}
.labelsHidden()
.pickerStyle(.menu)
}
SettingsCardDivider()
SettingsCardRow(
"Open Terminal Links in cmux Browser",
subtitle: "When off, links clicked in terminal output open in your default browser."
) {
Toggle("", isOn: $openTerminalLinksInCmuxBrowser)
.labelsHidden()
.controlSize(.small)
}
SettingsCardDivider()
SettingsCardRow(
"Intercept open http(s) in Terminal",
subtitle: "When off, `open https://...` and `open http://...` always use your default browser."
) {
Toggle("", isOn: $interceptTerminalOpenCommandInCmuxBrowser)
.labelsHidden()
.controlSize(.small)
}
if openTerminalLinksInCmuxBrowser || interceptTerminalOpenCommandInCmuxBrowser {
SettingsCardDivider()
VStack(alignment: .leading, spacing: 6) {
SettingsCardRow(
"Hosts to Open in Embedded Browser",
subtitle: "Applies to terminal link clicks and intercepted `open https://...` calls. Only these hosts open in cmux. Others open in your default browser. One host or wildcard per line (for example: example.com, *.internal.example). Leave empty to open all hosts in cmux."
) {
EmptyView()
}
TextEditor(text: $browserHostWhitelist)
.font(.system(.body, design: .monospaced))
.frame(minHeight: 60, maxHeight: 120)
.scrollContentBackground(.hidden)
.padding(6)
.background(Color(nsColor: .controlBackgroundColor))
.cornerRadius(6)
.overlay(
RoundedRectangle(cornerRadius: 6)
.stroke(Color(nsColor: .separatorColor), lineWidth: 0.5)
)
.padding(.horizontal, 16)
.padding(.bottom, 12)
}
}
SettingsCardDivider()
VStack(alignment: .leading, spacing: 8) {
Text("HTTP Hosts Allowed in Embedded Browser")
.font(.system(size: 13, weight: .semibold))
Text("Controls which HTTP (non-HTTPS) hosts can open in cmux without a warning prompt. Defaults include localhost, 127.0.0.1, ::1, 0.0.0.0, and *.localtest.me.")
.font(.caption)
.foregroundStyle(.secondary)
TextEditor(text: $browserInsecureHTTPAllowlistDraft)
.font(.system(size: 12, weight: .regular, design: .monospaced))
.frame(minHeight: 86)
.padding(6)
.background(
RoundedRectangle(cornerRadius: 8, style: .continuous)
.fill(Color(nsColor: .textBackgroundColor))
)
.overlay(
RoundedRectangle(cornerRadius: 8, style: .continuous)
.stroke(Color(nsColor: .separatorColor), lineWidth: 1)
)
.accessibilityIdentifier("SettingsBrowserHTTPAllowlistField")
ViewThatFits(in: .horizontal) {
HStack(alignment: .center, spacing: 10) {
Text("One host or wildcard per line (for example: localhost, 127.0.0.1, ::1, 0.0.0.0, *.localtest.me).")
.font(.caption)
.foregroundStyle(.secondary)
.fixedSize(horizontal: false, vertical: true)
Spacer(minLength: 0)
Button("Save") {
saveBrowserInsecureHTTPAllowlist()
}
.buttonStyle(.bordered)
.controlSize(.small)
.disabled(!browserInsecureHTTPAllowlistHasUnsavedChanges)
.accessibilityIdentifier("SettingsBrowserHTTPAllowlistSaveButton")
}
VStack(alignment: .leading, spacing: 8) {
Text("One host or wildcard per line (for example: localhost, 127.0.0.1, ::1, 0.0.0.0, *.localtest.me).")
.font(.caption)
.foregroundStyle(.secondary)
HStack {
Spacer(minLength: 0)
Button("Save") {
saveBrowserInsecureHTTPAllowlist()
}
.buttonStyle(.bordered)
.controlSize(.small)
.disabled(!browserInsecureHTTPAllowlistHasUnsavedChanges)
.accessibilityIdentifier("SettingsBrowserHTTPAllowlistSaveButton")
}
}
}
}
.padding(.horizontal, 14)
.padding(.vertical, 10)
SettingsCardDivider()
SettingsCardRow("Browsing History", subtitle: browserHistorySubtitle) {
Button("Clear History…") {
showClearBrowserHistoryConfirmation = true
}
.buttonStyle(.bordered)
.controlSize(.small)
.disabled(browserHistoryEntryCount == 0)
}
}
SettingsSectionHeader(title: "Keyboard Shortcuts")
SettingsCard {
let actions = KeyboardShortcutSettings.Action.allCases
ForEach(Array(actions.enumerated()), id: \.element.id) { index, action in
ShortcutSettingRow(action: action)
.padding(.horizontal, 14)
.padding(.vertical, 9)
if index < actions.count - 1 {
SettingsCardDivider()
}
}
}
.id(shortcutResetToken)
Text("Click a shortcut value to record a new shortcut.")
.font(.caption)
.foregroundColor(.secondary)
.padding(.leading, 2)
SettingsSectionHeader(title: "Reset")
SettingsCard {
HStack {
Spacer(minLength: 0)
Button("Reset All Settings") {
resetAllSettings()
}
.buttonStyle(.bordered)
.controlSize(.regular)
Spacer(minLength: 0)
}
.padding(.horizontal, 14)
.padding(.vertical, 10)
}
}
.padding(.horizontal, 20)
.padding(.bottom, 20)
.padding(.top, contentTopInset)
.background(
GeometryReader { proxy in
Color.clear.preference(
key: SettingsTopOffsetPreferenceKey.self,
value: proxy.frame(in: .named("SettingsScrollArea")).minY
)
}
)
}
.coordinateSpace(name: "SettingsScrollArea")
.onPreferenceChange(SettingsTopOffsetPreferenceKey.self) { value in
if topBlurBaselineOffset == nil {
topBlurBaselineOffset = value
}
topBlurOpacity = blurOpacity(forContentOffset: value)
}
ZStack(alignment: .top) {
SettingsTitleLeadingInsetReader(inset: $settingsTitleLeadingInset)
.frame(width: 0, height: 0)
AboutVisualEffectBackground(material: .underWindowBackground, blendingMode: .withinWindow)
.mask(
LinearGradient(
colors: [
Color.black.opacity(0.9),
Color.black.opacity(0.64),
Color.black.opacity(0.36),
Color.clear
],
startPoint: .top,
endPoint: .bottom
)
)
.opacity(0.52)
AboutVisualEffectBackground(material: .underWindowBackground, blendingMode: .withinWindow)
.mask(
LinearGradient(
colors: [
Color.black.opacity(0.98),
Color.black.opacity(0.78),
Color.black.opacity(0.42),
Color.clear
],
startPoint: .top,
endPoint: .bottom
)
)
.opacity(0.14 + (topBlurOpacity * 0.86))
HStack {
Text("Settings")
.font(.system(size: 16, weight: .semibold))
.foregroundColor(.primary.opacity(0.92))
Spacer(minLength: 0)
}
.padding(.leading, settingsTitleLeadingInset)
.padding(.top, 12)
}
.frame(height: 62)
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .top)
.ignoresSafeArea(.container, edges: .top)
.overlay(
Rectangle()
.fill(Color(nsColor: .separatorColor).opacity(0.07))
.frame(height: 1),
alignment: .bottom
)
.allowsHitTesting(false)
}
.background(Color(nsColor: .windowBackgroundColor).ignoresSafeArea())
.toggleStyle(.switch)
.onAppear {
BrowserHistoryStore.shared.loadIfNeeded()
browserThemeMode = BrowserThemeSettings.mode(defaults: .standard).rawValue
browserHistoryEntryCount = BrowserHistoryStore.shared.entries.count
browserInsecureHTTPAllowlistDraft = browserInsecureHTTPAllowlist
reloadWorkspaceTabColorSettings()
}
.onChange(of: browserInsecureHTTPAllowlist) { oldValue, newValue in
// Keep draft in sync with external changes unless the user has local unsaved edits.
if browserInsecureHTTPAllowlistDraft == oldValue {
browserInsecureHTTPAllowlistDraft = newValue
}
}
.onReceive(BrowserHistoryStore.shared.$entries) { entries in
browserHistoryEntryCount = entries.count
}
.onReceive(NotificationCenter.default.publisher(for: UserDefaults.didChangeNotification)) { _ in
reloadWorkspaceTabColorSettings()
}
.confirmationDialog(
"Clear browser history?",
isPresented: $showClearBrowserHistoryConfirmation,
titleVisibility: .visible
) {
Button("Clear History", role: .destructive) {
BrowserHistoryStore.shared.clearHistory()
}
Button("Cancel", role: .cancel) {}
} message: {
Text("This removes visited-page suggestions from the browser omnibar.")
}
.confirmationDialog(
"Enable full open access?",
isPresented: $showOpenAccessConfirmation,
titleVisibility: .visible
) {
Button("Enable Full Open Access", role: .destructive) {
socketControlMode = (pendingOpenAccessMode ?? .allowAll).rawValue
pendingOpenAccessMode = nil
}
Button("Cancel", role: .cancel) {
pendingOpenAccessMode = nil
}
} message: {
Text("This disables ancestry and password checks and opens the socket to all local users. Only enable when you understand the risk.")
}
}
private func resetAllSettings() {
appearanceMode = AppearanceSettings.defaultMode.rawValue
socketControlMode = SocketControlSettings.defaultMode.rawValue
claudeCodeHooksEnabled = ClaudeCodeIntegrationSettings.defaultHooksEnabled
sendAnonymousTelemetry = TelemetrySettings.defaultSendAnonymousTelemetry
browserSearchEngine = BrowserSearchSettings.defaultSearchEngine.rawValue
browserSearchSuggestionsEnabled = BrowserSearchSettings.defaultSearchSuggestionsEnabled
browserThemeMode = BrowserThemeSettings.defaultMode.rawValue
openTerminalLinksInCmuxBrowser = BrowserLinkOpenSettings.defaultOpenTerminalLinksInCmuxBrowser
interceptTerminalOpenCommandInCmuxBrowser = BrowserLinkOpenSettings.defaultInterceptTerminalOpenCommandInCmuxBrowser
browserHostWhitelist = BrowserLinkOpenSettings.defaultBrowserHostWhitelist
browserInsecureHTTPAllowlist = BrowserInsecureHTTPSettings.defaultAllowlistText
browserInsecureHTTPAllowlistDraft = BrowserInsecureHTTPSettings.defaultAllowlistText
notificationDockBadgeEnabled = NotificationBadgeSettings.defaultDockBadgeEnabled
warnBeforeQuitShortcut = QuitWarningSettings.defaultWarnBeforeQuit
commandPaletteRenameSelectAllOnFocus = CommandPaletteRenameSelectionSettings.defaultSelectAllOnFocus
newWorkspacePlacement = WorkspacePlacementSettings.defaultPlacement.rawValue
workspaceAutoReorder = WorkspaceAutoReorderSettings.defaultValue
sidebarBranchVerticalLayout = SidebarBranchLayoutSettings.defaultVerticalLayout
sidebarActiveTabIndicatorStyle = SidebarActiveTabIndicatorSettings.defaultStyle.rawValue
sidebarShowBranchDirectory = true
sidebarShowPullRequest = true
openSidebarPullRequestLinksInCmuxBrowser = BrowserLinkOpenSettings.defaultOpenSidebarPullRequestLinksInCmuxBrowser
sidebarShowPorts = true
sidebarShowLog = true
sidebarShowProgress = true
sidebarShowMetadata = true
showOpenAccessConfirmation = false
pendingOpenAccessMode = nil
socketPasswordDraft = ""
socketPasswordStatusMessage = nil
socketPasswordStatusIsError = false
KeyboardShortcutSettings.resetAll()
WorkspaceTabColorSettings.reset()
reloadWorkspaceTabColorSettings()
shortcutResetToken = UUID()
}
private func defaultTabColorBinding(for name: String) -> Binding<Color> {
Binding(
get: {
let hex = WorkspaceTabColorSettings.defaultColorHex(named: name)
return Color(nsColor: NSColor(hex: hex) ?? .systemBlue)
},
set: { newValue in
let hex = NSColor(newValue).hexString()
WorkspaceTabColorSettings.setDefaultColor(named: name, hex: hex)
reloadWorkspaceTabColorSettings()
}
)
}
private func baseTabColorHex(for name: String) -> String {
WorkspaceTabColorSettings.defaultPalette
.first(where: { $0.name == name })?
.hex ?? "#1565C0"
}
private func removeWorkspaceCustomColor(_ hex: String) {
WorkspaceTabColorSettings.removeCustomColor(hex)
reloadWorkspaceTabColorSettings()
}
private func resetWorkspaceTabColors() {
WorkspaceTabColorSettings.reset()
reloadWorkspaceTabColorSettings()
}
private func reloadWorkspaceTabColorSettings() {
workspaceTabDefaultEntries = WorkspaceTabColorSettings.defaultPaletteWithOverrides()
workspaceTabCustomColors = WorkspaceTabColorSettings.customColors()
}
private func saveBrowserInsecureHTTPAllowlist() {
browserInsecureHTTPAllowlist = browserInsecureHTTPAllowlistDraft
}
}
private struct SettingsTopOffsetPreferenceKey: PreferenceKey {
static var defaultValue: CGFloat = 0
static func reduce(value: inout CGFloat, nextValue: () -> CGFloat) {
value = nextValue()
}
}
private struct SettingsTitleLeadingInsetReader: NSViewRepresentable {
@Binding var inset: CGFloat
func makeNSView(context: Context) -> NSView {
let view = NSView(frame: .zero)
return view
}
func updateNSView(_ nsView: NSView, context: Context) {
DispatchQueue.main.async {
guard let window = nsView.window else { return }
let buttons: [NSWindow.ButtonType] = [.closeButton, .miniaturizeButton, .zoomButton]
let maxX = buttons
.compactMap { window.standardWindowButton($0)?.frame.maxX }
.max() ?? 78
let nextInset = maxX + 14
if abs(nextInset - inset) > 0.5 {
inset = nextInset
}
}
}
}
private struct SettingsSectionHeader: View {
let title: String
var body: some View {
Text(title)
.font(.system(size: 13, weight: .semibold))
.foregroundColor(.secondary)
.padding(.leading, 2)
.padding(.bottom, -2)
}
}
private struct SettingsCard<Content: View>: View {
@ViewBuilder let content: Content
init(@ViewBuilder content: () -> Content) {
self.content = content()
}
var body: some View {
VStack(alignment: .leading, spacing: 0) {
content
}
.background(
RoundedRectangle(cornerRadius: 13, style: .continuous)
.fill(Color(nsColor: NSColor.controlBackgroundColor).opacity(0.76))
.overlay(
RoundedRectangle(cornerRadius: 13, style: .continuous)
.stroke(Color(nsColor: NSColor.separatorColor).opacity(0.5), lineWidth: 1)
)
)
}
}
private struct SettingsCardRow<Trailing: View>: View {
let title: String
let subtitle: String?
let controlWidth: CGFloat?
@ViewBuilder let trailing: Trailing
init(
_ title: String,
subtitle: String? = nil,
controlWidth: CGFloat? = nil,
@ViewBuilder trailing: () -> Trailing
) {
self.title = title
self.subtitle = subtitle
self.controlWidth = controlWidth
self.trailing = trailing()
}
var body: some View {
HStack(alignment: .center, spacing: 12) {
VStack(alignment: .leading, spacing: subtitle == nil ? 0 : 3) {
Text(title)
.font(.system(size: 13, weight: .medium))
if let subtitle {
Text(subtitle)
.font(.caption)
.foregroundColor(.secondary)
.lineLimit(2)
}
}
.frame(maxWidth: .infinity, alignment: .leading)
Group {
if let controlWidth {
trailing
.frame(width: controlWidth, alignment: .trailing)
} else {
trailing
}
}
.layoutPriority(1)
}
.padding(.horizontal, 14)
.padding(.vertical, 9)
.frame(maxWidth: .infinity, alignment: .leading)
}
}
private struct SettingsCardDivider: View {
var body: some View {
Rectangle()
.fill(Color(nsColor: NSColor.separatorColor).opacity(0.5))
.frame(height: 1)
}
}
private struct SettingsCardNote: View {
let text: String
init(_ text: String) {
self.text = text
}
var body: some View {
Text(text)
.font(.caption)
.foregroundColor(.secondary)
.padding(.horizontal, 14)
.padding(.vertical, 8)
.frame(maxWidth: .infinity, alignment: .leading)
}
}
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")
applyCurrentSettingsWindowStyle(to: window)
let accessories = window.titlebarAccessoryViewControllers
for index in accessories.indices.reversed() {
guard let identifier = accessories[index].view.identifier?.rawValue else { continue }
guard identifier.hasPrefix("cmux.") else { continue }
window.removeTitlebarAccessoryViewController(at: index)
}
AppDelegate.shared?.applyWindowDecorations(to: window)
}
private func applyCurrentSettingsWindowStyle(to window: NSWindow) {
SettingsAboutTitlebarDebugStore.shared.applyCurrentOptions(to: window, for: .settings)
}
}