* 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>
3790 lines
161 KiB
Swift
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)
|
|
}
|
|
}
|