diff --git a/GhosttyTabs.xcodeproj/project.pbxproj b/GhosttyTabs.xcodeproj/project.pbxproj index 96ed08a2c204..f4cd924ee1d8 100644 --- a/GhosttyTabs.xcodeproj/project.pbxproj +++ b/GhosttyTabs.xcodeproj/project.pbxproj @@ -32,6 +32,7 @@ 84E00D47E4584162AE53BC8D /* xterm-ghostty in Resources */ = {isa = PBXBuildFile; fileRef = B2E7294509CC42FE9191870E /* xterm-ghostty */; }; 8C4BBF2DEF6DF93F395A9EE7 /* TerminalControllerSocketSecurityTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 491751CE2321474474F27DCF /* TerminalControllerSocketSecurityTests.swift */; }; A5001001 /* cmuxApp.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5001011 /* cmuxApp.swift */; }; + A50019A0 /* SettingsNavigation.swift in Sources */ = {isa = PBXBuildFile; fileRef = A50019A1 /* SettingsNavigation.swift */; }; A5001002 /* ContentView.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5001012 /* ContentView.swift */; }; C0DE32470000000000000001 /* ContentViewIdentifierCopyCommands.swift in Sources */ = {isa = PBXBuildFile; fileRef = C0DE32470000000000000002 /* ContentViewIdentifierCopyCommands.swift */; }; 619D509BC9B1EA946CBD6A8A /* WorkspaceRuntimeSettings.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9960CC3992F3C44489E7627C /* WorkspaceRuntimeSettings.swift */; }; @@ -289,6 +290,7 @@ 9CEC6EF35B71AE59FA45BA69 /* VMClient.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = VMClient.swift; sourceTree = ""; }; A5001000 /* cmux.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = cmux.app; sourceTree = BUILT_PRODUCTS_DIR; }; A5001011 /* cmuxApp.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = cmuxApp.swift; sourceTree = ""; }; + A50019A1 /* SettingsNavigation.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SettingsNavigation.swift; sourceTree = ""; }; A5001012 /* ContentView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ContentView.swift; sourceTree = ""; }; C0DE32470000000000000002 /* ContentViewIdentifierCopyCommands.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ContentViewIdentifierCopyCommands.swift; sourceTree = ""; }; 9960CC3992F3C44489E7627C /* WorkspaceRuntimeSettings.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = App/WorkspaceRuntimeSettings.swift; sourceTree = ""; }; @@ -583,12 +585,13 @@ A5001041 /* Sources */ = { isa = PBXGroup; children = ( - 3023B1003023B1003023B100 /* ConfigSource.swift */, - 3023B1013023B1013023B101 /* ConfigSettingsView.swift */, - A5001011 /* cmuxApp.swift */, - A5001012 /* ContentView.swift */, - C0DE32470000000000000002 /* ContentViewIdentifierCopyCommands.swift */, - 9960CC3992F3C44489E7627C /* WorkspaceRuntimeSettings.swift */, + 3023B1003023B1003023B100 /* ConfigSource.swift */, + 3023B1013023B1013023B101 /* ConfigSettingsView.swift */, + A5001011 /* cmuxApp.swift */, + A50019A1 /* SettingsNavigation.swift */, + A5001012 /* ContentView.swift */, + C0DE32470000000000000002 /* ContentViewIdentifierCopyCommands.swift */, + 9960CC3992F3C44489E7627C /* WorkspaceRuntimeSettings.swift */, 243632BA1DBBA36FD46E0610 /* SidebarAppearanceSupport.swift */, 610008EA5E3744DDBE05C8E2 /* InternalTabDragConfiguration.swift */, A080D1F6CCCBFBED8D95DD1D /* ShortcutHintPill.swift */, @@ -980,6 +983,7 @@ 3023A1003023A1003023A100 /* ConfigSource.swift in Sources */, 3023A1013023A1013023A101 /* ConfigSettingsView.swift in Sources */, A5001001 /* cmuxApp.swift in Sources */, + A50019A0 /* SettingsNavigation.swift in Sources */, A5001002 /* ContentView.swift in Sources */, C0DE32470000000000000001 /* ContentViewIdentifierCopyCommands.swift in Sources */, 619D509BC9B1EA946CBD6A8A /* WorkspaceRuntimeSettings.swift in Sources */, diff --git a/Prototypes/SettingsShellLab/.codex/environments/environment.toml b/Prototypes/SettingsShellLab/.codex/environments/environment.toml new file mode 100644 index 000000000000..607a272f36ef --- /dev/null +++ b/Prototypes/SettingsShellLab/.codex/environments/environment.toml @@ -0,0 +1,11 @@ +# THIS IS AUTOGENERATED. DO NOT EDIT MANUALLY +version = 1 +name = "SettingsShellLab" + +[setup] +script = "" + +[[actions]] +name = "Run" +icon = "run" +command = "./script/build_and_run.sh" diff --git a/Prototypes/SettingsShellLab/.gitignore b/Prototypes/SettingsShellLab/.gitignore new file mode 100644 index 000000000000..e11dfecddf41 --- /dev/null +++ b/Prototypes/SettingsShellLab/.gitignore @@ -0,0 +1,2 @@ +.build/ +dist/ diff --git a/Prototypes/SettingsShellLab/Package.swift b/Prototypes/SettingsShellLab/Package.swift new file mode 100644 index 000000000000..ad1232268078 --- /dev/null +++ b/Prototypes/SettingsShellLab/Package.swift @@ -0,0 +1,20 @@ +// swift-tools-version: 5.9 + +import PackageDescription + +let package = Package( + name: "SettingsShellLab", + platforms: [ + .macOS(.v14), + ], + products: [ + .executable(name: "SettingsShellLab", targets: ["SettingsShellLab"]), + ], + targets: [ + .executableTarget( + name: "SettingsShellLab", + path: "Sources/SettingsShellLab", + resources: [.process("Resources")] + ), + ] +) diff --git a/Prototypes/SettingsShellLab/Sources/SettingsShellLab/App/SettingsShellLabApp.swift b/Prototypes/SettingsShellLab/Sources/SettingsShellLab/App/SettingsShellLabApp.swift new file mode 100644 index 000000000000..5ded37a8128f --- /dev/null +++ b/Prototypes/SettingsShellLab/Sources/SettingsShellLab/App/SettingsShellLabApp.swift @@ -0,0 +1,24 @@ +import AppKit +import SwiftUI + +@main +struct SettingsShellLabApp: App { + @NSApplicationDelegateAdaptor(AppDelegate.self) private var appDelegate + + var body: some Scene { + WindowGroup(String(localized: "app.window.title", defaultValue: "Settings Shell Lab")) { + SettingsShellView() + } + .defaultSize(width: 980, height: 680) + .commands { + SidebarCommands() + } + } +} + +final class AppDelegate: NSObject, NSApplicationDelegate { + func applicationDidFinishLaunching(_ notification: Notification) { + NSApp.setActivationPolicy(.regular) + NSApp.activate(ignoringOtherApps: true) + } +} diff --git a/Prototypes/SettingsShellLab/Sources/SettingsShellLab/Models/SettingsSection.swift b/Prototypes/SettingsShellLab/Sources/SettingsShellLab/Models/SettingsSection.swift new file mode 100644 index 000000000000..a2d16a31a1fd --- /dev/null +++ b/Prototypes/SettingsShellLab/Sources/SettingsShellLab/Models/SettingsSection.swift @@ -0,0 +1,74 @@ +import SwiftUI + +enum SettingsSection: String, CaseIterable, Identifiable { + case general + case terminal + case sidebar + case browser + case keyboard + case automation + case advanced + + var id: Self { self } + + var title: String { + switch self { + case .general: + return String(localized: "section.general.title", defaultValue: "General") + case .terminal: + return String(localized: "section.terminal.title", defaultValue: "Terminal") + case .sidebar: + return String(localized: "section.sidebar.title", defaultValue: "Sidebar") + case .browser: + return String(localized: "section.browser.title", defaultValue: "Browser") + case .keyboard: + return String(localized: "section.keyboard.title", defaultValue: "Keyboard") + case .automation: + return String(localized: "section.automation.title", defaultValue: "Automation") + case .advanced: + return String(localized: "section.advanced.title", defaultValue: "Advanced") + } + } + + var symbolName: String { + switch self { + case .general: + return "gearshape" + case .terminal: + return "terminal" + case .sidebar: + return "sidebar.left" + case .browser: + return "globe" + case .keyboard: + return "keyboard" + case .automation: + return "bolt.horizontal" + case .advanced: + return "wrench.and.screwdriver" + } + } + + var detail: String { + switch self { + case .general: + return String(localized: "section.general.detail", defaultValue: "Language, appearance, updates") + case .terminal: + return String(localized: "section.terminal.detail", defaultValue: "Font, scrollback, bell") + case .sidebar: + return String(localized: "section.sidebar.detail", defaultValue: "Layout, badges, metadata") + case .browser: + return String(localized: "section.browser.detail", defaultValue: "Search, links, history") + case .keyboard: + return String(localized: "section.keyboard.detail", defaultValue: "Shortcuts and chords") + case .automation: + return String(localized: "section.automation.detail", defaultValue: "Socket, hooks, ports") + case .advanced: + return String(localized: "section.advanced.detail", defaultValue: "Diagnostics and reset") + } + } + + var searchText: String { + "\(title) \(detail)" + } +} diff --git a/Prototypes/SettingsShellLab/Sources/SettingsShellLab/Resources/Localizable.xcstrings b/Prototypes/SettingsShellLab/Sources/SettingsShellLab/Resources/Localizable.xcstrings new file mode 100644 index 000000000000..26fedbef6b9f --- /dev/null +++ b/Prototypes/SettingsShellLab/Sources/SettingsShellLab/Resources/Localizable.xcstrings @@ -0,0 +1,32 @@ +{ + "sourceLanguage": "en", + "version": "1.0", + "strings": { + "app.window.title": { "localizations": { "en": { "stringUnit": { "state": "translated", "value": "Settings Shell" } }, "ja": { "stringUnit": { "state": "translated", "value": "設定シェル" } } } }, + "detail.mode": { "localizations": { "en": { "stringUnit": { "state": "translated", "value": "Mode" } }, "ja": { "stringUnit": { "state": "translated", "value": "モード" } } } }, + "detail.primaryAction": { "localizations": { "en": { "stringUnit": { "state": "translated", "value": "Open Related File" } }, "ja": { "stringUnit": { "state": "translated", "value": "関連ファイルを開く" } } } }, + "detail.secondaryAction": { "localizations": { "en": { "stringUnit": { "state": "translated", "value": "Restore Defaults" } }, "ja": { "stringUnit": { "state": "translated", "value": "デフォルトに戻す" } } } }, + "detail.section": { "localizations": { "en": { "stringUnit": { "state": "translated", "value": "Section" } }, "ja": { "stringUnit": { "state": "translated", "value": "セクション" } } } }, + "detail.slider": { "localizations": { "en": { "stringUnit": { "state": "translated", "value": "Amount" } }, "ja": { "stringUnit": { "state": "translated", "value": "量" } } } }, + "detail.toggle": { "localizations": { "en": { "stringUnit": { "state": "translated", "value": "Enable" } }, "ja": { "stringUnit": { "state": "translated", "value": "有効にする" } } } }, + "mode.compact": { "localizations": { "en": { "stringUnit": { "state": "translated", "value": "Compact" } }, "ja": { "stringUnit": { "state": "translated", "value": "コンパクト" } } } }, + "mode.expanded": { "localizations": { "en": { "stringUnit": { "state": "translated", "value": "Expanded" } }, "ja": { "stringUnit": { "state": "translated", "value": "拡張" } } } }, + "mode.system": { "localizations": { "en": { "stringUnit": { "state": "translated", "value": "System" } }, "ja": { "stringUnit": { "state": "translated", "value": "システム" } } } }, + "section.advanced.detail": { "localizations": { "en": { "stringUnit": { "state": "translated", "value": "Advanced" } }, "ja": { "stringUnit": { "state": "translated", "value": "詳細" } } } }, + "section.advanced.title": { "localizations": { "en": { "stringUnit": { "state": "translated", "value": "Advanced" } }, "ja": { "stringUnit": { "state": "translated", "value": "詳細" } } } }, + "section.automation.detail": { "localizations": { "en": { "stringUnit": { "state": "translated", "value": "Automation" } }, "ja": { "stringUnit": { "state": "translated", "value": "自動化" } } } }, + "section.automation.title": { "localizations": { "en": { "stringUnit": { "state": "translated", "value": "Automation" } }, "ja": { "stringUnit": { "state": "translated", "value": "自動化" } } } }, + "section.browser.detail": { "localizations": { "en": { "stringUnit": { "state": "translated", "value": "Browser" } }, "ja": { "stringUnit": { "state": "translated", "value": "ブラウザ" } } } }, + "section.browser.title": { "localizations": { "en": { "stringUnit": { "state": "translated", "value": "Browser" } }, "ja": { "stringUnit": { "state": "translated", "value": "ブラウザ" } } } }, + "section.general.detail": { "localizations": { "en": { "stringUnit": { "state": "translated", "value": "General" } }, "ja": { "stringUnit": { "state": "translated", "value": "一般" } } } }, + "section.general.title": { "localizations": { "en": { "stringUnit": { "state": "translated", "value": "General" } }, "ja": { "stringUnit": { "state": "translated", "value": "一般" } } } }, + "section.keyboard.detail": { "localizations": { "en": { "stringUnit": { "state": "translated", "value": "Keyboard" } }, "ja": { "stringUnit": { "state": "translated", "value": "キーボード" } } } }, + "section.keyboard.title": { "localizations": { "en": { "stringUnit": { "state": "translated", "value": "Keyboard" } }, "ja": { "stringUnit": { "state": "translated", "value": "キーボード" } } } }, + "section.sidebar.detail": { "localizations": { "en": { "stringUnit": { "state": "translated", "value": "Sidebar" } }, "ja": { "stringUnit": { "state": "translated", "value": "サイドバー" } } } }, + "section.sidebar.title": { "localizations": { "en": { "stringUnit": { "state": "translated", "value": "Sidebar" } }, "ja": { "stringUnit": { "state": "translated", "value": "サイドバー" } } } }, + "section.terminal.detail": { "localizations": { "en": { "stringUnit": { "state": "translated", "value": "Terminal" } }, "ja": { "stringUnit": { "state": "translated", "value": "ターミナル" } } } }, + "section.terminal.title": { "localizations": { "en": { "stringUnit": { "state": "translated", "value": "Terminal" } }, "ja": { "stringUnit": { "state": "translated", "value": "ターミナル" } } } }, + "settings.search.prompt": { "localizations": { "en": { "stringUnit": { "state": "translated", "value": "Search" } }, "ja": { "stringUnit": { "state": "translated", "value": "検索" } } } }, + "settings.sidebar.title": { "localizations": { "en": { "stringUnit": { "state": "translated", "value": "Settings" } }, "ja": { "stringUnit": { "state": "translated", "value": "設定" } } } } + } +} diff --git a/Prototypes/SettingsShellLab/Sources/SettingsShellLab/Views/SettingsDetailView.swift b/Prototypes/SettingsShellLab/Sources/SettingsShellLab/Views/SettingsDetailView.swift new file mode 100644 index 000000000000..89a509065af0 --- /dev/null +++ b/Prototypes/SettingsShellLab/Sources/SettingsShellLab/Views/SettingsDetailView.swift @@ -0,0 +1,72 @@ +import SwiftUI + +struct SettingsDetailView: View { + let section: SettingsSection + + @State private var isEnabled = true + @State private var selectedMode = Mode.system + @State private var numericValue = 12.0 + + var body: some View { + Form { + Section { + LabeledContent( + String(localized: "detail.section", defaultValue: "Section"), + value: section.title + ) + + Toggle( + String(localized: "detail.toggle", defaultValue: "Enable"), + isOn: $isEnabled + ) + + Picker(String(localized: "detail.mode", defaultValue: "Mode"), selection: $selectedMode) { + ForEach(Mode.allCases) { mode in + Text(mode.title).tag(mode) + } + } + .pickerStyle(.segmented) + + Slider( + value: $numericValue, + in: 0...24 + ) { + Text(String(localized: "detail.slider", defaultValue: "Amount")) + } + } header: { + Text(section.title) + } + + Section { + Button(String(localized: "detail.primaryAction", defaultValue: "Open Related File")) { + } + .disabled(true) + + Button(String(localized: "detail.secondaryAction", defaultValue: "Restore Defaults")) { + } + .disabled(true) + } + } + .formStyle(.grouped) + .padding() + } +} + +private enum Mode: String, CaseIterable, Identifiable { + case system + case compact + case expanded + + var id: Self { self } + + var title: String { + switch self { + case .system: + return String(localized: "mode.system", defaultValue: "System") + case .compact: + return String(localized: "mode.compact", defaultValue: "Compact") + case .expanded: + return String(localized: "mode.expanded", defaultValue: "Expanded") + } + } +} diff --git a/Prototypes/SettingsShellLab/Sources/SettingsShellLab/Views/SettingsShellView.swift b/Prototypes/SettingsShellLab/Sources/SettingsShellLab/Views/SettingsShellView.swift new file mode 100644 index 000000000000..eeef26fd3dbc --- /dev/null +++ b/Prototypes/SettingsShellLab/Sources/SettingsShellLab/Views/SettingsShellView.swift @@ -0,0 +1,41 @@ +import SwiftUI + +struct SettingsShellView: View { + @SceneStorage("selectedSettingsSection") private var selectedSectionRaw = SettingsSection.general.rawValue + @State private var columnVisibility: NavigationSplitViewVisibility = .all + @State private var searchText = "" + + private var selectedSection: SettingsSection { + SettingsSection(rawValue: selectedSectionRaw) ?? .general + } + + private var filteredSections: [SettingsSection] { + let query = searchText.trimmingCharacters(in: .whitespacesAndNewlines) + guard !query.isEmpty else { return SettingsSection.allCases } + return SettingsSection.allCases.filter { section in + section.searchText.localizedStandardContains(query) + } + } + + var body: some View { + NavigationSplitView(columnVisibility: $columnVisibility) { + List(selection: $selectedSectionRaw) { + ForEach(filteredSections) { section in + Label(section.title, systemImage: section.symbolName) + .tag(section.rawValue) + } + } + .listStyle(.sidebar) + .navigationTitle(String(localized: "settings.sidebar.title", defaultValue: "Settings")) + .searchable( + text: $searchText, + placement: .sidebar, + prompt: Text(String(localized: "settings.search.prompt", defaultValue: "Search")) + ) + .navigationSplitViewColumnWidth(210) + } detail: { + SettingsDetailView(section: selectedSection) + } + .navigationSplitViewStyle(.balanced) + } +} diff --git a/Prototypes/SettingsShellLab/script/build_and_run.sh b/Prototypes/SettingsShellLab/script/build_and_run.sh new file mode 100755 index 000000000000..630c35dfb945 --- /dev/null +++ b/Prototypes/SettingsShellLab/script/build_and_run.sh @@ -0,0 +1,78 @@ +#!/usr/bin/env bash +set -euo pipefail + +MODE="${1:-run}" +APP_NAME="SettingsShellLab" +BUNDLE_ID="com.cmux.prototype.SettingsShellLab" +MIN_SYSTEM_VERSION="14.0" + +ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +DIST_DIR="$ROOT_DIR/dist" +APP_BUNDLE="$DIST_DIR/$APP_NAME.app" +APP_CONTENTS="$APP_BUNDLE/Contents" +APP_MACOS="$APP_CONTENTS/MacOS" +APP_BINARY="$APP_MACOS/$APP_NAME" +INFO_PLIST="$APP_CONTENTS/Info.plist" + +cd "$ROOT_DIR" + +pkill -x "$APP_NAME" >/dev/null 2>&1 || true + +swift build +BUILD_BINARY="$(swift build --show-bin-path)/$APP_NAME" + +rm -rf "$APP_BUNDLE" +mkdir -p "$APP_MACOS" +cp "$BUILD_BINARY" "$APP_BINARY" +chmod +x "$APP_BINARY" + +cat >"$INFO_PLIST" < + + + + CFBundleExecutable + $APP_NAME + CFBundleIdentifier + $BUNDLE_ID + CFBundleName + $APP_NAME + CFBundlePackageType + APPL + LSMinimumSystemVersion + $MIN_SYSTEM_VERSION + NSPrincipalClass + NSApplication + + +PLIST + +open_app() { + /usr/bin/open -n "$APP_BUNDLE" +} + +case "$MODE" in + run) + open_app + ;; + --debug|debug) + lldb -- "$APP_BINARY" + ;; + --logs|logs) + open_app + /usr/bin/log stream --info --style compact --predicate "process == \"$APP_NAME\"" + ;; + --telemetry|telemetry) + open_app + /usr/bin/log stream --info --style compact --predicate "subsystem == \"$BUNDLE_ID\"" + ;; + --verify|verify) + open_app + sleep 1 + pgrep -x "$APP_NAME" >/dev/null + ;; + *) + echo "usage: $0 [run|--debug|--logs|--telemetry|--verify]" >&2 + exit 2 + ;; +esac diff --git a/Resources/Localizable.xcstrings b/Resources/Localizable.xcstrings index 8842edc4575a..777009f7f1b0 100644 --- a/Resources/Localizable.xcstrings +++ b/Resources/Localizable.xcstrings @@ -2,6 +2,15 @@ "sourceLanguage": "en", "version": "1.0", "strings": { + "detail.mode": { "localizations": { "en": { "stringUnit": { "state": "translated", "value": "Mode" } }, "ja": { "stringUnit": { "state": "translated", "value": "モード" } } } }, + "detail.primaryAction": { "localizations": { "en": { "stringUnit": { "state": "translated", "value": "Open Related File" } }, "ja": { "stringUnit": { "state": "translated", "value": "関連ファイルを開く" } } } }, + "detail.secondaryAction": { "localizations": { "en": { "stringUnit": { "state": "translated", "value": "Restore Defaults" } }, "ja": { "stringUnit": { "state": "translated", "value": "デフォルトに戻す" } } } }, + "detail.section": { "localizations": { "en": { "stringUnit": { "state": "translated", "value": "Section" } }, "ja": { "stringUnit": { "state": "translated", "value": "セクション" } } } }, + "detail.slider": { "localizations": { "en": { "stringUnit": { "state": "translated", "value": "Amount" } }, "ja": { "stringUnit": { "state": "translated", "value": "量" } } } }, + "detail.toggle": { "localizations": { "en": { "stringUnit": { "state": "translated", "value": "Enable" } }, "ja": { "stringUnit": { "state": "translated", "value": "有効にする" } } } }, + "mode.compact": { "localizations": { "en": { "stringUnit": { "state": "translated", "value": "Compact" } }, "ja": { "stringUnit": { "state": "translated", "value": "コンパクト" } } } }, + "mode.expanded": { "localizations": { "en": { "stringUnit": { "state": "translated", "value": "Expanded" } }, "ja": { "stringUnit": { "state": "translated", "value": "拡張" } } } }, + "mode.system": { "localizations": { "en": { "stringUnit": { "state": "translated", "value": "System" } }, "ja": { "stringUnit": { "state": "translated", "value": "システム" } } } }, "about.appName": { "extractionState": "manual", "localizations": { @@ -1090,6 +1099,57 @@ } } }, + "debug.menu.aboutTitlebarDebug": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "About Titlebar Debug…" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "Aboutタイトルバーデバッグ…" + } + } + } + }, + "debug.aboutTitlebarDebug.title": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "About Titlebar Debug" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "Aboutタイトルバーデバッグ" + } + } + } + }, + "debug.aboutTitlebarDebug.resetAbout": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Reset About" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "Aboutをリセット" + } + } + } + }, "debug.menu.tabBarBackdropLab": { "extractionState": "manual", "localizations": { @@ -49730,6 +49790,23 @@ } } }, + "settings.app.appearance": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Appearance" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "外観" + } + } + } + }, "settings.app.appIcon.subtitle": { "extractionState": "manual", "localizations": { @@ -51120,6 +51197,167 @@ } } }, + "settings.settingsJSON.docsButton": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Open Docs" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "ドキュメントを開く" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "문서 열기" + } + } + } + }, + "settings.settingsJSON.documentation": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Documentation" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "ドキュメント" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "문서" + } + } + } + }, + "settings.settingsJSON.documentation.subtitle": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "View supported keys, file locations, schema, and reload behavior." + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "サポートされているキー、ファイルの場所、スキーマ、再読み込み方法を確認します。" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "지원되는 키, 파일 위치, 스키마, 다시 불러오기 동작을 확인합니다." + } + } + } + }, + "settings.settingsJSON.file": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "User settings file" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "ユーザー設定ファイル" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "사용자 설정 파일" + } + } + } + }, + "settings.settingsJSON.file.subtitle": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Edit cmux-owned app settings, shortcuts, automation, sidebar, notifications, and browser behavior." + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "cmux 管理のアプリ設定、ショートカット、オートメーション、サイドバー、通知、ブラウザ動作を編集します。" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "cmux가 관리하는 앱 설정, 단축키, 자동화, 사이드바, 알림, 브라우저 동작을 편집합니다." + } + } + } + }, + "settings.settingsJSON.openButton": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Open" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "開く" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "열기" + } + } + } + }, + "settings.settingsJSON.openFile": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Open settings.json" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "settings.jsonを開く" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "settings.json 열기" + } + } + } + }, "settings.app.configWindow": { "extractionState": "manual", "localizations": { @@ -56872,6 +57110,74 @@ } } }, + "settings.notifications.desktop": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Desktop Notifications" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "デスクトップ通知" + } + } + } + }, + "settings.notifications.command": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Notification Command" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "通知コマンド" + } + } + } + }, + "settings.notifications.command.subtitle": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Run a shell command when a notification arrives. $CMUX_NOTIFICATION_TITLE, $CMUX_NOTIFICATION_SUBTITLE, $CMUX_NOTIFICATION_BODY are set." + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "通知が届いたときにシェルコマンドを実行します。$CMUX_NOTIFICATION_TITLE、$CMUX_NOTIFICATION_SUBTITLE、$CMUX_NOTIFICATION_BODY が設定されます。" + } + } + } + }, + "settings.notifications.command.placeholder": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "say \"done\"" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "say \"done\"" + } + } + } + }, "settings.automation.claudeCode": { "extractionState": "manual", "localizations": { @@ -67264,6 +67570,29 @@ } } }, + "settings.section.settingsJSON": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "settings.json" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "settings.json" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "settings.json" + } + } + } + }, "settings.section.reset": { "extractionState": "manual", "localizations": { @@ -68661,6 +68990,40 @@ } } }, + "settings.search.prompt": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Search" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "検索" + } + } + } + }, + "settings.search.noResults": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "No Results" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "結果なし" + } + } + } + }, "settings.workspaceColors.base": { "extractionState": "manual", "localizations": { diff --git a/Sources/AppDelegate.swift b/Sources/AppDelegate.swift index 86ac82b67e9a..9450f31efa8b 100644 --- a/Sources/AppDelegate.swift +++ b/Sources/AppDelegate.swift @@ -6601,25 +6601,17 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent static func presentPreferencesWindow( navigationTarget: SettingsNavigationTarget? = nil, showFallbackSettingsWindow: @MainActor (SettingsNavigationTarget?) -> Void = { target in - SettingsWindowController.shared.show(navigationTarget: target) + SettingsWindowPresenter.show(navigationTarget: target) }, activateApplication: @MainActor () -> Void = { NSRunningApplication.current.activate(options: [.activateAllWindows, .activateIgnoringOtherApps]) } ) { #if DEBUG - cmuxDebugLog("settings.open.present path=customWindowDirect") + cmuxDebugLog("settings.open.present path=swiftuiWindow") #endif showFallbackSettingsWindow(navigationTarget) activateApplication() - if let window = SettingsWindowController.shared.window { - window.orderFrontRegardless() - window.makeKeyAndOrderFront(nil) - DispatchQueue.main.async { - window.orderFrontRegardless() - window.makeKeyAndOrderFront(nil) - } - } #if DEBUG cmuxDebugLog("settings.open.present activate=1") #endif diff --git a/Sources/SettingsNavigation.swift b/Sources/SettingsNavigation.swift new file mode 100644 index 000000000000..46a04e04f407 --- /dev/null +++ b/Sources/SettingsNavigation.swift @@ -0,0 +1,489 @@ +import SwiftUI + +enum SettingsNavigationTarget: String, CaseIterable, Identifiable { + case account + case app + case terminal + case sidebarAppearance + case automation + case browser + case browserImport + case globalHotkey + case keyboardShortcuts + case workspaceColors + case settingsJSON + case reset + + var id: Self { self } + + var title: String { + switch self { + case .account: + return String(localized: "settings.section.account", defaultValue: "Account") + case .app: + return String(localized: "settings.section.app", defaultValue: "App") + case .terminal: + return String(localized: "settings.section.terminal", defaultValue: "Terminal") + case .workspaceColors: + return String(localized: "settings.section.workspaceColors", defaultValue: "Workspace Colors") + case .sidebarAppearance: + return String(localized: "settings.section.sidebarAppearance", defaultValue: "Sidebar Appearance") + case .automation: + return String(localized: "settings.section.automation", defaultValue: "Automation") + case .browser: + return String(localized: "settings.section.browser", defaultValue: "Browser") + case .browserImport: + return String(localized: "settings.browser.import", defaultValue: "Import Browser Data") + case .globalHotkey: + return String(localized: "settings.section.globalHotkey", defaultValue: "Global Hotkey") + case .keyboardShortcuts: + return String(localized: "settings.section.keyboardShortcuts", defaultValue: "Keyboard Shortcuts") + case .settingsJSON: + return String(localized: "settings.section.settingsJSON", defaultValue: "settings.json") + case .reset: + return String(localized: "settings.section.reset", defaultValue: "Reset") + } + } + + var symbolName: String { + switch self { + case .account: + return "person.crop.circle" + case .app: + return "gearshape" + case .terminal: + return "terminal" + case .workspaceColors: + return "paintpalette" + case .sidebarAppearance: + return "sidebar.left" + case .automation: + return "wand.and.sparkles" + case .browser: + return "globe" + case .browserImport: + return "square.and.arrow.down" + case .globalHotkey: + return "keyboard.badge.ellipsis" + case .keyboardShortcuts: + return "keyboard" + case .settingsJSON: + return "doc.text" + case .reset: + return "arrow.counterclockwise" + } + } + + var searchText: String { + switch self { + case .account: + return "\(title) sign in team sync" + case .app: + return "\(title) appearance language workspace notifications menu bar telemetry" + case .terminal: + return "\(title) scrollbar" + case .workspaceColors: + return "\(title) palette tabs" + case .sidebarAppearance: + return "\(title) sidebar tint details branches badges" + case .automation: + return "\(title) socket integrations hooks ports claude cursor gemini" + case .browser: + return "\(title) search engine links history theme" + case .browserImport: + return "\(title) browser import data bookmarks history cookies" + case .globalHotkey: + return "\(title) system wide shortcut" + case .keyboardShortcuts: + return "\(title) keybindings commands chords" + case .settingsJSON: + return "\(title) config file preferences editor documentation schema jsonc reload" + case .reset: + return "\(title) defaults" + } + } +} + +enum SettingsNavigationRequest { + static let notificationName = Notification.Name("cmux.settings.navigate") + private static let targetKey = "target" + private static let anchorKey = "anchor" + private static let highlightKey = "highlight" + + static func post(_ target: SettingsNavigationTarget, anchorID: String? = nil, highlight: Bool = false) { + NotificationCenter.default.post( + name: notificationName, + object: nil, + userInfo: [ + targetKey: target.rawValue, + anchorKey: anchorID ?? SettingsSearchIndex.sectionID(for: target), + highlightKey: highlight + ] + ) + } + + static func target(from notification: Notification) -> SettingsNavigationTarget? { + destination(from: notification)?.target + } + + static func destination(from notification: Notification) -> SettingsNavigationDestination? { + guard + let rawValue = notification.userInfo?[targetKey] as? String, + let target = SettingsNavigationTarget(rawValue: rawValue) + else { + return nil + } + let anchorID = notification.userInfo?[anchorKey] as? String + let shouldHighlight = notification.userInfo?[highlightKey] as? Bool ?? false + return SettingsNavigationDestination( + target: target, + anchorID: anchorID ?? SettingsSearchIndex.sectionID(for: target), + shouldHighlight: shouldHighlight + ) + } +} + +struct SettingsNavigationDestination { + let target: SettingsNavigationTarget + let anchorID: String + let shouldHighlight: Bool +} + +struct SettingsSearchHighlightState: Equatable { + let anchorID: String? + let token: Int + let startedAt: Date? +} + +private struct SettingsSearchHighlightStateKey: EnvironmentKey { + static let defaultValue = SettingsSearchHighlightState(anchorID: nil, token: 0, startedAt: nil) +} + +extension EnvironmentValues { + var settingsSearchHighlightState: SettingsSearchHighlightState { + get { self[SettingsSearchHighlightStateKey.self] } + set { self[SettingsSearchHighlightStateKey.self] = newValue } + } +} + +extension View { + @ViewBuilder + func settingsSearchAnchor(_ anchorID: String?) -> some View { + if let anchorID { + settingsSearchAnchors([anchorID]) + } else { + self + } + } + + @ViewBuilder + func settingsSearchAnchors(_ anchorIDs: [String]) -> some View { + let filteredAnchorIDs = anchorIDs.filter { !$0.isEmpty } + if let primaryAnchorID = filteredAnchorIDs.first { + self + .id(primaryAnchorID) + .modifier(SettingsSearchHighlightModifier(anchorIDs: filteredAnchorIDs)) + } else { + self + } + } +} + +private struct SettingsSearchHighlightModifier: ViewModifier { + @Environment(\.settingsSearchHighlightState) private var highlightState + let anchorIDs: [String] + + private func matches(_ state: SettingsSearchHighlightState) -> Bool { + guard let anchorID = state.anchorID else { return false } + return anchorIDs.contains(anchorID) + } + + func body(content: Content) -> some View { + content + .background { + if matches(highlightState) { + TimelineView(.animation) { context in + let opacity = highlightOpacity(at: context.date, for: highlightState) + RoundedRectangle(cornerRadius: 8, style: .continuous) + .fill(Color.accentColor.opacity(opacity * 0.24)) + .overlay( + RoundedRectangle(cornerRadius: 8, style: .continuous) + .stroke(Color.accentColor.opacity(opacity), lineWidth: 2.5) + ) + .shadow(color: Color.accentColor.opacity(opacity * 0.24), radius: 8, x: 0, y: 0) + } + } + } + } + + private func highlightOpacity(at date: Date, for state: SettingsSearchHighlightState) -> Double { + guard matches(state), let startedAt = state.startedAt else { return 0 } + let elapsed = date.timeIntervalSince(startedAt) + if elapsed < 0.14 { + return max(0, min(1, elapsed / 0.14)) + } + if elapsed < 5 { + return 1 + } + if elapsed < 5.9 { + return max(0, 1 - ((elapsed - 5) / 0.9)) + } + return 0 + } +} + +enum SettingsSearchEntryKind { + case section + case setting +} + +struct SettingsSearchEntry: Identifiable { + let id: String + let kind: SettingsSearchEntryKind + let target: SettingsNavigationTarget + let title: String + let subtitle: String? + let symbolName: String + let normalizedSearchText: String + + init( + id: String, + kind: SettingsSearchEntryKind, + target: SettingsNavigationTarget, + title: String, + subtitle: String?, + symbolName: String, + searchText: String + ) { + self.id = id + self.kind = kind + self.target = target + self.title = title + self.subtitle = subtitle + self.symbolName = symbolName + normalizedSearchText = SettingsSearchIndex.normalized("\(title) \(subtitle ?? "") \(searchText)") + } +} + +enum SettingsSearchIndex { + static let defaultSelectionID = sectionID(for: .account) + + private static let sectionEntries: [SettingsSearchEntry] = SettingsNavigationTarget.allCases.map { target in + SettingsSearchEntry( + id: sectionID(for: target), + kind: .section, + target: target, + title: target.title, + subtitle: nil, + symbolName: target.symbolName, + searchText: target.searchText + ) + } + + private static let settingEntries: [SettingsSearchEntry] = [ + setting(.account, "account", String(localized: "settings.section.account", defaultValue: "Account"), "sign in login team sync user profile"), + setting(.app, "language", String(localized: "settings.app.language", defaultValue: "Language"), "locale translation japanese english restart"), + setting(.app, "appearance", String(localized: "settings.app.appearance", defaultValue: "Appearance"), "theme light dark system"), + setting(.app, "app-icon", String(localized: "settings.app.appIcon", defaultValue: "App Icon"), "dock icon alternate"), + setting(.app, "new-workspace-placement", String(localized: "settings.app.newWorkspacePlacement", defaultValue: "New Workspace Placement"), "workspace order position"), + setting(.app, "minimal-mode", String(localized: "settings.app.minimalMode", defaultValue: "Minimal Mode"), "presentation compact chrome"), + setting(.app, "keep-workspace-open", String(localized: "settings.app.closeWorkspaceOnLastSurfaceShortcut", defaultValue: "Keep Workspace Open When Closing Last Surface"), "close last surface shortcut cmd w"), + setting(.app, "focus-pane-first-click", String(localized: "settings.app.paneFirstClickFocus", defaultValue: "Focus Pane on First Click"), "mouse click focus"), + setting(.app, "preferred-editor", String(localized: "settings.app.preferredEditor", defaultValue: "Open Files With"), "editor code zed subl cmd click file"), + setting(.app, "terminal-config", String(localized: "settings.app.configWindow", defaultValue: "Terminal Config"), "ghostty config merged preview"), + setting(.app, "markdown-viewer", String(localized: "settings.app.openMarkdownInCmuxViewer", defaultValue: "Open Markdown in cmux Viewer"), "md markdown viewer"), + setting(.app, "reorder-notification", String(localized: "settings.app.reorderOnNotification", defaultValue: "Reorder on Notification"), "workspace notification order"), + setting(.app, "dock-badge", String(localized: "settings.app.dockBadge", defaultValue: "Dock Badge"), "unread count app icon"), + setting(.app, "menu-bar-only", String(localized: "settings.app.menuBarOnly", defaultValue: "Menu Bar Only"), "dock icon cmd tab"), + setting(.app, "show-menu-bar", String(localized: "settings.app.showInMenuBar", defaultValue: "Show in Menu Bar"), "menu extra status item"), + setting(.app, "unread-pane-ring", String(localized: "settings.notifications.paneRing.title", defaultValue: "Unread Pane Ring"), "notification blue ring pane"), + setting(.app, "pane-flash", String(localized: "settings.notifications.paneFlash.title", defaultValue: "Pane Flash"), "notification flash highlight"), + setting(.app, "desktop-notifications", String(localized: "settings.notifications.desktop", defaultValue: "Desktop Notifications"), "permission alerts test notification"), + setting(.app, "notification-sound", String(localized: "settings.notifications.sound.title", defaultValue: "Notification Sound"), "custom sound alert audio"), + setting(.app, "notification-command", String(localized: "settings.notifications.command", defaultValue: "Notification Command"), "shell command environment variables"), + setting(.app, "telemetry", String(localized: "settings.app.telemetry", defaultValue: "Send anonymous telemetry"), "analytics crash usage"), + setting(.app, "warn-before-quit", String(localized: "settings.app.warnBeforeQuit", defaultValue: "Warn Before Quit"), "cmd q confirmation"), + setting(.app, "rename-selects-name", String(localized: "settings.app.renameSelectsName", defaultValue: "Rename Selects Existing Name"), "command palette rename text selection"), + setting(.app, "palette-search-all", String(localized: "settings.app.commandPaletteSearchAllSurfaces", defaultValue: "Command Palette Searches All Surfaces"), "cmd p search terminal browser markdown"), + setting(.app, "hide-sidebar-details", String(localized: "settings.app.hideAllSidebarDetails", defaultValue: "Hide All Sidebar Details"), "workspace sidebar compact"), + setting(.app, "sidebar-branch-layout", String(localized: "settings.app.sidebarBranchLayout", defaultValue: "Sidebar Branch Layout"), "branch directory vertical inline"), + setting(.app, "show-notification-message", String(localized: "settings.app.showNotificationMessage", defaultValue: "Show Notification Message in Sidebar"), "workspace latest notification"), + setting(.app, "show-branch-directory", String(localized: "settings.app.showBranchDirectory", defaultValue: "Show Branch + Directory in Sidebar"), "git cwd path"), + setting(.app, "show-pull-requests", String(localized: "settings.app.showPullRequests", defaultValue: "Show Pull Requests in Sidebar"), "review pr mr link"), + setting(.app, "open-pr-links", String(localized: "settings.app.openSidebarPRLinks", defaultValue: "Open Sidebar PR Links in cmux Browser"), "pull request link browser"), + setting(.app, "open-port-links", String(localized: "settings.app.openSidebarPortLinks", defaultValue: "Open Sidebar Port Links in cmux Browser"), "port link browser"), + setting(.app, "show-ssh", String(localized: "settings.app.showSSH", defaultValue: "Show SSH in Sidebar"), "remote target"), + setting(.app, "show-ports", String(localized: "settings.app.showPorts", defaultValue: "Show Listening Ports in Sidebar"), "localhost port"), + setting(.app, "show-log", String(localized: "settings.app.showLog", defaultValue: "Show Latest Log in Sidebar"), "status message"), + setting(.app, "show-progress", String(localized: "settings.app.showProgress", defaultValue: "Show Progress in Sidebar"), "progress bar"), + setting(.app, "show-metadata", String(localized: "settings.app.showMetadata", defaultValue: "Show Custom Metadata in Sidebar"), "report meta status block"), + setting(.terminal, "scrollbar", String(localized: "settings.terminal.scrollBar", defaultValue: "Show Terminal Scroll Bar"), "terminal shell scrollback"), + setting(.sidebarAppearance, "match-terminal", String(localized: "settings.sidebarAppearance.matchTerminalBackground", defaultValue: "Match Terminal Background"), "sidebar material transparency"), + setting(.sidebarAppearance, "light-tint", String(localized: "settings.sidebarAppearance.tintColorLight", defaultValue: "Light Mode Tint"), "sidebar color light"), + setting(.sidebarAppearance, "dark-tint", String(localized: "settings.sidebarAppearance.tintColorDark", defaultValue: "Dark Mode Tint"), "sidebar color dark"), + setting(.sidebarAppearance, "tint-opacity", String(localized: "settings.sidebarAppearance.tintOpacity", defaultValue: "Tint Opacity"), "sidebar color opacity"), + setting(.sidebarAppearance, "reset-tint", String(localized: "settings.sidebarAppearance.reset", defaultValue: "Reset Sidebar Tint"), "restore default sidebar appearance"), + setting(.automation, "socket-mode", String(localized: "settings.automation.socketMode", defaultValue: "Socket Control Mode"), "unix socket api access password auth"), + setting(.automation, "socket-password", String(localized: "settings.automation.socketPassword", defaultValue: "Socket Password"), "socket auth credential"), + setting(.automation, "claude-code", String(localized: "settings.automation.claudeCode", defaultValue: "Claude Code Integration"), "agent hooks notifications"), + setting(.automation, "claude-path", String(localized: "settings.automation.claudeCode.customPath", defaultValue: "Claude Binary Path"), "custom claude executable"), + setting(.automation, "cursor", String(localized: "settings.automation.cursor", defaultValue: "Cursor Integration"), "agent hooks notifications"), + setting(.automation, "gemini", String(localized: "settings.automation.gemini", defaultValue: "Gemini CLI Integration"), "agent hooks notifications"), + setting(.automation, "port-base", String(localized: "settings.automation.portBase", defaultValue: "Port Base"), "CMUX_PORT start"), + setting(.automation, "port-range", String(localized: "settings.automation.portRange", defaultValue: "Port Range Size"), "CMUX_PORT_END workspace ports"), + setting(.browser, "search-engine", String(localized: "settings.browser.searchEngine", defaultValue: "Default Search Engine"), "address bar query google duckduckgo"), + setting(.browser, "search-suggestions", String(localized: "settings.browser.searchSuggestions", defaultValue: "Show Search Suggestions"), "browser address bar suggestions"), + setting(.browser, "theme", String(localized: "settings.browser.theme", defaultValue: "Browser Theme"), "web appearance light dark system"), + setting(.browser, "terminal-links", String(localized: "settings.browser.openTerminalLinks", defaultValue: "Open Terminal Links in cmux Browser"), "click links browser"), + setting(.browser, "intercept-open", String(localized: "settings.browser.interceptOpen", defaultValue: "Intercept open http(s) in Terminal"), "open command urls"), + setting(.browser, "host-whitelist", String(localized: "settings.browser.hostWhitelist", defaultValue: "Hosts to Open in Embedded Browser"), "hosts wildcard terminal links"), + setting(.browser, "external-patterns", String(localized: "settings.browser.externalPatterns", defaultValue: "URLs to Always Open Externally"), "regex url rules default browser"), + setting(.browser, "http-allowlist", String(localized: "settings.browser.httpAllowlist", defaultValue: "HTTP Hosts Allowed in Embedded Browser"), "localhost non https warning"), + setting(.browserImport, "import-data", String(localized: "settings.browser.import", defaultValue: "Import Browser Data"), "bookmarks history cookies profiles"), + setting(.browserImport, "import-hint", String(localized: "settings.browser.import.hint.show", defaultValue: "Show import hint on blank browser tabs"), "blank tab browser import"), + setting(.browser, "react-grab", String(localized: "settings.browser.reactGrabVersion", defaultValue: "React Grab Version"), "npm react grab toolbar"), + setting(.browser, "history", String(localized: "settings.browser.history", defaultValue: "Browsing History"), "clear visited suggestions"), + setting(.globalHotkey, "enable-hotkey", String(localized: "settings.globalHotkey.enable", defaultValue: "Enable System-Wide Hotkey"), "global shortcut show hide windows"), + setting(.globalHotkey, "shortcut", String(localized: "settings.section.globalHotkey", defaultValue: "Global Hotkey"), "keyboard recorder command option control"), + setting(.keyboardShortcuts, "shortcut-chords", String(localized: "settings.shortcuts.chords", defaultValue: "Shortcut Chords"), "tmux multi step keybindings"), + setting(.keyboardShortcuts, "show-hints", String(localized: "settings.shortcuts.showHints", defaultValue: "Show Cmd/Ctrl-Hold Shortcut Hints"), "modifier hold hints keyboard"), + setting(.keyboardShortcuts, "shortcuts", String(localized: "settings.section.keyboardShortcuts", defaultValue: "Keyboard Shortcuts"), "keybindings commands"), + setting(.workspaceColors, "indicator", String(localized: "settings.workspaceColors.indicator", defaultValue: "Workspace Color Indicator"), "tab color indicator"), + setting(.workspaceColors, "selection", String(localized: "settings.workspaceColors.selectionColor", defaultValue: "Selection Highlight"), "selected workspace background"), + setting(.workspaceColors, "badge", String(localized: "settings.workspaceColors.notificationBadgeColor", defaultValue: "Notification Badge"), "unread notification color"), + setting(.workspaceColors, "palette", String(localized: "settings.workspaceColors.resetPalette", defaultValue: "Reset Palette"), "named colors palette"), + setting(.settingsJSON, "open-file", String(localized: "settings.settingsJSON.openFile", defaultValue: "Open settings.json"), "config json file editor dotfiles"), + setting(.settingsJSON, "documentation", String(localized: "settings.settingsJSON.documentation", defaultValue: "Documentation"), "settings json schema reference docs"), + setting(.reset, "reset-all", String(localized: "settings.reset.resetAll", defaultValue: "Reset All Settings"), "restore defaults") + ] + + private static let allEntries = sectionEntries + settingEntries + + private static let entriesByID: [String: SettingsSearchEntry] = Dictionary( + uniqueKeysWithValues: allEntries.map { ($0.id, $0) } + ) + + private static let settingsPathAnchorIDs: [String: String] = [ + "app.language": settingID(for: .app, idSuffix: "language"), + "app.appearance": settingID(for: .app, idSuffix: "appearance"), + "app.appIcon": settingID(for: .app, idSuffix: "app-icon"), + "app.newWorkspacePlacement": settingID(for: .app, idSuffix: "new-workspace-placement"), + "app.minimalMode": settingID(for: .app, idSuffix: "minimal-mode"), + "app.keepWorkspaceOpenWhenClosingLastSurface": settingID(for: .app, idSuffix: "keep-workspace-open"), + "app.focusPaneOnFirstClick": settingID(for: .app, idSuffix: "focus-pane-first-click"), + "app.preferredEditor": settingID(for: .app, idSuffix: "preferred-editor"), + "app.openMarkdownInCmuxViewer": settingID(for: .app, idSuffix: "markdown-viewer"), + "app.reorderOnNotification": settingID(for: .app, idSuffix: "reorder-notification"), + "notifications.dockBadge": settingID(for: .app, idSuffix: "dock-badge"), + "app.menuBarOnly": settingID(for: .app, idSuffix: "menu-bar-only"), + "notifications.showInMenuBar": settingID(for: .app, idSuffix: "show-menu-bar"), + "notifications.unreadPaneRing": settingID(for: .app, idSuffix: "unread-pane-ring"), + "notifications.paneFlash": settingID(for: .app, idSuffix: "pane-flash"), + "notifications.sound": settingID(for: .app, idSuffix: "notification-sound"), + "notifications.customSoundFilePath": settingID(for: .app, idSuffix: "notification-sound"), + "notifications.command": settingID(for: .app, idSuffix: "notification-command"), + "app.sendAnonymousTelemetry": settingID(for: .app, idSuffix: "telemetry"), + "app.warnBeforeQuit": settingID(for: .app, idSuffix: "warn-before-quit"), + "app.renameSelectsExistingName": settingID(for: .app, idSuffix: "rename-selects-name"), + "app.commandPaletteSearchesAllSurfaces": settingID(for: .app, idSuffix: "palette-search-all"), + "sidebar.hideAllDetails": settingID(for: .app, idSuffix: "hide-sidebar-details"), + "sidebar.branchLayout": settingID(for: .app, idSuffix: "sidebar-branch-layout"), + "sidebar.showNotificationMessage": settingID(for: .app, idSuffix: "show-notification-message"), + "sidebar.showBranchDirectory": settingID(for: .app, idSuffix: "show-branch-directory"), + "sidebar.showPullRequests": settingID(for: .app, idSuffix: "show-pull-requests"), + "sidebar.openPullRequestLinksInCmuxBrowser": settingID(for: .app, idSuffix: "open-pr-links"), + "sidebar.openPortLinksInCmuxBrowser": settingID(for: .app, idSuffix: "open-port-links"), + "sidebar.showSSH": settingID(for: .app, idSuffix: "show-ssh"), + "sidebar.showPorts": settingID(for: .app, idSuffix: "show-ports"), + "sidebar.showLog": settingID(for: .app, idSuffix: "show-log"), + "sidebar.showProgress": settingID(for: .app, idSuffix: "show-progress"), + "sidebar.showCustomMetadata": settingID(for: .app, idSuffix: "show-metadata"), + "terminal.showScrollBar": settingID(for: .terminal, idSuffix: "scrollbar"), + "workspaceColors.indicatorStyle": settingID(for: .workspaceColors, idSuffix: "indicator"), + "workspaceColors.selectionColor": settingID(for: .workspaceColors, idSuffix: "selection"), + "workspaceColors.notificationBadgeColor": settingID(for: .workspaceColors, idSuffix: "badge"), + "sidebarAppearance.matchTerminalBackground": settingID(for: .sidebarAppearance, idSuffix: "match-terminal"), + "sidebarAppearance.lightModeTintColor": settingID(for: .sidebarAppearance, idSuffix: "light-tint"), + "sidebarAppearance.darkModeTintColor": settingID(for: .sidebarAppearance, idSuffix: "dark-tint"), + "sidebarAppearance.tintOpacity": settingID(for: .sidebarAppearance, idSuffix: "tint-opacity"), + "automation.socketControlMode": settingID(for: .automation, idSuffix: "socket-mode"), + "automation.socketPassword": settingID(for: .automation, idSuffix: "socket-password"), + "automation.claudeCodeIntegration": settingID(for: .automation, idSuffix: "claude-code"), + "automation.claudeBinaryPath": settingID(for: .automation, idSuffix: "claude-path"), + "automation.cursorIntegration": settingID(for: .automation, idSuffix: "cursor"), + "automation.geminiIntegration": settingID(for: .automation, idSuffix: "gemini"), + "automation.portBase": settingID(for: .automation, idSuffix: "port-base"), + "automation.portRange": settingID(for: .automation, idSuffix: "port-range"), + "browser.defaultSearchEngine": settingID(for: .browser, idSuffix: "search-engine"), + "browser.showSearchSuggestions": settingID(for: .browser, idSuffix: "search-suggestions"), + "browser.theme": settingID(for: .browser, idSuffix: "theme"), + "browser.openTerminalLinksInCmuxBrowser": settingID(for: .browser, idSuffix: "terminal-links"), + "browser.interceptTerminalOpenCommandInCmuxBrowser": settingID(for: .browser, idSuffix: "intercept-open"), + "browser.hostsToOpenInEmbeddedBrowser": settingID(for: .browser, idSuffix: "host-whitelist"), + "browser.urlsToAlwaysOpenExternally": settingID(for: .browser, idSuffix: "external-patterns"), + "browser.insecureHttpHostsAllowedInEmbeddedBrowser": settingID(for: .browser, idSuffix: "http-allowlist"), + "browser.showImportHintOnBlankTabs": settingID(for: .browserImport, idSuffix: "import-hint"), + "browser.reactGrabVersion": settingID(for: .browser, idSuffix: "react-grab"), + "shortcuts.showModifierHoldHints": settingID(for: .keyboardShortcuts, idSuffix: "show-hints") + ] + + static func entries(matching query: String) -> [SettingsSearchEntry] { + let tokens = normalizedTokens(for: query) + guard !tokens.isEmpty else { return sectionEntries } + return allEntries.filter { entry in + tokens.allSatisfy { token in entry.normalizedSearchText.contains(token) } + } + } + + static func entry(withID id: String) -> SettingsSearchEntry? { + entriesByID[id] + } + + static func sectionEntry(for target: SettingsNavigationTarget) -> SettingsSearchEntry { + entriesByID[sectionID(for: target)] ?? sectionEntries[0] + } + + static func sectionID(for target: SettingsNavigationTarget) -> String { + "section:\(target.rawValue)" + } + + static func settingID(for target: SettingsNavigationTarget, idSuffix: String) -> String { + "setting:\(target.rawValue):\(idSuffix)" + } + + static func anchorID(forSettingsPath path: String) -> String? { + settingsPathAnchorIDs[path] + } + + static func normalized(_ text: String) -> String { + text.folding(options: [.caseInsensitive, .diacriticInsensitive], locale: .current) + } + + private static func setting( + _ target: SettingsNavigationTarget, + _ idSuffix: String, + _ title: String, + _ searchText: String + ) -> SettingsSearchEntry { + SettingsSearchEntry( + id: settingID(for: target, idSuffix: idSuffix), + kind: .setting, + target: target, + title: title, + subtitle: target.title, + symbolName: target.symbolName, + searchText: "\(target.searchText) \(searchText)" + ) + } + + private static func normalizedTokens(for query: String) -> [String] { + normalized(query) + .split { character in + character.unicodeScalars.allSatisfy { scalar in + CharacterSet.whitespacesAndNewlines.contains(scalar) + || CharacterSet.punctuationCharacters.contains(scalar) + } + } + .map(String.init) + } +} diff --git a/Sources/TerminalController.swift b/Sources/TerminalController.swift index a756b697d746..19e6c25d1de3 100644 --- a/Sources/TerminalController.swift +++ b/Sources/TerminalController.swift @@ -7366,20 +7366,20 @@ class TerminalController { let shouldActivate = v2FocusAllowed(requested: v2Bool(params, "activate") ?? true) let navigationTarget: SettingsNavigationTarget? - switch targetRaw { - case nil: + if let targetRaw { + guard let target = SettingsNavigationTarget(rawValue: targetRaw) else { + return .err(code: "invalid_params", message: "Unknown settings target", data: ["target": targetRaw]) + } + navigationTarget = target + } else { navigationTarget = nil - case SettingsNavigationTarget.keyboardShortcuts.rawValue: - navigationTarget = .keyboardShortcuts - default: - return .err(code: "invalid_params", message: "Unknown settings target", data: ["target": targetRaw ?? ""]) } DispatchQueue.main.async { if shouldActivate { AppDelegate.presentPreferencesWindow(navigationTarget: navigationTarget) } else { - SettingsWindowController.shared.show(navigationTarget: navigationTarget) + SettingsWindowPresenter.show(navigationTarget: navigationTarget) } } return .ok([ diff --git a/Sources/WindowDecorationsController.swift b/Sources/WindowDecorationsController.swift index 306b81e78e25..a57ce3cae128 100644 --- a/Sources/WindowDecorationsController.swift +++ b/Sources/WindowDecorationsController.swift @@ -69,9 +69,7 @@ final class WindowDecorationsController { } private func trafficLightOffset(for window: NSWindow) -> NSPoint { - guard window.identifier?.rawValue == "cmux.settings" else { return .zero } - // Nudge controls slightly right/down to align with the custom Settings title row. - return NSPoint(x: 7, y: -4) + return .zero } private func shouldHideTrafficLights(for window: NSWindow) -> Bool { diff --git a/Sources/cmuxApp.swift b/Sources/cmuxApp.swift index 815951fa58d7..a2109f48a53f 100644 --- a/Sources/cmuxApp.swift +++ b/Sources/cmuxApp.swift @@ -18,6 +18,7 @@ struct cmuxApp: App { @AppStorage(SocketControlSettings.appStorageKey) private var socketControlMode = SocketControlSettings.defaultMode.rawValue @AppStorage(BrowserToolbarAccessorySpacingDebugSettings.key) private var browserToolbarAccessorySpacingRaw = BrowserToolbarAccessorySpacingDebugSettings.defaultSpacing @NSApplicationDelegateAdaptor(AppDelegate.self) private var appDelegate + @Environment(\.openWindow) private var openWindow private var browserToolbarAccessorySpacing: Int { BrowserToolbarAccessorySpacingDebugSettings.resolved(browserToolbarAccessorySpacingRaw) @@ -188,6 +189,9 @@ struct cmuxApp: App { WindowGroup { MainWindowBootstrapView() .onAppear { + SettingsWindowPresenter.configure { + openWindow(id: SettingsWindowPresenter.windowID) + } #if DEBUG if ProcessInfo.processInfo.environment["CMUX_UI_TEST_MODE"] == "1" { UpdateLogStore.shared.append("ui test: cmuxApp onAppear") @@ -365,8 +369,13 @@ struct cmuxApp: App { Button("Menu Bar Extra Debug…") { MenuBarExtraDebugWindowController.shared.show() } - Button("Settings/About Titlebar Debug…") { - SettingsAboutTitlebarDebugWindowController.shared.show() + Button( + String( + localized: "debug.menu.aboutTitlebarDebug", + defaultValue: "About Titlebar Debug…" + ) + ) { + AboutTitlebarDebugWindowController.shared.show() } Button("Sidebar Debug…") { SidebarDebugWindowController.shared.show() @@ -731,6 +740,18 @@ struct cmuxApp: App { } } + Window(String(localized: "settings.title", defaultValue: "Settings"), id: SettingsWindowPresenter.windowID) { + SettingsRootView() + .background(WindowAccessor { window in + SettingsWindowPresenter.configure(window: window) + }) + } + .defaultSize(width: 980, height: 680) + .windowResizability(.contentMinSize) + .commands { + SidebarCommands() + } + Window(String(localized: "settings.config.windowTitle", defaultValue: "Config"), id: ConfigSettingsView.windowID) { ConfigSettingsView() } @@ -1054,7 +1075,7 @@ struct cmuxApp: App { private func openAllDebugWindows() { BrowserImportHintDebugWindowController.shared.show() BrowserProfilePopoverDebugWindowController.shared.show() - SettingsAboutTitlebarDebugWindowController.shared.show() + AboutTitlebarDebugWindowController.shared.show() SidebarDebugWindowController.shared.show() BackgroundDebugWindowController.shared.show() StartupAppearanceDebugWindowController.shared.show() @@ -1087,7 +1108,7 @@ private let cmuxAuxiliaryWindowIdentifiers: Set = [ "cmux.about", "cmux.licenses", "cmux.browser-popup", - "cmux.settingsAboutTitlebarDebug", + "cmux.aboutTitlebarDebug", "cmux.debugWindowControls", "cmux.browserImportHintDebug", "cmux.sidebarDebug", @@ -1104,16 +1125,13 @@ func cmuxWindowShouldOwnCloseShortcut(_ window: NSWindow?) -> Bool { return cmuxAuxiliaryWindowIdentifiers.contains(identifier) } -private enum SettingsAboutWindowKind: String, CaseIterable, Identifiable { - case settings +private enum AboutWindowKind: String, CaseIterable, Identifiable { case about var id: String { rawValue } var displayTitle: String { switch self { - case .settings: - return "Settings Window" case .about: return "About Window" } @@ -1121,8 +1139,6 @@ private enum SettingsAboutWindowKind: String, CaseIterable, Identifiable { var windowIdentifier: String { switch self { - case .settings: - return "cmux.settings" case .about: return "cmux.about" } @@ -1130,8 +1146,6 @@ private enum SettingsAboutWindowKind: String, CaseIterable, Identifiable { var fallbackTitle: String { switch self { - case .settings: - return "Settings" case .about: return "About cmux" } @@ -1139,8 +1153,6 @@ private enum SettingsAboutWindowKind: String, CaseIterable, Identifiable { var minimumSize: NSSize { switch self { - case .settings: - return NSSize(width: 420, height: 360) case .about: return NSSize(width: 360, height: 520) } @@ -1212,7 +1224,7 @@ private enum TitlebarToolbarStyleOption: String, CaseIterable, Identifiable { } } -private struct SettingsAboutTitlebarDebugOptions: Equatable { +private struct AboutTitlebarDebugOptions: Equatable { var overridesEnabled: Bool var windowTitle: String var titleVisibility: TitlebarVisibilityOption @@ -1226,25 +1238,10 @@ private struct SettingsAboutTitlebarDebugOptions: Equatable { var showToolbar: Bool var toolbarStyle: TitlebarToolbarStyleOption - static func defaults(for kind: SettingsAboutWindowKind) -> SettingsAboutTitlebarDebugOptions { + static func defaults(for kind: AboutWindowKind) -> AboutTitlebarDebugOptions { 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( + return AboutTitlebarDebugOptions( overridesEnabled: false, windowTitle: "About cmux", titleVisibility: .hidden, @@ -1263,72 +1260,51 @@ private struct SettingsAboutTitlebarDebugOptions: Equatable { } @MainActor -private final class SettingsAboutTitlebarDebugStore: ObservableObject { - static let shared = SettingsAboutTitlebarDebugStore() +private final class AboutTitlebarDebugStore: ObservableObject { + static let shared = AboutTitlebarDebugStore() - @Published var settingsOptions = SettingsAboutTitlebarDebugOptions.defaults(for: .settings) { - didSet { applyToOpenWindows(for: .settings) } - } - @Published var aboutOptions = SettingsAboutTitlebarDebugOptions.defaults(for: .about) { + @Published var aboutOptions = AboutTitlebarDebugOptions.defaults(for: .about) { didSet { applyToOpenWindows(for: .about) } } private init() {} - func options(for kind: SettingsAboutWindowKind) -> SettingsAboutTitlebarDebugOptions { + func options(for kind: AboutWindowKind) -> AboutTitlebarDebugOptions { switch kind { - case .settings: - return settingsOptions case .about: return aboutOptions } } - func update(_ newValue: SettingsAboutTitlebarDebugOptions, for kind: SettingsAboutWindowKind) { + func update(_ newValue: AboutTitlebarDebugOptions, for kind: AboutWindowKind) { switch kind { - case .settings: - settingsOptions = newValue case .about: aboutOptions = newValue } } - func reset(_ kind: SettingsAboutWindowKind) { - update(SettingsAboutTitlebarDebugOptions.defaults(for: kind), for: kind) + func reset(_ kind: AboutWindowKind) { + update(AboutTitlebarDebugOptions.defaults(for: kind), for: kind) } - func applyToOpenWindows(for kind: SettingsAboutWindowKind) { + func applyToOpenWindows(for kind: AboutWindowKind) { 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) { + func applyCurrentOptions(to window: NSWindow, for kind: AboutWindowKind) { 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 Titlebar Debug about.overridesEnabled=\(about.overridesEnabled) about.title=\(about.windowTitle) about.titleVisibility=\(about.titleVisibility.rawValue) @@ -1347,8 +1323,8 @@ private final class SettingsAboutTitlebarDebugStore: ObservableObject { 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) + private func apply(_ options: AboutTitlebarDebugOptions, to window: NSWindow, for kind: AboutWindowKind) { + let effective = options.overridesEnabled ? options : AboutTitlebarDebugOptions.defaults(for: kind) let resolvedTitle = effective.windowTitle.trimmingCharacters(in: .whitespacesAndNewlines) window.title = resolvedTitle.isEmpty ? kind.fallbackTitle : resolvedTitle window.titleVisibility = effective.titleVisibility.windowValue @@ -1379,7 +1355,7 @@ private final class SettingsAboutTitlebarDebugStore: ObservableObject { AppDelegate.shared?.applyWindowDecorations(to: window) } - private func ensureToolbar(on window: NSWindow, kind: SettingsAboutWindowKind) { + private func ensureToolbar(on window: NSWindow, kind: AboutWindowKind) { guard window.toolbar == nil else { return } let identifier = NSToolbar.Identifier("cmux.debug.titlebar.\(kind.rawValue)") let toolbar = NSToolbar(identifier: identifier) @@ -1403,8 +1379,8 @@ private final class SettingsAboutTitlebarDebugStore: ObservableObject { } } -private final class SettingsAboutTitlebarDebugWindowController: NSWindowController, NSWindowDelegate { - static let shared = SettingsAboutTitlebarDebugWindowController() +private final class AboutTitlebarDebugWindowController: NSWindowController, NSWindowDelegate { + static let shared = AboutTitlebarDebugWindowController() private init() { let window = NSPanel( @@ -1413,14 +1389,17 @@ private final class SettingsAboutTitlebarDebugWindowController: NSWindowControll backing: .buffered, defer: false ) - window.title = "Settings/About Titlebar Debug" + window.title = String( + localized: "debug.aboutTitlebarDebug.title", + defaultValue: "About Titlebar Debug" + ) window.titleVisibility = .visible window.titlebarAppearsTransparent = false window.isMovableByWindowBackground = true window.isReleasedWhenClosed = false - window.identifier = NSUserInterfaceItemIdentifier("cmux.settingsAboutTitlebarDebug") + window.identifier = NSUserInterfaceItemIdentifier("cmux.aboutTitlebarDebug") window.center() - window.contentView = NSHostingView(rootView: SettingsAboutTitlebarDebugView()) + window.contentView = NSHostingView(rootView: AboutTitlebarDebugView()) AppDelegate.shared?.applyWindowDecorations(to: window) super.init(window: window) window.delegate = self @@ -1434,26 +1413,24 @@ private final class SettingsAboutTitlebarDebugWindowController: NSWindowControll func show() { window?.center() window?.makeKeyAndOrderFront(nil) - SettingsAboutTitlebarDebugStore.shared.applyToOpenWindows() + AboutTitlebarDebugStore.shared.applyToOpenWindows() } } -private struct SettingsAboutTitlebarDebugView: View { - @ObservedObject private var store = SettingsAboutTitlebarDebugStore.shared +private struct AboutTitlebarDebugView: View { + @ObservedObject private var store = AboutTitlebarDebugStore.shared var body: some View { ScrollView { VStack(alignment: .leading, spacing: 14) { - Text("Settings/About Titlebar Debug") + Text(String(localized: "debug.aboutTitlebarDebug.title", defaultValue: "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") { @@ -1475,7 +1452,7 @@ private struct SettingsAboutTitlebarDebugView: View { .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading) } - private func editor(for kind: SettingsAboutWindowKind) -> some View { + private func editor(for kind: AboutWindowKind) -> some View { let overridesEnabled = binding(for: kind, keyPath: \.overridesEnabled) return GroupBox(kind.displayTitle) { @@ -1524,7 +1501,7 @@ private struct SettingsAboutTitlebarDebugView: View { Toggle("Full Size Content View", isOn: binding(for: kind, keyPath: \.fullSizeContentView)) HStack(spacing: 10) { - Button("Reset \(kind == .settings ? "Settings" : "About")") { + Button(String(localized: "debug.aboutTitlebarDebug.resetAbout", defaultValue: "Reset About")) { store.reset(kind) } Button("Apply Now") { @@ -1540,8 +1517,8 @@ private struct SettingsAboutTitlebarDebugView: View { } private func binding( - for kind: SettingsAboutWindowKind, - keyPath: WritableKeyPath + for kind: AboutWindowKind, + keyPath: WritableKeyPath ) -> Binding { Binding( get: { store.options(for: kind)[keyPath: keyPath] }, @@ -1719,8 +1696,13 @@ private struct DebugWindowControlsView: View { ) { BrowserProfilePopoverDebugWindowController.shared.show() } - Button("Settings/About Titlebar Debug…") { - SettingsAboutTitlebarDebugWindowController.shared.show() + Button( + String( + localized: "debug.menu.aboutTitlebarDebug", + defaultValue: "About Titlebar Debug…" + ) + ) { + AboutTitlebarDebugWindowController.shared.show() } Button("Sidebar Debug…") { SidebarDebugWindowController.shared.show() @@ -1758,7 +1740,7 @@ private struct DebugWindowControlsView: View { Button("Open All Debug Windows") { BrowserImportHintDebugWindowController.shared.show() BrowserProfilePopoverDebugWindowController.shared.show() - SettingsAboutTitlebarDebugWindowController.shared.show() + AboutTitlebarDebugWindowController.shared.show() SidebarDebugWindowController.shared.show() BackgroundDebugWindowController.shared.show() StartupAppearanceDebugWindowController.shared.show() @@ -2383,7 +2365,7 @@ private final class AboutWindowController: NSWindowController, NSWindowDelegate window.identifier = NSUserInterfaceItemIdentifier("cmux.about") window.center() window.contentView = NSHostingView(rootView: AboutPanelView()) - SettingsAboutTitlebarDebugStore.shared.applyCurrentOptions(to: window, for: .about) + AboutTitlebarDebugStore.shared.applyCurrentOptions(to: window, for: .about) AppDelegate.shared?.applyWindowDecorations(to: window) super.init(window: window) window.delegate = self @@ -2396,7 +2378,7 @@ private final class AboutWindowController: NSWindowController, NSWindowDelegate func show() { guard let window else { return } - SettingsAboutTitlebarDebugStore.shared.applyCurrentOptions(to: window, for: .about) + AboutTitlebarDebugStore.shared.applyCurrentOptions(to: window, for: .about) window.center() window.makeKeyAndOrderFront(nil) } @@ -2452,162 +2434,86 @@ private struct AcknowledgmentsView: View { } } -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 +@MainActor +enum SettingsWindowPresenter { + static let windowID = "settings" + static let windowIdentifier = "cmux.settings" + static let minimumSize = NSSize(width: 820, height: 540) + + private static var openWindow: (@MainActor () -> Void)? + private static weak var settingsWindow: NSWindow? + private static var pendingNavigationTarget: SettingsNavigationTarget? + private static var shouldOpenWhenConfigured = false + + static func configure(openWindow: @escaping @MainActor () -> Void) { + self.openWindow = openWindow + if shouldOpenWhenConfigured { + shouldOpenWhenConfigured = false + openWindow() + } } - @available(*, unavailable) - required init?(coder: NSCoder) { - fatalError("init(coder:) has not been implemented") + static func configure(window: NSWindow) { + settingsWindow = window + window.identifier = NSUserInterfaceItemIdentifier(windowIdentifier) + window.minSize = minimumSize + window.contentMinSize = minimumSize } - func show(navigationTarget: SettingsNavigationTarget? = nil) { - guard let window else { return } + static func show(navigationTarget: SettingsNavigationTarget? = nil) { #if DEBUG - cmuxDebugLog("settings.window.show requested isVisible=\(window.isVisible ? 1 : 0) isKey=\(window.isKeyWindow ? 1 : 0)") + cmuxDebugLog("settings.window.show path=swiftuiWindow") #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) { + pendingNavigationTarget = navigationTarget + } else { + pendingNavigationTarget = nil + } + + if let window = existingWindow() { + pendingNavigationTarget = nil + focus(window) + if let navigationTarget { SettingsNavigationRequest.post(navigationTarget) } + return } -#if DEBUG - cmuxDebugLog("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) + guard let openWindow else { + shouldOpenWhenConfigured = true + return } + openWindow() } - private func cancelPendingFocusRestore() { - pendingFocusRestoreWorkItems.forEach { $0.cancel() } - pendingFocusRestoreWorkItems.removeAll() - focusRestoreGeneration = 0 + static func consumePendingNavigationTarget() -> SettingsNavigationTarget? { + let target = pendingNavigationTarget + pendingNavigationTarget = nil + return target } - 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) + static func refocusIfVisible() { + guard let window = existingWindow() else { return } + focus(window) } - 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 [:] + private static func existingWindow() -> NSWindow? { + if let settingsWindow, settingsWindow.isVisible || settingsWindow.isMiniaturized { + return settingsWindow + } + return NSApp.windows.first { + $0.identifier?.rawValue == windowIdentifier && ($0.isVisible || $0.isMiniaturized) } - 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 static func focus(_ window: NSWindow) { + if window.isMiniaturized { + window.deminiaturize(nil) + } + NSRunningApplication.current.activate(options: [.activateAllWindows]) + window.makeKeyAndOrderFront(nil) + window.orderFrontRegardless() } } @@ -5262,21 +5168,16 @@ private func openCmuxSettingsFileInEditor() { PreferredEditorSettings.open(url) } -private func openCmuxSettingsFileInTextEdit() { - #if os(macOS) - let fileURL = KeyboardShortcutSettings.settingsFileStore.settingsFileURLForEditing() - let editorURL = URL(fileURLWithPath: "/System/Applications/TextEdit.app") - let configuration = NSWorkspace.OpenConfiguration() - NSWorkspace.shared.open([fileURL], withApplicationAt: editorURL, configuration: configuration) - #endif -} - struct SettingsView: View { - private let contentTopInset: CGFloat = 8 private let pickerColumnWidth: CGFloat = 196 private let notificationSoundControlWidth: CGFloat = 280 private let shortcutChordsDocsURL = URL(string: "https://cmux.com/docs/keyboard-shortcuts#shortcut-chords")! + private let settingsJSONDocsURL = URL(string: "https://cmux.com/docs/configuration#settings-json")! @Environment(\.openWindow) private var openWindow + @State private var highlightedSearchAnchorID: String? + @State private var searchHighlightToken = 0 + @State private var searchHighlightStartedAt: Date? + @State private var settingsNavigationGeneration = 0 @AppStorage(LanguageSettings.languageKey) private var appLanguage = LanguageSettings.defaultLanguage.rawValue @AppStorage(AppearanceSettings.appearanceModeKey) private var appearanceMode = AppearanceSettings.defaultMode.rawValue @@ -5371,9 +5272,6 @@ struct SettingsView: View { @ObservedObject private var authManager = AuthManager.shared @StateObject private var keyboardShortcutSettingsObserver = KeyboardShortcutSettingsObserver.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? @@ -5602,7 +5500,6 @@ struct SettingsView: View { workspacePresentationMode = newValue ? WorkspacePresentationModeSettings.Mode.minimal.rawValue : WorkspacePresentationModeSettings.Mode.standard.rawValue - SettingsWindowController.shared.preserveFocusAfterPreferenceMutation() } ) } @@ -5612,7 +5509,7 @@ struct SettingsView: View { get: { menuBarOnly }, set: { newValue in menuBarOnly = newValue - SettingsWindowController.shared.preserveFocusAfterPreferenceMutation() + SettingsWindowPresenter.refocusIfVisible() } ) } @@ -5623,7 +5520,6 @@ struct SettingsView: View { set: { newValue in guard !menuBarOnly else { return } showMenuBarExtra = newValue - SettingsWindowController.shared.preserveFocusAfterPreferenceMutation() } ) } @@ -5750,12 +5646,6 @@ struct SettingsView: View { } } - 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) @@ -5922,15 +5812,17 @@ struct SettingsView: View { let _ = keyboardShortcutSettingsObserver.revision let _ = Self.validateBypassedSettingsConfigurationReviews() ScrollViewReader { proxy in - ZStack(alignment: .top) { ScrollView { VStack(alignment: .leading, spacing: 14) { SettingsSectionHeader(title: String(localized: "settings.section.account", defaultValue: "Account")) + .settingsSearchAnchor(SettingsSearchIndex.sectionID(for: .account)) SettingsCard { AuthSettingsRow(authManager: authManager) } + .settingsSearchAnchor(SettingsSearchIndex.settingID(for: .account, idSuffix: "account")) SettingsSectionHeader(title: String(localized: "settings.section.app", defaultValue: "App")) + .settingsSearchAnchor(SettingsSearchIndex.sectionID(for: .app)) SettingsCard { SettingsCardRow( configurationReview: .json("app.language"), @@ -6063,7 +5955,8 @@ struct SettingsView: View { subtitle: String( localized: "settings.app.configWindow.subtitle", defaultValue: "Open the cmux config, standalone Ghostty config, and merged preview in one utility window." - ) + ), + searchAnchorID: SettingsSearchIndex.settingID(for: .app, idSuffix: "terminal-config") ) { Button(String(localized: "settings.app.configWindow.openButton", defaultValue: "Open Config Window")) { openWindow(id: ConfigSettingsView.windowID) @@ -6177,8 +6070,9 @@ struct SettingsView: View { SettingsCardRow( configurationReview: .action, - "Desktop Notifications", - subtitle: notificationPermissionSubtitle + String(localized: "settings.notifications.desktop", defaultValue: "Desktop Notifications"), + subtitle: notificationPermissionSubtitle, + searchAnchorID: SettingsSearchIndex.settingID(for: .app, idSuffix: "desktop-notifications") ) { HStack(spacing: 6) { Text(notificationPermissionStatusText) @@ -6271,10 +6165,10 @@ struct SettingsView: View { SettingsCardRow( configurationReview: .json("notifications.command"), - "Notification Command", - subtitle: "Run a shell command when a notification arrives. $CMUX_NOTIFICATION_TITLE, $CMUX_NOTIFICATION_SUBTITLE, $CMUX_NOTIFICATION_BODY are set." + String(localized: "settings.notifications.command", defaultValue: "Notification Command"), + subtitle: String(localized: "settings.notifications.command.subtitle", defaultValue: "Run a shell command when a notification arrives. $CMUX_NOTIFICATION_TITLE, $CMUX_NOTIFICATION_SUBTITLE, $CMUX_NOTIFICATION_BODY are set.") ) { - TextField("say \"done\"", text: $notificationCustomCommand) + TextField(String(localized: "settings.notifications.command.placeholder", defaultValue: "say \"done\""), text: $notificationCustomCommand) .textFieldStyle(.roundedBorder) .frame(width: 200) } @@ -6504,6 +6398,7 @@ struct SettingsView: View { } SettingsSectionHeader(title: String(localized: "settings.section.terminal", defaultValue: "Terminal")) + .settingsSearchAnchor(SettingsSearchIndex.sectionID(for: .terminal)) SettingsCard { SettingsCardRow( configurationReview: .json("terminal.showScrollBar"), @@ -6522,157 +6417,8 @@ struct SettingsView: View { } } - SettingsSectionHeader(title: String(localized: "settings.section.workspaceColors", defaultValue: "Workspace Colors")) - SettingsCard { - SettingsPickerRow( - configurationReview: .json("workspaceColors.indicatorStyle"), - 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() - - SettingsCardRow( - configurationReview: .json("workspaceColors.selectionColor"), - String(localized: "settings.workspaceColors.selectionColor", defaultValue: "Selection Highlight"), - subtitle: String(localized: "settings.workspaceColors.selectionColor.subtitle", defaultValue: "Background color of the selected workspace in the sidebar.") - ) { - HStack(spacing: 8) { - if sidebarSelectionColorHex != nil { - Button(String(localized: "settings.workspaceColors.selectionColor.reset", defaultValue: "Reset")) { - sidebarSelectionColorHex = nil - } - .buttonStyle(.bordered) - .controlSize(.small) - } - - ColorPicker( - "", - selection: selectionColorBinding, - supportsOpacity: false - ) - .labelsHidden() - .frame(width: 38) - - Text(sidebarSelectionColorHex ?? String(localized: "settings.sidebarAppearance.defaultLabel", defaultValue: "Default")) - .font(.system(size: 12, weight: .medium, design: .monospaced)) - .foregroundStyle(.secondary) - .frame(width: 76, alignment: .trailing) - } - } - - SettingsCardDivider() - - SettingsCardRow( - configurationReview: .json("workspaceColors.notificationBadgeColor"), - String(localized: "settings.workspaceColors.notificationBadgeColor", defaultValue: "Notification Badge"), - subtitle: String(localized: "settings.workspaceColors.notificationBadgeColor.subtitle", defaultValue: "Color of the unread notification badge on workspace tabs.") - ) { - HStack(spacing: 8) { - if sidebarNotificationBadgeColorHex != nil { - Button(String(localized: "settings.workspaceColors.notificationBadgeColor.reset", defaultValue: "Reset")) { - sidebarNotificationBadgeColorHex = nil - } - .buttonStyle(.bordered) - .controlSize(.small) - } - - ColorPicker( - "", - selection: notificationBadgeColorBinding, - supportsOpacity: false - ) - .labelsHidden() - .frame(width: 38) - - Text(sidebarNotificationBadgeColorHex ?? String(localized: "settings.sidebarAppearance.defaultLabel", defaultValue: "Default")) - .font(.system(size: 12, weight: .medium, design: .monospaced)) - .foregroundStyle(.secondary) - .frame(width: 76, alignment: .trailing) - } - } - - SettingsCardDivider() - - SettingsCardNote( - String( - localized: "settings.workspaceColors.dictionaryNote", - defaultValue: "Edit settings.json to add or remove named colors. \"Choose Custom Color...\" still adds local Custom N entries." - ) - ) - - if workspaceTabPaletteEntries.isEmpty { - SettingsCardNote( - String( - localized: "settings.workspaceColors.emptyPalette", - defaultValue: "No palette entries. Add colors in settings.json or use \"Choose Custom Color...\" from a workspace context menu." - ) - ) - } else { - ForEach(Array(workspaceTabPaletteEntries.enumerated()), id: \.element.name) { index, entry in - if index > 0 { - SettingsCardDivider() - } - SettingsCardRow( - configurationReview: .json("workspaceColors.colors"), - entry.name, - subtitle: baseTabColorHex(for: entry.name).map { - String(localized: "settings.workspaceColors.base", defaultValue: "Base: \($0)") - } ?? String( - localized: "settings.workspaceColors.customEntry", - defaultValue: "Named palette entry." - ) - ) { - HStack(spacing: 8) { - ColorPicker( - "", - selection: tabColorBinding(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) - - if baseTabColorHex(for: entry.name) == nil { - Button(String(localized: "settings.workspaceColors.remove", defaultValue: "Remove")) { - removeWorkspaceColor(named: entry.name) - } - .buttonStyle(.bordered) - .controlSize(.small) - } - } - } - } - } - - SettingsCardDivider() - - SettingsCardRow( - configurationReview: .action, - String(localized: "settings.workspaceColors.resetPalette", defaultValue: "Reset Palette"), - subtitle: String( - localized: "settings.workspaceColors.resetPalette.subtitleV2", - defaultValue: "Restore the built-in palette and remove extra named 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")) + .settingsSearchAnchor(SettingsSearchIndex.sectionID(for: .sidebarAppearance)) SettingsCard { SettingsCardRow( configurationReview: .json("sidebarAppearance.matchTerminalBackground"), @@ -6753,7 +6499,8 @@ struct SettingsView: View { SettingsCardRow( configurationReview: .action, String(localized: "settings.sidebarAppearance.reset", defaultValue: "Reset Sidebar Tint"), - subtitle: String(localized: "settings.sidebarAppearance.reset.subtitle", defaultValue: "Restore default sidebar appearance.") + subtitle: String(localized: "settings.sidebarAppearance.reset.subtitle", defaultValue: "Restore default sidebar appearance."), + searchAnchorID: SettingsSearchIndex.settingID(for: .sidebarAppearance, idSuffix: "reset-tint") ) { Button(String(localized: "settings.sidebarAppearance.reset.button", defaultValue: "Reset")) { sidebarTintHexLight = nil @@ -6767,6 +6514,7 @@ struct SettingsView: View { } SettingsSectionHeader(title: String(localized: "settings.section.automation", defaultValue: "Automation")) + .settingsSearchAnchor(SettingsSearchIndex.sectionID(for: .automation)) SettingsCard { SettingsPickerRow( configurationReview: .json("automation.socketControlMode"), @@ -6924,7 +6672,7 @@ struct SettingsView: View { } SettingsSectionHeader(title: String(localized: "settings.section.browser", defaultValue: "Browser")) - .id(SettingsNavigationTarget.browser) + .settingsSearchAnchor(SettingsSearchIndex.sectionID(for: .browser)) .accessibilityIdentifier("SettingsBrowserSection") SettingsCard { browserEnabledSettingsRows @@ -7105,6 +6853,7 @@ struct SettingsView: View { } .padding(.horizontal, 14) .padding(.vertical, 10) + .settingsSearchAnchor(SettingsSearchIndex.settingID(for: .browser, idSuffix: "http-allowlist")) SettingsCardDivider() @@ -7165,13 +6914,17 @@ struct SettingsView: View { ) .controlSize(.small) .accessibilityIdentifier("SettingsBrowserImportHintToggle") + .settingsSearchAnchor(SettingsSearchIndex.settingID(for: .browserImport, idSuffix: "import-hint")) Text(browserImportHintSettingsNote) .font(.caption) .foregroundStyle(.secondary) .fixedSize(horizontal: false, vertical: true) } - .id(SettingsNavigationTarget.browserImport) + .settingsSearchAnchors([ + SettingsSearchIndex.sectionID(for: .browserImport), + SettingsSearchIndex.settingID(for: .browserImport, idSuffix: "import-data") + ]) .accessibilityIdentifier("SettingsBrowserImportSection") .padding(.horizontal, 14) .padding(.vertical, 10) @@ -7192,7 +6945,12 @@ struct SettingsView: View { SettingsCardDivider() - SettingsCardRow(configurationReview: .action, String(localized: "settings.browser.history", defaultValue: "Browsing History"), subtitle: browserHistorySubtitle) { + SettingsCardRow( + configurationReview: .action, + String(localized: "settings.browser.history", defaultValue: "Browsing History"), + subtitle: browserHistorySubtitle, + searchAnchorID: SettingsSearchIndex.settingID(for: .browser, idSuffix: "history") + ) { Button(String(localized: "settings.browser.history.clearButton", defaultValue: "Clear History…")) { showClearBrowserHistoryConfirmation = true } @@ -7205,13 +6963,14 @@ struct SettingsView: View { GlobalHotkeySection() SettingsSectionHeader(title: String(localized: "settings.section.keyboardShortcuts", defaultValue: "Keyboard Shortcuts")) - .id(SettingsNavigationTarget.keyboardShortcuts) + .settingsSearchAnchor(SettingsSearchIndex.sectionID(for: .keyboardShortcuts)) .accessibilityIdentifier("SettingsKeyboardShortcutsSection") SettingsCard { SettingsCardRow( configurationReview: .action, String(localized: "settings.shortcuts.chords", defaultValue: "Shortcut Chords"), - subtitle: String(localized: "settings.shortcuts.chords.subtitle", defaultValue: "Add tmux-style multi-step shortcuts in settings.json, for example [\"ctrl+b\", \"c\"].") + subtitle: String(localized: "settings.shortcuts.chords.subtitle", defaultValue: "Add tmux-style multi-step shortcuts in settings.json, for example [\"ctrl+b\", \"c\"]."), + searchAnchorID: SettingsSearchIndex.settingID(for: .keyboardShortcuts, idSuffix: "shortcut-chords") ) { HStack(spacing: 8) { Link(String(localized: "settings.shortcuts.chords.docsButton", defaultValue: "Chord docs"), destination: shortcutChordsDocsURL) @@ -7219,7 +6978,7 @@ struct SettingsView: View { .accessibilityIdentifier("SettingsKeyboardShortcutsChordDocsLink") Button(String(localized: "settings.app.settingsFile.openButton", defaultValue: "Open settings.json")) { - openCmuxSettingsFileInTextEdit() + openCmuxSettingsFileInEditor() } .buttonStyle(.bordered) .controlSize(.small) @@ -7265,6 +7024,7 @@ struct SettingsView: View { } } .id(shortcutResetToken) + .settingsSearchAnchor(SettingsSearchIndex.settingID(for: .keyboardShortcuts, idSuffix: "shortcuts")) Text(String(localized: "settings.shortcuts.recordHint", defaultValue: "Click a shortcut value to record a new shortcut.")) .font(.caption) @@ -7272,7 +7032,203 @@ struct SettingsView: View { .padding(.leading, 2) .accessibilityIdentifier("ShortcutRecordingHint") + SettingsSectionHeader(title: String(localized: "settings.section.workspaceColors", defaultValue: "Workspace Colors")) + .settingsSearchAnchor(SettingsSearchIndex.sectionID(for: .workspaceColors)) + SettingsCard { + SettingsPickerRow( + configurationReview: .json("workspaceColors.indicatorStyle"), + 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() + + SettingsCardRow( + configurationReview: .json("workspaceColors.selectionColor"), + String(localized: "settings.workspaceColors.selectionColor", defaultValue: "Selection Highlight"), + subtitle: String(localized: "settings.workspaceColors.selectionColor.subtitle", defaultValue: "Background color of the selected workspace in the sidebar.") + ) { + HStack(spacing: 8) { + if sidebarSelectionColorHex != nil { + Button(String(localized: "settings.workspaceColors.selectionColor.reset", defaultValue: "Reset")) { + sidebarSelectionColorHex = nil + } + .buttonStyle(.bordered) + .controlSize(.small) + } + + ColorPicker( + "", + selection: selectionColorBinding, + supportsOpacity: false + ) + .labelsHidden() + .frame(width: 38) + + Text(sidebarSelectionColorHex ?? String(localized: "settings.sidebarAppearance.defaultLabel", defaultValue: "Default")) + .font(.system(size: 12, weight: .medium, design: .monospaced)) + .foregroundStyle(.secondary) + .frame(width: 76, alignment: .trailing) + } + } + + SettingsCardDivider() + + SettingsCardRow( + configurationReview: .json("workspaceColors.notificationBadgeColor"), + String(localized: "settings.workspaceColors.notificationBadgeColor", defaultValue: "Notification Badge"), + subtitle: String(localized: "settings.workspaceColors.notificationBadgeColor.subtitle", defaultValue: "Color of the unread notification badge on workspace tabs.") + ) { + HStack(spacing: 8) { + if sidebarNotificationBadgeColorHex != nil { + Button(String(localized: "settings.workspaceColors.notificationBadgeColor.reset", defaultValue: "Reset")) { + sidebarNotificationBadgeColorHex = nil + } + .buttonStyle(.bordered) + .controlSize(.small) + } + + ColorPicker( + "", + selection: notificationBadgeColorBinding, + supportsOpacity: false + ) + .labelsHidden() + .frame(width: 38) + + Text(sidebarNotificationBadgeColorHex ?? String(localized: "settings.sidebarAppearance.defaultLabel", defaultValue: "Default")) + .font(.system(size: 12, weight: .medium, design: .monospaced)) + .foregroundStyle(.secondary) + .frame(width: 76, alignment: .trailing) + } + } + + SettingsCardDivider() + + SettingsCardNote( + String( + localized: "settings.workspaceColors.dictionaryNote", + defaultValue: "Edit settings.json to add or remove named colors. \"Choose Custom Color...\" still adds local Custom N entries." + ) + ) + + if workspaceTabPaletteEntries.isEmpty { + SettingsCardNote( + String( + localized: "settings.workspaceColors.emptyPalette", + defaultValue: "No palette entries. Add colors in settings.json or use \"Choose Custom Color...\" from a workspace context menu." + ) + ) + } else { + ForEach(Array(workspaceTabPaletteEntries.enumerated()), id: \.element.name) { index, entry in + if index > 0 { + SettingsCardDivider() + } + SettingsCardRow( + configurationReview: .json("workspaceColors.colors"), + entry.name, + subtitle: baseTabColorHex(for: entry.name).map { + String(localized: "settings.workspaceColors.base", defaultValue: "Base: \($0)") + } ?? String( + localized: "settings.workspaceColors.customEntry", + defaultValue: "Named palette entry." + ) + ) { + HStack(spacing: 8) { + ColorPicker( + "", + selection: tabColorBinding(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) + + if baseTabColorHex(for: entry.name) == nil { + Button(String(localized: "settings.workspaceColors.remove", defaultValue: "Remove")) { + removeWorkspaceColor(named: entry.name) + } + .buttonStyle(.bordered) + .controlSize(.small) + } + } + } + } + } + + SettingsCardDivider() + + SettingsCardRow( + configurationReview: .action, + String(localized: "settings.workspaceColors.resetPalette", defaultValue: "Reset Palette"), + subtitle: String( + localized: "settings.workspaceColors.resetPalette.subtitleV2", + defaultValue: "Restore the built-in palette and remove extra named colors." + ), + searchAnchorID: SettingsSearchIndex.settingID(for: .workspaceColors, idSuffix: "palette") + ) { + Button(String(localized: "settings.workspaceColors.resetPalette.button", defaultValue: "Reset")) { + resetWorkspaceTabColors() + } + .buttonStyle(.bordered) + .controlSize(.small) + } + } + + SettingsSectionHeader(title: String(localized: "settings.section.settingsJSON", defaultValue: "settings.json")) + .settingsSearchAnchor(SettingsSearchIndex.sectionID(for: .settingsJSON)) + .accessibilityIdentifier("SettingsJSONSection") + SettingsCard { + SettingsCardRow( + configurationReview: .action, + String(localized: "settings.settingsJSON.file", defaultValue: "User settings file"), + subtitle: String(localized: "settings.settingsJSON.file.subtitle", defaultValue: "Edit cmux-owned app settings, shortcuts, automation, sidebar, notifications, and browser behavior."), + controlWidth: 330, + searchAnchorID: SettingsSearchIndex.settingID(for: .settingsJSON, idSuffix: "open-file") + ) { + HStack(spacing: 8) { + Text(KeyboardShortcutSettings.settingsFileStore.settingsFileDisplayPath()) + .font(.system(size: 11, weight: .medium, design: .monospaced)) + .foregroundStyle(.secondary) + .lineLimit(1) + .truncationMode(.middle) + .frame(maxWidth: .infinity, alignment: .trailing) + + Button(String(localized: "settings.settingsJSON.openButton", defaultValue: "Open")) { + openCmuxSettingsFileInEditor() + } + .buttonStyle(.bordered) + .controlSize(.small) + .accessibilityIdentifier("SettingsJSONOpenButton") + } + .accessibilityIdentifier("SettingsJSONOpenFileRowActions") + } + + SettingsCardDivider() + + SettingsCardRow( + configurationReview: .action, + String(localized: "settings.settingsJSON.documentation", defaultValue: "Documentation"), + subtitle: String(localized: "settings.settingsJSON.documentation.subtitle", defaultValue: "View supported keys, file locations, schema, and reload behavior."), + searchAnchorID: SettingsSearchIndex.settingID(for: .settingsJSON, idSuffix: "documentation") + ) { + Link(String(localized: "settings.settingsJSON.docsButton", defaultValue: "Open Docs"), destination: settingsJSONDocsURL) + .font(.caption) + .accessibilityIdentifier("SettingsJSONDocsLink") + } + } + SettingsSectionHeader(title: String(localized: "settings.section.reset", defaultValue: "Reset")) + .settingsSearchAnchor(SettingsSearchIndex.sectionID(for: .reset)) SettingsCard { HStack { Spacer(minLength: 0) @@ -7285,91 +7241,17 @@ struct SettingsView: View { } .padding(.horizontal, 14) .padding(.vertical, 10) + .settingsSearchAnchor(SettingsSearchIndex.settingID(for: .reset, idSuffix: "reset-all")) } } .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 - ) - } + .padding(.top, 20) + .environment( + \.settingsSearchHighlightState, + SettingsSearchHighlightState(anchorID: highlightedSearchAnchorID, token: searchHighlightToken, startedAt: searchHighlightStartedAt) ) } - .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) - HStack(spacing: 6) { - SettingsHeaderActionButton( - title: String(localized: "settings.app.settingsFile.openButton", defaultValue: "Open settings.json"), - helpText: KeyboardShortcutSettings.settingsFileStore.settingsFileDisplayPath(), - accessibilityIdentifier: "SettingsFileOpenButton", - action: openCmuxSettingsFileInTextEdit - ) - } - } - .padding(.leading, settingsTitleLeadingInset) - .padding(.trailing, 20) - .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 - ) - } - .background(Color(nsColor: .windowBackgroundColor).ignoresSafeArea()) .toggleStyle(.switch) .onAppear { BrowserHistoryStore.shared.loadIfNeeded() @@ -7401,10 +7283,23 @@ struct SettingsView: View { reloadWorkspaceTabColorSettings() } .onReceive(NotificationCenter.default.publisher(for: SettingsNavigationRequest.notificationName)) { notification in - guard let target = SettingsNavigationRequest.target(from: notification) else { return } + guard let destination = SettingsNavigationRequest.destination(from: notification) else { return } + settingsNavigationGeneration += 1 + let navigationGeneration = settingsNavigationGeneration + let sectionID = SettingsSearchIndex.sectionID(for: destination.target) + if destination.shouldHighlight { + highlightedSearchAnchorID = destination.anchorID + searchHighlightStartedAt = Date() + searchHighlightToken += 1 + } else { + highlightedSearchAnchorID = nil + searchHighlightStartedAt = nil + } DispatchQueue.main.async { - withAnimation(.easeInOut(duration: 0.2)) { - proxy.scrollTo(target, anchor: .top) + guard navigationGeneration == settingsNavigationGeneration else { return } + proxy.scrollTo(sectionID, anchor: .top) + if destination.shouldHighlight { + proxy.scrollTo(destination.anchorID, anchor: .center) } } } @@ -7621,37 +7516,6 @@ struct SettingsView: View { } } -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 @@ -7764,40 +7628,12 @@ private struct SettingsCard: View { } } -private struct SettingsHeaderActionButton: View { - let title: String - let helpText: String - let accessibilityIdentifier: String - let action: () -> Void - - var body: some View { - Button(action: action) { - Text(title) - .font(.system(size: 11.5, weight: .medium)) - .foregroundStyle(.secondary) - .padding(.horizontal, 9) - .padding(.vertical, 4) - .background( - Capsule(style: .continuous) - .fill(Color(nsColor: NSColor.controlBackgroundColor).opacity(0.34)) - ) - .overlay( - Capsule(style: .continuous) - .stroke(Color(nsColor: NSColor.separatorColor).opacity(0.22), lineWidth: 1) - ) - } - .buttonStyle(.plain) - .controlSize(.small) - .help(helpText) - .accessibilityIdentifier(accessibilityIdentifier) - } -} - private struct SettingsCardRow: View { let configurationReview: SettingsConfigurationReview let title: String let subtitle: String? let controlWidth: CGFloat? + let searchAnchorID: String? @ViewBuilder let trailing: Trailing init( @@ -7805,6 +7641,7 @@ private struct SettingsCardRow: View { _ title: String, subtitle: String? = nil, controlWidth: CGFloat? = nil, + searchAnchorID: String? = nil, @ViewBuilder trailing: () -> Trailing ) { configurationReview.validate() @@ -7812,9 +7649,17 @@ private struct SettingsCardRow: View { self.title = title self.subtitle = subtitle self.controlWidth = controlWidth + self.searchAnchorID = searchAnchorID self.trailing = trailing() } + private var searchAnchorIDs: [String] { + if let searchAnchorID { + return [searchAnchorID] + } + return configurationReview.searchAnchorIDs + } + var body: some View { HStack(alignment: .center, spacing: 12) { VStack(alignment: .leading, spacing: subtitle == nil ? 0 : 3) { @@ -7842,6 +7687,7 @@ private struct SettingsCardRow: View { .padding(.horizontal, 14) .padding(.vertical, 9) .frame(maxWidth: .infinity, alignment: .leading) + .settingsSearchAnchors(searchAnchorIDs) } } @@ -7917,6 +7763,11 @@ private enum SettingsConfigurationReview: Equatable { .settingsFile(paths) } + var searchAnchorIDs: [String] { + guard case .settingsFile(let paths) = self else { return [] } + return paths.compactMap(SettingsSearchIndex.anchorID(forSettingsPath:)) + } + func validate(file: StaticString = #fileID, line: UInt = #line) { guard case .settingsFile(let paths) = self else { return } let unknownPaths = paths.filter { !CmuxSettingsFileStore.supportedSettingsJSONPaths.contains($0) } @@ -8161,6 +8012,7 @@ private struct ThemePickerRow: View { .padding(.horizontal, 14) .padding(.vertical, 9) .frame(maxWidth: .infinity, alignment: .leading) + .settingsSearchAnchors(configurationReview.searchAnchorIDs) } } @@ -8255,6 +8107,7 @@ private struct AppIconPickerRow: View { .padding(.horizontal, 14) .padding(.vertical, 9) .frame(maxWidth: .infinity, alignment: .leading) + .settingsSearchAnchors(configurationReview.searchAnchorIDs) } } @@ -8381,12 +8234,14 @@ private struct GlobalHotkeySection: View { var body: some View { SettingsSectionHeader(title: String(localized: "settings.section.globalHotkey", defaultValue: "Global Hotkey")) .accessibilityIdentifier("SettingsGlobalHotkeySection") + .settingsSearchAnchor(SettingsSearchIndex.sectionID(for: .globalHotkey)) SettingsCard { SettingsCardRow( configurationReview: .settingsOnly, String(localized: "settings.globalHotkey.enable", defaultValue: "Enable System-Wide Hotkey"), - subtitle: enableSubtitle + subtitle: enableSubtitle, + searchAnchorID: SettingsSearchIndex.settingID(for: .globalHotkey, idSuffix: "enable-hotkey") ) { Toggle("", isOn: enabledBinding) .labelsHidden() @@ -8405,6 +8260,7 @@ private struct GlobalHotkeySection: View { .padding(.horizontal, 14) .padding(.vertical, 9) .accessibilityIdentifier("SettingsGlobalHotkeyRecorder") + .settingsSearchAnchor(SettingsSearchIndex.settingID(for: .globalHotkey, idSuffix: "shortcut")) } .onChange(of: shortcut) { newValue in KeyboardShortcutSettings.setShortcut(newValue, for: SystemWideHotkeySettings.action) @@ -8431,27 +8287,119 @@ private struct GlobalHotkeySection: View { } private struct SettingsRootView: View { + @SceneStorage("selectedSettingsSection") private var selectedSectionRaw = SettingsNavigationTarget.account.rawValue + @SceneStorage("selectedSettingsSidebarEntry") private var selectedSidebarEntryID = SettingsSearchIndex.defaultSelectionID + @State private var columnVisibility: NavigationSplitViewVisibility = .all + @State private var searchText = "" + + private var selectedSection: SettingsNavigationTarget { + SettingsNavigationTarget(rawValue: selectedSectionRaw) ?? .account + } + + private var sidebarEntries: [SettingsSearchEntry] { + SettingsSearchIndex.entries(matching: searchText) + } + + private var isSearching: Bool { + !searchText.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty + } + + private var sidebarSelection: Binding { + Binding( + get: { selectedSidebarEntryID }, + set: { selectSidebarEntry($0) } + ) + } + var body: some View { - SettingsView() - .background(WindowAccessor { window in - configureSettingsWindow(window) - }) + NavigationSplitView(columnVisibility: $columnVisibility) { + List(selection: sidebarSelection) { + if sidebarEntries.isEmpty { + Text(String(localized: "settings.search.noResults", defaultValue: "No Results")) + .foregroundStyle(.secondary) + } else { + ForEach(sidebarEntries) { entry in + SettingsSidebarEntryRow(entry: entry) + .tag(entry.id) + } + } + } + .listStyle(.sidebar) + .navigationTitle(String(localized: "settings.title", defaultValue: "Settings")) + .searchable( + text: $searchText, + placement: .sidebar, + prompt: Text(String(localized: "settings.search.prompt", defaultValue: "Search")) + ) + .navigationSplitViewColumnWidth(210) + } detail: { + SettingsView() + } + .navigationSplitViewStyle(.balanced) + .frame(minWidth: SettingsWindowPresenter.minimumSize.width, minHeight: SettingsWindowPresenter.minimumSize.height) + .onChange(of: searchText) { _, newValue in + guard newValue.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty else { return } + selectedSidebarEntryID = SettingsSearchIndex.sectionID(for: selectedSection) + } + .onAppear { + searchText = "" + if let target = SettingsWindowPresenter.consumePendingNavigationTarget() { + navigate(to: target, postRequest: true) + } else { + navigate(to: selectedSection, postRequest: true) + } + } + .onReceive(NotificationCenter.default.publisher(for: SettingsNavigationRequest.notificationName)) { notification in + guard let target = SettingsNavigationRequest.target(from: notification) else { return } + let selectedEntry = SettingsSearchIndex.entry(withID: selectedSidebarEntryID) + let shouldPreserveSearchSelection = isSearching && selectedEntry?.target == target + navigate(to: target, preferSectionSelection: !shouldPreserveSearchSelection, postRequest: false) + } } - private func configureSettingsWindow(_ window: NSWindow) { - window.identifier = NSUserInterfaceItemIdentifier("cmux.settings") - applyCurrentSettingsWindowStyle(to: window) + private func selectSidebarEntry(_ entryID: String) { + guard let entry = SettingsSearchIndex.entry(withID: entryID) else { return } + selectedSidebarEntryID = entry.id + selectedSectionRaw = entry.target.rawValue + SettingsNavigationRequest.post(entry.target, anchorID: entry.id, highlight: isSearching) + } - 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) + private func navigate( + to target: SettingsNavigationTarget, + preferSectionSelection: Bool = true, + postRequest: Bool + ) { + selectedSectionRaw = target.rawValue + if preferSectionSelection { + selectedSidebarEntryID = SettingsSearchIndex.sectionID(for: target) + } + if postRequest { + SettingsNavigationRequest.post(target) } - AppDelegate.shared?.applyWindowDecorations(to: window) } +} + +private struct SettingsSidebarEntryRow: View { + let entry: SettingsSearchEntry + + var body: some View { + HStack(spacing: 10) { + Image(systemName: entry.symbolName) + .foregroundStyle(.secondary) + .frame(width: 16) + + VStack(alignment: .leading, spacing: 2) { + Text(entry.title) + .lineLimit(1) - private func applyCurrentSettingsWindowStyle(to window: NSWindow) { - SettingsAboutTitlebarDebugStore.shared.applyCurrentOptions(to: window, for: .settings) + if let subtitle = entry.subtitle { + Text(subtitle) + .font(.caption) + .foregroundStyle(.secondary) + .lineLimit(1) + } + } + } + .frame(maxWidth: .infinity, alignment: .leading) } } diff --git a/cmuxTests/AppDelegateShortcutRoutingTests.swift b/cmuxTests/AppDelegateShortcutRoutingTests.swift index 9114aaf9d02b..e55c8a5eb526 100644 --- a/cmuxTests/AppDelegateShortcutRoutingTests.swift +++ b/cmuxTests/AppDelegateShortcutRoutingTests.swift @@ -357,7 +357,6 @@ final class AppDelegateShortcutRoutingTests: XCTestCase { let windowId = appDelegate.createMainWindow() defer { - SettingsWindowController.shared.close() closeWindow(withId: windowId) } diff --git a/web/app/[locale]/docs/configuration/page.tsx b/web/app/[locale]/docs/configuration/page.tsx index 8c01457bfa24..8991f69f5b9d 100644 --- a/web/app/[locale]/docs/configuration/page.tsx +++ b/web/app/[locale]/docs/configuration/page.tsx @@ -222,9 +222,6 @@ export default function ConfigurationPage() { schemaVersion: schemaProperties.schemaVersion, } satisfies Record; - const shortcutsSection = schemaProperties.shortcuts; - const shortcutProperties = shortcutsSection?.properties ?? {}; - return ( <>

{t("title")}

@@ -252,12 +249,16 @@ scrollback-limit = 50000000 split-divider-color = #3e4451 working-directory = ~/code`} -

cmux settings.json

+

cmux settings.json

cmux keeps app-owned settings in a separate user file instead of mixing them into Ghostty config. On launch, if neither settings location exists, cmux writes a commented template to{" "} ~/.config/cmux/settings.json.

+

+ Open cmux Settings, then use the settings.json section to open the active file + in your preferred text editor. +

  1. ~/.config/cmux/settings.json