* Handle Cmd+O in handleCustomShortcut to prevent Documents folder open Cmd+O for "Open Folder" was only handled in SwiftUI menu, which can fail due to focus bugs when terminal is focused. This caused AppKit's default NSDocumentController to open the Documents folder instead. Now Cmd+O is intercepted in handleCustomShortcut like other shortcuts. Fixes #2010 Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * Fix fallback directory loss and deduplicate Open Folder logic Address review feedback: 1. Pass selected directory URL to fallback window creation so the user's folder choice is not silently discarded 2. Replace inline NSOpenPanel code in cmuxApp.swift menu action with a call to AppDelegate.showOpenFolderPanel() to avoid future divergence between the two code paths Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * Set NSOpenPanel directoryURL to current terminal working directory Address review feedback: set panel.directoryURL to the focused terminal's working directory so Open Folder starts in a contextually relevant location instead of AppKit's default. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * Use shared main-window resolver and openWorkspaceForExternalDirectory in showOpenFolderPanel Address review feedback: use preferredMainWindowContextForWorkspaceCreation for directory seeding (works when auxiliary windows are key) and openWorkspaceForExternalDirectory for workspace creation (ensures shouldBringToFront and consistent fallback behavior). --------- Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com> Co-authored-by: Lawrence Chen <lawrencecchen@users.noreply.github.com>
6244 lines
284 KiB
Swift
6244 lines
284 KiB
Swift
import AppKit
|
|
import SwiftUI
|
|
import Darwin
|
|
import Bonsplit
|
|
import UniformTypeIdentifiers
|
|
|
|
enum WorkspaceTitlebarSettings {
|
|
static let showTitlebarKey = "workspaceTitlebarVisible"
|
|
static let defaultShowTitlebar = true
|
|
|
|
static func isVisible(defaults: UserDefaults = .standard) -> Bool {
|
|
if defaults.object(forKey: showTitlebarKey) == nil {
|
|
return defaultShowTitlebar
|
|
}
|
|
return defaults.bool(forKey: showTitlebarKey)
|
|
}
|
|
}
|
|
|
|
enum WorkspacePresentationModeSettings {
|
|
static let modeKey = "workspacePresentationMode"
|
|
|
|
enum Mode: String {
|
|
case standard
|
|
case minimal
|
|
}
|
|
|
|
static let defaultMode: Mode = .standard
|
|
|
|
static func mode(for rawValue: String?) -> Mode {
|
|
Mode(rawValue: rawValue ?? "") ?? defaultMode
|
|
}
|
|
|
|
static func mode(defaults: UserDefaults = .standard) -> Mode {
|
|
mode(for: defaults.string(forKey: modeKey))
|
|
}
|
|
|
|
static func isMinimal(defaults: UserDefaults = .standard) -> Bool {
|
|
mode(defaults: defaults) == .minimal
|
|
}
|
|
}
|
|
|
|
enum WorkspaceButtonFadeSettings {
|
|
static let modeKey = "workspaceButtonsFadeMode"
|
|
static let legacyTitlebarControlsVisibilityModeKey = "titlebarControlsVisibilityMode"
|
|
static let legacyPaneTabBarControlsVisibilityModeKey = "paneTabBarControlsVisibilityMode"
|
|
|
|
enum Mode: String {
|
|
case enabled
|
|
case disabled
|
|
}
|
|
|
|
static let defaultMode: Mode = .disabled
|
|
|
|
static func mode(for rawValue: String?) -> Mode {
|
|
Mode(rawValue: rawValue ?? "") ?? defaultMode
|
|
}
|
|
|
|
static func isEnabled(defaults: UserDefaults = .standard) -> Bool {
|
|
mode(for: defaults.string(forKey: modeKey)) == .enabled
|
|
}
|
|
|
|
static func initializeStoredModeIfNeeded(defaults: UserDefaults = .standard) {
|
|
guard defaults.string(forKey: modeKey) == nil else { return }
|
|
|
|
if let migratedMode = migratedLegacyMode(defaults: defaults) {
|
|
defaults.set(migratedMode.rawValue, forKey: modeKey)
|
|
return
|
|
}
|
|
|
|
let initialMode: Mode = WorkspaceTitlebarSettings.isVisible(defaults: defaults) ? .disabled : .enabled
|
|
defaults.set(initialMode.rawValue, forKey: modeKey)
|
|
}
|
|
|
|
private static func migratedLegacyMode(defaults: UserDefaults) -> Mode? {
|
|
let legacyValues = [
|
|
defaults.string(forKey: legacyTitlebarControlsVisibilityModeKey),
|
|
defaults.string(forKey: legacyPaneTabBarControlsVisibilityModeKey),
|
|
]
|
|
|
|
if legacyValues.contains(where: { $0 == "onHover" || $0 == "hover" || $0 == "enabled" }) {
|
|
return .enabled
|
|
}
|
|
if legacyValues.contains(where: { $0 == "always" || $0 == "disabled" }) {
|
|
return .disabled
|
|
}
|
|
return nil
|
|
}
|
|
}
|
|
|
|
enum PaneFirstClickFocusSettings {
|
|
static let enabledKey = "paneFirstClickFocus.enabled"
|
|
static let defaultEnabled = false
|
|
|
|
static func isEnabled(defaults: UserDefaults = .standard) -> Bool {
|
|
defaults.object(forKey: enabledKey) as? Bool ?? defaultEnabled
|
|
}
|
|
}
|
|
|
|
enum UITestLaunchManifest {
|
|
static let argumentName = "-cmuxUITestLaunchManifest"
|
|
|
|
struct Payload: Decodable {
|
|
let environment: [String: String]
|
|
}
|
|
|
|
static func applyIfPresent(
|
|
arguments: [String] = CommandLine.arguments,
|
|
loadData: (String) -> Data? = { path in
|
|
try? Data(contentsOf: URL(fileURLWithPath: path))
|
|
},
|
|
applyEnvironment: (String, String) -> Void = { key, value in
|
|
setenv(key, value, 1)
|
|
}
|
|
) {
|
|
guard let path = manifestPath(from: arguments),
|
|
let data = loadData(path),
|
|
let payload = try? JSONDecoder().decode(Payload.self, from: data) else {
|
|
return
|
|
}
|
|
|
|
for (key, value) in payload.environment {
|
|
applyEnvironment(key, value)
|
|
}
|
|
}
|
|
|
|
static func manifestPath(from arguments: [String]) -> String? {
|
|
guard let index = arguments.firstIndex(of: argumentName) else { return nil }
|
|
let valueIndex = arguments.index(after: index)
|
|
guard valueIndex < arguments.endIndex else { return nil }
|
|
|
|
let rawPath = arguments[valueIndex].trimmingCharacters(in: .whitespacesAndNewlines)
|
|
return rawPath.isEmpty ? nil : rawPath
|
|
}
|
|
}
|
|
|
|
@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()
|
|
@StateObject private var cmuxConfigStore = CmuxConfigStore()
|
|
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(DevBuildBannerDebugSettings.sidebarBannerVisibleKey)
|
|
private var showSidebarDevBuildBanner = DevBuildBannerDebugSettings.defaultShowSidebarBanner
|
|
@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.selectWorkspaceByNumber.defaultsKey) private var selectWorkspaceByNumberShortcutData = Data()
|
|
@AppStorage(KeyboardShortcutSettings.Action.splitRight.defaultsKey) private var splitRightShortcutData = Data()
|
|
@AppStorage(KeyboardShortcutSettings.Action.splitDown.defaultsKey) private var splitDownShortcutData = Data()
|
|
@AppStorage(BrowserToolbarAccessorySpacingDebugSettings.key) private var browserToolbarAccessorySpacingRaw = BrowserToolbarAccessorySpacingDebugSettings.defaultSpacing
|
|
@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
|
|
|
|
private var browserToolbarAccessorySpacing: Int {
|
|
BrowserToolbarAccessorySpacingDebugSettings.resolved(browserToolbarAccessorySpacingRaw)
|
|
}
|
|
|
|
init() {
|
|
UITestLaunchManifest.applyIfPresent()
|
|
|
|
if SocketControlSettings.shouldBlockUntaggedDebugLaunch() {
|
|
Self.terminateForMissingLaunchTag()
|
|
}
|
|
|
|
Self.configureGhosttyEnvironment()
|
|
|
|
// Apply saved language preference before any UI loads
|
|
LanguageSettings.apply(LanguageSettings.languageAtLaunch)
|
|
|
|
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)
|
|
}
|
|
// Skip keychain migration for DEV/staging builds. Each tagged build gets a
|
|
// unique bundle ID with its own UserDefaults domain, so migration would run
|
|
// on every launch and trigger a macOS keychain access prompt (the legacy
|
|
// keychain item was created by a differently-signed app).
|
|
let bundleID = Bundle.main.bundleIdentifier
|
|
if !SocketControlSettings.isDebugLikeBundleIdentifier(bundleID)
|
|
&& !SocketControlSettings.isStagingBundleIdentifier(bundleID) {
|
|
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)
|
|
.environmentObject(cmuxConfigStore)
|
|
.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)
|
|
cmuxConfigStore.wireDirectoryTracking(tabManager: tabManager)
|
|
cmuxConfigStore.loadAll()
|
|
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(String(localized: "menu.app.settings", defaultValue: "Settings…")) {
|
|
appDelegate.openPreferencesWindow(debugSource: "menu.cmdComma")
|
|
}
|
|
.keyboardShortcut(",", modifiers: .command)
|
|
}
|
|
|
|
CommandGroup(replacing: .appInfo) {
|
|
Button(String(localized: "menu.app.about", defaultValue: "About cmux")) {
|
|
showAboutPanel()
|
|
}
|
|
Button(String(localized: "menu.app.ghosttySettings", defaultValue: "Ghostty Settings…")) {
|
|
GhosttyApp.shared.openConfigurationInTextEdit()
|
|
}
|
|
Button(String(localized: "menu.app.reloadConfiguration", defaultValue: "Reload Configuration")) {
|
|
GhosttyApp.shared.reloadConfiguration(source: "menu.reload_configuration")
|
|
}
|
|
.keyboardShortcut(",", modifiers: [.command, .shift])
|
|
Divider()
|
|
Button(String(localized: "menu.app.checkForUpdates", defaultValue: "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(String(localized: "menu.notifications.title", defaultValue: "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: String(localized: "menu.notifications.show", defaultValue: "Show Notifications"), shortcut: showNotificationsMenuShortcut) {
|
|
showNotificationsPopover()
|
|
}
|
|
|
|
splitCommandButton(title: String(localized: "menu.notifications.jumpToUnread", defaultValue: "Jump to Latest Unread"), shortcut: jumpToUnreadMenuShortcut) {
|
|
appDelegate.jumpToLatestUnread()
|
|
}
|
|
.disabled(!snapshot.hasUnreadNotifications)
|
|
|
|
Button(String(localized: "menu.notifications.markAllRead", defaultValue: "Mark All Read")) {
|
|
notificationStore.markAllRead()
|
|
}
|
|
.disabled(!snapshot.hasUnreadNotifications)
|
|
|
|
Button(String(localized: "menu.notifications.clearAll", defaultValue: "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)
|
|
}
|
|
|
|
Button(
|
|
String(
|
|
localized: "debug.menu.openStressWorkspacesWithLoadedSurfaces",
|
|
defaultValue: "Open Stress Workspaces and Load All Terminals"
|
|
)
|
|
) {
|
|
appDelegate.openDebugStressWorkspacesWithLoadedSurfaces(nil)
|
|
}
|
|
|
|
Divider()
|
|
Menu("Debug Windows") {
|
|
Button("Debug Window Controls…") {
|
|
DebugWindowControlsWindowController.shared.show()
|
|
}
|
|
|
|
Button("Browser Import Hint Debug…") {
|
|
BrowserImportHintDebugWindowController.shared.show()
|
|
}
|
|
|
|
Button(
|
|
String(
|
|
localized: "debug.menu.browserProfilePopoverDebug",
|
|
defaultValue: "Browser Profile Popover Debug…"
|
|
)
|
|
) {
|
|
BrowserProfilePopoverDebugWindowController.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()
|
|
}
|
|
}
|
|
|
|
Menu(
|
|
String(
|
|
localized: "debug.menu.browserToolbarButtonSpacing",
|
|
defaultValue: "Browser Toolbar Button Spacing"
|
|
)
|
|
) {
|
|
ForEach(BrowserToolbarAccessorySpacingDebugSettings.supportedValues, id: \.self) { spacing in
|
|
Button {
|
|
browserToolbarAccessorySpacingRaw = spacing
|
|
} label: {
|
|
if browserToolbarAccessorySpacing == spacing {
|
|
Label {
|
|
Text(verbatim: "\(spacing)")
|
|
} icon: {
|
|
Image(systemName: "checkmark")
|
|
}
|
|
} else {
|
|
Text(verbatim: "\(spacing)")
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
Toggle("Always Show Shortcut Hints", isOn: $alwaysShowShortcutHints)
|
|
Toggle(
|
|
String(localized: "debug.devBuildBanner.show", defaultValue: "Show Dev Build Banner"),
|
|
isOn: $showSidebarDevBuildBanner
|
|
)
|
|
|
|
Divider()
|
|
|
|
Picker("Titlebar Controls Style", selection: $titlebarControlsStyle) {
|
|
ForEach(TitlebarControlsStyle.allCases) { style in
|
|
Text(style.menuTitle).tag(style.rawValue)
|
|
}
|
|
}
|
|
|
|
Divider()
|
|
|
|
Button(String(localized: "menu.updateLogs.copyUpdateLogs", defaultValue: "Copy Update Logs")) {
|
|
appDelegate.copyUpdateLogs(nil)
|
|
}
|
|
Button(String(localized: "menu.updateLogs.copyFocusLogs", defaultValue: "Copy Focus Logs")) {
|
|
appDelegate.copyFocusLogs(nil)
|
|
}
|
|
|
|
Divider()
|
|
|
|
Button("Trigger Sentry Test Crash") {
|
|
appDelegate.triggerSentryTestCrash(nil)
|
|
}
|
|
}
|
|
#endif
|
|
|
|
// New tab commands
|
|
CommandGroup(replacing: .newItem) {
|
|
splitCommandButton(title: String(localized: "menu.file.newWindow", defaultValue: "New Window"), shortcut: newWindowMenuShortcut) {
|
|
appDelegate.openNewMainWindow(nil)
|
|
}
|
|
|
|
splitCommandButton(title: String(localized: "menu.file.newWorkspace", defaultValue: "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: String(localized: "menu.file.openFolder", defaultValue: "Open Folder…"), shortcut: openFolderMenuShortcut) {
|
|
AppDelegate.shared?.showOpenFolderPanel()
|
|
}
|
|
}
|
|
|
|
// Close tab/workspace
|
|
CommandGroup(after: .newItem) {
|
|
Button(String(localized: "menu.file.goToWorkspace", defaultValue: "Go to Workspace…")) {
|
|
let targetWindow = NSApp.keyWindow ?? NSApp.mainWindow
|
|
NotificationCenter.default.post(name: .commandPaletteSwitcherRequested, object: targetWindow)
|
|
}
|
|
.keyboardShortcut("p", modifiers: [.command])
|
|
|
|
Button(String(localized: "menu.file.commandPalette", defaultValue: "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/surface (with confirmation if needed). By
|
|
// default, closing the last surface also closes the workspace and the window
|
|
// if it was also the last workspace. Users can opt into keeping the workspace
|
|
// open instead.
|
|
Button(String(localized: "menu.file.closeTab", defaultValue: "Close Tab")) {
|
|
closePanelOrWindow()
|
|
}
|
|
.keyboardShortcut("w", modifiers: .command)
|
|
|
|
Button(String(localized: "menu.file.closeOtherTabs", defaultValue: "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: String(localized: "menu.file.closeWorkspace", defaultValue: "Close Workspace"), shortcut: closeWorkspaceMenuShortcut) {
|
|
closeTabOrWindow()
|
|
}
|
|
|
|
Menu(String(localized: "commandPalette.switcher.workspaceLabel", defaultValue: "Workspace")) {
|
|
workspaceCommandMenuContent(manager: activeTabManager)
|
|
}
|
|
|
|
Button(String(localized: "menu.file.reopenClosedBrowserPanel", defaultValue: "Reopen Closed Browser Panel")) {
|
|
_ = activeTabManager.reopenMostRecentlyClosedBrowserPanel()
|
|
}
|
|
.keyboardShortcut("t", modifiers: [.command, .shift])
|
|
}
|
|
|
|
// Find
|
|
CommandGroup(after: .textEditing) {
|
|
Menu(String(localized: "menu.find.title", defaultValue: "Find")) {
|
|
Button(String(localized: "menu.find.find", defaultValue: "Find…")) {
|
|
#if DEBUG
|
|
dlog("find.menu Cmd+F fired")
|
|
#endif
|
|
activeTabManager.startSearch()
|
|
}
|
|
.keyboardShortcut("f", modifiers: .command)
|
|
|
|
Button(String(localized: "menu.find.findNext", defaultValue: "Find Next")) {
|
|
activeTabManager.findNext()
|
|
}
|
|
.keyboardShortcut("g", modifiers: .command)
|
|
|
|
Button(String(localized: "menu.find.findPrevious", defaultValue: "Find Previous")) {
|
|
activeTabManager.findPrevious()
|
|
}
|
|
.keyboardShortcut("g", modifiers: [.command, .shift])
|
|
|
|
Divider()
|
|
|
|
Button(String(localized: "menu.find.hideFindBar", defaultValue: "Hide Find Bar")) {
|
|
activeTabManager.hideFind()
|
|
}
|
|
.keyboardShortcut("f", modifiers: [.command, .shift])
|
|
.disabled(!(activeTabManager.isFindVisible))
|
|
|
|
Divider()
|
|
|
|
Button(String(localized: "menu.find.useSelectionForFind", defaultValue: "Use Selection for Find")) {
|
|
activeTabManager.searchSelection()
|
|
}
|
|
.keyboardShortcut("e", modifiers: .command)
|
|
.disabled(!(activeTabManager.canUseSelectionForFind))
|
|
}
|
|
}
|
|
|
|
// Tab navigation
|
|
CommandGroup(after: .toolbar) {
|
|
splitCommandButton(title: String(localized: "menu.view.toggleSidebar", defaultValue: "Toggle Sidebar"), shortcut: toggleSidebarMenuShortcut) {
|
|
if AppDelegate.shared?.toggleSidebarInActiveMainWindow() != true {
|
|
sidebarState.toggle()
|
|
}
|
|
}
|
|
|
|
Divider()
|
|
|
|
splitCommandButton(title: String(localized: "menu.view.nextSurface", defaultValue: "Next Surface"), shortcut: nextSurfaceMenuShortcut) {
|
|
activeTabManager.selectNextSurface()
|
|
}
|
|
|
|
splitCommandButton(title: String(localized: "menu.view.previousSurface", defaultValue: "Previous Surface"), shortcut: prevSurfaceMenuShortcut) {
|
|
activeTabManager.selectPreviousSurface()
|
|
}
|
|
|
|
Button(String(localized: "menu.view.back", defaultValue: "Back")) {
|
|
activeTabManager.focusedBrowserPanel?.goBack()
|
|
}
|
|
.keyboardShortcut("[", modifiers: .command)
|
|
|
|
Button(String(localized: "menu.view.forward", defaultValue: "Forward")) {
|
|
activeTabManager.focusedBrowserPanel?.goForward()
|
|
}
|
|
.keyboardShortcut("]", modifiers: .command)
|
|
|
|
Button(String(localized: "menu.view.reloadPage", defaultValue: "Reload Page")) {
|
|
activeTabManager.focusedBrowserPanel?.reload()
|
|
}
|
|
.keyboardShortcut("r", modifiers: .command)
|
|
|
|
splitCommandButton(title: String(localized: "menu.view.toggleDevTools", defaultValue: "Toggle Developer Tools"), shortcut: toggleBrowserDeveloperToolsMenuShortcut) {
|
|
let manager = activeTabManager
|
|
if !manager.toggleDeveloperToolsFocusedBrowser() {
|
|
NSSound.beep()
|
|
}
|
|
}
|
|
|
|
splitCommandButton(title: String(localized: "menu.view.showJSConsole", defaultValue: "Show JavaScript Console"), shortcut: showBrowserJavaScriptConsoleMenuShortcut) {
|
|
let manager = activeTabManager
|
|
if !manager.showJavaScriptConsoleFocusedBrowser() {
|
|
NSSound.beep()
|
|
}
|
|
}
|
|
|
|
Button(String(localized: "menu.view.zoomIn", defaultValue: "Zoom In")) {
|
|
_ = activeTabManager.zoomInFocusedBrowser()
|
|
}
|
|
.keyboardShortcut("=", modifiers: .command)
|
|
|
|
Button(String(localized: "menu.view.zoomOut", defaultValue: "Zoom Out")) {
|
|
_ = activeTabManager.zoomOutFocusedBrowser()
|
|
}
|
|
.keyboardShortcut("-", modifiers: .command)
|
|
|
|
Button(String(localized: "menu.view.actualSize", defaultValue: "Actual Size")) {
|
|
_ = activeTabManager.resetZoomFocusedBrowser()
|
|
}
|
|
.keyboardShortcut("0", modifiers: .command)
|
|
|
|
Button(String(localized: "menu.view.clearBrowserHistory", defaultValue: "Clear Browser History")) {
|
|
BrowserHistoryStore.shared.clearHistory()
|
|
}
|
|
|
|
Button(String(localized: "menu.view.importFromBrowser", defaultValue: "Import Browser Data…")) {
|
|
// Defer modal presentation until after AppKit finishes menu tracking.
|
|
DispatchQueue.main.async {
|
|
BrowserDataImportCoordinator.shared.presentImportDialog()
|
|
}
|
|
}
|
|
|
|
splitCommandButton(title: String(localized: "menu.view.nextWorkspace", defaultValue: "Next Workspace"), shortcut: nextWorkspaceMenuShortcut) {
|
|
activeTabManager.selectNextTab()
|
|
}
|
|
|
|
splitCommandButton(title: String(localized: "menu.view.previousWorkspace", defaultValue: "Previous Workspace"), shortcut: prevWorkspaceMenuShortcut) {
|
|
activeTabManager.selectPreviousTab()
|
|
}
|
|
|
|
splitCommandButton(title: String(localized: "menu.view.renameWorkspace", defaultValue: "Rename Workspace…"), shortcut: renameWorkspaceMenuShortcut) {
|
|
_ = AppDelegate.shared?.requestRenameWorkspaceViaCommandPalette()
|
|
}
|
|
|
|
Divider()
|
|
|
|
splitCommandButton(title: String(localized: "menu.view.splitRight", defaultValue: "Split Right"), shortcut: splitRightMenuShortcut) {
|
|
performSplitFromMenu(direction: .right)
|
|
}
|
|
|
|
splitCommandButton(title: String(localized: "menu.view.splitDown", defaultValue: "Split Down"), shortcut: splitDownMenuShortcut) {
|
|
performSplitFromMenu(direction: .down)
|
|
}
|
|
|
|
splitCommandButton(title: String(localized: "menu.view.splitBrowserRight", defaultValue: "Split Browser Right"), shortcut: splitBrowserRightMenuShortcut) {
|
|
performBrowserSplitFromMenu(direction: .right)
|
|
}
|
|
|
|
splitCommandButton(title: String(localized: "menu.view.splitBrowserDown", defaultValue: "Split Browser Down"), shortcut: splitBrowserDownMenuShortcut) {
|
|
performBrowserSplitFromMenu(direction: .down)
|
|
}
|
|
|
|
Divider()
|
|
|
|
// Numbered workspace selection (9 = last workspace)
|
|
ForEach(1...9, id: \.self) { number in
|
|
Button(String(localized: "menu.view.workspace", defaultValue: "Workspace \(number)")) {
|
|
let manager = activeTabManager
|
|
if let targetIndex = WorkspaceShortcutMapper.workspaceIndex(forDigit: number, workspaceCount: manager.tabs.count) {
|
|
manager.selectTab(at: targetIndex)
|
|
}
|
|
}
|
|
.keyboardShortcut(
|
|
KeyEquivalent(Character("\(number)")),
|
|
modifiers: selectWorkspaceByNumberMenuShortcut.eventModifiers
|
|
)
|
|
}
|
|
|
|
Divider()
|
|
|
|
splitCommandButton(title: String(localized: "menu.view.jumpToUnread", defaultValue: "Jump to Latest Unread"), shortcut: jumpToUnreadMenuShortcut) {
|
|
AppDelegate.shared?.jumpToLatestUnread()
|
|
}
|
|
|
|
splitCommandButton(title: String(localized: "menu.view.showNotifications", defaultValue: "Show Notifications"), shortcut: showNotificationsMenuShortcut) {
|
|
showNotificationsPopover()
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
private func showAboutPanel() {
|
|
AboutWindowController.shared.show()
|
|
}
|
|
|
|
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 selectWorkspaceByNumberMenuShortcut: StoredShortcut {
|
|
decodeShortcut(
|
|
from: selectWorkspaceByNumberShortcutData,
|
|
fallback: KeyboardShortcutSettings.Action.selectWorkspaceByNumber.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)
|
|
}
|
|
|
|
private func selectedWorkspaceIndex(in manager: TabManager, workspaceId: UUID) -> Int? {
|
|
manager.tabs.firstIndex { $0.id == workspaceId }
|
|
}
|
|
|
|
private func selectedWorkspaceWindowMoveTargets(in manager: TabManager) -> [AppDelegate.WindowMoveTarget] {
|
|
let referenceWindowId = AppDelegate.shared?.windowId(for: manager)
|
|
return AppDelegate.shared?.windowMoveTargets(referenceWindowId: referenceWindowId) ?? []
|
|
}
|
|
|
|
private func toggleSelectedWorkspacePinned(in manager: TabManager) {
|
|
guard let workspace = manager.selectedWorkspace else { return }
|
|
manager.setPinned(workspace, pinned: !workspace.isPinned)
|
|
}
|
|
|
|
private func clearSelectedWorkspaceCustomName(in manager: TabManager) {
|
|
guard let workspace = manager.selectedWorkspace else { return }
|
|
manager.clearCustomTitle(tabId: workspace.id)
|
|
}
|
|
|
|
private func moveSelectedWorkspace(in manager: TabManager, by delta: Int) {
|
|
guard let workspace = manager.selectedWorkspace,
|
|
let currentIndex = selectedWorkspaceIndex(in: manager, workspaceId: workspace.id) else { return }
|
|
let targetIndex = currentIndex + delta
|
|
guard targetIndex >= 0, targetIndex < manager.tabs.count else { return }
|
|
_ = manager.reorderWorkspace(tabId: workspace.id, toIndex: targetIndex)
|
|
manager.selectWorkspace(workspace)
|
|
}
|
|
|
|
private func moveSelectedWorkspaceToTop(in manager: TabManager) {
|
|
guard let workspace = manager.selectedWorkspace else { return }
|
|
manager.moveTabsToTop([workspace.id])
|
|
manager.selectWorkspace(workspace)
|
|
}
|
|
|
|
private func moveSelectedWorkspace(in manager: TabManager, toWindow windowId: UUID) {
|
|
guard let workspace = manager.selectedWorkspace else { return }
|
|
_ = AppDelegate.shared?.moveWorkspaceToWindow(workspaceId: workspace.id, windowId: windowId, focus: true)
|
|
}
|
|
|
|
private func moveSelectedWorkspaceToNewWindow(in manager: TabManager) {
|
|
guard let workspace = manager.selectedWorkspace else { return }
|
|
_ = AppDelegate.shared?.moveWorkspaceToNewWindow(workspaceId: workspace.id, focus: true)
|
|
}
|
|
|
|
private func closeWorkspaceIds(
|
|
_ workspaceIds: [UUID],
|
|
in manager: TabManager,
|
|
allowPinned: Bool
|
|
) {
|
|
manager.closeWorkspacesWithConfirmation(workspaceIds, allowPinned: allowPinned)
|
|
}
|
|
|
|
private func closeOtherSelectedWorkspacePeers(in manager: TabManager) {
|
|
guard let workspace = manager.selectedWorkspace else { return }
|
|
let workspaceIds = manager.tabs.compactMap { $0.id == workspace.id ? nil : $0.id }
|
|
closeWorkspaceIds(workspaceIds, in: manager, allowPinned: true)
|
|
}
|
|
|
|
private func closeSelectedWorkspacesBelow(in manager: TabManager) {
|
|
guard let workspace = manager.selectedWorkspace,
|
|
let anchorIndex = selectedWorkspaceIndex(in: manager, workspaceId: workspace.id) else { return }
|
|
let workspaceIds = manager.tabs.suffix(from: anchorIndex + 1).map(\.id)
|
|
closeWorkspaceIds(workspaceIds, in: manager, allowPinned: true)
|
|
}
|
|
|
|
private func closeSelectedWorkspacesAbove(in manager: TabManager) {
|
|
guard let workspace = manager.selectedWorkspace,
|
|
let anchorIndex = selectedWorkspaceIndex(in: manager, workspaceId: workspace.id) else { return }
|
|
let workspaceIds = manager.tabs.prefix(upTo: anchorIndex).map(\.id)
|
|
closeWorkspaceIds(workspaceIds, in: manager, allowPinned: true)
|
|
}
|
|
|
|
private func selectedWorkspaceHasUnreadNotifications(in manager: TabManager) -> Bool {
|
|
guard let workspaceId = manager.selectedWorkspace?.id else { return false }
|
|
return notificationStore.notifications.contains { $0.tabId == workspaceId && !$0.isRead }
|
|
}
|
|
|
|
private func selectedWorkspaceHasReadNotifications(in manager: TabManager) -> Bool {
|
|
guard let workspaceId = manager.selectedWorkspace?.id else { return false }
|
|
return notificationStore.notifications.contains { $0.tabId == workspaceId && $0.isRead }
|
|
}
|
|
|
|
private func markSelectedWorkspaceRead(in manager: TabManager) {
|
|
guard let workspaceId = manager.selectedWorkspace?.id else { return }
|
|
notificationStore.markRead(forTabId: workspaceId)
|
|
}
|
|
|
|
private func markSelectedWorkspaceUnread(in manager: TabManager) {
|
|
guard let workspaceId = manager.selectedWorkspace?.id else { return }
|
|
notificationStore.markUnread(forTabId: workspaceId)
|
|
}
|
|
|
|
@ViewBuilder
|
|
private func workspaceCommandMenuContent(manager: TabManager) -> some View {
|
|
let workspace = manager.selectedWorkspace
|
|
let workspaceIndex = workspace.flatMap { selectedWorkspaceIndex(in: manager, workspaceId: $0.id) }
|
|
let windowMoveTargets = selectedWorkspaceWindowMoveTargets(in: manager)
|
|
|
|
Button(
|
|
workspace?.isPinned == true
|
|
? String(localized: "contextMenu.unpinWorkspace", defaultValue: "Unpin Workspace")
|
|
: String(localized: "contextMenu.pinWorkspace", defaultValue: "Pin Workspace")
|
|
) {
|
|
toggleSelectedWorkspacePinned(in: manager)
|
|
}
|
|
.disabled(workspace == nil)
|
|
|
|
Button(String(localized: "menu.view.renameWorkspace", defaultValue: "Rename Workspace…")) {
|
|
_ = AppDelegate.shared?.requestRenameWorkspaceViaCommandPalette()
|
|
}
|
|
.disabled(workspace == nil)
|
|
|
|
if workspace?.hasCustomTitle == true {
|
|
Button(String(localized: "contextMenu.removeCustomWorkspaceName", defaultValue: "Remove Custom Workspace Name")) {
|
|
clearSelectedWorkspaceCustomName(in: manager)
|
|
}
|
|
}
|
|
|
|
Divider()
|
|
|
|
Button(String(localized: "contextMenu.moveUp", defaultValue: "Move Up")) {
|
|
moveSelectedWorkspace(in: manager, by: -1)
|
|
}
|
|
.disabled(workspaceIndex == nil || workspaceIndex == 0)
|
|
|
|
Button(String(localized: "contextMenu.moveDown", defaultValue: "Move Down")) {
|
|
moveSelectedWorkspace(in: manager, by: 1)
|
|
}
|
|
.disabled(workspaceIndex == nil || workspaceIndex == manager.tabs.count - 1)
|
|
|
|
Button(String(localized: "contextMenu.moveToTop", defaultValue: "Move to Top")) {
|
|
moveSelectedWorkspaceToTop(in: manager)
|
|
}
|
|
.disabled(workspace == nil || workspaceIndex == 0)
|
|
|
|
Menu(String(localized: "contextMenu.moveWorkspaceToWindow", defaultValue: "Move Workspace to Window")) {
|
|
Button(String(localized: "contextMenu.newWindow", defaultValue: "New Window")) {
|
|
moveSelectedWorkspaceToNewWindow(in: manager)
|
|
}
|
|
.disabled(workspace == nil)
|
|
|
|
if !windowMoveTargets.isEmpty {
|
|
Divider()
|
|
}
|
|
|
|
ForEach(windowMoveTargets) { target in
|
|
Button(target.label) {
|
|
moveSelectedWorkspace(in: manager, toWindow: target.windowId)
|
|
}
|
|
.disabled(target.isCurrentWindow || workspace == nil)
|
|
}
|
|
}
|
|
.disabled(workspace == nil)
|
|
|
|
Divider()
|
|
|
|
Button(String(localized: "menu.file.closeWorkspace", defaultValue: "Close Workspace")) {
|
|
manager.closeCurrentWorkspaceWithConfirmation()
|
|
}
|
|
.disabled(workspace == nil)
|
|
|
|
Button(String(localized: "contextMenu.closeOtherWorkspaces", defaultValue: "Close Other Workspaces")) {
|
|
closeOtherSelectedWorkspacePeers(in: manager)
|
|
}
|
|
.disabled(workspace == nil || manager.tabs.count <= 1)
|
|
|
|
Button(String(localized: "contextMenu.closeWorkspacesBelow", defaultValue: "Close Workspaces Below")) {
|
|
closeSelectedWorkspacesBelow(in: manager)
|
|
}
|
|
.disabled(workspaceIndex == nil || workspaceIndex == manager.tabs.count - 1)
|
|
|
|
Button(String(localized: "contextMenu.closeWorkspacesAbove", defaultValue: "Close Workspaces Above")) {
|
|
closeSelectedWorkspacesAbove(in: manager)
|
|
}
|
|
.disabled(workspaceIndex == nil || workspaceIndex == 0)
|
|
|
|
Divider()
|
|
|
|
Button(String(localized: "contextMenu.markWorkspaceRead", defaultValue: "Mark Workspace as Read")) {
|
|
markSelectedWorkspaceRead(in: manager)
|
|
}
|
|
.disabled(!selectedWorkspaceHasUnreadNotifications(in: manager))
|
|
|
|
Button(String(localized: "contextMenu.markWorkspaceUnread", defaultValue: "Mark Workspace as Unread")) {
|
|
markSelectedWorkspaceUnread(in: manager)
|
|
}
|
|
.disabled(!selectedWorkspaceHasReadNotifications(in: manager))
|
|
}
|
|
|
|
@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 ?? NSApp.mainWindow,
|
|
cmuxWindowShouldOwnCloseShortcut(window) {
|
|
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() {
|
|
BrowserImportHintDebugWindowController.shared.show()
|
|
BrowserProfilePopoverDebugWindowController.shared.show()
|
|
SettingsAboutTitlebarDebugWindowController.shared.show()
|
|
SidebarDebugWindowController.shared.show()
|
|
BackgroundDebugWindowController.shared.show()
|
|
MenuBarExtraDebugWindowController.shared.show()
|
|
}
|
|
}
|
|
|
|
private let cmuxAuxiliaryWindowIdentifiers: Set<String> = [
|
|
"cmux.settings",
|
|
"cmux.about",
|
|
"cmux.licenses",
|
|
"cmux.browser-popup",
|
|
"cmux.settingsAboutTitlebarDebug",
|
|
"cmux.debugWindowControls",
|
|
"cmux.browserImportHintDebug",
|
|
"cmux.sidebarDebug",
|
|
"cmux.menubarDebug",
|
|
"cmux.backgroundDebug",
|
|
]
|
|
|
|
/// Returns whether the given window should handle the standard close shortcut
|
|
/// as a standalone auxiliary window instead of routing it through workspace or
|
|
/// panel-close behavior.
|
|
func cmuxWindowShouldOwnCloseShortcut(_ window: NSWindow?) -> Bool {
|
|
guard let identifier = window?.identifier?.rawValue else { return false }
|
|
return cmuxAuxiliaryWindowIdentifiers.contains(identifier)
|
|
}
|
|
|
|
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"))
|
|
sidebarTintHexLight=\(stringValue(defaults, key: "sidebarTintHexLight", fallback: "(nil)"))
|
|
sidebarTintHexDark=\(stringValue(defaults, key: "sidebarTintHexDark", fallback: "(nil)"))
|
|
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))
|
|
sidebarDevBuildBannerVisible=\(boolValue(defaults, key: DevBuildBannerDebugSettings.sidebarBannerVisibleKey, fallback: DevBuildBannerDebugSettings.defaultShowSidebarBanner))
|
|
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))
|
|
shortcutHintShowOnCommandHold=\(boolValue(defaults, key: ShortcutHintDebugSettings.showHintsOnCommandHoldKey, fallback: ShortcutHintDebugSettings.defaultShowHintsOnCommandHold))
|
|
"""
|
|
|
|
let backgroundPayload = """
|
|
bgGlassEnabled=\(boolValue(defaults, key: "bgGlassEnabled", fallback: false))
|
|
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("Browser Import Hint Debug…") {
|
|
BrowserImportHintDebugWindowController.shared.show()
|
|
}
|
|
Button(
|
|
String(
|
|
localized: "debug.menu.browserProfilePopoverDebug",
|
|
defaultValue: "Browser Profile Popover Debug…"
|
|
)
|
|
) {
|
|
BrowserProfilePopoverDebugWindowController.shared.show()
|
|
}
|
|
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") {
|
|
BrowserImportHintDebugWindowController.shared.show()
|
|
BrowserProfilePopoverDebugWindowController.shared.show()
|
|
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 BrowserImportHintDebugWindowController: NSWindowController, NSWindowDelegate {
|
|
static let shared = BrowserImportHintDebugWindowController()
|
|
|
|
private init() {
|
|
let window = NSPanel(
|
|
contentRect: NSRect(x: 0, y: 0, width: 380, height: 420),
|
|
styleMask: [.titled, .closable, .utilityWindow],
|
|
backing: .buffered,
|
|
defer: false
|
|
)
|
|
window.title = "Browser Import Hint Debug"
|
|
window.titleVisibility = .visible
|
|
window.titlebarAppearsTransparent = false
|
|
window.isMovableByWindowBackground = true
|
|
window.isReleasedWhenClosed = false
|
|
window.identifier = NSUserInterfaceItemIdentifier("cmux.browserImportHintDebug")
|
|
window.center()
|
|
window.contentView = NSHostingView(rootView: BrowserImportHintDebugView())
|
|
AppDelegate.shared?.applyWindowDecorations(to: window)
|
|
super.init(window: window)
|
|
window.delegate = self
|
|
}
|
|
|
|
@available(*, unavailable)
|
|
required init?(coder: NSCoder) {
|
|
fatalError("init(coder:) has not been implemented")
|
|
}
|
|
|
|
func show() {
|
|
window?.center()
|
|
window?.makeKeyAndOrderFront(nil)
|
|
}
|
|
}
|
|
|
|
private final class BrowserProfilePopoverDebugWindowController: NSWindowController, NSWindowDelegate {
|
|
static let shared = BrowserProfilePopoverDebugWindowController()
|
|
|
|
private init() {
|
|
let window = NSPanel(
|
|
contentRect: NSRect(x: 0, y: 0, width: 360, height: 340),
|
|
styleMask: [.titled, .closable, .utilityWindow],
|
|
backing: .buffered,
|
|
defer: false
|
|
)
|
|
window.title = String(
|
|
localized: "debug.windows.browserProfilePopover.title",
|
|
defaultValue: "Browser Profile Popover Debug"
|
|
)
|
|
window.titleVisibility = .visible
|
|
window.titlebarAppearsTransparent = false
|
|
window.isMovableByWindowBackground = true
|
|
window.isReleasedWhenClosed = false
|
|
window.identifier = NSUserInterfaceItemIdentifier("cmux.browserProfilePopoverDebug")
|
|
window.center()
|
|
window.contentView = NSHostingView(rootView: BrowserProfilePopoverDebugView())
|
|
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 BrowserProfilePopoverDebugView: View {
|
|
@AppStorage(BrowserProfilePopoverDebugSettings.horizontalPaddingKey)
|
|
private var horizontalPaddingRaw = BrowserProfilePopoverDebugSettings.defaultHorizontalPadding
|
|
@AppStorage(BrowserProfilePopoverDebugSettings.verticalPaddingKey)
|
|
private var verticalPaddingRaw = BrowserProfilePopoverDebugSettings.defaultVerticalPadding
|
|
|
|
private var horizontalPaddingBinding: Binding<Double> {
|
|
Binding(
|
|
get: { BrowserProfilePopoverDebugSettings.resolvedHorizontalPadding(horizontalPaddingRaw) },
|
|
set: { horizontalPaddingRaw = BrowserProfilePopoverDebugSettings.resolvedHorizontalPadding($0) }
|
|
)
|
|
}
|
|
|
|
private var verticalPaddingBinding: Binding<Double> {
|
|
Binding(
|
|
get: { BrowserProfilePopoverDebugSettings.resolvedVerticalPadding(verticalPaddingRaw) },
|
|
set: { verticalPaddingRaw = BrowserProfilePopoverDebugSettings.resolvedVerticalPadding($0) }
|
|
)
|
|
}
|
|
|
|
var body: some View {
|
|
ScrollView {
|
|
VStack(alignment: .leading, spacing: 14) {
|
|
Text(
|
|
String(
|
|
localized: "debug.browserProfilePopover.heading",
|
|
defaultValue: "Browser Profile Popover"
|
|
)
|
|
)
|
|
.font(.headline)
|
|
|
|
Text(
|
|
String(
|
|
localized: "debug.browserProfilePopover.note",
|
|
defaultValue: "Tune the profile popover padding live while comparing it against the browser toolbar menu."
|
|
)
|
|
)
|
|
.font(.caption)
|
|
.foregroundStyle(.secondary)
|
|
|
|
GroupBox(
|
|
String(
|
|
localized: "debug.browserProfilePopover.group.padding",
|
|
defaultValue: "Padding"
|
|
)
|
|
) {
|
|
VStack(alignment: .leading, spacing: 8) {
|
|
sliderRow(
|
|
String(
|
|
localized: "debug.browserProfilePopover.label.horizontal",
|
|
defaultValue: "Horizontal"
|
|
),
|
|
value: horizontalPaddingBinding,
|
|
range: BrowserProfilePopoverDebugSettings.horizontalPaddingRange
|
|
)
|
|
sliderRow(
|
|
String(
|
|
localized: "debug.browserProfilePopover.label.vertical",
|
|
defaultValue: "Vertical"
|
|
),
|
|
value: verticalPaddingBinding,
|
|
range: BrowserProfilePopoverDebugSettings.verticalPaddingRange
|
|
)
|
|
}
|
|
.padding(.top, 2)
|
|
}
|
|
|
|
GroupBox(
|
|
String(
|
|
localized: "debug.browserProfilePopover.group.preview",
|
|
defaultValue: "Preview"
|
|
)
|
|
) {
|
|
profilePopoverPreview
|
|
.padding(.top, 2)
|
|
}
|
|
|
|
HStack(spacing: 12) {
|
|
Button(
|
|
String(
|
|
localized: "debug.browserProfilePopover.reset",
|
|
defaultValue: "Reset"
|
|
)
|
|
) {
|
|
horizontalPaddingRaw = BrowserProfilePopoverDebugSettings.defaultHorizontalPadding
|
|
verticalPaddingRaw = BrowserProfilePopoverDebugSettings.defaultVerticalPadding
|
|
}
|
|
}
|
|
|
|
Text(
|
|
String(
|
|
localized: "debug.browserProfilePopover.liveNote",
|
|
defaultValue: "Changes apply live to the browser profile popover."
|
|
)
|
|
)
|
|
.font(.caption)
|
|
.foregroundStyle(.secondary)
|
|
|
|
Spacer(minLength: 0)
|
|
}
|
|
.padding(16)
|
|
.frame(maxWidth: .infinity, alignment: .topLeading)
|
|
}
|
|
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading)
|
|
}
|
|
|
|
private var profilePopoverPreview: some View {
|
|
VStack(alignment: .leading, spacing: 8) {
|
|
Text(String(localized: "browser.profile.menu.title", defaultValue: "Profiles"))
|
|
.font(.system(size: 12, weight: .semibold))
|
|
.foregroundStyle(.secondary)
|
|
|
|
VStack(alignment: .leading, spacing: 2) {
|
|
HStack(spacing: 8) {
|
|
Image(systemName: "checkmark")
|
|
.font(.system(size: 10, weight: .semibold))
|
|
.frame(width: 12, alignment: .center)
|
|
Text(String(localized: "browser.profile.default", defaultValue: "Default"))
|
|
.font(.system(size: 12))
|
|
Spacer(minLength: 0)
|
|
}
|
|
.padding(.horizontal, 8)
|
|
.frame(height: 24)
|
|
.background(
|
|
RoundedRectangle(cornerRadius: 6, style: .continuous)
|
|
.fill(Color.primary.opacity(0.12))
|
|
)
|
|
}
|
|
|
|
Divider()
|
|
|
|
Text(String(localized: "browser.profile.new", defaultValue: "New Profile..."))
|
|
.font(.system(size: 12))
|
|
|
|
Text(String(localized: "menu.view.importFromBrowser", defaultValue: "Import Browser Data…"))
|
|
.font(.system(size: 12))
|
|
}
|
|
.padding(.horizontal, BrowserProfilePopoverDebugSettings.resolvedHorizontalPadding(horizontalPaddingRaw))
|
|
.padding(.vertical, BrowserProfilePopoverDebugSettings.resolvedVerticalPadding(verticalPaddingRaw))
|
|
.frame(maxWidth: .infinity, alignment: .leading)
|
|
.background(
|
|
RoundedRectangle(cornerRadius: 16, style: .continuous)
|
|
.fill(Color(nsColor: .windowBackgroundColor))
|
|
.overlay(
|
|
RoundedRectangle(cornerRadius: 16, style: .continuous)
|
|
.stroke(Color.primary.opacity(0.08))
|
|
)
|
|
)
|
|
}
|
|
|
|
private func sliderRow(_ label: String, value: Binding<Double>, range: ClosedRange<Double>) -> some View {
|
|
HStack(spacing: 8) {
|
|
Text(label)
|
|
Slider(value: value, in: range, step: 1)
|
|
Text(String(format: "%.0f", value.wrappedValue))
|
|
.font(.caption)
|
|
.monospacedDigit()
|
|
.frame(width: 32, alignment: .trailing)
|
|
}
|
|
}
|
|
}
|
|
|
|
private struct BrowserImportHintDebugView: View {
|
|
@AppStorage(BrowserImportHintSettings.variantKey)
|
|
private var variantRaw = BrowserImportHintSettings.defaultVariant.rawValue
|
|
@AppStorage(BrowserImportHintSettings.showOnBlankTabsKey)
|
|
private var showOnBlankTabs = BrowserImportHintSettings.defaultShowOnBlankTabs
|
|
@AppStorage(BrowserImportHintSettings.dismissedKey)
|
|
private var isDismissed = BrowserImportHintSettings.defaultDismissed
|
|
|
|
private var selectedVariant: BrowserImportHintVariant {
|
|
BrowserImportHintSettings.variant(for: variantRaw)
|
|
}
|
|
|
|
private var variantSelection: Binding<String> {
|
|
Binding(
|
|
get: { selectedVariant.rawValue },
|
|
set: { variantRaw = BrowserImportHintSettings.variant(for: $0).rawValue }
|
|
)
|
|
}
|
|
|
|
private var showOnBlankTabsBinding: Binding<Bool> {
|
|
Binding(
|
|
get: { showOnBlankTabs },
|
|
set: { newValue in
|
|
showOnBlankTabs = newValue
|
|
if newValue {
|
|
isDismissed = false
|
|
}
|
|
}
|
|
)
|
|
}
|
|
|
|
private var presentation: BrowserImportHintPresentation {
|
|
BrowserImportHintPresentation(
|
|
variant: selectedVariant,
|
|
showOnBlankTabs: showOnBlankTabs,
|
|
isDismissed: isDismissed
|
|
)
|
|
}
|
|
|
|
var body: some View {
|
|
ScrollView {
|
|
VStack(alignment: .leading, spacing: 14) {
|
|
Text("Browser Import Hint")
|
|
.font(.headline)
|
|
|
|
Text("Try lighter blank-tab import surfaces and dismissal states without touching the permanent Browser settings home.")
|
|
.font(.caption)
|
|
.foregroundStyle(.secondary)
|
|
|
|
GroupBox("Variant") {
|
|
VStack(alignment: .leading, spacing: 10) {
|
|
Picker("Blank Tab Style", selection: variantSelection) {
|
|
ForEach(BrowserImportHintVariant.allCases) { variant in
|
|
Text(title(for: variant)).tag(variant.rawValue)
|
|
}
|
|
}
|
|
.pickerStyle(.menu)
|
|
|
|
Text(description(for: selectedVariant))
|
|
.font(.caption)
|
|
.foregroundStyle(.secondary)
|
|
.fixedSize(horizontal: false, vertical: true)
|
|
}
|
|
.padding(.top, 2)
|
|
}
|
|
|
|
GroupBox("State") {
|
|
VStack(alignment: .leading, spacing: 10) {
|
|
Toggle("Show on blank browser tabs", isOn: showOnBlankTabsBinding)
|
|
Toggle("Pretend the user dismissed it", isOn: $isDismissed)
|
|
|
|
Text("Current blank-tab placement: \(placementTitle(presentation.blankTabPlacement))")
|
|
.font(.caption)
|
|
.foregroundStyle(.secondary)
|
|
Text("Settings status: \(settingsStatusTitle(presentation.settingsStatus))")
|
|
.font(.caption)
|
|
.foregroundStyle(.secondary)
|
|
}
|
|
.padding(.top, 2)
|
|
}
|
|
|
|
GroupBox("Quick Actions") {
|
|
VStack(alignment: .leading, spacing: 8) {
|
|
HStack(spacing: 10) {
|
|
Button("Open Browser Settings") {
|
|
AppDelegate.presentPreferencesWindow(navigationTarget: .browser)
|
|
}
|
|
Button("Open Import Dialog") {
|
|
DispatchQueue.main.async {
|
|
BrowserDataImportCoordinator.shared.presentImportDialog()
|
|
}
|
|
}
|
|
}
|
|
|
|
Button("Reset Hint Debug State") {
|
|
BrowserImportHintSettings.reset()
|
|
}
|
|
}
|
|
.padding(.top, 2)
|
|
}
|
|
|
|
GroupBox("Ideas") {
|
|
VStack(alignment: .leading, spacing: 6) {
|
|
Text("Inline strip: default candidate, visible but quieter than the old floating card.")
|
|
Text("Floating card: strongest nudge, useful when we want more explanation.")
|
|
Text("Toolbar chip: most subtle, best when the hint should stay out of the content area.")
|
|
Text("Settings only: no in-browser nudge, Browser settings becomes the only permanent home.")
|
|
}
|
|
.font(.caption)
|
|
.foregroundStyle(.secondary)
|
|
.padding(.top, 2)
|
|
}
|
|
|
|
Spacer(minLength: 0)
|
|
}
|
|
.padding(16)
|
|
.frame(maxWidth: .infinity, alignment: .topLeading)
|
|
}
|
|
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading)
|
|
}
|
|
|
|
private func title(for variant: BrowserImportHintVariant) -> String {
|
|
switch variant {
|
|
case .inlineStrip:
|
|
return "Inline Strip"
|
|
case .floatingCard:
|
|
return "Floating Card"
|
|
case .toolbarChip:
|
|
return "Toolbar Chip"
|
|
case .settingsOnly:
|
|
return "Settings Only"
|
|
}
|
|
}
|
|
|
|
private func description(for variant: BrowserImportHintVariant) -> String {
|
|
switch variant {
|
|
case .inlineStrip:
|
|
return "Shows a thin hint bar at the top of blank browser tabs."
|
|
case .floatingCard:
|
|
return "Shows the fuller callout card inside blank browser tabs."
|
|
case .toolbarChip:
|
|
return "Moves the hint into a small toolbar chip beside the browser controls."
|
|
case .settingsOnly:
|
|
return "Hides the blank-tab hint and leaves Browser settings as the only home."
|
|
}
|
|
}
|
|
|
|
private func placementTitle(_ placement: BrowserImportHintBlankTabPlacement) -> String {
|
|
switch placement {
|
|
case .hidden:
|
|
return "Hidden"
|
|
case .inlineStrip:
|
|
return "Inline Strip"
|
|
case .floatingCard:
|
|
return "Floating Card"
|
|
case .toolbarChip:
|
|
return "Toolbar Chip"
|
|
}
|
|
}
|
|
|
|
private func settingsStatusTitle(_ status: BrowserImportHintSettingsStatus) -> String {
|
|
switch status {
|
|
case .visible:
|
|
return "Visible"
|
|
case .hidden:
|
|
return "Hidden"
|
|
case .settingsOnly:
|
|
return "Settings Only"
|
|
}
|
|
}
|
|
}
|
|
|
|
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 = String(localized: "about.licenses.windowTitle", defaultValue: "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 String(localized: "about.licenses.notFound", defaultValue: "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 var pendingFocusRestoreWorkItems: [DispatchWorkItem] = []
|
|
private var focusRestoreGeneration = 0
|
|
|
|
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(navigationTarget: SettingsNavigationTarget? = nil) {
|
|
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 let navigationTarget {
|
|
DispatchQueue.main.asyncAfter(deadline: .now() + 0.05) {
|
|
SettingsNavigationRequest.post(navigationTarget)
|
|
}
|
|
}
|
|
#if DEBUG
|
|
dlog("settings.window.show completed isVisible=\(window.isVisible ? 1 : 0) isKey=\(window.isKeyWindow ? 1 : 0)")
|
|
#endif
|
|
}
|
|
|
|
func preserveFocusAfterPreferenceMutation() {
|
|
guard let window, window.isVisible else { return }
|
|
cancelPendingFocusRestore()
|
|
focusRestoreGeneration += 1
|
|
let generation = focusRestoreGeneration
|
|
writeFocusDiagnosticsIfNeeded(stage: "requested")
|
|
scheduleFocusRestore(
|
|
for: window,
|
|
generation: generation,
|
|
delays: [0, 0.04, 0.12, 0.24, 0.4, 0.7]
|
|
)
|
|
}
|
|
|
|
func windowWillClose(_ notification: Notification) {
|
|
cancelPendingFocusRestore()
|
|
writeFocusDiagnosticsIfNeeded(stage: "windowWillClose")
|
|
}
|
|
|
|
func windowDidBecomeKey(_ notification: Notification) {
|
|
writeFocusDiagnosticsIfNeeded(stage: "didBecomeKey")
|
|
}
|
|
|
|
func windowDidResignKey(_ notification: Notification) {
|
|
guard let window else { return }
|
|
writeFocusDiagnosticsIfNeeded(stage: "didResignKey")
|
|
guard focusRestoreGeneration > 0 else { return }
|
|
scheduleFocusRestore(
|
|
for: window,
|
|
generation: focusRestoreGeneration,
|
|
delays: [0, 0.03, 0.1]
|
|
)
|
|
}
|
|
|
|
private func scheduleFocusRestore(
|
|
for window: NSWindow,
|
|
generation: Int,
|
|
delays: [TimeInterval]
|
|
) {
|
|
for (index, delay) in delays.enumerated() {
|
|
let isLastAttempt = index == delays.count - 1
|
|
let workItem = DispatchWorkItem { [weak self, weak window] in
|
|
guard let self, let window, window.isVisible else { return }
|
|
guard self.focusRestoreGeneration == generation else { return }
|
|
self.writeFocusDiagnosticsIfNeeded(stage: "restoreAttempt.\(index)")
|
|
if !window.isKeyWindow {
|
|
NSApp.activate(ignoringOtherApps: true)
|
|
window.orderFrontRegardless()
|
|
window.makeKeyAndOrderFront(nil)
|
|
self.writeFocusDiagnosticsIfNeeded(stage: "restoreApplied.\(index)")
|
|
}
|
|
if isLastAttempt, self.focusRestoreGeneration == generation {
|
|
self.focusRestoreGeneration = 0
|
|
}
|
|
}
|
|
pendingFocusRestoreWorkItems.append(workItem)
|
|
DispatchQueue.main.asyncAfter(deadline: .now() + delay, execute: workItem)
|
|
}
|
|
}
|
|
|
|
private func cancelPendingFocusRestore() {
|
|
pendingFocusRestoreWorkItems.forEach { $0.cancel() }
|
|
pendingFocusRestoreWorkItems.removeAll()
|
|
focusRestoreGeneration = 0
|
|
}
|
|
|
|
private func writeFocusDiagnosticsIfNeeded(stage: String) {
|
|
let env = ProcessInfo.processInfo.environment
|
|
guard let path = env["CMUX_UI_TEST_DIAGNOSTICS_PATH"], !path.isEmpty else { return }
|
|
|
|
var payload = loadFocusDiagnostics(at: path)
|
|
payload["focusStage"] = stage
|
|
payload["keyWindowIdentifier"] = NSApp.keyWindow?.identifier?.rawValue ?? ""
|
|
payload["mainWindowIdentifier"] = NSApp.mainWindow?.identifier?.rawValue ?? ""
|
|
payload["settingsWindowIsKey"] = (window?.isKeyWindow ?? false) ? "1" : "0"
|
|
|
|
guard let data = try? JSONSerialization.data(withJSONObject: payload) else { return }
|
|
try? data.write(to: URL(fileURLWithPath: path), options: .atomic)
|
|
}
|
|
|
|
private func loadFocusDiagnostics(at path: String) -> [String: String] {
|
|
guard let data = try? Data(contentsOf: URL(fileURLWithPath: path)),
|
|
let object = try? JSONSerialization.jsonObject(with: data) as? [String: String] else {
|
|
return [:]
|
|
}
|
|
return object
|
|
}
|
|
}
|
|
|
|
enum SettingsNavigationTarget: String {
|
|
case browser
|
|
case browserImport
|
|
case keyboardShortcuts
|
|
}
|
|
|
|
enum SettingsNavigationRequest {
|
|
static let notificationName = Notification.Name("cmux.settings.navigate")
|
|
private static let targetKey = "target"
|
|
|
|
static func post(_ target: SettingsNavigationTarget) {
|
|
NotificationCenter.default.post(
|
|
name: notificationName,
|
|
object: nil,
|
|
userInfo: [targetKey: target.rawValue]
|
|
)
|
|
}
|
|
|
|
static func target(from notification: Notification) -> SettingsNavigationTarget? {
|
|
guard let rawValue = notification.userInfo?[targetKey] as? String else { return nil }
|
|
return SettingsNavigationTarget(rawValue: rawValue)
|
|
}
|
|
}
|
|
|
|
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.com/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(String(localized: "about.appName", defaultValue: "cmux"))
|
|
.bold()
|
|
.font(.title)
|
|
Text(String(localized: "about.description", defaultValue: "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: String(localized: "about.version", defaultValue: "Version"), text: version)
|
|
}
|
|
if let build {
|
|
AboutPropertyRow(label: String(localized: "about.build", defaultValue: "Build"), text: build)
|
|
}
|
|
let commitText = commit ?? "—"
|
|
let commitURL = commit.flatMap { hash in
|
|
URL(string: "https://github.com/manaflow-ai/cmux/commit/\(hash)")
|
|
}
|
|
AboutPropertyRow(label: String(localized: "about.commit", defaultValue: "Commit"), text: commitText, url: commitURL)
|
|
}
|
|
.frame(maxWidth: .infinity)
|
|
|
|
HStack(spacing: 8) {
|
|
if let url = docsURL {
|
|
Button(String(localized: "about.docs", defaultValue: "Docs")) {
|
|
openURL(url)
|
|
}
|
|
}
|
|
if let url = githubURL {
|
|
Button(String(localized: "about.github", defaultValue: "GitHub")) {
|
|
openURL(url)
|
|
}
|
|
}
|
|
Button(String(localized: "about.licenses", defaultValue: "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 = SidebarTintDefaults.opacity
|
|
@AppStorage("sidebarTintHex") private var sidebarTintHex = SidebarTintDefaults.hex
|
|
@AppStorage("sidebarTintHexLight") private var sidebarTintHexLight: String?
|
|
@AppStorage("sidebarTintHexDark") private var sidebarTintHexDark: String?
|
|
@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(DevBuildBannerDebugSettings.sidebarBannerVisibleKey)
|
|
private var showSidebarDevBuildBanner = DevBuildBannerDebugSettings.defaultShowSidebarBanner
|
|
@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 = SidebarTintDefaults.hex
|
|
sidebarTintHexLight = nil
|
|
sidebarTintHexDark = nil
|
|
}
|
|
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)
|
|
sidebarTintHexLight=\(sidebarTintHexLight ?? "(nil)")
|
|
sidebarTintHexDark=\(sidebarTintHexDark ?? "(nil)")
|
|
sidebarTintOpacity=\(String(format: "%.2f", sidebarTintOpacity))
|
|
sidebarCornerRadius=\(String(format: "%.1f", sidebarCornerRadius))
|
|
sidebarBranchVerticalLayout=\(sidebarBranchVerticalLayout)
|
|
sidebarActiveTabIndicatorStyle=\(sidebarActiveTabIndicatorStyle)
|
|
sidebarDevBuildBannerVisible=\(showSidebarDevBuildBanner)
|
|
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
|
|
sidebarTintHexLight = nil
|
|
sidebarTintHexDark = nil
|
|
}
|
|
}
|
|
|
|
// 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 = false
|
|
|
|
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 = false
|
|
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 String(localized: "appearance.system", defaultValue: "System")
|
|
case .light:
|
|
return String(localized: "appearance.light", defaultValue: "Light")
|
|
case .dark:
|
|
return String(localized: "appearance.dark", defaultValue: "Dark")
|
|
case .auto:
|
|
return String(localized: "appearance.auto", defaultValue: "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 AppLanguage: String, CaseIterable, Identifiable {
|
|
case system
|
|
case en
|
|
case ar
|
|
case bs
|
|
case zhHans = "zh-Hans"
|
|
case zhHant = "zh-Hant"
|
|
case da
|
|
case de
|
|
case es
|
|
case fr
|
|
case it
|
|
case ja
|
|
case ko
|
|
case nb
|
|
case pl
|
|
case ptBR = "pt-BR"
|
|
case ru
|
|
case th
|
|
case tr
|
|
|
|
var id: String { rawValue }
|
|
|
|
var displayName: String {
|
|
switch self {
|
|
case .system: return String(localized: "language.system", defaultValue: "System")
|
|
case .en: return "English"
|
|
case .ar: return "\u{200E}العربية (Arabic)"
|
|
case .bs: return "Bosanski (Bosnian)"
|
|
case .zhHans: return "简体中文 (Chinese Simplified)"
|
|
case .zhHant: return "繁體中文 (Chinese Traditional)"
|
|
case .da: return "Dansk (Danish)"
|
|
case .de: return "Deutsch (German)"
|
|
case .es: return "Español (Spanish)"
|
|
case .fr: return "Français (French)"
|
|
case .it: return "Italiano (Italian)"
|
|
case .ja: return "日本語 (Japanese)"
|
|
case .ko: return "한국어 (Korean)"
|
|
case .nb: return "Norsk (Norwegian)"
|
|
case .pl: return "Polski (Polish)"
|
|
case .ptBR: return "Português (Brasil)"
|
|
case .ru: return "Русский (Russian)"
|
|
case .th: return "ไทย (Thai)"
|
|
case .tr: return "Türkçe (Turkish)"
|
|
}
|
|
}
|
|
}
|
|
|
|
enum LanguageSettings {
|
|
static let languageKey = "appLanguage"
|
|
static let defaultLanguage: AppLanguage = .system
|
|
|
|
static func apply(_ language: AppLanguage) {
|
|
if language == .system {
|
|
UserDefaults.standard.removeObject(forKey: "AppleLanguages")
|
|
} else {
|
|
UserDefaults.standard.set([language.rawValue], forKey: "AppleLanguages")
|
|
}
|
|
}
|
|
|
|
static var languageAtLaunch: AppLanguage = {
|
|
let stored = UserDefaults.standard.string(forKey: languageKey)
|
|
guard let stored, let lang = AppLanguage(rawValue: stored) else { return .system }
|
|
return lang
|
|
}()
|
|
}
|
|
|
|
enum AppIconMode: String, CaseIterable, Identifiable {
|
|
case automatic
|
|
case light
|
|
case dark
|
|
|
|
var id: String { rawValue }
|
|
|
|
var displayName: String {
|
|
switch self {
|
|
case .automatic: return String(localized: "appIcon.automatic", defaultValue: "Automatic")
|
|
case .light: return String(localized: "appIcon.light", defaultValue: "Light")
|
|
case .dark: return String(localized: "appIcon.dark", defaultValue: "Dark")
|
|
}
|
|
}
|
|
|
|
var imageName: String? {
|
|
switch self {
|
|
case .automatic: return nil
|
|
case .light: return "AppIconLight"
|
|
case .dark: return "AppIconDark"
|
|
}
|
|
}
|
|
}
|
|
|
|
enum AppIconSettings {
|
|
static let modeKey = "appIconMode"
|
|
static let defaultMode: AppIconMode = .automatic
|
|
|
|
static func resolvedMode(defaults: UserDefaults = .standard) -> AppIconMode {
|
|
guard let raw = defaults.string(forKey: modeKey),
|
|
let mode = AppIconMode(rawValue: raw) else {
|
|
return defaultMode
|
|
}
|
|
return mode
|
|
}
|
|
|
|
static func applyIcon(_ mode: AppIconMode) {
|
|
switch mode {
|
|
case .automatic:
|
|
AppIconAppearanceObserver.shared.startObserving()
|
|
case .light:
|
|
AppIconAppearanceObserver.shared.stopObserving()
|
|
if let icon = NSImage(named: "AppIconLight") {
|
|
NSApplication.shared.applicationIconImage = icon
|
|
}
|
|
case .dark:
|
|
AppIconAppearanceObserver.shared.stopObserving()
|
|
if let icon = NSImage(named: "AppIconDark") {
|
|
NSApplication.shared.applicationIconImage = icon
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
final class AppIconAppearanceObserver: NSObject {
|
|
static let shared = AppIconAppearanceObserver()
|
|
private var observation: NSKeyValueObservation?
|
|
|
|
private override init() { super.init() }
|
|
|
|
func startObserving() {
|
|
applyIconForCurrentAppearance()
|
|
guard observation == nil else { return }
|
|
observation = NSApp.observe(\.effectiveAppearance, options: []) { [weak self] _, _ in
|
|
DispatchQueue.main.async {
|
|
guard let self, self.observation != nil else { return }
|
|
self.applyIconForCurrentAppearance()
|
|
}
|
|
}
|
|
}
|
|
|
|
func stopObserving() {
|
|
observation?.invalidate()
|
|
observation = nil
|
|
}
|
|
|
|
private func applyIconForCurrentAppearance() {
|
|
let isDark = NSApp.effectiveAppearance.bestMatch(from: [.darkAqua, .aqua]) == .darkAqua
|
|
let imageName = isDark ? "AppIconDark" : "AppIconLight"
|
|
if let icon = NSImage(named: imageName) {
|
|
NSApplication.shared.applicationIconImage = icon
|
|
}
|
|
}
|
|
}
|
|
|
|
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 CommandPaletteSwitcherSearchSettings {
|
|
static let searchAllSurfacesKey = "commandPalette.switcherSearchAllSurfaces"
|
|
static let defaultSearchAllSurfaces = false
|
|
|
|
static func searchAllSurfacesEnabled(defaults: UserDefaults = .standard) -> Bool {
|
|
if defaults.object(forKey: searchAllSurfacesKey) == nil {
|
|
return defaultSearchAllSurfaces
|
|
}
|
|
return defaults.bool(forKey: searchAllSurfacesKey)
|
|
}
|
|
}
|
|
|
|
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 WelcomeSettings {
|
|
static let shownKey = "cmuxWelcomeShown"
|
|
}
|
|
|
|
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
|
|
private let notificationSoundControlWidth: CGFloat = 280
|
|
|
|
@AppStorage(LanguageSettings.languageKey) private var appLanguage = LanguageSettings.defaultLanguage.rawValue
|
|
@AppStorage(AppearanceSettings.appearanceModeKey) private var appearanceMode = AppearanceSettings.defaultMode.rawValue
|
|
@AppStorage(AppIconSettings.modeKey) private var appIconMode = AppIconSettings.defaultMode.rawValue
|
|
@AppStorage(WorkspacePresentationModeSettings.modeKey)
|
|
private var workspacePresentationMode = WorkspacePresentationModeSettings.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(BrowserImportHintSettings.variantKey) private var browserImportHintVariantRaw = BrowserImportHintSettings.defaultVariant.rawValue
|
|
@AppStorage(BrowserImportHintSettings.showOnBlankTabsKey) private var showBrowserImportHintOnBlankTabs = BrowserImportHintSettings.defaultShowOnBlankTabs
|
|
@AppStorage(BrowserImportHintSettings.dismissedKey) private var isBrowserImportHintDismissed = BrowserImportHintSettings.defaultDismissed
|
|
@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(BrowserLinkOpenSettings.browserExternalOpenPatternsKey)
|
|
private var browserExternalOpenPatterns = BrowserLinkOpenSettings.defaultBrowserExternalOpenPatterns
|
|
@AppStorage(BrowserInsecureHTTPSettings.allowlistKey) private var browserInsecureHTTPAllowlist = BrowserInsecureHTTPSettings.defaultAllowlistText
|
|
@AppStorage(NotificationSoundSettings.key) private var notificationSound = NotificationSoundSettings.defaultValue
|
|
@AppStorage(NotificationSoundSettings.customFilePathKey)
|
|
private var notificationSoundCustomFilePath = NotificationSoundSettings.defaultCustomFilePath
|
|
@AppStorage(NotificationSoundSettings.customCommandKey) private var notificationCustomCommand = NotificationSoundSettings.defaultCustomCommand
|
|
@AppStorage(NotificationBadgeSettings.dockBadgeEnabledKey) private var notificationDockBadgeEnabled = NotificationBadgeSettings.defaultDockBadgeEnabled
|
|
@AppStorage(NotificationPaneRingSettings.enabledKey) private var notificationPaneRingEnabled = NotificationPaneRingSettings.defaultEnabled
|
|
@AppStorage(NotificationPaneFlashSettings.enabledKey) private var notificationPaneFlashEnabled = NotificationPaneFlashSettings.defaultEnabled
|
|
@AppStorage(MenuBarExtraSettings.showInMenuBarKey) private var showMenuBarExtra = MenuBarExtraSettings.defaultShowInMenuBar
|
|
@AppStorage(QuitWarningSettings.warnBeforeQuitKey) private var warnBeforeQuitShortcut = QuitWarningSettings.defaultWarnBeforeQuit
|
|
@AppStorage(CommandPaletteRenameSelectionSettings.selectAllOnFocusKey)
|
|
private var commandPaletteRenameSelectAllOnFocus = CommandPaletteRenameSelectionSettings.defaultSelectAllOnFocus
|
|
@AppStorage(CommandPaletteSwitcherSearchSettings.searchAllSurfacesKey)
|
|
private var commandPaletteSearchAllSurfaces = CommandPaletteSwitcherSearchSettings.defaultSearchAllSurfaces
|
|
@AppStorage(ShortcutHintDebugSettings.alwaysShowHintsKey)
|
|
private var alwaysShowShortcutHints = ShortcutHintDebugSettings.defaultAlwaysShowHints
|
|
@AppStorage(WorkspacePlacementSettings.placementKey) private var newWorkspacePlacement = WorkspacePlacementSettings.defaultPlacement.rawValue
|
|
@AppStorage(LastSurfaceCloseShortcutSettings.key)
|
|
private var closeWorkspaceOnLastSurfaceShortcut = LastSurfaceCloseShortcutSettings.defaultValue
|
|
@AppStorage(PaneFirstClickFocusSettings.enabledKey)
|
|
private var paneFirstClickFocusEnabled = PaneFirstClickFocusSettings.defaultEnabled
|
|
@AppStorage(WorkspaceAutoReorderSettings.key) private var workspaceAutoReorder = WorkspaceAutoReorderSettings.defaultValue
|
|
@AppStorage(SidebarWorkspaceDetailSettings.hideAllDetailsKey)
|
|
private var sidebarHideAllDetails = SidebarWorkspaceDetailSettings.defaultHideAllDetails
|
|
@AppStorage(SidebarWorkspaceDetailSettings.showNotificationMessageKey)
|
|
private var sidebarShowNotificationMessage = SidebarWorkspaceDetailSettings.defaultShowNotificationMessage
|
|
@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(ShortcutHintDebugSettings.showHintsOnCommandHoldKey)
|
|
private var showShortcutHintsOnCommandHold = ShortcutHintDebugSettings.defaultShowHintsOnCommandHold
|
|
@AppStorage("sidebarShowSSH") private var sidebarShowSSH = true
|
|
@AppStorage("sidebarShowPorts") private var sidebarShowPorts = true
|
|
@AppStorage("sidebarShowLog") private var sidebarShowLog = true
|
|
@AppStorage("sidebarShowProgress") private var sidebarShowProgress = true
|
|
@AppStorage("sidebarShowStatusPills") private var sidebarShowMetadata = true
|
|
@AppStorage("sidebarTintHex") private var sidebarTintHex = SidebarTintDefaults.hex
|
|
@AppStorage("sidebarTintHexLight") private var sidebarTintHexLight: String?
|
|
@AppStorage("sidebarTintHexDark") private var sidebarTintHexDark: String?
|
|
@AppStorage("sidebarTintOpacity") private var sidebarTintOpacity = SidebarTintDefaults.opacity
|
|
|
|
@ObservedObject private var notificationStore = TerminalNotificationStore.shared
|
|
@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 detectedImportBrowsers: [InstalledBrowserCandidate] = []
|
|
@State private var browserInsecureHTTPAllowlistDraft = BrowserInsecureHTTPSettings.defaultAllowlistText
|
|
@State private var socketPasswordDraft = ""
|
|
@State private var socketPasswordStatusMessage: String?
|
|
@State private var socketPasswordStatusIsError = false
|
|
@State private var notificationCustomSoundStatusMessage: String?
|
|
@State private var notificationCustomSoundStatusIsError = false
|
|
@State private var showNotificationCustomSoundErrorAlert = false
|
|
@State private var notificationCustomSoundErrorAlertMessage = ""
|
|
@State private var telemetryValueAtLaunch = TelemetrySettings.enabledForCurrentLaunch
|
|
@State private var showLanguageRestartAlert = false
|
|
@State private var isResettingSettings = false
|
|
@State private var workspaceTabDefaultEntries = WorkspaceTabColorSettings.defaultPaletteWithOverrides()
|
|
@State private var workspaceTabCustomColors = WorkspaceTabColorSettings.customColors()
|
|
@State private var trustedDirectoriesDraft: String = CmuxDirectoryTrust.shared.allTrustedPaths.joined(separator: "\n")
|
|
|
|
private var selectedWorkspacePlacement: NewWorkspacePlacement {
|
|
NewWorkspacePlacement(rawValue: newWorkspacePlacement) ?? WorkspacePlacementSettings.defaultPlacement
|
|
}
|
|
|
|
private var minimalModeEnabled: Bool {
|
|
WorkspacePresentationModeSettings.mode(for: workspacePresentationMode) == .minimal
|
|
}
|
|
|
|
private var minimalModeSubtitle: String {
|
|
if minimalModeEnabled {
|
|
return String(
|
|
localized: "settings.app.minimalMode.subtitleOn",
|
|
defaultValue: "Hide the workspace title bar and move workspace controls into the sidebar."
|
|
)
|
|
}
|
|
return String(
|
|
localized: "settings.app.minimalMode.subtitleOff",
|
|
defaultValue: "Use the standard workspace title bar and controls."
|
|
)
|
|
}
|
|
|
|
private var keepWorkspaceOpenOnLastSurfaceShortcut: Bool {
|
|
!closeWorkspaceOnLastSurfaceShortcut
|
|
}
|
|
|
|
private var keepWorkspaceOpenOnLastSurfaceShortcutBinding: Binding<Bool> {
|
|
Binding(
|
|
get: { keepWorkspaceOpenOnLastSurfaceShortcut },
|
|
set: { closeWorkspaceOnLastSurfaceShortcut = !$0 }
|
|
)
|
|
}
|
|
|
|
private var closeWorkspaceOnLastSurfaceShortcutSubtitle: String {
|
|
if keepWorkspaceOpenOnLastSurfaceShortcut {
|
|
return String(
|
|
localized: "settings.app.closeWorkspaceOnLastSurfaceShortcut.subtitleOn",
|
|
defaultValue: "When the focused surface is the last one in its workspace, the close-surface shortcut closes only the surface and keeps the workspace open. Use the close-workspace shortcut to close the workspace explicitly."
|
|
)
|
|
}
|
|
return String(
|
|
localized: "settings.app.closeWorkspaceOnLastSurfaceShortcut.subtitleOff",
|
|
defaultValue: "When the focused surface is the last one in its workspace, the close-surface shortcut also closes the workspace."
|
|
)
|
|
}
|
|
|
|
private var paneFirstClickFocusSubtitle: String {
|
|
if paneFirstClickFocusEnabled {
|
|
return String(
|
|
localized: "settings.app.paneFirstClickFocus.subtitleOn",
|
|
defaultValue: "When cmux is inactive, clicking a pane activates the window and focuses that pane in one click."
|
|
)
|
|
}
|
|
return String(
|
|
localized: "settings.app.paneFirstClickFocus.subtitleOff",
|
|
defaultValue: "When cmux is inactive, the first click only activates the window. Click again to focus the pane."
|
|
)
|
|
}
|
|
|
|
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 browserImportHintVariant: BrowserImportHintVariant {
|
|
BrowserImportHintSettings.variant(for: browserImportHintVariantRaw)
|
|
}
|
|
|
|
private var browserImportHintPresentation: BrowserImportHintPresentation {
|
|
BrowserImportHintPresentation(
|
|
variant: browserImportHintVariant,
|
|
showOnBlankTabs: showBrowserImportHintOnBlankTabs,
|
|
isDismissed: isBrowserImportHintDismissed
|
|
)
|
|
}
|
|
|
|
private var browserImportHintVisibilityBinding: Binding<Bool> {
|
|
Binding(
|
|
get: { showBrowserImportHintOnBlankTabs },
|
|
set: { newValue in
|
|
showBrowserImportHintOnBlankTabs = newValue
|
|
if newValue {
|
|
isBrowserImportHintDismissed = false
|
|
}
|
|
}
|
|
)
|
|
}
|
|
|
|
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 minimalModeBinding: Binding<Bool> {
|
|
Binding(
|
|
get: { minimalModeEnabled },
|
|
set: { newValue in
|
|
workspacePresentationMode = newValue
|
|
? WorkspacePresentationModeSettings.Mode.minimal.rawValue
|
|
: WorkspacePresentationModeSettings.Mode.standard.rawValue
|
|
SettingsWindowController.shared.preserveFocusAfterPreferenceMutation()
|
|
}
|
|
)
|
|
}
|
|
|
|
private var settingsSidebarTintLightBinding: Binding<Color> {
|
|
Binding(
|
|
get: {
|
|
Color(nsColor: NSColor(hex: sidebarTintHexLight ?? sidebarTintHex) ?? .black)
|
|
},
|
|
set: { newColor in
|
|
let nsColor = NSColor(newColor)
|
|
sidebarTintHexLight = nsColor.hexString()
|
|
}
|
|
)
|
|
}
|
|
|
|
private var settingsSidebarTintDarkBinding: Binding<Color> {
|
|
Binding(
|
|
get: {
|
|
Color(nsColor: NSColor(hex: sidebarTintHexDark ?? sidebarTintHex) ?? .black)
|
|
},
|
|
set: { newColor in
|
|
let nsColor = NSColor(newColor)
|
|
sidebarTintHexDark = nsColor.hexString()
|
|
}
|
|
)
|
|
}
|
|
|
|
private var hasSocketPasswordConfigured: Bool {
|
|
SocketControlPasswordStore.hasConfiguredPassword()
|
|
}
|
|
|
|
private var browserHistorySubtitle: String {
|
|
switch browserHistoryEntryCount {
|
|
case 0:
|
|
return String(localized: "settings.browser.history.subtitleEmpty", defaultValue: "No saved pages yet.")
|
|
case 1:
|
|
return String(localized: "settings.browser.history.subtitleOne", defaultValue: "1 saved page appears in omnibar suggestions.")
|
|
default:
|
|
return String(localized: "settings.browser.history.subtitleMany", defaultValue: "\(browserHistoryEntryCount) saved pages appear in omnibar suggestions.")
|
|
}
|
|
}
|
|
|
|
private var browserImportSubtitle: String {
|
|
InstalledBrowserDetector.summaryText(for: detectedImportBrowsers)
|
|
}
|
|
|
|
private var browserImportHintSettingsNote: String {
|
|
switch browserImportHintPresentation.settingsStatus {
|
|
case .visible:
|
|
return String(localized: "settings.browser.import.hint.note.visible", defaultValue: "Blank browser tabs can show this import suggestion. Hide or re-enable it here.")
|
|
case .hidden:
|
|
return String(localized: "settings.browser.import.hint.note.hidden", defaultValue: "The blank-tab import hint is hidden. Turn it back on here any time.")
|
|
case .settingsOnly:
|
|
return String(localized: "settings.browser.import.hint.note.settingsOnly", defaultValue: "Blank tabs are currently using Settings only mode from the debug window.")
|
|
}
|
|
}
|
|
|
|
private var browserInsecureHTTPAllowlistHasUnsavedChanges: Bool {
|
|
browserInsecureHTTPAllowlistDraft != browserInsecureHTTPAllowlist
|
|
}
|
|
|
|
private func saveTrustedDirectories() {
|
|
let paths = trustedDirectoriesDraft
|
|
.split(separator: "\n", omittingEmptySubsequences: true)
|
|
.map { $0.trimmingCharacters(in: .whitespaces) }
|
|
.filter { !$0.isEmpty }
|
|
CmuxDirectoryTrust.shared.replaceAll(with: paths)
|
|
}
|
|
|
|
private var hasCustomNotificationSoundFilePath: Bool {
|
|
!notificationSoundCustomFilePath.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty
|
|
}
|
|
|
|
private var notificationSoundCustomFileDisplayName: String {
|
|
guard hasCustomNotificationSoundFilePath else {
|
|
return String(
|
|
localized: "settings.notifications.sound.custom.file.none",
|
|
defaultValue: "No file selected"
|
|
)
|
|
}
|
|
return URL(fileURLWithPath: notificationSoundCustomFilePath).lastPathComponent
|
|
}
|
|
|
|
private var canPreviewNotificationSound: Bool {
|
|
switch notificationSound {
|
|
case "none":
|
|
return false
|
|
case NotificationSoundSettings.customFileValue:
|
|
return hasCustomNotificationSoundFilePath
|
|
default:
|
|
return true
|
|
}
|
|
}
|
|
|
|
private var notificationPermissionStatusText: String {
|
|
notificationStore.authorizationState.statusLabel
|
|
}
|
|
|
|
private var notificationPermissionStatusColor: Color {
|
|
switch notificationStore.authorizationState {
|
|
case .authorized, .provisional, .ephemeral:
|
|
return .green
|
|
case .denied:
|
|
return .red
|
|
case .unknown, .notDetermined:
|
|
return .secondary
|
|
}
|
|
}
|
|
|
|
private var notificationPermissionSubtitle: String {
|
|
switch notificationStore.authorizationState {
|
|
case .unknown, .notDetermined:
|
|
return "Desktop notifications are not enabled yet."
|
|
case .authorized:
|
|
return "Desktop notifications are enabled."
|
|
case .denied:
|
|
return "Desktop notifications are disabled in System Settings."
|
|
case .provisional:
|
|
return "Desktop notifications are enabled with quiet delivery."
|
|
case .ephemeral:
|
|
return "Desktop notifications are temporarily enabled."
|
|
}
|
|
}
|
|
|
|
private var notificationPermissionActionTitle: String {
|
|
switch notificationStore.authorizationState {
|
|
case .unknown, .notDetermined:
|
|
return "Enable"
|
|
case .authorized, .denied, .provisional, .ephemeral:
|
|
return "Open Settings"
|
|
}
|
|
}
|
|
|
|
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 previewNotificationSound() {
|
|
if notificationSound == NotificationSoundSettings.customFileValue {
|
|
NotificationSoundSettings.playCustomFileSound(path: notificationSoundCustomFilePath)
|
|
return
|
|
}
|
|
NotificationSoundSettings.previewSound(value: notificationSound)
|
|
}
|
|
|
|
private func notificationCustomSoundIssueMessage(_ issue: NotificationSoundSettings.CustomSoundPreparationIssue) -> String {
|
|
switch issue {
|
|
case .emptyPath:
|
|
return String(
|
|
localized: "settings.notifications.sound.custom.status.empty",
|
|
defaultValue: "Choose a custom audio file first."
|
|
)
|
|
case .missingFile(let path):
|
|
let fileName = URL(fileURLWithPath: path).lastPathComponent
|
|
return String(
|
|
localized: "settings.notifications.sound.custom.status.missingFilePrefix",
|
|
defaultValue: "File not found: "
|
|
) + fileName
|
|
case .missingFileExtension(let path):
|
|
let fileName = URL(fileURLWithPath: path).lastPathComponent
|
|
return String(
|
|
localized: "settings.notifications.sound.custom.status.missingExtensionPrefix",
|
|
defaultValue: "File needs an extension: "
|
|
) + fileName
|
|
case .stagingFailed(_, let details):
|
|
let prefix = String(
|
|
localized: "settings.notifications.sound.custom.status.prepareFailed",
|
|
defaultValue: "Could not prepare this file for notifications. Try WAV, AIFF, or CAF."
|
|
)
|
|
return "\(prefix) (\(details))"
|
|
}
|
|
}
|
|
|
|
private func notificationCustomSoundReadyStatusMessage(for path: String) -> String {
|
|
let sourceExtension = URL(fileURLWithPath: path).pathExtension
|
|
.trimmingCharacters(in: .whitespacesAndNewlines)
|
|
.lowercased()
|
|
let stagedExtension = NotificationSoundSettings.stagedCustomSoundFileExtension(forSourceExtension: sourceExtension)
|
|
if !sourceExtension.isEmpty, stagedExtension != sourceExtension {
|
|
return String(
|
|
localized: "settings.notifications.sound.custom.status.readyConverted",
|
|
defaultValue: "Prepared for notifications (converted to CAF)."
|
|
)
|
|
}
|
|
return String(
|
|
localized: "settings.notifications.sound.custom.status.ready",
|
|
defaultValue: "Ready for notifications."
|
|
)
|
|
}
|
|
|
|
private func refreshNotificationCustomSoundStatus(showAlertOnFailure: Bool = false) {
|
|
guard notificationSound == NotificationSoundSettings.customFileValue else {
|
|
notificationCustomSoundStatusMessage = nil
|
|
notificationCustomSoundStatusIsError = false
|
|
return
|
|
}
|
|
let pathSnapshot = notificationSoundCustomFilePath
|
|
DispatchQueue.global(qos: .userInitiated).async {
|
|
let result = NotificationSoundSettings.prepareCustomFileForNotifications(path: pathSnapshot)
|
|
DispatchQueue.main.async {
|
|
guard notificationSound == NotificationSoundSettings.customFileValue else {
|
|
notificationCustomSoundStatusMessage = nil
|
|
notificationCustomSoundStatusIsError = false
|
|
return
|
|
}
|
|
guard notificationSoundCustomFilePath == pathSnapshot else { return }
|
|
switch result {
|
|
case .success:
|
|
notificationCustomSoundStatusMessage = notificationCustomSoundReadyStatusMessage(for: pathSnapshot)
|
|
notificationCustomSoundStatusIsError = false
|
|
case .failure(let issue):
|
|
let message = notificationCustomSoundIssueMessage(issue)
|
|
notificationCustomSoundStatusMessage = message
|
|
notificationCustomSoundStatusIsError = true
|
|
if showAlertOnFailure {
|
|
notificationCustomSoundErrorAlertMessage = message
|
|
showNotificationCustomSoundErrorAlert = true
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
private func chooseNotificationSoundFile() {
|
|
let panel = NSOpenPanel()
|
|
panel.canChooseFiles = true
|
|
panel.canChooseDirectories = false
|
|
panel.allowsMultipleSelection = false
|
|
panel.allowedContentTypes = [.audio]
|
|
panel.title = String(
|
|
localized: "settings.notifications.sound.custom.choose.title",
|
|
defaultValue: "Choose Notification Sound"
|
|
)
|
|
panel.prompt = String(
|
|
localized: "settings.notifications.sound.custom.choose.prompt",
|
|
defaultValue: "Choose"
|
|
)
|
|
guard panel.runModal() == .OK, let url = panel.url else { return }
|
|
let selectedPath = url.path
|
|
switch NotificationSoundSettings.prepareCustomFileForNotifications(path: selectedPath) {
|
|
case .success:
|
|
notificationSoundCustomFilePath = selectedPath
|
|
notificationSound = NotificationSoundSettings.customFileValue
|
|
notificationCustomSoundStatusMessage = notificationCustomSoundReadyStatusMessage(for: selectedPath)
|
|
notificationCustomSoundStatusIsError = false
|
|
previewNotificationSound()
|
|
case .failure(let issue):
|
|
let message = notificationCustomSoundIssueMessage(issue)
|
|
notificationCustomSoundErrorAlertMessage = message
|
|
showNotificationCustomSoundErrorAlert = true
|
|
refreshNotificationCustomSoundStatus()
|
|
}
|
|
}
|
|
|
|
private func handleNotificationPermissionAction() {
|
|
let state = notificationStore.authorizationState.statusLabel
|
|
#if DEBUG
|
|
dlog("notification.ui enableTapped state=\(state)")
|
|
#endif
|
|
NSLog("notification.ui enableTapped state=%@", state)
|
|
switch notificationStore.authorizationState {
|
|
case .unknown, .notDetermined:
|
|
notificationStore.requestAuthorizationFromSettings()
|
|
case .authorized, .denied, .provisional, .ephemeral:
|
|
notificationStore.openNotificationSettings()
|
|
}
|
|
}
|
|
|
|
private func saveSocketPassword() {
|
|
let trimmed = socketPasswordDraft.trimmingCharacters(in: .whitespacesAndNewlines)
|
|
guard !trimmed.isEmpty else {
|
|
socketPasswordStatusMessage = String(localized: "settings.automation.socketPassword.enterFirst", defaultValue: "Enter a password first.")
|
|
socketPasswordStatusIsError = true
|
|
return
|
|
}
|
|
|
|
do {
|
|
try SocketControlPasswordStore.savePassword(trimmed)
|
|
socketPasswordDraft = ""
|
|
socketPasswordStatusMessage = String(localized: "settings.automation.socketPassword.saved", defaultValue: "Password saved.")
|
|
socketPasswordStatusIsError = false
|
|
} catch {
|
|
socketPasswordStatusMessage = String(localized: "settings.automation.socketPassword.saveFailed", defaultValue: "Failed to save password (\(error.localizedDescription)).")
|
|
socketPasswordStatusIsError = true
|
|
}
|
|
}
|
|
|
|
private func clearSocketPassword() {
|
|
do {
|
|
try SocketControlPasswordStore.clearPassword()
|
|
socketPasswordDraft = ""
|
|
socketPasswordStatusMessage = String(localized: "settings.automation.socketPassword.cleared", defaultValue: "Password cleared.")
|
|
socketPasswordStatusIsError = false
|
|
} catch {
|
|
socketPasswordStatusMessage = String(localized: "settings.automation.socketPassword.clearFailed", defaultValue: "Failed to clear password (\(error.localizedDescription)).")
|
|
socketPasswordStatusIsError = true
|
|
}
|
|
}
|
|
|
|
var body: some View {
|
|
ScrollViewReader { proxy in
|
|
ZStack(alignment: .top) {
|
|
ScrollView {
|
|
VStack(alignment: .leading, spacing: 14) {
|
|
SettingsSectionHeader(title: String(localized: "settings.section.app", defaultValue: "App"))
|
|
SettingsCard {
|
|
SettingsCardRow(
|
|
String(localized: "settings.app.language", defaultValue: "Language"),
|
|
subtitle: appLanguage != LanguageSettings.languageAtLaunch.rawValue
|
|
? String(localized: "settings.app.language.restartSubtitle", defaultValue: "Restart cmux to apply")
|
|
: nil,
|
|
controlWidth: pickerColumnWidth
|
|
) {
|
|
Picker("", selection: $appLanguage) {
|
|
ForEach(AppLanguage.allCases) { lang in
|
|
Text(lang.displayName).tag(lang.rawValue)
|
|
}
|
|
}
|
|
.labelsHidden()
|
|
.pickerStyle(.menu)
|
|
.onChange(of: appLanguage) { newValue in
|
|
guard !isResettingSettings else { return }
|
|
DispatchQueue.main.asyncAfter(deadline: .now() + 0.3) { [self] in
|
|
// Re-check current value to handle rapid changes
|
|
let current = appLanguage
|
|
if let lang = AppLanguage(rawValue: current) {
|
|
LanguageSettings.apply(lang)
|
|
}
|
|
if current != LanguageSettings.languageAtLaunch.rawValue {
|
|
showLanguageRestartAlert = true
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
SettingsCardDivider()
|
|
|
|
ThemePickerRow(
|
|
selectedMode: appearanceMode,
|
|
onSelect: { mode in
|
|
appearanceMode = mode.rawValue
|
|
}
|
|
)
|
|
|
|
SettingsCardDivider()
|
|
|
|
AppIconPickerRow(
|
|
selectedMode: appIconMode,
|
|
onSelect: { mode in
|
|
appIconMode = mode.rawValue
|
|
AppIconSettings.applyIcon(mode)
|
|
}
|
|
)
|
|
|
|
SettingsCardDivider()
|
|
|
|
SettingsPickerRow(
|
|
String(localized: "settings.app.newWorkspacePlacement", defaultValue: "New Workspace Placement"),
|
|
subtitle: selectedWorkspacePlacement.description,
|
|
controlWidth: pickerColumnWidth,
|
|
selection: $newWorkspacePlacement
|
|
) {
|
|
ForEach(NewWorkspacePlacement.allCases) { placement in
|
|
Text(placement.displayName).tag(placement.rawValue)
|
|
}
|
|
}
|
|
|
|
SettingsCardDivider()
|
|
|
|
SettingsCardRow(
|
|
String(localized: "settings.app.minimalMode", defaultValue: "Minimal Mode"),
|
|
subtitle: minimalModeSubtitle
|
|
) {
|
|
Toggle("", isOn: minimalModeBinding)
|
|
.labelsHidden()
|
|
.controlSize(.small)
|
|
.accessibilityIdentifier("SettingsMinimalModeToggle")
|
|
.accessibilityLabel(
|
|
String(localized: "settings.app.minimalMode", defaultValue: "Minimal Mode")
|
|
)
|
|
}
|
|
|
|
SettingsCardDivider()
|
|
|
|
SettingsCardRow(
|
|
String(localized: "settings.app.closeWorkspaceOnLastSurfaceShortcut", defaultValue: "Keep Workspace Open When Closing Last Surface"),
|
|
subtitle: closeWorkspaceOnLastSurfaceShortcutSubtitle
|
|
) {
|
|
Toggle("", isOn: keepWorkspaceOpenOnLastSurfaceShortcutBinding)
|
|
.labelsHidden()
|
|
.controlSize(.small)
|
|
}
|
|
|
|
SettingsCardDivider()
|
|
|
|
SettingsCardRow(
|
|
String(localized: "settings.app.paneFirstClickFocus", defaultValue: "Focus Pane on First Click"),
|
|
subtitle: paneFirstClickFocusSubtitle
|
|
) {
|
|
Toggle("", isOn: $paneFirstClickFocusEnabled)
|
|
.labelsHidden()
|
|
.controlSize(.small)
|
|
.accessibilityLabel(
|
|
String(localized: "settings.app.paneFirstClickFocus", defaultValue: "Focus Pane on First Click")
|
|
)
|
|
}
|
|
|
|
SettingsCardDivider()
|
|
|
|
SettingsCardRow(
|
|
String(localized: "settings.app.reorderOnNotification", defaultValue: "Reorder on Notification"),
|
|
subtitle: String(localized: "settings.app.reorderOnNotification.subtitle", defaultValue: "Move workspaces to the top when they receive a notification. Disable for stable shortcut positions.")
|
|
) {
|
|
Toggle("", isOn: $workspaceAutoReorder)
|
|
.labelsHidden()
|
|
.controlSize(.small)
|
|
}
|
|
|
|
SettingsCardDivider()
|
|
|
|
SettingsCardRow(
|
|
String(localized: "settings.app.dockBadge", defaultValue: "Dock Badge"),
|
|
subtitle: String(localized: "settings.app.dockBadge.subtitle", defaultValue: "Show unread count on app icon (Dock and Cmd+Tab).")
|
|
) {
|
|
Toggle("", isOn: $notificationDockBadgeEnabled)
|
|
.labelsHidden()
|
|
.controlSize(.small)
|
|
}
|
|
|
|
SettingsCardDivider()
|
|
|
|
SettingsCardRow(
|
|
String(localized: "settings.app.showInMenuBar", defaultValue: "Show in Menu Bar"),
|
|
subtitle: String(localized: "settings.app.showInMenuBar.subtitle", defaultValue: "Keep cmux in the menu bar for unread notifications and quick actions.")
|
|
) {
|
|
Toggle("", isOn: $showMenuBarExtra)
|
|
.labelsHidden()
|
|
.controlSize(.small)
|
|
.accessibilityLabel(
|
|
String(localized: "settings.app.showInMenuBar", defaultValue: "Show in Menu Bar")
|
|
)
|
|
}
|
|
|
|
SettingsCardDivider()
|
|
|
|
SettingsCardRow(
|
|
String(localized: "settings.notifications.paneRing.title", defaultValue: "Unread Pane Ring"),
|
|
subtitle: String(localized: "settings.notifications.paneRing.subtitle", defaultValue: "Show a blue ring around panes with unread notifications.")
|
|
) {
|
|
Toggle("", isOn: $notificationPaneRingEnabled)
|
|
.labelsHidden()
|
|
.controlSize(.small)
|
|
.accessibilityLabel(
|
|
String(localized: "settings.notifications.paneRing.title", defaultValue: "Unread Pane Ring")
|
|
)
|
|
}
|
|
|
|
SettingsCardDivider()
|
|
|
|
SettingsCardRow(
|
|
String(localized: "settings.notifications.paneFlash.title", defaultValue: "Pane Flash"),
|
|
subtitle: String(localized: "settings.notifications.paneFlash.subtitle", defaultValue: "Briefly flash a blue outline when cmux highlights a pane.")
|
|
) {
|
|
Toggle("", isOn: $notificationPaneFlashEnabled)
|
|
.labelsHidden()
|
|
.controlSize(.small)
|
|
.accessibilityLabel(
|
|
String(localized: "settings.notifications.paneFlash.title", defaultValue: "Pane Flash")
|
|
)
|
|
}
|
|
|
|
SettingsCardDivider()
|
|
|
|
SettingsCardRow(
|
|
"Desktop Notifications",
|
|
subtitle: notificationPermissionSubtitle
|
|
) {
|
|
HStack(spacing: 6) {
|
|
Text(notificationPermissionStatusText)
|
|
.font(.system(size: 11, weight: .semibold))
|
|
.foregroundStyle(notificationPermissionStatusColor)
|
|
.frame(width: 98, alignment: .trailing)
|
|
|
|
Button(notificationPermissionActionTitle) {
|
|
handleNotificationPermissionAction()
|
|
}
|
|
.controlSize(.small)
|
|
|
|
Button("Send Test") {
|
|
notificationStore.sendSettingsTestNotification()
|
|
}
|
|
.controlSize(.small)
|
|
}
|
|
}
|
|
|
|
SettingsCardDivider()
|
|
|
|
SettingsCardRow(
|
|
String(localized: "settings.notifications.sound.title", defaultValue: "Notification Sound"),
|
|
subtitle: String(localized: "settings.notifications.sound.subtitle", defaultValue: "Sound played when a notification arrives."),
|
|
controlWidth: notificationSoundControlWidth
|
|
) {
|
|
VStack(alignment: .trailing, spacing: 6) {
|
|
HStack(spacing: 6) {
|
|
Picker("", selection: $notificationSound) {
|
|
ForEach(NotificationSoundSettings.systemSounds, id: \.value) { sound in
|
|
Text(sound.label).tag(sound.value)
|
|
}
|
|
}
|
|
.labelsHidden()
|
|
Button {
|
|
previewNotificationSound()
|
|
} label: {
|
|
Image(systemName: "play.fill")
|
|
.font(.system(size: 9))
|
|
}
|
|
.buttonStyle(.bordered)
|
|
.controlSize(.small)
|
|
.disabled(!canPreviewNotificationSound)
|
|
}
|
|
|
|
if notificationSound == NotificationSoundSettings.customFileValue {
|
|
HStack(spacing: 6) {
|
|
Text(notificationSoundCustomFileDisplayName)
|
|
.font(.system(size: 11))
|
|
.foregroundStyle(.secondary)
|
|
.lineLimit(1)
|
|
.truncationMode(.middle)
|
|
.frame(width: 170, alignment: .trailing)
|
|
Button(
|
|
String(
|
|
localized: "settings.notifications.sound.custom.choose.button",
|
|
defaultValue: "Choose..."
|
|
)
|
|
) {
|
|
chooseNotificationSoundFile()
|
|
}
|
|
.controlSize(.small)
|
|
Button(
|
|
String(
|
|
localized: "settings.notifications.sound.custom.clear.button",
|
|
defaultValue: "Clear"
|
|
)
|
|
) {
|
|
notificationSoundCustomFilePath = NotificationSoundSettings.defaultCustomFilePath
|
|
refreshNotificationCustomSoundStatus()
|
|
}
|
|
.controlSize(.small)
|
|
.disabled(!hasCustomNotificationSoundFilePath)
|
|
}
|
|
if let notificationCustomSoundStatusMessage {
|
|
Text(notificationCustomSoundStatusMessage)
|
|
.font(.system(size: 11))
|
|
.foregroundStyle(notificationCustomSoundStatusIsError ? Color.red : Color.secondary)
|
|
.lineLimit(2)
|
|
.multilineTextAlignment(.trailing)
|
|
.frame(width: 260, alignment: .trailing)
|
|
}
|
|
}
|
|
}
|
|
.frame(maxWidth: .infinity, alignment: .trailing)
|
|
}
|
|
|
|
SettingsCardDivider()
|
|
|
|
SettingsCardRow(
|
|
"Notification Command",
|
|
subtitle: "Run a shell command when a notification arrives. $CMUX_NOTIFICATION_TITLE, $CMUX_NOTIFICATION_SUBTITLE, $CMUX_NOTIFICATION_BODY are set."
|
|
) {
|
|
TextField("say \"done\"", text: $notificationCustomCommand)
|
|
.textFieldStyle(.roundedBorder)
|
|
.frame(width: 200)
|
|
}
|
|
|
|
SettingsCardDivider()
|
|
|
|
SettingsCardRow(
|
|
String(localized: "settings.app.telemetry", defaultValue: "Send anonymous telemetry"),
|
|
subtitle: sendAnonymousTelemetry != telemetryValueAtLaunch
|
|
? String(localized: "settings.app.telemetry.subtitleChanged", defaultValue: "Change takes effect on next launch.")
|
|
: String(localized: "settings.app.telemetry.subtitle", defaultValue: "Share anonymized crash and usage data to help improve cmux.")
|
|
) {
|
|
Toggle("", isOn: $sendAnonymousTelemetry)
|
|
.labelsHidden()
|
|
.controlSize(.small)
|
|
}
|
|
|
|
SettingsCardDivider()
|
|
|
|
SettingsCardRow(
|
|
String(localized: "settings.app.warnBeforeQuit", defaultValue: "Warn Before Quit"),
|
|
subtitle: warnBeforeQuitShortcut
|
|
? String(localized: "settings.app.warnBeforeQuit.subtitleOn", defaultValue: "Show a confirmation before quitting with Cmd+Q.")
|
|
: String(localized: "settings.app.warnBeforeQuit.subtitleOff", defaultValue: "Cmd+Q quits immediately without confirmation.")
|
|
) {
|
|
Toggle("", isOn: $warnBeforeQuitShortcut)
|
|
.labelsHidden()
|
|
.controlSize(.small)
|
|
}
|
|
|
|
SettingsCardDivider()
|
|
|
|
SettingsCardRow(
|
|
String(localized: "settings.app.renameSelectsName", defaultValue: "Rename Selects Existing Name"),
|
|
subtitle: commandPaletteRenameSelectAllOnFocus
|
|
? String(localized: "settings.app.renameSelectsName.subtitleOn", defaultValue: "Command Palette rename starts with all text selected.")
|
|
: String(localized: "settings.app.renameSelectsName.subtitleOff", defaultValue: "Command Palette rename keeps the caret at the end.")
|
|
) {
|
|
Toggle("", isOn: $commandPaletteRenameSelectAllOnFocus)
|
|
.labelsHidden()
|
|
.controlSize(.small)
|
|
}
|
|
|
|
SettingsCardDivider()
|
|
|
|
SettingsCardRow(
|
|
String(localized: "settings.app.commandPaletteSearchAllSurfaces", defaultValue: "Command Palette Searches All Surfaces"),
|
|
subtitle: commandPaletteSearchAllSurfaces
|
|
? String(localized: "settings.app.commandPaletteSearchAllSurfaces.subtitleOn", defaultValue: "Cmd+P also matches terminal, browser, and markdown surfaces across workspaces.")
|
|
: String(localized: "settings.app.commandPaletteSearchAllSurfaces.subtitleOff", defaultValue: "Cmd+P matches workspace rows only.")
|
|
) {
|
|
Toggle("", isOn: $commandPaletteSearchAllSurfaces)
|
|
.labelsHidden()
|
|
.controlSize(.small)
|
|
.accessibilityIdentifier("CommandPaletteSearchAllSurfacesToggle")
|
|
.accessibilityLabel(
|
|
String(localized: "settings.app.commandPaletteSearchAllSurfaces", defaultValue: "Command Palette Searches All Surfaces")
|
|
)
|
|
}
|
|
|
|
SettingsCardDivider()
|
|
|
|
SettingsCardRow(
|
|
String(localized: "settings.app.hideAllSidebarDetails", defaultValue: "Hide All Sidebar Details"),
|
|
subtitle: sidebarHideAllDetails
|
|
? String(localized: "settings.app.hideAllSidebarDetails.subtitleOn", defaultValue: "Show only the workspace title row. Overrides the detail toggles below.")
|
|
: String(localized: "settings.app.hideAllSidebarDetails.subtitleOff", defaultValue: "Show secondary workspace details as controlled by the toggles below.")
|
|
) {
|
|
Toggle("", isOn: $sidebarHideAllDetails)
|
|
.labelsHidden()
|
|
.controlSize(.small)
|
|
}
|
|
|
|
SettingsCardDivider()
|
|
|
|
SettingsPickerRow(
|
|
String(localized: "settings.app.sidebarBranchLayout", defaultValue: "Sidebar Branch Layout"),
|
|
subtitle: sidebarBranchVerticalLayout
|
|
? String(localized: "settings.app.sidebarBranchLayout.subtitleVertical", defaultValue: "Vertical: each branch appears on its own line.")
|
|
: String(localized: "settings.app.sidebarBranchLayout.subtitleInline", defaultValue: "Inline: all branches share one line."),
|
|
controlWidth: pickerColumnWidth,
|
|
selection: $sidebarBranchVerticalLayout
|
|
) {
|
|
Text(String(localized: "settings.app.sidebarBranchLayout.vertical", defaultValue: "Vertical")).tag(true)
|
|
Text(String(localized: "settings.app.sidebarBranchLayout.inline", defaultValue: "Inline")).tag(false)
|
|
}
|
|
.disabled(sidebarHideAllDetails)
|
|
|
|
SettingsCardDivider()
|
|
|
|
SettingsCardRow(
|
|
String(localized: "settings.app.showNotificationMessage", defaultValue: "Show Notification Message in Sidebar"),
|
|
subtitle: String(localized: "settings.app.showNotificationMessage.subtitle", defaultValue: "Display the latest notification message below the workspace title.")
|
|
) {
|
|
Toggle("", isOn: $sidebarShowNotificationMessage)
|
|
.labelsHidden()
|
|
.controlSize(.small)
|
|
}
|
|
.disabled(sidebarHideAllDetails)
|
|
|
|
SettingsCardDivider()
|
|
|
|
SettingsCardRow(
|
|
String(localized: "settings.app.showBranchDirectory", defaultValue: "Show Branch + Directory in Sidebar"),
|
|
subtitle: String(localized: "settings.app.showBranchDirectory.subtitle", defaultValue: "Display the built-in git branch and working-directory row.")
|
|
) {
|
|
Toggle("", isOn: $sidebarShowBranchDirectory)
|
|
.labelsHidden()
|
|
.controlSize(.small)
|
|
}
|
|
.disabled(sidebarHideAllDetails)
|
|
|
|
SettingsCardDivider()
|
|
|
|
SettingsCardRow(
|
|
String(localized: "settings.app.showPullRequests", defaultValue: "Show Pull Requests in Sidebar"),
|
|
subtitle: String(localized: "settings.app.showPullRequests.subtitle", defaultValue: "Display review items (PR/MR/etc.) with status, number, and clickable link.")
|
|
) {
|
|
Toggle("", isOn: $sidebarShowPullRequest)
|
|
.labelsHidden()
|
|
.controlSize(.small)
|
|
}
|
|
.disabled(sidebarHideAllDetails)
|
|
|
|
SettingsCardDivider()
|
|
|
|
SettingsCardRow(
|
|
String(localized: "settings.app.openSidebarPRLinks", defaultValue: "Open Sidebar PR Links in cmux Browser"),
|
|
subtitle: openSidebarPullRequestLinksInCmuxBrowser
|
|
? String(localized: "settings.app.openSidebarPRLinks.subtitleOn", defaultValue: "Clicks open inside cmux browser.")
|
|
: String(localized: "settings.app.openSidebarPRLinks.subtitleOff", defaultValue: "Clicks open in your default browser.")
|
|
) {
|
|
Toggle("", isOn: $openSidebarPullRequestLinksInCmuxBrowser)
|
|
.labelsHidden()
|
|
.controlSize(.small)
|
|
}
|
|
.disabled(sidebarHideAllDetails)
|
|
|
|
SettingsCardDivider()
|
|
|
|
SettingsCardRow(
|
|
String(localized: "settings.app.showSSH", defaultValue: "Show SSH in Sidebar"),
|
|
subtitle: String(localized: "settings.app.showSSH.subtitle", defaultValue: "Display the SSH target for remote workspaces in its own row.")
|
|
) {
|
|
Toggle("", isOn: $sidebarShowSSH)
|
|
.labelsHidden()
|
|
.controlSize(.small)
|
|
}
|
|
|
|
SettingsCardDivider()
|
|
|
|
SettingsCardRow(
|
|
String(localized: "settings.app.showPorts", defaultValue: "Show Listening Ports in Sidebar"),
|
|
subtitle: String(localized: "settings.app.showPorts.subtitle", defaultValue: "Display detected listening ports for the active workspace.")
|
|
) {
|
|
Toggle("", isOn: $sidebarShowPorts)
|
|
.labelsHidden()
|
|
.controlSize(.small)
|
|
}
|
|
.disabled(sidebarHideAllDetails)
|
|
|
|
SettingsCardDivider()
|
|
|
|
SettingsCardRow(
|
|
String(localized: "settings.app.showLog", defaultValue: "Show Latest Log in Sidebar"),
|
|
subtitle: String(localized: "settings.app.showLog.subtitle", defaultValue: "Display the latest imperative log/status message.")
|
|
) {
|
|
Toggle("", isOn: $sidebarShowLog)
|
|
.labelsHidden()
|
|
.controlSize(.small)
|
|
}
|
|
.disabled(sidebarHideAllDetails)
|
|
|
|
SettingsCardDivider()
|
|
|
|
SettingsCardRow(
|
|
String(localized: "settings.app.showProgress", defaultValue: "Show Progress in Sidebar"),
|
|
subtitle: String(localized: "settings.app.showProgress.subtitle", defaultValue: "Display the built-in progress bar from set_progress.")
|
|
) {
|
|
Toggle("", isOn: $sidebarShowProgress)
|
|
.labelsHidden()
|
|
.controlSize(.small)
|
|
}
|
|
.disabled(sidebarHideAllDetails)
|
|
|
|
SettingsCardDivider()
|
|
|
|
SettingsCardRow(
|
|
String(localized: "settings.app.showMetadata", defaultValue: "Show Custom Metadata in Sidebar"),
|
|
subtitle: String(localized: "settings.app.showMetadata.subtitle", defaultValue: "Display custom metadata from report_meta/set_status and report_meta_block.")
|
|
) {
|
|
Toggle("", isOn: $sidebarShowMetadata)
|
|
.labelsHidden()
|
|
.controlSize(.small)
|
|
}
|
|
.disabled(sidebarHideAllDetails)
|
|
}
|
|
|
|
SettingsSectionHeader(title: String(localized: "settings.section.workspaceColors", defaultValue: "Workspace Colors"))
|
|
SettingsCard {
|
|
SettingsPickerRow(
|
|
String(localized: "settings.workspaceColors.indicator", defaultValue: "Workspace Color Indicator"),
|
|
controlWidth: pickerColumnWidth,
|
|
selection: sidebarIndicatorStyleSelection
|
|
) {
|
|
ForEach(SidebarActiveTabIndicatorStyle.allCases) { style in
|
|
Text(style.displayName).tag(style.rawValue)
|
|
}
|
|
}
|
|
|
|
SettingsCardDivider()
|
|
|
|
SettingsCardNote(String(localized: "settings.workspaceColors.paletteNote", defaultValue: "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: String(localized: "settings.workspaceColors.base", defaultValue: "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(String(localized: "settings.workspaceColors.noCustomColors", defaultValue: "Custom colors: none yet. Use \"Choose Custom Color...\" from a workspace context menu."))
|
|
} else {
|
|
VStack(alignment: .leading, spacing: 8) {
|
|
Text(String(localized: "settings.workspaceColors.customColors", defaultValue: "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(String(localized: "settings.workspaceColors.remove", defaultValue: "Remove")) {
|
|
removeWorkspaceCustomColor(hex)
|
|
}
|
|
.buttonStyle(.bordered)
|
|
.controlSize(.small)
|
|
}
|
|
}
|
|
}
|
|
.padding(.horizontal, 14)
|
|
.padding(.vertical, 10)
|
|
}
|
|
|
|
SettingsCardDivider()
|
|
|
|
SettingsCardRow(
|
|
String(localized: "settings.workspaceColors.resetPalette", defaultValue: "Reset Palette"),
|
|
subtitle: String(localized: "settings.workspaceColors.resetPalette.subtitle", defaultValue: "Restore built-in defaults and clear all custom colors.")
|
|
) {
|
|
Button(String(localized: "settings.workspaceColors.resetPalette.button", defaultValue: "Reset")) {
|
|
resetWorkspaceTabColors()
|
|
}
|
|
.buttonStyle(.bordered)
|
|
.controlSize(.small)
|
|
}
|
|
}
|
|
|
|
SettingsSectionHeader(title: String(localized: "settings.section.sidebarAppearance", defaultValue: "Sidebar Appearance"))
|
|
SettingsCard {
|
|
SettingsCardRow(
|
|
String(localized: "settings.sidebarAppearance.tintColorLight", defaultValue: "Light Mode Tint"),
|
|
subtitle: String(localized: "settings.sidebarAppearance.tintColorLight.subtitle", defaultValue: "Sidebar tint color when using light appearance.")
|
|
) {
|
|
HStack(spacing: 8) {
|
|
ColorPicker(
|
|
String(localized: "settings.sidebarAppearance.tintColorLight.picker", defaultValue: "Light tint"),
|
|
selection: settingsSidebarTintLightBinding,
|
|
supportsOpacity: false
|
|
)
|
|
.labelsHidden()
|
|
.frame(width: 38)
|
|
|
|
Text(sidebarTintHexLight ?? String(localized: "settings.sidebarAppearance.defaultLabel", defaultValue: "Default"))
|
|
.font(.system(size: 12, weight: .medium, design: .monospaced))
|
|
.foregroundStyle(.secondary)
|
|
.frame(width: 76, alignment: .trailing)
|
|
}
|
|
}
|
|
|
|
SettingsCardDivider()
|
|
|
|
SettingsCardRow(
|
|
String(localized: "settings.sidebarAppearance.tintColorDark", defaultValue: "Dark Mode Tint"),
|
|
subtitle: String(localized: "settings.sidebarAppearance.tintColorDark.subtitle", defaultValue: "Sidebar tint color when using dark appearance.")
|
|
) {
|
|
HStack(spacing: 8) {
|
|
ColorPicker(
|
|
String(localized: "settings.sidebarAppearance.tintColorDark.picker", defaultValue: "Dark tint"),
|
|
selection: settingsSidebarTintDarkBinding,
|
|
supportsOpacity: false
|
|
)
|
|
.labelsHidden()
|
|
.frame(width: 38)
|
|
|
|
Text(sidebarTintHexDark ?? String(localized: "settings.sidebarAppearance.defaultLabel", defaultValue: "Default"))
|
|
.font(.system(size: 12, weight: .medium, design: .monospaced))
|
|
.foregroundStyle(.secondary)
|
|
.frame(width: 76, alignment: .trailing)
|
|
}
|
|
}
|
|
|
|
SettingsCardDivider()
|
|
|
|
SettingsCardRow(
|
|
String(localized: "settings.sidebarAppearance.tintOpacity", defaultValue: "Tint Opacity"),
|
|
subtitle: String(localized: "settings.sidebarAppearance.tintOpacity.subtitle", defaultValue: "How strongly the tint color shows over the sidebar material.")
|
|
) {
|
|
HStack(spacing: 8) {
|
|
Slider(value: $sidebarTintOpacity, in: 0...1)
|
|
.frame(width: 140)
|
|
Text(String(format: "%.0f%%", sidebarTintOpacity * 100))
|
|
.font(.system(size: 12, weight: .medium, design: .monospaced))
|
|
.foregroundStyle(.secondary)
|
|
.frame(width: 36, alignment: .trailing)
|
|
}
|
|
}
|
|
|
|
SettingsCardDivider()
|
|
|
|
SettingsCardRow(
|
|
String(localized: "settings.sidebarAppearance.reset", defaultValue: "Reset Sidebar Tint"),
|
|
subtitle: String(localized: "settings.sidebarAppearance.reset.subtitle", defaultValue: "Restore default sidebar appearance.")
|
|
) {
|
|
Button(String(localized: "settings.sidebarAppearance.reset.button", defaultValue: "Reset")) {
|
|
sidebarTintHexLight = nil
|
|
sidebarTintHexDark = nil
|
|
sidebarTintHex = SidebarTintDefaults.hex
|
|
sidebarTintOpacity = SidebarTintDefaults.opacity
|
|
}
|
|
.buttonStyle(.bordered)
|
|
.controlSize(.small)
|
|
}
|
|
}
|
|
|
|
SettingsSectionHeader(title: String(localized: "settings.section.automation", defaultValue: "Automation"))
|
|
SettingsCard {
|
|
SettingsPickerRow(
|
|
String(localized: "settings.automation.socketMode", defaultValue: "Socket Control Mode"),
|
|
subtitle: selectedSocketControlMode.description,
|
|
controlWidth: pickerColumnWidth,
|
|
selection: socketModeSelection,
|
|
accessibilityId: "AutomationSocketModePicker"
|
|
) {
|
|
ForEach(SocketControlMode.uiCases) { mode in
|
|
Text(mode.displayName).tag(mode.rawValue)
|
|
}
|
|
}
|
|
|
|
SettingsCardDivider()
|
|
|
|
SettingsCardNote(String(localized: "settings.automation.socketMode.note", defaultValue: "Controls access to the local Unix socket for programmatic control. Choose a mode that matches your threat model."))
|
|
if selectedSocketControlMode == .password {
|
|
SettingsCardDivider()
|
|
SettingsCardRow(
|
|
String(localized: "settings.automation.socketPassword", defaultValue: "Socket Password"),
|
|
subtitle: hasSocketPasswordConfigured
|
|
? String(localized: "settings.automation.socketPassword.subtitleSet", defaultValue: "Stored in Application Support.")
|
|
: String(localized: "settings.automation.socketPassword.subtitleUnset", defaultValue: "No password set. External clients will be blocked until one is configured.")
|
|
) {
|
|
HStack(spacing: 8) {
|
|
SecureField(String(localized: "settings.automation.socketPassword.placeholder", defaultValue: "Password"), text: $socketPasswordDraft)
|
|
.textFieldStyle(.roundedBorder)
|
|
.frame(width: 170)
|
|
Button(hasSocketPasswordConfigured ? String(localized: "settings.automation.socketPassword.change", defaultValue: "Change") : String(localized: "settings.automation.socketPassword.set", defaultValue: "Set")) {
|
|
saveSocketPassword()
|
|
}
|
|
.buttonStyle(.bordered)
|
|
.controlSize(.small)
|
|
.disabled(socketPasswordDraft.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty)
|
|
if hasSocketPasswordConfigured {
|
|
Button(String(localized: "settings.automation.socketPassword.clear", defaultValue: "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(String(localized: "settings.automation.openAccessWarning", defaultValue: "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(String(localized: "settings.automation.socketOverrides.note", defaultValue: "Overrides: CMUX_SOCKET_ENABLE, CMUX_SOCKET_MODE, and CMUX_SOCKET_PATH (set CMUX_ALLOW_SOCKET_OVERRIDE=1 for stable/nightly builds)."))
|
|
}
|
|
|
|
SettingsCard {
|
|
SettingsCardRow(
|
|
String(localized: "settings.automation.claudeCode", defaultValue: "Claude Code Integration"),
|
|
subtitle: claudeCodeHooksEnabled
|
|
? String(localized: "settings.automation.claudeCode.subtitleOn", defaultValue: "Sidebar shows Claude session status and notifications.")
|
|
: String(localized: "settings.automation.claudeCode.subtitleOff", defaultValue: "Claude Code runs without cmux integration.")
|
|
) {
|
|
Toggle("", isOn: $claudeCodeHooksEnabled)
|
|
.labelsHidden()
|
|
.controlSize(.small)
|
|
.accessibilityIdentifier("SettingsClaudeCodeHooksToggle")
|
|
}
|
|
|
|
SettingsCardDivider()
|
|
|
|
SettingsCardNote(String(localized: "settings.automation.claudeCode.note", defaultValue: "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(String(localized: "settings.automation.portBase", defaultValue: "Port Base"), subtitle: String(localized: "settings.automation.portBase.subtitle", defaultValue: "Starting port for CMUX_PORT env var."), controlWidth: pickerColumnWidth) {
|
|
TextField("", value: $cmuxPortBase, format: .number)
|
|
.textFieldStyle(.roundedBorder)
|
|
.multilineTextAlignment(.trailing)
|
|
}
|
|
|
|
SettingsCardDivider()
|
|
|
|
SettingsCardRow(String(localized: "settings.automation.portRange", defaultValue: "Port Range Size"), subtitle: String(localized: "settings.automation.portRange.subtitle", defaultValue: "Number of ports per workspace."), controlWidth: pickerColumnWidth) {
|
|
TextField("", value: $cmuxPortRange, format: .number)
|
|
.textFieldStyle(.roundedBorder)
|
|
.multilineTextAlignment(.trailing)
|
|
}
|
|
|
|
SettingsCardDivider()
|
|
|
|
SettingsCardNote(String(localized: "settings.automation.port.note", defaultValue: "Each workspace gets CMUX_PORT and CMUX_PORT_END env vars with a dedicated port range. New terminals inherit these values."))
|
|
}
|
|
|
|
SettingsSectionHeader(title: String(localized: "settings.section.customCommands", defaultValue: "Custom Commands"))
|
|
SettingsCard {
|
|
VStack(alignment: .leading, spacing: 6) {
|
|
SettingsCardRow(
|
|
String(localized: "settings.customCommands.trustedDirectories", defaultValue: "Trusted Directories"),
|
|
subtitle: String(localized: "settings.customCommands.trustedDirectories.subtitle", defaultValue: "Commands from cmux.json in these directories run without confirmation. One path per line.")
|
|
) {
|
|
EmptyView()
|
|
}
|
|
|
|
TextEditor(text: $trustedDirectoriesDraft)
|
|
.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)
|
|
.onChange(of: trustedDirectoriesDraft) { _ in
|
|
saveTrustedDirectories()
|
|
}
|
|
}
|
|
|
|
SettingsCardDivider()
|
|
SettingsCardNote(String(localized: "settings.customCommands.trustedDirectories.note", defaultValue: "Place a cmux.json in your project root to define custom commands. Trust a directory from the confirmation dialog, or add paths here. For git repos, trusting the root covers all subdirectories."))
|
|
}
|
|
|
|
SettingsSectionHeader(title: String(localized: "settings.section.browser", defaultValue: "Browser"))
|
|
.id(SettingsNavigationTarget.browser)
|
|
.accessibilityIdentifier("SettingsBrowserSection")
|
|
SettingsCard {
|
|
SettingsPickerRow(
|
|
String(localized: "settings.browser.searchEngine", defaultValue: "Default Search Engine"),
|
|
subtitle: String(localized: "settings.browser.searchEngine.subtitle", defaultValue: "Used by the browser address bar when input is not a URL."),
|
|
controlWidth: pickerColumnWidth,
|
|
selection: $browserSearchEngine
|
|
) {
|
|
ForEach(BrowserSearchEngine.allCases) { engine in
|
|
Text(engine.displayName).tag(engine.rawValue)
|
|
}
|
|
}
|
|
|
|
SettingsCardDivider()
|
|
|
|
SettingsCardRow(String(localized: "settings.browser.searchSuggestions", defaultValue: "Show Search Suggestions")) {
|
|
Toggle("", isOn: $browserSearchSuggestionsEnabled)
|
|
.labelsHidden()
|
|
.controlSize(.small)
|
|
}
|
|
|
|
SettingsCardDivider()
|
|
|
|
SettingsPickerRow(
|
|
String(localized: "settings.browser.theme", defaultValue: "Browser Theme"),
|
|
subtitle: selectedBrowserThemeMode == .system
|
|
? String(localized: "settings.browser.theme.subtitleSystem", defaultValue: "System follows app and macOS appearance.")
|
|
: String(localized: "settings.browser.theme.subtitleForced", defaultValue: "\(selectedBrowserThemeMode.displayName) forces that color scheme for compatible pages."),
|
|
controlWidth: pickerColumnWidth,
|
|
selection: browserThemeModeSelection
|
|
) {
|
|
ForEach(BrowserThemeMode.allCases) { mode in
|
|
Text(mode.displayName).tag(mode.rawValue)
|
|
}
|
|
}
|
|
|
|
SettingsCardDivider()
|
|
|
|
SettingsCardRow(
|
|
String(localized: "settings.browser.openTerminalLinks", defaultValue: "Open Terminal Links in cmux Browser"),
|
|
subtitle: String(localized: "settings.browser.openTerminalLinks.subtitle", defaultValue: "When off, links clicked in terminal output open in your default browser.")
|
|
) {
|
|
Toggle("", isOn: $openTerminalLinksInCmuxBrowser)
|
|
.labelsHidden()
|
|
.controlSize(.small)
|
|
}
|
|
|
|
SettingsCardDivider()
|
|
|
|
SettingsCardRow(
|
|
String(localized: "settings.browser.interceptOpen", defaultValue: "Intercept open http(s) in Terminal"),
|
|
subtitle: String(localized: "settings.browser.interceptOpen.subtitle", defaultValue: "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(
|
|
String(localized: "settings.browser.hostWhitelist", defaultValue: "Hosts to Open in Embedded Browser"),
|
|
subtitle: String(localized: "settings.browser.hostWhitelist.subtitle", defaultValue: "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: 6) {
|
|
SettingsCardRow(
|
|
String(localized: "settings.browser.externalPatterns", defaultValue: "URLs to Always Open Externally"),
|
|
subtitle: String(localized: "settings.browser.externalPatterns.subtitle", defaultValue: "Applies to terminal link clicks and intercepted `open https://...` calls. One rule per line. Plain text matches any URL substring, or prefix with `re:` for regex (for example: openai.com/usage, re:^https?://[^/]*\\.example\\.com/(billing|usage)).")
|
|
) {
|
|
EmptyView()
|
|
}
|
|
|
|
TextEditor(text: $browserExternalOpenPatterns)
|
|
.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(String(localized: "settings.browser.httpAllowlist", defaultValue: "HTTP Hosts Allowed in Embedded Browser"))
|
|
.font(.system(size: 13, weight: .semibold))
|
|
|
|
Text(String(localized: "settings.browser.httpAllowlist.description", defaultValue: "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(String(localized: "settings.browser.httpAllowlist.hint", defaultValue: "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(String(localized: "settings.browser.httpAllowlist.save", defaultValue: "Save")) {
|
|
saveBrowserInsecureHTTPAllowlist()
|
|
}
|
|
.buttonStyle(.bordered)
|
|
.controlSize(.small)
|
|
.disabled(!browserInsecureHTTPAllowlistHasUnsavedChanges)
|
|
.accessibilityIdentifier("SettingsBrowserHTTPAllowlistSaveButton")
|
|
}
|
|
|
|
VStack(alignment: .leading, spacing: 8) {
|
|
Text(String(localized: "settings.browser.httpAllowlist.hint", defaultValue: "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(String(localized: "settings.browser.httpAllowlist.save", defaultValue: "Save")) {
|
|
saveBrowserInsecureHTTPAllowlist()
|
|
}
|
|
.buttonStyle(.bordered)
|
|
.controlSize(.small)
|
|
.disabled(!browserInsecureHTTPAllowlistHasUnsavedChanges)
|
|
.accessibilityIdentifier("SettingsBrowserHTTPAllowlistSaveButton")
|
|
}
|
|
}
|
|
}
|
|
}
|
|
.padding(.horizontal, 14)
|
|
.padding(.vertical, 10)
|
|
|
|
SettingsCardDivider()
|
|
|
|
VStack(alignment: .leading, spacing: 12) {
|
|
VStack(alignment: .leading, spacing: 8) {
|
|
Text(String(localized: "settings.browser.import", defaultValue: "Import Browser Data"))
|
|
.font(.system(size: 13, weight: .semibold))
|
|
|
|
VStack(alignment: .leading, spacing: 6) {
|
|
Text(String(localized: "browser.import.hint.title", defaultValue: "Import browser data"))
|
|
.font(.system(size: 12.5, weight: .semibold))
|
|
|
|
Text(browserImportSubtitle)
|
|
.font(.caption)
|
|
.foregroundStyle(.secondary)
|
|
.fixedSize(horizontal: false, vertical: true)
|
|
|
|
Text(String(localized: "browser.import.hint.settingsFootnote", defaultValue: "You can always find this in Settings > Browser."))
|
|
.font(.system(size: 10.5))
|
|
.foregroundStyle(.tertiary)
|
|
.fixedSize(horizontal: false, vertical: true)
|
|
}
|
|
.padding(.horizontal, 12)
|
|
.padding(.vertical, 10)
|
|
.frame(maxWidth: .infinity, alignment: .leading)
|
|
.background(
|
|
RoundedRectangle(cornerRadius: 10, style: .continuous)
|
|
.fill(Color(nsColor: .controlBackgroundColor))
|
|
)
|
|
.overlay(
|
|
RoundedRectangle(cornerRadius: 10, style: .continuous)
|
|
.stroke(Color(nsColor: .separatorColor).opacity(0.4), lineWidth: 1)
|
|
)
|
|
}
|
|
|
|
HStack(spacing: 8) {
|
|
Button(String(localized: "settings.browser.import.choose", defaultValue: "Choose…")) {
|
|
DispatchQueue.main.async {
|
|
BrowserDataImportCoordinator.shared.presentImportDialog()
|
|
refreshDetectedImportBrowsers()
|
|
}
|
|
}
|
|
.buttonStyle(.bordered)
|
|
.controlSize(.small)
|
|
.accessibilityIdentifier("SettingsBrowserImportChooseButton")
|
|
|
|
Button(String(localized: "settings.browser.import.refresh", defaultValue: "Refresh")) {
|
|
refreshDetectedImportBrowsers()
|
|
}
|
|
.buttonStyle(.bordered)
|
|
.controlSize(.small)
|
|
}
|
|
.accessibilityIdentifier("SettingsBrowserImportActions")
|
|
|
|
Toggle(
|
|
String(localized: "settings.browser.import.hint.show", defaultValue: "Show import hint on blank browser tabs"),
|
|
isOn: browserImportHintVisibilityBinding
|
|
)
|
|
.controlSize(.small)
|
|
.accessibilityIdentifier("SettingsBrowserImportHintToggle")
|
|
|
|
Text(browserImportHintSettingsNote)
|
|
.font(.caption)
|
|
.foregroundStyle(.secondary)
|
|
.fixedSize(horizontal: false, vertical: true)
|
|
}
|
|
.id(SettingsNavigationTarget.browserImport)
|
|
.accessibilityIdentifier("SettingsBrowserImportSection")
|
|
.padding(.horizontal, 14)
|
|
.padding(.vertical, 10)
|
|
|
|
SettingsCardDivider()
|
|
|
|
SettingsCardRow(String(localized: "settings.browser.history", defaultValue: "Browsing History"), subtitle: browserHistorySubtitle) {
|
|
Button(String(localized: "settings.browser.history.clearButton", defaultValue: "Clear History…")) {
|
|
showClearBrowserHistoryConfirmation = true
|
|
}
|
|
.buttonStyle(.bordered)
|
|
.controlSize(.small)
|
|
.disabled(browserHistoryEntryCount == 0)
|
|
}
|
|
}
|
|
|
|
SettingsSectionHeader(title: String(localized: "settings.section.keyboardShortcuts", defaultValue: "Keyboard Shortcuts"))
|
|
.id(SettingsNavigationTarget.keyboardShortcuts)
|
|
.accessibilityIdentifier("SettingsKeyboardShortcutsSection")
|
|
SettingsCard {
|
|
SettingsCardRow(
|
|
String(localized: "settings.shortcuts.showHints", defaultValue: "Show Cmd/Ctrl-Hold Shortcut Hints"),
|
|
subtitle: showShortcutHintsOnCommandHold
|
|
? String(localized: "settings.shortcuts.showHints.subtitleOn", defaultValue: "Holding Cmd (sidebar/titlebar) or Ctrl/Cmd (pane tabs) shows shortcut hint pills.")
|
|
: String(localized: "settings.shortcuts.showHints.subtitleOff", defaultValue: "Holding Cmd or Ctrl keeps shortcut hint pills hidden.")
|
|
) {
|
|
Toggle("", isOn: $showShortcutHintsOnCommandHold)
|
|
.labelsHidden()
|
|
.controlSize(.small)
|
|
}
|
|
|
|
SettingsCardDivider()
|
|
|
|
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(String(localized: "settings.shortcuts.recordHint", defaultValue: "Click a shortcut value to record a new shortcut."))
|
|
.font(.caption)
|
|
.foregroundColor(.secondary)
|
|
.padding(.leading, 2)
|
|
.accessibilityIdentifier("ShortcutRecordingHint")
|
|
|
|
SettingsSectionHeader(title: String(localized: "settings.section.reset", defaultValue: "Reset"))
|
|
SettingsCard {
|
|
HStack {
|
|
Spacer(minLength: 0)
|
|
Button(String(localized: "settings.reset.resetAll", defaultValue: "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(String(localized: "settings.title", defaultValue: "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()
|
|
notificationStore.refreshAuthorizationStatus()
|
|
browserThemeMode = BrowserThemeSettings.mode(defaults: .standard).rawValue
|
|
browserImportHintVariantRaw = BrowserImportHintSettings.variant(for: browserImportHintVariantRaw).rawValue
|
|
browserHistoryEntryCount = BrowserHistoryStore.shared.entries.count
|
|
browserInsecureHTTPAllowlistDraft = browserInsecureHTTPAllowlist
|
|
refreshDetectedImportBrowsers()
|
|
reloadWorkspaceTabColorSettings()
|
|
refreshNotificationCustomSoundStatus()
|
|
}
|
|
.onChange(of: notificationSound) { _, _ in
|
|
refreshNotificationCustomSoundStatus()
|
|
}
|
|
.onChange(of: notificationSoundCustomFilePath) { _, _ in
|
|
refreshNotificationCustomSoundStatus()
|
|
}
|
|
.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()
|
|
}
|
|
.onReceive(NotificationCenter.default.publisher(for: SettingsNavigationRequest.notificationName)) { notification in
|
|
guard let target = SettingsNavigationRequest.target(from: notification) else { return }
|
|
DispatchQueue.main.async {
|
|
withAnimation(.easeInOut(duration: 0.2)) {
|
|
proxy.scrollTo(target, anchor: .top)
|
|
}
|
|
}
|
|
}
|
|
.confirmationDialog(
|
|
String(localized: "settings.browser.history.clearDialog.title", defaultValue: "Clear browser history?"),
|
|
isPresented: $showClearBrowserHistoryConfirmation,
|
|
titleVisibility: .visible
|
|
) {
|
|
Button(String(localized: "settings.browser.history.clearDialog.confirm", defaultValue: "Clear History"), role: .destructive) {
|
|
BrowserHistoryStore.shared.clearHistory()
|
|
}
|
|
Button(String(localized: "settings.browser.history.clearDialog.cancel", defaultValue: "Cancel"), role: .cancel) {}
|
|
} message: {
|
|
Text(String(localized: "settings.browser.history.clearDialog.message", defaultValue: "This removes visited-page suggestions from the browser omnibar."))
|
|
}
|
|
.confirmationDialog(
|
|
String(localized: "settings.automation.openAccess.dialog.title", defaultValue: "Enable full open access?"),
|
|
isPresented: $showOpenAccessConfirmation,
|
|
titleVisibility: .visible
|
|
) {
|
|
Button(String(localized: "settings.automation.openAccess.dialog.confirm", defaultValue: "Enable Full Open Access"), role: .destructive) {
|
|
socketControlMode = (pendingOpenAccessMode ?? .allowAll).rawValue
|
|
pendingOpenAccessMode = nil
|
|
}
|
|
Button(String(localized: "settings.automation.openAccess.dialog.cancel", defaultValue: "Cancel"), role: .cancel) {
|
|
pendingOpenAccessMode = nil
|
|
}
|
|
} message: {
|
|
Text(String(localized: "settings.automation.openAccess.dialog.message", defaultValue: "This disables ancestry and password checks and opens the socket to all local users. Only enable when you understand the risk."))
|
|
}
|
|
.confirmationDialog(
|
|
String(localized: "settings.app.language.restartDialog.title", defaultValue: "Restart to apply language change?"),
|
|
isPresented: $showLanguageRestartAlert,
|
|
titleVisibility: .visible
|
|
) {
|
|
Button(String(localized: "settings.app.language.restartDialog.confirm", defaultValue: "Restart Now")) {
|
|
relaunchApp()
|
|
}
|
|
Button(String(localized: "settings.app.language.restartDialog.later", defaultValue: "Later"), role: .cancel) {}
|
|
}
|
|
.alert(
|
|
String(
|
|
localized: "settings.notifications.sound.custom.error.title",
|
|
defaultValue: "Custom Notification Sound Error"
|
|
),
|
|
isPresented: $showNotificationCustomSoundErrorAlert
|
|
) {
|
|
Button(String(localized: "common.ok", defaultValue: "OK"), role: .cancel) {}
|
|
} message: {
|
|
Text(notificationCustomSoundErrorAlertMessage)
|
|
}
|
|
}
|
|
}
|
|
|
|
private func relaunchApp() {
|
|
let bundlePath = Bundle.main.bundlePath
|
|
let task = Process()
|
|
task.executableURL = URL(fileURLWithPath: "/bin/sh")
|
|
task.arguments = ["-c", "sleep 1 && open -n -- \"$RELAUNCH_PATH\""]
|
|
task.environment = ["RELAUNCH_PATH": bundlePath]
|
|
do {
|
|
try task.run()
|
|
} catch {
|
|
return
|
|
}
|
|
NSApplication.shared.terminate(nil)
|
|
}
|
|
|
|
private func resetAllSettings() {
|
|
isResettingSettings = true
|
|
appLanguage = LanguageSettings.defaultLanguage.rawValue
|
|
LanguageSettings.apply(.system)
|
|
if appLanguage != LanguageSettings.languageAtLaunch.rawValue {
|
|
showLanguageRestartAlert = true
|
|
}
|
|
appearanceMode = AppearanceSettings.defaultMode.rawValue
|
|
appIconMode = AppIconSettings.defaultMode.rawValue
|
|
AppIconSettings.applyIcon(.automatic)
|
|
socketControlMode = SocketControlSettings.defaultMode.rawValue
|
|
claudeCodeHooksEnabled = ClaudeCodeIntegrationSettings.defaultHooksEnabled
|
|
sendAnonymousTelemetry = TelemetrySettings.defaultSendAnonymousTelemetry
|
|
browserSearchEngine = BrowserSearchSettings.defaultSearchEngine.rawValue
|
|
browserSearchSuggestionsEnabled = BrowserSearchSettings.defaultSearchSuggestionsEnabled
|
|
browserThemeMode = BrowserThemeSettings.defaultMode.rawValue
|
|
browserImportHintVariantRaw = BrowserImportHintSettings.defaultVariant.rawValue
|
|
showBrowserImportHintOnBlankTabs = BrowserImportHintSettings.defaultShowOnBlankTabs
|
|
isBrowserImportHintDismissed = BrowserImportHintSettings.defaultDismissed
|
|
openTerminalLinksInCmuxBrowser = BrowserLinkOpenSettings.defaultOpenTerminalLinksInCmuxBrowser
|
|
interceptTerminalOpenCommandInCmuxBrowser = BrowserLinkOpenSettings.defaultInterceptTerminalOpenCommandInCmuxBrowser
|
|
browserHostWhitelist = BrowserLinkOpenSettings.defaultBrowserHostWhitelist
|
|
browserExternalOpenPatterns = BrowserLinkOpenSettings.defaultBrowserExternalOpenPatterns
|
|
browserInsecureHTTPAllowlist = BrowserInsecureHTTPSettings.defaultAllowlistText
|
|
browserInsecureHTTPAllowlistDraft = BrowserInsecureHTTPSettings.defaultAllowlistText
|
|
notificationSound = NotificationSoundSettings.defaultValue
|
|
notificationSoundCustomFilePath = NotificationSoundSettings.defaultCustomFilePath
|
|
notificationCustomSoundStatusMessage = nil
|
|
notificationCustomSoundStatusIsError = false
|
|
showNotificationCustomSoundErrorAlert = false
|
|
notificationCustomSoundErrorAlertMessage = ""
|
|
notificationCustomCommand = NotificationSoundSettings.defaultCustomCommand
|
|
notificationDockBadgeEnabled = NotificationBadgeSettings.defaultDockBadgeEnabled
|
|
notificationPaneRingEnabled = NotificationPaneRingSettings.defaultEnabled
|
|
notificationPaneFlashEnabled = NotificationPaneFlashSettings.defaultEnabled
|
|
showMenuBarExtra = MenuBarExtraSettings.defaultShowInMenuBar
|
|
warnBeforeQuitShortcut = QuitWarningSettings.defaultWarnBeforeQuit
|
|
commandPaletteRenameSelectAllOnFocus = CommandPaletteRenameSelectionSettings.defaultSelectAllOnFocus
|
|
commandPaletteSearchAllSurfaces = CommandPaletteSwitcherSearchSettings.defaultSearchAllSurfaces
|
|
ShortcutHintDebugSettings.resetVisibilityDefaults()
|
|
alwaysShowShortcutHints = ShortcutHintDebugSettings.defaultAlwaysShowHints
|
|
newWorkspacePlacement = WorkspacePlacementSettings.defaultPlacement.rawValue
|
|
workspacePresentationMode = WorkspacePresentationModeSettings.defaultMode.rawValue
|
|
let defaults = UserDefaults.standard
|
|
defaults.removeObject(forKey: WorkspaceTitlebarSettings.showTitlebarKey)
|
|
defaults.removeObject(forKey: WorkspaceButtonFadeSettings.modeKey)
|
|
defaults.removeObject(forKey: WorkspaceButtonFadeSettings.legacyTitlebarControlsVisibilityModeKey)
|
|
defaults.removeObject(forKey: WorkspaceButtonFadeSettings.legacyPaneTabBarControlsVisibilityModeKey)
|
|
closeWorkspaceOnLastSurfaceShortcut = LastSurfaceCloseShortcutSettings.defaultValue
|
|
paneFirstClickFocusEnabled = PaneFirstClickFocusSettings.defaultEnabled
|
|
workspaceAutoReorder = WorkspaceAutoReorderSettings.defaultValue
|
|
sidebarHideAllDetails = SidebarWorkspaceDetailSettings.defaultHideAllDetails
|
|
sidebarShowNotificationMessage = SidebarWorkspaceDetailSettings.defaultShowNotificationMessage
|
|
sidebarBranchVerticalLayout = SidebarBranchLayoutSettings.defaultVerticalLayout
|
|
sidebarActiveTabIndicatorStyle = SidebarActiveTabIndicatorSettings.defaultStyle.rawValue
|
|
sidebarShowBranchDirectory = true
|
|
sidebarShowPullRequest = true
|
|
openSidebarPullRequestLinksInCmuxBrowser = BrowserLinkOpenSettings.defaultOpenSidebarPullRequestLinksInCmuxBrowser
|
|
showShortcutHintsOnCommandHold = ShortcutHintDebugSettings.defaultShowHintsOnCommandHold
|
|
sidebarShowSSH = true
|
|
sidebarShowPorts = true
|
|
sidebarShowLog = true
|
|
sidebarShowProgress = true
|
|
sidebarShowMetadata = true
|
|
sidebarTintHex = SidebarTintDefaults.hex
|
|
sidebarTintHexLight = nil
|
|
sidebarTintHexDark = nil
|
|
sidebarTintOpacity = SidebarTintDefaults.opacity
|
|
showOpenAccessConfirmation = false
|
|
pendingOpenAccessMode = nil
|
|
socketPasswordDraft = ""
|
|
socketPasswordStatusMessage = nil
|
|
socketPasswordStatusIsError = false
|
|
refreshDetectedImportBrowsers()
|
|
KeyboardShortcutSettings.resetAll()
|
|
WorkspaceTabColorSettings.reset()
|
|
reloadWorkspaceTabColorSettings()
|
|
shortcutResetToken = UUID()
|
|
DispatchQueue.main.async { isResettingSettings = false }
|
|
}
|
|
|
|
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 func refreshDetectedImportBrowsers() {
|
|
detectedImportBrowsers = InstalledBrowserDetector.detectInstalledBrowsers()
|
|
}
|
|
}
|
|
|
|
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 SettingsPickerRow<SelectionValue: Hashable, PickerContent: View, ExtraTrailing: View>: View {
|
|
let title: String
|
|
let subtitle: String?
|
|
let controlWidth: CGFloat
|
|
@Binding var selection: SelectionValue
|
|
let pickerContent: PickerContent
|
|
let extraTrailing: ExtraTrailing
|
|
let accessibilityId: String?
|
|
|
|
init(
|
|
_ title: String,
|
|
subtitle: String? = nil,
|
|
controlWidth: CGFloat,
|
|
selection: Binding<SelectionValue>,
|
|
accessibilityId: String? = nil,
|
|
@ViewBuilder content: () -> PickerContent,
|
|
@ViewBuilder extraTrailing: () -> ExtraTrailing
|
|
) {
|
|
self.title = title
|
|
self.subtitle = subtitle
|
|
self.controlWidth = controlWidth
|
|
self._selection = selection
|
|
self.pickerContent = content()
|
|
self.extraTrailing = extraTrailing()
|
|
self.accessibilityId = accessibilityId
|
|
}
|
|
|
|
var body: some View {
|
|
SettingsCardRow(title, subtitle: subtitle, controlWidth: controlWidth) {
|
|
HStack(spacing: 6) {
|
|
Picker("", selection: $selection) {
|
|
pickerContent
|
|
}
|
|
.labelsHidden()
|
|
.pickerStyle(.menu)
|
|
.applyIf(accessibilityId != nil) { $0.accessibilityIdentifier(accessibilityId!) }
|
|
extraTrailing
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
extension SettingsPickerRow where ExtraTrailing == EmptyView {
|
|
init(
|
|
_ title: String,
|
|
subtitle: String? = nil,
|
|
controlWidth: CGFloat,
|
|
selection: Binding<SelectionValue>,
|
|
accessibilityId: String? = nil,
|
|
@ViewBuilder content: () -> PickerContent
|
|
) {
|
|
self.init(title, subtitle: subtitle, controlWidth: controlWidth, selection: selection, accessibilityId: accessibilityId, content: content) {
|
|
EmptyView()
|
|
}
|
|
}
|
|
}
|
|
|
|
private extension View {
|
|
@ViewBuilder
|
|
func applyIf(_ condition: Bool, transform: (Self) -> some View) -> some View {
|
|
if condition {
|
|
transform(self)
|
|
} else {
|
|
self
|
|
}
|
|
}
|
|
}
|
|
|
|
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 ThemeWindowThumbnail: View {
|
|
let isDark: Bool
|
|
|
|
var body: some View {
|
|
GeometryReader { geo in
|
|
let width = geo.size.width
|
|
let height = geo.size.height
|
|
|
|
ZStack {
|
|
// Wallpaper background
|
|
if isDark {
|
|
LinearGradient(
|
|
colors: [Color(red: 0.1, green: 0.1, blue: 0.3), Color(red: 0.05, green: 0.05, blue: 0.1)],
|
|
startPoint: .topLeading,
|
|
endPoint: .bottomTrailing
|
|
)
|
|
Path { path in
|
|
path.move(to: CGPoint(x: 0, y: height * 0.5))
|
|
path.addQuadCurve(to: CGPoint(x: width, y: height), control: CGPoint(x: width * 0.5, y: height * 0.2))
|
|
path.addLine(to: CGPoint(x: width, y: 0))
|
|
path.addLine(to: CGPoint(x: 0, y: 0))
|
|
}
|
|
.fill(LinearGradient(colors: [Color(red: 0.2, green: 0.2, blue: 0.6).opacity(0.5), .clear], startPoint: .topLeading, endPoint: .bottomTrailing))
|
|
} else {
|
|
LinearGradient(
|
|
colors: [Color(red: 0.6, green: 0.8, blue: 0.95), Color(red: 0.2, green: 0.4, blue: 0.8)],
|
|
startPoint: .topLeading,
|
|
endPoint: .bottomTrailing
|
|
)
|
|
Path { path in
|
|
path.move(to: CGPoint(x: 0, y: height * 0.5))
|
|
path.addQuadCurve(to: CGPoint(x: width, y: height), control: CGPoint(x: width * 0.5, y: height * 0.2))
|
|
path.addLine(to: CGPoint(x: width, y: 0))
|
|
path.addLine(to: CGPoint(x: 0, y: 0))
|
|
}
|
|
.fill(LinearGradient(colors: [Color(red: 0.8, green: 0.9, blue: 1.0).opacity(0.6), .clear], startPoint: .topLeading, endPoint: .bottomTrailing))
|
|
}
|
|
|
|
// Menu bar
|
|
VStack(spacing: 0) {
|
|
HStack {
|
|
Image(systemName: "applelogo")
|
|
.font(.system(size: max(height * 0.08, 6)))
|
|
.foregroundColor(isDark ? .white : .black)
|
|
.opacity(0.8)
|
|
Spacer()
|
|
}
|
|
.padding(.horizontal, max(width * 0.04, 4))
|
|
.frame(height: max(height * 0.12, 8))
|
|
.background(.ultraThinMaterial)
|
|
Spacer()
|
|
}
|
|
|
|
// Back window
|
|
VStack(spacing: 0) {
|
|
Rectangle()
|
|
.fill(isDark ? Color(white: 0.2) : Color(white: 0.9))
|
|
.frame(height: max(height * 0.15, 8))
|
|
ZStack(alignment: .top) {
|
|
Rectangle()
|
|
.fill(isDark ? Color(white: 0.15) : Color(white: 0.98))
|
|
RoundedRectangle(cornerRadius: max(width * 0.02, 2), style: .continuous)
|
|
.fill(Color.accentColor)
|
|
.frame(height: max(height * 0.12, 6))
|
|
.padding(max(width * 0.04, 4))
|
|
}
|
|
}
|
|
.clipShape(RoundedRectangle(cornerRadius: max(width * 0.04, 4), style: .continuous))
|
|
.frame(width: width * 0.65, height: height * 0.45)
|
|
.shadow(color: .black.opacity(isDark ? 0.4 : 0.15), radius: 4, x: 0, y: 2)
|
|
.offset(x: -width * 0.08, y: -height * 0.1)
|
|
|
|
// Front window with traffic lights
|
|
VStack(spacing: 0) {
|
|
ZStack {
|
|
Rectangle()
|
|
.fill(isDark ? Color(white: 0.18) : Color(white: 0.92))
|
|
HStack(spacing: max(width * 0.025, 2)) {
|
|
Circle().fill(Color(red: 1.0, green: 0.37, blue: 0.34)).frame(width: max(width * 0.04, 3))
|
|
Circle().fill(Color(red: 1.0, green: 0.74, blue: 0.18)).frame(width: max(width * 0.04, 3))
|
|
Circle().fill(Color(red: 0.15, green: 0.79, blue: 0.25)).frame(width: max(width * 0.04, 3))
|
|
Spacer()
|
|
}
|
|
.padding(.horizontal, max(width * 0.04, 4))
|
|
}
|
|
.frame(height: max(height * 0.18, 10))
|
|
Rectangle()
|
|
.fill(isDark ? Color(white: 0.1) : .white)
|
|
}
|
|
.clipShape(RoundedRectangle(cornerRadius: max(width * 0.05, 5), style: .continuous))
|
|
.shadow(color: .black.opacity(isDark ? 0.5 : 0.2), radius: 6, x: 0, y: 3)
|
|
.frame(width: width * 0.75, height: height * 0.55)
|
|
.offset(x: width * 0.12, y: height * 0.2)
|
|
}
|
|
}
|
|
.clipShape(RoundedRectangle(cornerRadius: 14, style: .continuous))
|
|
.overlay(
|
|
RoundedRectangle(cornerRadius: 14, style: .continuous)
|
|
.stroke(Color.primary.opacity(0.1), lineWidth: 1)
|
|
)
|
|
}
|
|
}
|
|
|
|
private struct ThemePickerRow: View {
|
|
let selectedMode: String
|
|
let onSelect: (AppearanceMode) -> Void
|
|
|
|
private let thumbWidth: CGFloat = 76
|
|
private let thumbHeight: CGFloat = 50
|
|
|
|
var body: some View {
|
|
HStack(alignment: .top, spacing: 12) {
|
|
Text(String(localized: "settings.app.theme", defaultValue: "Theme"))
|
|
.font(.system(size: 13, weight: .medium))
|
|
.frame(maxWidth: .infinity, alignment: .leading)
|
|
|
|
HStack(spacing: 8) {
|
|
ForEach(AppearanceMode.visibleCases) { mode in
|
|
let isSelected = selectedMode == mode.rawValue
|
|
Button {
|
|
onSelect(mode)
|
|
} label: {
|
|
VStack(spacing: 4) {
|
|
Group {
|
|
if mode == .system {
|
|
ZStack {
|
|
ThemeWindowThumbnail(isDark: false)
|
|
.mask(
|
|
GeometryReader { geo in
|
|
Rectangle()
|
|
.frame(width: geo.size.width / 2, height: geo.size.height)
|
|
.position(x: geo.size.width / 4, y: geo.size.height / 2)
|
|
}
|
|
)
|
|
ThemeWindowThumbnail(isDark: true)
|
|
.mask(
|
|
GeometryReader { geo in
|
|
Rectangle()
|
|
.frame(width: geo.size.width / 2, height: geo.size.height)
|
|
.position(x: geo.size.width * 0.75, y: geo.size.height / 2)
|
|
}
|
|
)
|
|
GeometryReader { geo in
|
|
Rectangle()
|
|
.fill(Color.primary.opacity(0.15))
|
|
.frame(width: 1, height: geo.size.height)
|
|
.position(x: geo.size.width / 2, y: geo.size.height / 2)
|
|
}
|
|
}
|
|
} else {
|
|
ThemeWindowThumbnail(isDark: mode == .dark)
|
|
}
|
|
}
|
|
.frame(width: thumbWidth, height: thumbHeight)
|
|
|
|
Text(mode.displayName)
|
|
.font(.system(size: 10))
|
|
.fontWeight(isSelected ? .semibold : .regular)
|
|
.foregroundColor(isSelected ? .primary : .secondary)
|
|
}
|
|
.padding(.vertical, 8)
|
|
.padding(.horizontal, 10)
|
|
.contentShape(Rectangle())
|
|
.background(
|
|
RoundedRectangle(cornerRadius: 8, style: .continuous)
|
|
.fill(isSelected
|
|
? Color.accentColor.opacity(0.12)
|
|
: Color.clear)
|
|
)
|
|
.overlay(
|
|
RoundedRectangle(cornerRadius: 8, style: .continuous)
|
|
.stroke(isSelected ? Color.accentColor : Color.clear, lineWidth: 2)
|
|
)
|
|
}
|
|
.buttonStyle(.plain)
|
|
.focusable(false)
|
|
.accessibilityAddTraits(isSelected ? .isSelected : [])
|
|
}
|
|
}
|
|
.layoutPriority(1)
|
|
}
|
|
.padding(.horizontal, 14)
|
|
.padding(.vertical, 9)
|
|
.frame(maxWidth: .infinity, alignment: .leading)
|
|
}
|
|
}
|
|
|
|
private struct AppIconPickerRow: View {
|
|
let selectedMode: String
|
|
let onSelect: (AppIconMode) -> Void
|
|
|
|
private let iconSize: CGFloat = 48
|
|
private let autoIconSize: CGFloat = 36
|
|
|
|
var body: some View {
|
|
HStack(alignment: .top, spacing: 12) {
|
|
VStack(alignment: .leading, spacing: 3) {
|
|
Text(String(localized: "settings.app.appIcon", defaultValue: "App Icon"))
|
|
.font(.system(size: 13, weight: .medium))
|
|
Text(String(localized: "settings.app.appIcon.subtitle", defaultValue: "Dock and app switcher"))
|
|
.font(.caption)
|
|
.foregroundColor(.secondary)
|
|
}
|
|
.frame(maxWidth: .infinity, alignment: .leading)
|
|
|
|
HStack(spacing: 8) {
|
|
ForEach(AppIconMode.allCases) { mode in
|
|
let isSelected = selectedMode == mode.rawValue
|
|
Button {
|
|
onSelect(mode)
|
|
} label: {
|
|
VStack(spacing: 4) {
|
|
Group {
|
|
if mode == .automatic {
|
|
ZStack {
|
|
Image("AppIconLight")
|
|
.resizable()
|
|
.interpolation(.high)
|
|
.frame(width: autoIconSize, height: autoIconSize)
|
|
.clipShape(RoundedRectangle(cornerRadius: autoIconSize * 0.22, style: .continuous))
|
|
.offset(x: -10)
|
|
Image("AppIconDark")
|
|
.resizable()
|
|
.interpolation(.high)
|
|
.frame(width: autoIconSize, height: autoIconSize)
|
|
.clipShape(RoundedRectangle(cornerRadius: autoIconSize * 0.22, style: .continuous))
|
|
.offset(x: 10)
|
|
}
|
|
.frame(width: iconSize, height: iconSize)
|
|
} else {
|
|
Image(mode.imageName ?? "AppIconLight")
|
|
.resizable()
|
|
.interpolation(.high)
|
|
.frame(width: iconSize, height: iconSize)
|
|
.clipShape(RoundedRectangle(cornerRadius: iconSize * 0.22, style: .continuous))
|
|
}
|
|
}
|
|
|
|
Text(mode.displayName)
|
|
.font(.system(size: 10))
|
|
.foregroundColor(isSelected ? .primary : .secondary)
|
|
}
|
|
.padding(.vertical, 8)
|
|
.padding(.horizontal, 10)
|
|
.contentShape(Rectangle())
|
|
.background(
|
|
RoundedRectangle(cornerRadius: 8, style: .continuous)
|
|
.fill(isSelected
|
|
? Color.accentColor.opacity(0.12)
|
|
: Color.clear)
|
|
)
|
|
.overlay(
|
|
RoundedRectangle(cornerRadius: 8, style: .continuous)
|
|
.stroke(isSelected ? Color.accentColor : Color.clear, lineWidth: 2)
|
|
)
|
|
}
|
|
.buttonStyle(.plain)
|
|
.focusable(false)
|
|
.accessibilityAddTraits(isSelected ? .isSelected : [])
|
|
}
|
|
}
|
|
.layoutPriority(1)
|
|
}
|
|
.padding(.horizontal, 14)
|
|
.padding(.vertical, 9)
|
|
.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,
|
|
displayString: { action.displayedShortcutString(for: $0) },
|
|
transformRecordedShortcut: { action.normalizedRecordedShortcut($0) }
|
|
)
|
|
.onChange(of: shortcut) { newValue in
|
|
KeyboardShortcutSettings.setShortcut(newValue, for: action)
|
|
}
|
|
.onReceive(NotificationCenter.default.publisher(for: KeyboardShortcutSettings.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)
|
|
}
|
|
}
|