diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 1f111a45690c..93ac37c8bdff 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -392,7 +392,13 @@ jobs: # and performance regressions stay isolated. Broader interactive UI suites # still run via test-e2e.yml on GitHub-hosted runners. runs-on: ${{ vars.MACOS_RUNNER_15 || 'warp-macos-15-arm64-6x' }} - timeout-minutes: 20 + # A cold DerivedData cache (any project.pbxproj or Package.resolved change + # mints a new cache key with no restore-keys fallback) forces a full + # cmux build whose Swift codegen alone runs ~18-20 min, so 20 was right at + # the edge and got guillotined mid-build (the post-cache step never ran, so + # the cache stayed cold and every retry timed out the same way). 35 gives a + # cold build enough room to finish and populate the cache. + timeout-minutes: 35 steps: - name: Checkout uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 diff --git a/CLAUDE.md b/CLAUDE.md index 4d8b37eba568..00d1b5815b84 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -281,6 +281,80 @@ Every `public` symbol in any new Swift package under `Packages/` is documented w This rule applies to all packages under `Packages/`. Code in the main app target is not retroactively required to be documented, but new `public` symbols added to packages must be. +## Package design discipline + +These are the recurring design mistakes that have to be caught at the design step, not at code review: + +- **No shared-singleton accessors.** `static let standard` / `shared` / `default` on a package type that holds runtime state is a singleton-by-another-name. Construct the package type at the app's startup site and inject it. `static let` is fine for *declarations* — identifiers, schema entries, enum cases — but not for behavior. +- **No namespace-enums.** `enum Foo { static func bar() }` (a no-case enum used as a namespace) is a fake namespace that fights the rest of the design (no instances, no DI, no test seam). Prefer a value-typed struct passed via constructor when the helper might gain configuration, or a file-scope `private func` for pure helpers internal to one file. +- **No parallel hand-maintained registries.** When a list mirrors a set of declared items (e.g. `catalog.all` mirroring the catalog's stored properties), derive the list via `Mirror` reflection or a macro. Two sources of truth drift silently; the IDE doesn't tell you. +- **Prefer compile-time invariants to runtime traps.** If the pattern is `guard ... else { assertionFailure(...); return default }` for a "programmer error" case, encode it in the type system (phantom types, separate concrete flavors). Runtime traps become silent fallbacks in release builds. +- **File-scope `private func` is not `private static func`.** `private static` inside a type signals "this operation belongs to the type." File-scope `private func` signals "pure helper local to this file, unrelated to any type." Use the latter for recursive helpers that take a sliced view as input and never read host-type state. +- **Nested types still count for the one-major-type-per-file rule.** A `private final class WatcherAttachment` inside `JSONConfigFileWatcher.swift` is a major type. Move it to its own file the moment it has a meaningful body. + +## Testability + +Every public type added to `Packages/` must be **testable from a test target** without launching the app target, without booting AppKit, and without depending on the user's filesystem or `UserDefaults.standard`. Production-grade designs surface a test seam at every boundary: + +- **No global state in package code.** Every public type that needs `UserDefaults`, `FileManager`, an on-disk path, an environment variable, or a clock takes it via initializer parameter. Tests pass a `UserDefaults(suiteName:)` scoped to the test, a temp directory URL, a fixed `Date`, etc. +- **No reliance on `.shared` / `.standard`.** A public type that hardcodes `UserDefaults.standard` or `FileManager.default` inside its implementation cannot be tested without polluting the developer's actual settings. Inject these at the seam. +- **Public APIs return values, not side effects, where possible.** A function that mutates global UserDefaults and returns `Void` is harder to test than one that returns the changed value and lets the caller persist. Prefer pure transformations + thin imperative layers. +- **Asynchronous APIs surface their observation as `AsyncStream`.** Tests can iterate `AsyncStream` deterministically and assert the sequence of yielded values. Avoid `NotificationCenter`-only patterns where the test has to spin a runloop. +- **Document the test pattern** alongside any non-trivial public surface. The package's `README.md` and any DocC catalog should show how to instantiate the type with test-friendly dependencies. + +If a design is hard to test, it is wrong. Reach for the constructor parameter list, not the test bench. + +## Modern Swift concurrency + +All new code in `Packages/` and any new files added to the app target use Swift 6 concurrency primitives: `actor`, `async`/`await`, `AsyncStream`/`AsyncSequence`, `@Observable`, `@MainActor`. Old primitives — locks, manual KVO, `@Published`, completion handlers, `DispatchQueue` used as a serial lock — are not allowed. + +If you find yourself reaching for a lock, the type is the wrong shape. Promote it to an `actor`. + +**Forbidden in new code (no exceptions without a written justification in the PR description):** + +- **Locks.** `NSLock`, `NSRecursiveLock`, `os_unfair_lock`, `OSAllocatedUnfairLock`, `pthread_mutex_t`, `Synchronization.Mutex`, `DispatchSemaphore` used as a lock. Use `actor` isolation. Mutable shared state belongs in an actor; reads and writes are `async`. +- **KVO via `NSObject` subclassing.** Any `class Foo: NSObject` whose purpose is to override `observeValue(forKeyPath:...)` or call `addObserver(_:forKeyPath:...)`. Replace with `NotificationCenter.default.notifications(named:)` `AsyncSequence`, or the `NSKeyValueObservation` token API at the seam only. +- **`DispatchQueue` used as a synchronization primitive.** A `DispatchQueue(label:)` accessed via `queue.sync { ... }` to serialize mutable state is a lock with different syntax. Use an `actor`. Queues are fine for *event delivery* (e.g. a `DispatchSource` handler), not for protecting state. +- **Combine for change propagation.** No `@Published`, no `ObservableObject`, no `PassthroughSubject`/`CurrentValueSubject`, no `AnyCancellable` for change observation. Use `@Observable` (Observation framework, Swift 5.9+) for SwiftUI state, or `AsyncStream`/`AsyncSequence` for cross-actor change propagation. +- **Completion-handler APIs.** Authoring a new public API with a `(Result) -> Void` or `(T?, Error?) -> Void` callback is forbidden. Use `async throws -> T`. When wrapping a legacy callback at the boundary, use `withCheckedContinuation`/`withCheckedThrowingContinuation` and keep it confined to that one seam. +- **`DispatchQueue.main.async { ... }`.** Annotate the destination with `@MainActor`. Call sites either `await` the main-isolated function or are themselves `@MainActor`. +- **`Task.sleep` in app/runtime code.** Already forbidden by the cmuxterm-hq top-level rule against sleep-based timing. Allowed in tests only. + +**Required shape:** + +- Mutable shared state → `actor`. Reads/writes/reset are `async`. Observers receive `AsyncStream` returned by the actor. +- SwiftUI view-render-friendly state → `@Observable @MainActor` view-model that subscribes to the actor's `AsyncStream` and projects snapshots. Don't read actor state synchronously from view code. +- Cross-process / cross-thread invariants → expressed via actor isolation, not via locks or queues. +- New public observable surfaces → `AsyncStream` or `AsyncSequence`. Not callbacks, not `@Published`, not raw `NotificationCenter` subscription. + +**Acceptable with a one-line justification comment on the declaration:** + +These low-level primitives have no async-native replacement. They must be hidden behind an `AsyncStream` or `actor` surface; callers never see them. + +- `DispatchSource.makeFileSystemObjectSource` for file watching (no Foundation async equivalent). +- `DispatchSource.makeReadSource`/`makeWriteSource` for low-level socket I/O. +- `NSKeyValueObservation` token (the closure-based API) when wrapping a Foundation/AppKit type that exposes change only via KVO. + +**`@unchecked Sendable` and `nonisolated(unsafe)`:** + +Both require a comment on the declaration explaining the safety argument. Examples that pass review: + +```swift +// Wraps DispatchSourceFileSystemObject; every mutation happens on `queue`. +private final class WatcherAttachment: @unchecked Sendable { ... } + +// UserDefaults is Apple-documented thread-safe; OK to read nonisolated. +private nonisolated(unsafe) let defaults: UserDefaults +``` + +Without a justification comment, the diff is rejected. `@unchecked Sendable` on an entire actor or struct is almost always wrong; prefer `nonisolated(unsafe) let` on the single non-Sendable property. + +**Scope and enforcement:** + +- Applies to: every new file in `Packages/`, every new file in the app target, every meaningful rewrite of an existing Swift file. +- Existing app target code may continue to use the old primitives until rewritten. Do not retrofit blindly. +- Code review checklist (Codex, CodeRabbit, Greptile, and human reviewers): reject diffs that introduce `NSLock`/`NSRecursiveLock`/`@Published`/`ObservableObject`/`DispatchQueue.main.async`/`addObserver(_:forKeyPath:...)`/`Task.sleep` outside tests in new code. Reject `@unchecked Sendable` or `nonisolated(unsafe)` without a justification comment. + ## Test quality policy - Do not add tests that only verify source code text, method signatures, AST fragments, or grep-style patterns. diff --git a/Packages/CmuxSettings/Package.swift b/Packages/CmuxSettings/Package.swift new file mode 100644 index 000000000000..679c7612d13e --- /dev/null +++ b/Packages/CmuxSettings/Package.swift @@ -0,0 +1,25 @@ +// swift-tools-version: 6.0 + +import PackageDescription + +let package = Package( + name: "CmuxSettings", + platforms: [ + .macOS(.v14), + ], + products: [ + .library( + name: "CmuxSettings", + targets: ["CmuxSettings"] + ), + ], + targets: [ + .target( + name: "CmuxSettings" + ), + .testTarget( + name: "CmuxSettingsTests", + dependencies: ["CmuxSettings"] + ), + ] +) diff --git a/Packages/CmuxSettings/README.md b/Packages/CmuxSettings/README.md new file mode 100644 index 000000000000..f00a627117b3 --- /dev/null +++ b/Packages/CmuxSettings/README.md @@ -0,0 +1,191 @@ +# CmuxSettings + +Strongly-typed, migratable settings storage for cmux. Zero non-Foundation +dependencies. Modern Swift 6 throughout: actors, `AsyncStream`, value-typed +keys, dependency injection. No locks, no KVO, no `@Published`. + +Settings live in one of two stores: + +- **`UserDefaultsSettingsStore`** — wraps `UserDefaults`. User-toggled + preferences that the Settings UI writes. +- **`JSONConfigStore`** — wraps `~/.config/cmux/cmux.json`. Structured config + authored by users (hooks, shortcut bindings) or MDM profiles. + +Each setting is declared once on a `SettingCatalog` instance with the typed +backend it belongs to. The two stores accept only their respective flavor +of key — wrong-store mismatches are compile errors, not runtime traps. + +## On-disk layout + +- **UserDefaults** persists under the normal Apple-managed plist for the + process's `Bundle.main.bundleIdentifier`, viewable with + `defaults read `. +- **JSON config** lives at `~/.config/cmux/cmux.json` by default + (`CmuxConfigLocation().userConfigFile`). The store creates the file (and + parent directory) on first write. File is missing on first launch, in + which case every read returns the key's default value. + +A populated cmux.json looks like this — pretty-printed, sorted keys, JSONC +comments tolerated on read but stripped on write: + +```jsonc +{ + // Automation socket configuration. + "automation": { + "socketPassword": "hunter2" + } +} +``` + +The dotted-id of each JSON-backed key is the JSON path; e.g. +`SettingCatalog.automation.socketPassword.id == "automation.socketPassword"` +addresses `root["automation"]["socketPassword"]` above. + +## Quick start + +```swift +import CmuxSettings + +// 1. Construct the catalog at app startup. +let catalog = SettingCatalog() + +// 2. Construct each store (DI; no shared singletons). +let userDefaultsStore = UserDefaultsSettingsStore( + defaults: .standard, + migrating: catalog.all +) +let jsonConfigStore = JSONConfigStore( + fileURL: CmuxConfigLocation().userConfigFile +) + +// 3. Read / write. +let mode = await userDefaultsStore.value(for: catalog.app.appearance) +await userDefaultsStore.set(.dark, for: catalog.app.appearance) + +try await jsonConfigStore.set( + "hunter2", + for: catalog.automation.socketPassword +) + +// 4. Observe changes. Returns AsyncStream that yields the current value +// first, then every later change. +for await newMode in userDefaultsStore.values(for: catalog.app.appearance) { + applyAppearance(newMode) +} +``` + +## End-to-end example + +A runnable snippet that exercises read, write, and external-edit +observation through a temp directory — useful as a copy-paste-and-run +sanity check or as a starting point for integration tests: + +```swift +import CmuxSettings +import Foundation + +// Scope the JSON store to a temp file so this example is hermetic. +let tempDir = FileManager.default.temporaryDirectory + .appending(path: "cmux-readme-\(UUID().uuidString)") +try FileManager.default.createDirectory(at: tempDir, withIntermediateDirectories: true) +let fileURL = tempDir.appending(path: "cmux.json") + +let catalog = SettingCatalog() +let json = JSONConfigStore(fileURL: fileURL) + +// Initially missing → default. +let initial = await json.value(for: catalog.automation.socketPassword) +assert(initial == "") + +// Write → file is created with the parent directory. +try await json.set("hunter2", for: catalog.automation.socketPassword) +assert(await json.value(for: catalog.automation.socketPassword) == "hunter2") + +// Observe → react to external edits (simulated here). +let task = Task { + for await value in json.values(for: catalog.automation.socketPassword) { + print("password is now:", value) + } +} + +try Data(#"{"automation":{"socketPassword":"rotated"}}"#.utf8) + .write(to: fileURL, options: .atomic) + +// ... do other work; cancel when done observing. +// task.cancel() +``` + +## Handling errors + +`UserDefaultsSettingsStore` reads and writes never throw (UserDefaults is +fire-and-forget). `JSONConfigStore.set(_:for:)` and `reset(_:)` are +`throws`: they propagate filesystem errors (permission denied, disk full). +The store's in-memory cache is **only** updated after the disk write +succeeds, so a failed write leaves subsequent reads consistent with what +is actually on disk. + +## Adding a new setting + +1. Pick the catalog section under `Sources/CmuxSettings/Keys/` (e.g. + `AppCatalogSection.swift`). Add a new stored property. +2. Choose the backend: + - `DefaultsKey` for UserDefaults-backed settings. + - `JSONKey` for JSON-config-backed settings. +3. Pick the value type. Common cases work out of the box: + - Primitives (`Bool`, `Int`, `Double`, `String`, `Data`, `URL`) — done. + - Arrays / `[String: …]` of conforming elements — done. + - `RawRepresentable` enums whose raw value is `SettingCodable` — done. + - Custom struct — implement `SettingCodable` (4 methods, one for each + decode/encode + backend pair). +4. `catalog.all` updates automatically via `Mirror` reflection over the + catalog's stored properties; no parallel list to maintain. + +## Adding a whole new section + +1. Create `Sources/CmuxSettings/Keys/FooCatalogSection.swift`: + ```swift + public struct FooCatalogSection: SettingCatalogSection { + public let bar = DefaultsKey( + id: "foo.bar", + defaultValue: false, + userDefaultsKey: "fooBar" + ) + public init() {} + } + ``` +2. Add `public let foo = FooCatalogSection()` to `SettingCatalog`. + +Reflection picks it up recursively; `catalog.all` includes every leaf. + +## Architecture + +- **`SettingCatalog`** — root value-typed registry. Composed of + `SettingCatalogSection` sub-structs grouped by dotted-id prefix. +- **`DefaultsKey` / `JSONKey`** — strongly-typed setting handles. Each + store accepts only its flavor. +- **`AnySettingKey`** — type-erased view. Used for catalog enumeration + (`catalog.all`) and legacy-key migration that's still + type-safe via a captured closure. +- **`UserDefaultsSettingsStore`** — `actor`. Async reads/writes/observe. + Observation uses `NotificationCenter.default.notifications(named:)`. +- **`JSONConfigStore`** — `actor`. Async reads/writes/observe. Owns one + `JSONConfigFileWatcher` and fans out file-change events to per-subscriber + bounded signal streams (no `N × parse` work under burst changes). +- **`JSONConfigFileWatcher`** — `actor` wrapping `DispatchSource` + file-system events behind an `AsyncStream`. The only public surface + is the typed event stream; the underlying dispatch sources never leak. + +## Testing + +Tests construct `DefaultsKey` / `JSONKey` directly with a temp-suite +`UserDefaults` or a temp-dir file URL. The catalog isn't a test fixture; it +is the production registry. See `Tests/CmuxSettingsTests/` for patterns. + +## Concurrency + +The cmux repo enforces a strict modern-concurrency policy +(see the cmux `CLAUDE.md` "Modern Swift concurrency" section): no locks, +no manual KVO, no `@Published`/`ObservableObject`, no `DispatchQueue.main.async`, +no completion-handler APIs in new code. This package adheres to that policy +end-to-end and builds clean under +`-strict-concurrency=complete -warnings-as-errors`. diff --git a/Packages/CmuxSettings/Sources/CmuxSettings/CmuxSettings.docc/CmuxSettings.md b/Packages/CmuxSettings/Sources/CmuxSettings/CmuxSettings.docc/CmuxSettings.md new file mode 100644 index 000000000000..29a8664ec92e --- /dev/null +++ b/Packages/CmuxSettings/Sources/CmuxSettings/CmuxSettings.docc/CmuxSettings.md @@ -0,0 +1,52 @@ +# ``CmuxSettings`` + +Strongly-typed, migratable settings storage for cmux. Foundation-only. + +## Overview + +`CmuxSettings` is the settings layer of cmux. It is built on three +principles: + +- **Typed at compile time.** Each setting is a value-typed + ``DefaultsKey`` or ``JSONKey`` declared once on a + ``SettingCatalog``. Stores accept only their flavor; passing a + ``JSONKey`` to ``UserDefaultsSettingsStore`` does not compile. +- **Modern Swift 6 concurrency.** Both stores are `actor`s. + Observation is exposed as `AsyncStream`. There are no + locks, no KVO, no `@Published`/`ObservableObject`, no + completion-handler APIs. +- **Dependency-injected.** No shared singletons. The app constructs + the catalog and the stores at startup and passes them to consumers. + +## Topics + +### Getting started + +- ``SettingCatalog`` +- ``UserDefaultsSettingsStore`` +- ``JSONConfigStore`` + +### Declaring settings + +- ``DefaultsKey`` +- ``JSONKey`` +- ``SettingCatalogSection`` +- ``AppCatalogSection`` +- ``AutomationCatalogSection`` + +### Value types + +- ``SettingCodable`` +- ``AppearanceMode`` +- ``SocketControlMode`` + +### Type erasure and migration + +- ``AnySettingKey`` + +### JSON config internals + +- ``JSONConfigFileWatcher`` +- ``JSONCSanitizer`` +- ``JSONPath`` +- ``CmuxConfigLocation`` diff --git a/Packages/CmuxSettings/Sources/CmuxSettings/Codable/Array+SettingCodable.swift b/Packages/CmuxSettings/Sources/CmuxSettings/Codable/Array+SettingCodable.swift new file mode 100644 index 000000000000..19f84fdb3440 --- /dev/null +++ b/Packages/CmuxSettings/Sources/CmuxSettings/Codable/Array+SettingCodable.swift @@ -0,0 +1,34 @@ +import Foundation + +/// Array ``SettingCodable`` conformance for arrays of conforming elements. +/// +/// Decode is all-or-nothing: if any element fails to decode, the whole +/// array returns `nil` and the store falls back to the key's default. +/// Partial recovery would silently hide single-element corruption. +extension Array: SettingCodable where Element: SettingCodable { + public static func decodeFromUserDefaults(_ raw: Any?) -> [Element]? { + guard let array = raw as? [Any] else { return nil } + var result: [Element] = [] + result.reserveCapacity(array.count) + for element in array { + guard let value = Element.decodeFromUserDefaults(element) else { return nil } + result.append(value) + } + return result + } + + public func encodeForUserDefaults() -> Any { map { $0.encodeForUserDefaults() } } + + public static func decodeFromJSON(_ raw: Any?) -> [Element]? { + guard let array = raw as? [Any] else { return nil } + var result: [Element] = [] + result.reserveCapacity(array.count) + for element in array { + guard let value = Element.decodeFromJSON(element) else { return nil } + result.append(value) + } + return result + } + + public func encodeForJSON() -> Any { map { $0.encodeForJSON() } } +} diff --git a/Packages/CmuxSettings/Sources/CmuxSettings/Codable/Bool+SettingCodable.swift b/Packages/CmuxSettings/Sources/CmuxSettings/Codable/Bool+SettingCodable.swift new file mode 100644 index 000000000000..db79532161b8 --- /dev/null +++ b/Packages/CmuxSettings/Sources/CmuxSettings/Codable/Bool+SettingCodable.swift @@ -0,0 +1,22 @@ +import Foundation + +extension Bool: SettingCodable { + public static func decodeFromUserDefaults(_ raw: Any?) -> Bool? { + if let number = raw as? NSNumber, CFGetTypeID(number) == CFBooleanGetTypeID() { + return number.boolValue + } + if let bool = raw as? Bool { return bool } + return nil + } + + public func encodeForUserDefaults() -> Any { self } + + public static func decodeFromJSON(_ raw: Any?) -> Bool? { + guard let number = raw as? NSNumber, CFGetTypeID(number) == CFBooleanGetTypeID() else { + return nil + } + return number.boolValue + } + + public func encodeForJSON() -> Any { self } +} diff --git a/Packages/CmuxSettings/Sources/CmuxSettings/Codable/Data+SettingCodable.swift b/Packages/CmuxSettings/Sources/CmuxSettings/Codable/Data+SettingCodable.swift new file mode 100644 index 000000000000..5f418921bdca --- /dev/null +++ b/Packages/CmuxSettings/Sources/CmuxSettings/Codable/Data+SettingCodable.swift @@ -0,0 +1,22 @@ +import Foundation + +/// Binary ``Data`` ``SettingCodable`` conformance. +/// +/// UserDefaults stores `Data` natively. JSON has no native binary type, so +/// the value round-trips through a Base64 string when the backing store is +/// ``SettingBackend/jsonConfig``. +extension Data: SettingCodable { + public static func decodeFromUserDefaults(_ raw: Any?) -> Data? { raw as? Data } + public func encodeForUserDefaults() -> Any { self } + + /// Decodes binary data from a Base64 string. + /// + /// JSON has no native binary type, so ``Data`` round-trips through Base64 + /// when the backing store is ``SettingBackend/jsonConfig``. + public static func decodeFromJSON(_ raw: Any?) -> Data? { + guard let string = raw as? String else { return nil } + return Data(base64Encoded: string) + } + + public func encodeForJSON() -> Any { base64EncodedString() } +} diff --git a/Packages/CmuxSettings/Sources/CmuxSettings/Codable/Dictionary+SettingCodable.swift b/Packages/CmuxSettings/Sources/CmuxSettings/Codable/Dictionary+SettingCodable.swift new file mode 100644 index 000000000000..09a64d9e7135 --- /dev/null +++ b/Packages/CmuxSettings/Sources/CmuxSettings/Codable/Dictionary+SettingCodable.swift @@ -0,0 +1,34 @@ +import Foundation + +/// Dictionary ``SettingCodable`` conformance for string-keyed dictionaries +/// of conforming values. +/// +/// Decode is all-or-nothing for the same reason as +/// ``Array+SettingCodable``: silent partial recovery hides corruption. +extension Dictionary: SettingCodable where Key == String, Value: SettingCodable { + public static func decodeFromUserDefaults(_ raw: Any?) -> [String: Value]? { + guard let dictionary = raw as? [String: Any] else { return nil } + var result: [String: Value] = [:] + result.reserveCapacity(dictionary.count) + for (key, value) in dictionary { + guard let decoded = Value.decodeFromUserDefaults(value) else { return nil } + result[key] = decoded + } + return result + } + + public func encodeForUserDefaults() -> Any { mapValues { $0.encodeForUserDefaults() } } + + public static func decodeFromJSON(_ raw: Any?) -> [String: Value]? { + guard let dictionary = raw as? [String: Any] else { return nil } + var result: [String: Value] = [:] + result.reserveCapacity(dictionary.count) + for (key, value) in dictionary { + guard let decoded = Value.decodeFromJSON(value) else { return nil } + result[key] = decoded + } + return result + } + + public func encodeForJSON() -> Any { mapValues { $0.encodeForJSON() } } +} diff --git a/Packages/CmuxSettings/Sources/CmuxSettings/Codable/Double+SettingCodable.swift b/Packages/CmuxSettings/Sources/CmuxSettings/Codable/Double+SettingCodable.swift new file mode 100644 index 000000000000..90f3028e376c --- /dev/null +++ b/Packages/CmuxSettings/Sources/CmuxSettings/Codable/Double+SettingCodable.swift @@ -0,0 +1,26 @@ +import Foundation + +/// Double ``SettingCodable`` conformance. +/// +/// Both backends accept any numeric-shaped `NSNumber` that is not a +/// `CFBoolean`. Integers widen to `Double` without loss. +extension Double: SettingCodable { + public static func decodeFromUserDefaults(_ raw: Any?) -> Double? { + if let double = raw as? Double { return double } + if let number = raw as? NSNumber, CFGetTypeID(number) != CFBooleanGetTypeID() { + return number.doubleValue + } + return nil + } + + public func encodeForUserDefaults() -> Any { self } + + public static func decodeFromJSON(_ raw: Any?) -> Double? { + guard let number = raw as? NSNumber, CFGetTypeID(number) != CFBooleanGetTypeID() else { + return nil + } + return number.doubleValue + } + + public func encodeForJSON() -> Any { self } +} diff --git a/Packages/CmuxSettings/Sources/CmuxSettings/Codable/Int+SettingCodable.swift b/Packages/CmuxSettings/Sources/CmuxSettings/Codable/Int+SettingCodable.swift new file mode 100644 index 000000000000..2094ed061896 --- /dev/null +++ b/Packages/CmuxSettings/Sources/CmuxSettings/Codable/Int+SettingCodable.swift @@ -0,0 +1,35 @@ +import Foundation + +/// Integer ``SettingCodable`` conformance. +/// +/// JSON decode rejects boolean-typed numerics (`true`/`false`) and any +/// fractional double — the value must be exactly integral. UserDefaults +/// decode is more lenient and accepts any numeric-shaped object. +extension Int: SettingCodable { + public static func decodeFromUserDefaults(_ raw: Any?) -> Int? { + if let int = raw as? Int { return int } + if let number = raw as? NSNumber, CFGetTypeID(number) != CFBooleanGetTypeID() { + return number.intValue + } + return nil + } + + public func encodeForUserDefaults() -> Any { self } + + /// Decodes an integer from JSON, rejecting boolean numerics and fractional doubles. + /// + /// `JSONSerialization` represents both booleans and numbers as `NSNumber`. + /// This implementation distinguishes the two using `CFBooleanGetTypeID` so + /// `true` is not silently coerced to `1`. Doubles whose value is not + /// exactly representable as an `Int` are rejected. + public static func decodeFromJSON(_ raw: Any?) -> Int? { + guard let number = raw as? NSNumber, CFGetTypeID(number) != CFBooleanGetTypeID() else { + return nil + } + let double = number.doubleValue + guard double.rounded() == double else { return nil } + return number.intValue + } + + public func encodeForJSON() -> Any { self } +} diff --git a/Packages/CmuxSettings/Sources/CmuxSettings/Codable/RawRepresentable+SettingCodable.swift b/Packages/CmuxSettings/Sources/CmuxSettings/Codable/RawRepresentable+SettingCodable.swift new file mode 100644 index 000000000000..85a6d6bbe43e --- /dev/null +++ b/Packages/CmuxSettings/Sources/CmuxSettings/Codable/RawRepresentable+SettingCodable.swift @@ -0,0 +1,30 @@ +import Foundation + +/// Free ``SettingCodable`` conformance for `RawRepresentable` types whose +/// raw value is itself ``SettingCodable``. +/// +/// Most cmux value enums use a `String` raw value, so adding `: SettingCodable` +/// to their declaration is enough to make them storable: +/// +/// ```swift +/// public enum AppearanceMode: String, CaseIterable, Sendable, SettingCodable { +/// case system, light, dark +/// } +/// ``` +/// +/// The default implementation forwards encode/decode through the raw value's +/// own ``SettingCodable`` conformance, so the enum gains the same UserDefaults +/// and JSON round-trip behavior as its `RawValue`. +extension SettingCodable where Self: RawRepresentable, Self.RawValue: SettingCodable { + public static func decodeFromUserDefaults(_ raw: Any?) -> Self? { + RawValue.decodeFromUserDefaults(raw).flatMap { Self(rawValue: $0) } + } + + public func encodeForUserDefaults() -> Any { rawValue.encodeForUserDefaults() } + + public static func decodeFromJSON(_ raw: Any?) -> Self? { + RawValue.decodeFromJSON(raw).flatMap { Self(rawValue: $0) } + } + + public func encodeForJSON() -> Any { rawValue.encodeForJSON() } +} diff --git a/Packages/CmuxSettings/Sources/CmuxSettings/Codable/SettingCodable.swift b/Packages/CmuxSettings/Sources/CmuxSettings/Codable/SettingCodable.swift new file mode 100644 index 000000000000..3d9f2764857f --- /dev/null +++ b/Packages/CmuxSettings/Sources/CmuxSettings/Codable/SettingCodable.swift @@ -0,0 +1,51 @@ +import Foundation + +/// A value that can round-trip through both `UserDefaults` and the cmux JSON config file. +/// +/// Conformers describe how to read themselves from the loose `Any` representations +/// produced by `UserDefaults` and `JSONSerialization`, and how to write themselves +/// back as values those same APIs accept (`Bool`, `Int`, `Double`, `String`, +/// `Data`, `[Any]`, `[String: Any]`, or `NSNumber`). +/// +/// Built-in conformances live next to this protocol in +/// `Bool+SettingCodable.swift`, `Int+SettingCodable.swift`, and so on. They +/// cover `Bool`, `Int`, `Double`, `String`, `Data`, `URL`, arrays and string +/// dictionaries of conforming elements, and every `RawRepresentable` whose +/// raw value is itself ``SettingCodable``. +/// +/// Add a new conformance when introducing a value type that does not reduce to +/// one of the above (for example a struct stored as a nested JSON object). +/// +/// ```swift +/// extension AppearanceMode: SettingCodable {} +/// // RawRepresentable enums pick up the default implementation for free. +/// ``` +public protocol SettingCodable: Sendable, Equatable { + /// Decodes a value from a property-list representation produced by `UserDefaults`. + /// + /// - Parameter raw: A value returned by `UserDefaults.object(forKey:)`, or + /// `nil` when the key has no override. + /// - Returns: The decoded value, or `nil` when `raw` is `nil` or has an + /// unexpected shape. The store falls back to the key's default value on + /// `nil`. + static func decodeFromUserDefaults(_ raw: Any?) -> Self? + + /// Encodes a value for storage in `UserDefaults`. + /// + /// - Returns: A property-list compatible value (`Bool`, `Int`, `Double`, + /// `String`, `Data`, or arrays/dictionaries of those). + func encodeForUserDefaults() -> Any + + /// Decodes a value from the loose representation produced by `JSONSerialization`. + /// + /// - Parameter raw: A value found at the setting's JSON path, or `nil` when + /// the path is absent. + /// - Returns: The decoded value, or `nil` when `raw` is `nil` or has an + /// unexpected shape. + static func decodeFromJSON(_ raw: Any?) -> Self? + + /// Encodes a value for storage in the cmux JSON config file. + /// + /// - Returns: A `JSONSerialization`-compatible value. + func encodeForJSON() -> Any +} diff --git a/Packages/CmuxSettings/Sources/CmuxSettings/Codable/String+SettingCodable.swift b/Packages/CmuxSettings/Sources/CmuxSettings/Codable/String+SettingCodable.swift new file mode 100644 index 000000000000..ea23ecccbba3 --- /dev/null +++ b/Packages/CmuxSettings/Sources/CmuxSettings/Codable/String+SettingCodable.swift @@ -0,0 +1,10 @@ +import Foundation + +/// String ``SettingCodable`` conformance — passthrough on both backends. +extension String: SettingCodable { + public static func decodeFromUserDefaults(_ raw: Any?) -> String? { raw as? String } + public func encodeForUserDefaults() -> Any { self } + + public static func decodeFromJSON(_ raw: Any?) -> String? { raw as? String } + public func encodeForJSON() -> Any { self } +} diff --git a/Packages/CmuxSettings/Sources/CmuxSettings/Codable/URL+SettingCodable.swift b/Packages/CmuxSettings/Sources/CmuxSettings/Codable/URL+SettingCodable.swift new file mode 100644 index 000000000000..86ae57a968d8 --- /dev/null +++ b/Packages/CmuxSettings/Sources/CmuxSettings/Codable/URL+SettingCodable.swift @@ -0,0 +1,19 @@ +import Foundation + +/// URL ``SettingCodable`` conformance — encodes as an absolute-string in +/// both backends. +extension URL: SettingCodable { + public static func decodeFromUserDefaults(_ raw: Any?) -> URL? { + guard let string = raw as? String else { return nil } + return URL(string: string) + } + + public func encodeForUserDefaults() -> Any { absoluteString } + + public static func decodeFromJSON(_ raw: Any?) -> URL? { + guard let string = raw as? String else { return nil } + return URL(string: string) + } + + public func encodeForJSON() -> Any { absoluteString } +} diff --git a/Packages/CmuxSettings/Sources/CmuxSettings/Keys/AccountCatalogSection.swift b/Packages/CmuxSettings/Sources/CmuxSettings/Keys/AccountCatalogSection.swift new file mode 100644 index 000000000000..0c66feb27195 --- /dev/null +++ b/Packages/CmuxSettings/Sources/CmuxSettings/Keys/AccountCatalogSection.swift @@ -0,0 +1,31 @@ +import Foundation + +/// Settings under the dotted-id prefix `account.*` — account-related +/// preferences that persist locally regardless of whether the user is +/// signed in to the cmux backend. +/// +/// The sign-in flow, token storage, and `CMUXAuthUser` model itself +/// live in the host app's auth package and reach the package's +/// ``AccountSection`` via an injected delegate (see ``AccountFlow`` +/// in CmuxSettingsUI), not through this catalog. +public struct AccountCatalogSection: SettingCatalogSection { + public let piiDisplayMode = DefaultsKey( + id: "account.piiDisplayMode", + defaultValue: .visible, + userDefaultsKey: "cmux.settings.piiDisplayMode" + ) + + public let selectedTeamID = DefaultsKey( + id: "account.selectedTeamID", + defaultValue: "", + userDefaultsKey: "cmux.auth.selectedTeamID" + ) + + public let welcomeShown = DefaultsKey( + id: "account.welcomeShown", + defaultValue: false, + userDefaultsKey: "cmuxWelcomeShown" + ) + + public init() {} +} diff --git a/Packages/CmuxSettings/Sources/CmuxSettings/Keys/AnySettingKey.swift b/Packages/CmuxSettings/Sources/CmuxSettings/Keys/AnySettingKey.swift new file mode 100644 index 000000000000..1bcd9c6a827a --- /dev/null +++ b/Packages/CmuxSettings/Sources/CmuxSettings/Keys/AnySettingKey.swift @@ -0,0 +1,123 @@ +import Foundation + +/// Type-erased view onto a ``DefaultsKey`` or ``JSONKey``. +/// +/// Used to enumerate every catalog entry uniformly — for example when running +/// legacy-key migrations at app startup, building schema docs, or driving the +/// settings search index. The concrete key types are generic over `Value` and +/// can't live in a single heterogeneous array, so ``AnySettingKey`` strips +/// the value type while preserving the metadata needed for those tasks. +/// +/// Type-sensitive operations (like legacy-key migration, which must validate +/// that the legacy value decodes as the new key's `Value` before copying) +/// are exposed as closures that capture `Value` at construction. +public struct AnySettingKey: Sendable { + /// Which storage backend the underlying key targets, plus its + /// backend-specific metadata. + public enum Kind: Sendable, Hashable { + /// The key persists in `UserDefaults`. + /// + /// - Parameters: + /// - key: The UserDefaults storage key. + /// - suite: Optional suite name (`nil` for `UserDefaults.standard`). + /// - legacyKeys: Renamed keys to migrate from on first read. + case userDefaults(key: String, suite: String?, legacyKeys: [String]) + + /// The key persists in the cmux JSON config file at the key's ``id``. + case jsonConfig + } + + /// The dotted identifier from the underlying key. + public let id: String + + /// Where the underlying key persists, plus backend-specific metadata. + public let kind: Kind + + /// Runs legacy-key migration for this entry against a `UserDefaults` + /// suite. The closure was captured with the underlying `Value` type, so + /// it validates the legacy value decodes correctly before copying — a + /// type mismatch (e.g. legacy was Bool, new is String) is detected and + /// the migration is skipped rather than silently coercing. + /// + /// No-op for ``Kind/jsonConfig`` keys. + public let migrateUserDefaultsLegacyKeys: @Sendable (UserDefaults) -> Void + + /// Removes this key from the JSON config store, if it is a + /// JSON-backed key. No-op for ``Kind/userDefaults`` keys. Errors + /// from the underlying ``JSONConfigStore/reset(_:)`` call are + /// swallowed because batch reset paths (e.g. ``ResetSection``) + /// surface them separately or treat them as best-effort. + public let resetInJSON: @Sendable (JSONConfigStore) async -> Void + + /// Wraps a UserDefaults-backed key. + public init(_ key: DefaultsKey) { + self.id = key.id + self.kind = .userDefaults( + key: key.userDefaultsKey, + suite: key.suite, + legacyKeys: key.legacyUserDefaultsKeys + ) + self.migrateUserDefaultsLegacyKeys = { defaults in + AnySettingKey.migrateLegacyDefaultsKey(key, defaults: defaults) + } + self.resetInJSON = { _ in } + } + + /// Wraps a JSON-backed key. + public init(_ key: JSONKey) { + self.id = key.id + self.kind = .jsonConfig + self.migrateUserDefaultsLegacyKeys = { _ in } + self.resetInJSON = { store in + try? await store.reset(key) + } + } + + private static func migrateLegacyDefaultsKey( + _ key: DefaultsKey, + defaults: UserDefaults + ) { + guard !key.legacyUserDefaultsKeys.isEmpty else { return } + guard defaults.object(forKey: key.userDefaultsKey) == nil else { return } + for legacy in key.legacyUserDefaultsKeys { + guard let raw = defaults.object(forKey: legacy) else { continue } + // Only migrate if the legacy value decodes as the new key's + // Value type. Otherwise the legacy entry was a different shape + // and copying would silently produce stale defaults; leave the + // primary key empty and let the default value take effect. + guard Value.decodeFromUserDefaults(raw) != nil else { continue } + defaults.set(raw, forKey: key.userDefaultsKey) + for cleanup in key.legacyUserDefaultsKeys { + defaults.removeObject(forKey: cleanup) + } + return + } + } +} + +extension AnySettingKey: Equatable { + public static func == (lhs: AnySettingKey, rhs: AnySettingKey) -> Bool { + lhs.id == rhs.id && lhs.kind == rhs.kind + } +} + +extension AnySettingKey: Hashable { + public func hash(into hasher: inout Hasher) { + hasher.combine(id) + hasher.combine(kind) + } +} + +/// Internal protocol that both ``DefaultsKey`` and ``JSONKey`` conform to so +/// ``SettingCatalog`` can derive ``SettingCatalogSection/all`` via reflection. +protocol AnySettingKeyConvertible { + var asAnySettingKey: AnySettingKey { get } +} + +extension DefaultsKey: AnySettingKeyConvertible { + var asAnySettingKey: AnySettingKey { AnySettingKey(self) } +} + +extension JSONKey: AnySettingKeyConvertible { + var asAnySettingKey: AnySettingKey { AnySettingKey(self) } +} diff --git a/Packages/CmuxSettings/Sources/CmuxSettings/Keys/AppCatalogSection.swift b/Packages/CmuxSettings/Sources/CmuxSettings/Keys/AppCatalogSection.swift new file mode 100644 index 000000000000..3d275d2f90da --- /dev/null +++ b/Packages/CmuxSettings/Sources/CmuxSettings/Keys/AppCatalogSection.swift @@ -0,0 +1,182 @@ +import Foundation + +/// Settings under the dotted-id prefix `app.*` — user-facing app behavior. +public struct AppCatalogSection: SettingCatalogSection { + public let appearance = DefaultsKey( + id: "app.appearance", + defaultValue: .system, + userDefaultsKey: "appearanceMode" + ) + + public let language = DefaultsKey( + id: "app.language", + defaultValue: .system, + userDefaultsKey: "appLanguage" + ) + + public let appIcon = DefaultsKey( + id: "app.appIcon", + defaultValue: .automatic, + userDefaultsKey: "appIconMode" + ) + + public let menuBarOnly = DefaultsKey( + id: "app.menuBarOnly", + defaultValue: false, + userDefaultsKey: "menuBarOnly" + ) + + public let newWorkspacePlacement = DefaultsKey( + id: "app.newWorkspacePlacement", + defaultValue: .afterCurrent, + userDefaultsKey: "newWorkspacePlacement" + ) + + public let workspaceInheritWorkingDirectory = DefaultsKey( + id: "app.workspaceInheritWorkingDirectory", + defaultValue: true, + userDefaultsKey: "workspaceInheritWorkingDirectory" + ) + + public let presentationMode = DefaultsKey( + id: "app.minimalMode", + defaultValue: .standard, + userDefaultsKey: "workspacePresentationMode" + ) + + /// Stored under the legacy `closeWorkspaceOnLastSurfaceShortcut` key + /// whose value carries *close*-on-last-surface semantics (true = + /// close the workspace), with legacy default `true`. The "Keep + /// Workspace Open" toggle in the UI therefore binds to the inverse + /// of this value, matching the legacy SettingsView. + public let keepWorkspaceOpenWhenClosingLastSurface = DefaultsKey( + id: "app.keepWorkspaceOpenWhenClosingLastSurface", + defaultValue: true, + userDefaultsKey: "closeWorkspaceOnLastSurfaceShortcut" + ) + + public let focusPaneOnFirstClick = DefaultsKey( + id: "app.focusPaneOnFirstClick", + defaultValue: false, + userDefaultsKey: "paneFirstClickFocus.enabled" + ) + + public let preferredEditor = DefaultsKey( + id: "app.preferredEditor", + defaultValue: "", + userDefaultsKey: "preferredEditorCommand" + ) + + public let openSupportedFilesInCmux = DefaultsKey( + id: "app.openSupportedFilesInCmux", + defaultValue: true, + userDefaultsKey: "openSupportedFilesInCmux" + ) + + public let openMarkdownInCmuxViewer = DefaultsKey( + id: "app.openMarkdownInCmuxViewer", + defaultValue: false, + userDefaultsKey: "openMarkdownInCmuxViewer" + ) + + public let iMessageMode = DefaultsKey( + id: "app.iMessageMode", + defaultValue: false, + userDefaultsKey: "app.iMessageMode" + ) + + public let reorderOnNotification = DefaultsKey( + id: "app.reorderOnNotification", + defaultValue: true, + userDefaultsKey: "workspaceAutoReorderOnNotification" + ) + + public let sendAnonymousTelemetry = DefaultsKey( + id: "app.sendAnonymousTelemetry", + defaultValue: true, + userDefaultsKey: "sendAnonymousTelemetry" + ) + + public let confirmQuitMode = DefaultsKey( + id: "app.confirmQuit", + defaultValue: .always, + userDefaultsKey: "confirmQuit" + ) + + public let warnBeforeQuit = DefaultsKey( + id: "app.warnBeforeQuit", + defaultValue: true, + userDefaultsKey: "warnBeforeQuitShortcut" + ) + + public let warnBeforeClosingTab = DefaultsKey( + id: "app.warnBeforeClosingTab", + defaultValue: true, + userDefaultsKey: "warnBeforeClosingTabShortcut" + ) + + public let warnBeforeClosingTabXButton = DefaultsKey( + id: "app.warnBeforeClosingTabXButton", + defaultValue: false, + userDefaultsKey: "warnBeforeClosingTabXButton" + ) + + public let hideTabCloseButton = DefaultsKey( + id: "app.hideTabCloseButton", + defaultValue: false, + userDefaultsKey: "hideTabCloseButton" + ) + + public let renameSelectsExistingName = DefaultsKey( + id: "app.renameSelectsExistingName", + defaultValue: true, + userDefaultsKey: "commandPalette.renameSelectAllOnFocus" + ) + + public let commandPaletteSearchesAllSurfaces = DefaultsKey( + id: "app.commandPaletteSearchesAllSurfaces", + defaultValue: false, + userDefaultsKey: "commandPalette.switcherSearchAllSurfaces" + ) + + public let fileDropDefaultBehavior = DefaultsKey( + id: "app.fileDropDefaultBehavior", + defaultValue: .text, + userDefaultsKey: "fileDrop.defaultBehavior" + ) + + /// Titlebar controls style. Legacy stores the `Int` raw value of + /// `TitlebarControlsStyle` (`.classic == 0`) under + /// `titlebarControlsStyle`; a `String` here would write a value the + /// app's `integer(forKey:)` readers can't decode. + public let titlebarControlsStyle = DefaultsKey( + id: "app.titlebarControlsStyle", + defaultValue: 0, + userDefaultsKey: "titlebarControlsStyle" + ) + + /// Workspace button fade mode. Legacy `WorkspaceButtonFadeSettings` + /// stores the `String` raw value of its `enabled`/`disabled` enum + /// (default `disabled`) under `workspaceButtonsFadeMode`. + public let workspaceButtonFade = DefaultsKey( + id: "app.workspaceButtonFade", + defaultValue: "disabled", + userDefaultsKey: "workspaceButtonsFadeMode" + ) + + /// Workspace titlebar visibility. Legacy `WorkspaceTitlebarSettings` + /// stores a `Bool` (default `true`) under `workspaceTitlebarVisible`. + public let workspaceTitlebarVisibility = DefaultsKey( + id: "app.workspaceTitlebarVisibility", + defaultValue: true, + userDefaultsKey: "workspaceTitlebarVisible" + ) + + public let systemWideHotkeyEnabled = DefaultsKey( + id: "app.systemWideHotkeyEnabled", + defaultValue: false, + userDefaultsKey: "systemWideHotkey.enabled" + ) + + public init() {} +} diff --git a/Packages/CmuxSettings/Sources/CmuxSettings/Keys/AutomationCatalogSection.swift b/Packages/CmuxSettings/Sources/CmuxSettings/Keys/AutomationCatalogSection.swift new file mode 100644 index 000000000000..396505708c15 --- /dev/null +++ b/Packages/CmuxSettings/Sources/CmuxSettings/Keys/AutomationCatalogSection.swift @@ -0,0 +1,65 @@ +import Foundation + +/// Settings under the dotted-id prefix `automation.*`. +public struct AutomationCatalogSection: SettingCatalogSection { + public let socketControlMode = DefaultsKey( + id: "automation.socketControlMode", + defaultValue: .cmuxOnly, + userDefaultsKey: "socketControlMode" + ) + + public let socketPassword = JSONKey( + id: "automation.socketPassword", + defaultValue: "" + ) + + public let claudeCodeIntegration = DefaultsKey( + id: "automation.claudeCodeIntegration", + defaultValue: false, + userDefaultsKey: "claudeCodeHooksEnabled" + ) + + public let claudeBinaryPath = DefaultsKey( + id: "automation.claudeBinaryPath", + defaultValue: "", + userDefaultsKey: "claudeCodeCustomClaudePath" + ) + + public let ripgrepBinaryPath = DefaultsKey( + id: "automation.ripgrepBinaryPath", + defaultValue: "", + userDefaultsKey: "ripgrepCustomBinaryPath" + ) + + public let suppressSubagentNotifications = DefaultsKey( + id: "automation.suppressSubagentNotifications", + defaultValue: false, + userDefaultsKey: "suppressSubagentNotifications" + ) + + public let cursorIntegration = DefaultsKey( + id: "automation.cursorIntegration", + defaultValue: false, + userDefaultsKey: "cursorHooksEnabled" + ) + + public let geminiIntegration = DefaultsKey( + id: "automation.geminiIntegration", + defaultValue: false, + userDefaultsKey: "geminiHooksEnabled" + ) + + public let portBase = DefaultsKey( + id: "automation.portBase", + defaultValue: 9100, + userDefaultsKey: "cmuxPortBase" + ) + + public let portRange = DefaultsKey( + id: "automation.portRange", + defaultValue: 10, + userDefaultsKey: "cmuxPortRange" + ) + + public init() {} +} diff --git a/Packages/CmuxSettings/Sources/CmuxSettings/Keys/BetaFeaturesCatalogSection.swift b/Packages/CmuxSettings/Sources/CmuxSettings/Keys/BetaFeaturesCatalogSection.swift new file mode 100644 index 000000000000..050c525b14ee --- /dev/null +++ b/Packages/CmuxSettings/Sources/CmuxSettings/Keys/BetaFeaturesCatalogSection.swift @@ -0,0 +1,19 @@ +import Foundation + +/// Beta-feature toggles. Each key here gates an experimental code path +/// in the running app. The id prefix is `rightSidebar.beta.*` for the +/// existing right-sidebar Dock toggle; new betas should follow the +/// pattern `.beta.` so the cmux.json view +/// groups them sensibly. +public struct BetaFeaturesCatalogSection: SettingCatalogSection { + /// Right-sidebar Dock: an experimental terminal-controls dock that + /// replaces the per-pane action chrome with a unified right-side + /// rail. Defaults off; flagged as unstable in the Settings UI. + public let rightSidebarDock = DefaultsKey( + id: "rightSidebar.beta.dock.enabled", + defaultValue: false, + userDefaultsKey: "rightSidebar.beta.dock.enabled" + ) + + public init() {} +} diff --git a/Packages/CmuxSettings/Sources/CmuxSettings/Keys/BrowserCatalogSection.swift b/Packages/CmuxSettings/Sources/CmuxSettings/Keys/BrowserCatalogSection.swift new file mode 100644 index 000000000000..af89b7aee6ab --- /dev/null +++ b/Packages/CmuxSettings/Sources/CmuxSettings/Keys/BrowserCatalogSection.swift @@ -0,0 +1,112 @@ +import Foundation + +/// Settings under the dotted-id prefix `browser.*`. +public struct BrowserCatalogSection: SettingCatalogSection { + public let defaultSearchEngine = DefaultsKey( + id: "browser.defaultSearchEngine", + defaultValue: .google, + userDefaultsKey: "browserSearchEngine" + ) + + public let customSearchEngineName = DefaultsKey( + id: "browser.customSearchEngineName", + defaultValue: "", + userDefaultsKey: "browserCustomSearchEngineName" + ) + + public let customSearchEngineURLTemplate = DefaultsKey( + id: "browser.customSearchEngineURLTemplate", + defaultValue: "", + userDefaultsKey: "browserCustomSearchEngineURLTemplate" + ) + + public let showSearchSuggestions = DefaultsKey( + id: "browser.showSearchSuggestions", + defaultValue: true, + userDefaultsKey: "browserSearchSuggestionsEnabled" + ) + + public let theme = DefaultsKey( + id: "browser.theme", + defaultValue: .system, + userDefaultsKey: "browserThemeMode" + ) + + public let discardHiddenWebViews = DefaultsKey( + id: "browser.discardHiddenWebViews", + defaultValue: true, + userDefaultsKey: "browserHiddenWebViewDiscardEnabled" + ) + + public let hiddenWebViewDiscardDelaySeconds = DefaultsKey( + id: "browser.hiddenWebViewDiscardDelaySeconds", + defaultValue: 300, + userDefaultsKey: "browserHiddenWebViewDiscardDelaySeconds" + ) + + public let openTerminalLinksInCmuxBrowser = DefaultsKey( + id: "browser.openTerminalLinksInCmuxBrowser", + defaultValue: true, + userDefaultsKey: "browserOpenTerminalLinksInCmuxBrowser" + ) + + public let interceptTerminalOpenCommandInCmuxBrowser = DefaultsKey( + id: "browser.interceptTerminalOpenCommandInCmuxBrowser", + defaultValue: true, + userDefaultsKey: "browserInterceptTerminalOpenCommandInCmuxBrowser" + ) + + public let hostsToOpenInEmbeddedBrowser = DefaultsKey( + id: "browser.hostsToOpenInEmbeddedBrowser", + defaultValue: "", + userDefaultsKey: "browserHostWhitelist" + ) + + public let urlsToAlwaysOpenExternally = DefaultsKey( + id: "browser.urlsToAlwaysOpenExternally", + defaultValue: "", + userDefaultsKey: "browserExternalOpenPatterns" + ) + + public let insecureHttpHostsAllowedInEmbeddedBrowser = DefaultsKey( + id: "browser.insecureHttpHostsAllowedInEmbeddedBrowser", + defaultValue: "localhost\n*.localhost\n127.0.0.1\n::1\n0.0.0.0\n*.localtest.me", + userDefaultsKey: "browserInsecureHTTPAllowlist" + ) + + public let showImportHintOnBlankTabs = DefaultsKey( + id: "browser.showImportHintOnBlankTabs", + defaultValue: true, + userDefaultsKey: "browserImportHintShowOnBlankTabs" + ) + + public let reactGrabVersion = DefaultsKey( + id: "browser.reactGrabVersion", + defaultValue: "0.1.29", + userDefaultsKey: "reactGrabVersion" + ) + + /// Stored under `browserDisabledOverride` — the key the runtime + /// gate `BrowserAvailabilitySettings.isDisabled()` actually reads. + /// (The intuitive `browserDisabled` is read by nothing, so the + /// "Enable cmux Browser" toggle was a no-op until this was aligned.) + public let disabled = DefaultsKey( + id: "browser.disabled", + defaultValue: false, + userDefaultsKey: "browserDisabledOverride" + ) + + public let importHintVariant = DefaultsKey( + id: "browser.importHintVariant", + defaultValue: "toolbarChip", + userDefaultsKey: "browserImportHintVariant" + ) + + public let importHintDismissed = DefaultsKey( + id: "browser.importHintDismissed", + defaultValue: false, + userDefaultsKey: "browserImportHintDismissed" + ) + + public init() {} +} diff --git a/Packages/CmuxSettings/Sources/CmuxSettings/Keys/DefaultsKey.swift b/Packages/CmuxSettings/Sources/CmuxSettings/Keys/DefaultsKey.swift new file mode 100644 index 000000000000..c8842502fa06 --- /dev/null +++ b/Packages/CmuxSettings/Sources/CmuxSettings/Keys/DefaultsKey.swift @@ -0,0 +1,67 @@ +import Foundation + +/// A strongly-typed handle to a setting persisted in `UserDefaults`. +/// +/// `DefaultsKey` is one of two key flavors in ``CmuxSettings``; the other is +/// ``JSONKey``. Each flavor only matches its own store, so a +/// ``UserDefaultsSettingsStore`` refuses a ``JSONKey`` at compile time and +/// vice versa. There are no runtime traps for wrong-store mismatches. +/// +/// Declare keys on a ``SettingCatalog`` instance and reference them from +/// every call site so the source of truth stays in one place. +/// +/// ```swift +/// public let appAppearance = DefaultsKey( +/// id: "app.appearance", +/// defaultValue: .system, +/// userDefaultsKey: "appearanceMode" +/// ) +/// ``` +public struct DefaultsKey: Sendable, Equatable { + /// The dotted identifier for the setting (e.g. `"app.appearance"`). + /// + /// Used for diagnostics, search, and JSON-schema generation. It is + /// independent of ``userDefaultsKey``, which is the actual UserDefaults + /// storage key — they may differ for legacy reasons. + public let id: String + + /// The value returned when no override is stored in the suite. + public let defaultValue: Value + + /// The actual `UserDefaults` key the value is stored under. + public let userDefaultsKey: String + + /// Optional `UserDefaults` suite name. `nil` means `UserDefaults.standard`. + public let suite: String? + + /// UserDefaults keys to migrate from on first read. + /// + /// When the primary ``userDefaultsKey`` has no value but a legacy key + /// does, the legacy value is copied to the primary key and every legacy + /// key is removed. Use this to retire renamed keys without breaking + /// existing users. + public let legacyUserDefaultsKeys: [String] + + /// Creates a UserDefaults-backed setting key. + /// + /// - Parameters: + /// - id: The dotted identifier (used for diagnostics; usually mirrors + /// `userDefaultsKey`, but is allowed to differ). + /// - defaultValue: The fallback when no override is stored. + /// - userDefaultsKey: The actual UserDefaults key. + /// - suite: Optional suite name. `nil` is `UserDefaults.standard`. + /// - legacyUserDefaultsKeys: Renamed keys to migrate from on first read. + public init( + id: String, + defaultValue: Value, + userDefaultsKey: String, + suite: String? = nil, + legacyUserDefaultsKeys: [String] = [] + ) { + self.id = id + self.defaultValue = defaultValue + self.userDefaultsKey = userDefaultsKey + self.suite = suite + self.legacyUserDefaultsKeys = legacyUserDefaultsKeys + } +} diff --git a/Packages/CmuxSettings/Sources/CmuxSettings/Keys/IntegrationsCatalogSection.swift b/Packages/CmuxSettings/Sources/CmuxSettings/Keys/IntegrationsCatalogSection.swift new file mode 100644 index 000000000000..42f5c5c3ddaa --- /dev/null +++ b/Packages/CmuxSettings/Sources/CmuxSettings/Keys/IntegrationsCatalogSection.swift @@ -0,0 +1,43 @@ +import Foundation + +/// Settings under the dotted-id prefix `integrations.*` — external +/// agent / editor / search integrations. +public struct IntegrationsCatalogSection: SettingCatalogSection { + public let claudeCodeHooksEnabled = DefaultsKey( + id: "integrations.claudeCode.hooksEnabled", + defaultValue: false, + userDefaultsKey: "claudeCodeHooksEnabled" + ) + + public let claudeCodeCustomClaudePath = DefaultsKey( + id: "integrations.claudeCode.customClaudePath", + defaultValue: "", + userDefaultsKey: "claudeCodeCustomClaudePath" + ) + + public let cursorHooksEnabled = DefaultsKey( + id: "integrations.cursor.hooksEnabled", + defaultValue: false, + userDefaultsKey: "cursorHooksEnabled" + ) + + public let geminiHooksEnabled = DefaultsKey( + id: "integrations.gemini.hooksEnabled", + defaultValue: false, + userDefaultsKey: "geminiHooksEnabled" + ) + + public let ripgrepCustomBinaryPath = DefaultsKey( + id: "integrations.ripgrep.customBinaryPath", + defaultValue: "", + userDefaultsKey: "ripgrepCustomBinaryPath" + ) + + public let suppressSubagentNotifications = DefaultsKey( + id: "integrations.suppressSubagentNotifications", + defaultValue: false, + userDefaultsKey: "suppressSubagentNotifications" + ) + + public init() {} +} diff --git a/Packages/CmuxSettings/Sources/CmuxSettings/Keys/JSONKey.swift b/Packages/CmuxSettings/Sources/CmuxSettings/Keys/JSONKey.swift new file mode 100644 index 000000000000..400b4c3ed100 --- /dev/null +++ b/Packages/CmuxSettings/Sources/CmuxSettings/Keys/JSONKey.swift @@ -0,0 +1,42 @@ +import Foundation + +/// A strongly-typed handle to a setting persisted in the cmux JSON config file. +/// +/// `JSONKey` is one of two key flavors in ``CmuxSettings``; the other is +/// ``DefaultsKey``. Each flavor only matches its own store, so a +/// ``JSONConfigStore`` refuses a ``DefaultsKey`` at compile time and vice +/// versa. There are no runtime traps for wrong-store mismatches. +/// +/// The key's ``id`` is used directly as the dotted JSON path. The matching +/// ``JSONPath`` value is precomputed at construction so reads and writes +/// never re-split the path string. +/// +/// ```swift +/// public let automationSocketPassword = JSONKey( +/// id: "automation.socketPassword", +/// defaultValue: "" +/// ) +/// ``` +public struct JSONKey: Sendable, Equatable { + /// The dotted identifier (also the JSON path inside the cmux config file). + public let id: String + + /// The value returned when the file is missing or the path is absent. + public let defaultValue: Value + + /// The precomputed path matching ``id``. Used by ``JSONConfigStore`` to + /// walk the JSON tree without re-splitting per call. + public let path: JSONPath + + /// Creates a JSON-backed setting key. + /// + /// - Parameters: + /// - id: The dotted identifier, which is also the JSON path. + /// - defaultValue: The fallback when the file is missing or the path + /// is absent. + public init(id: String, defaultValue: Value) { + self.id = id + self.defaultValue = defaultValue + self.path = JSONPath(dottedPath: id) + } +} diff --git a/Packages/CmuxSettings/Sources/CmuxSettings/Keys/KeyboardShortcutsCatalogSection.swift b/Packages/CmuxSettings/Sources/CmuxSettings/Keys/KeyboardShortcutsCatalogSection.swift new file mode 100644 index 000000000000..f7447c5cfc9b --- /dev/null +++ b/Packages/CmuxSettings/Sources/CmuxSettings/Keys/KeyboardShortcutsCatalogSection.swift @@ -0,0 +1,22 @@ +import Foundation + +/// Settings under the dotted-id prefix `shortcuts.*`. +/// +/// Everything user-customisable about keyboard shortcuts lives behind a +/// single JSON-backed key: a dictionary mapping each action's stable id +/// to the user's stored binding. The action catalog itself (display +/// names, defaults, search keywords) belongs to the +/// `CmuxKeyboardShortcuts` layer that consumes this catalog; this +/// section is intentionally minimal and purely declarative. +public struct KeyboardShortcutsCatalogSection: SettingCatalogSection { + /// The persisted user bindings: `[actionID: StoredShortcut]`. + /// Actions absent from this dictionary fall back to the layer's + /// declared default. ``StoredShortcut/unbound`` for an action + /// represents an explicit "no shortcut" override. + public let bindings = JSONKey<[String: StoredShortcut]>( + id: "shortcuts.bindings", + defaultValue: [:] + ) + + public init() {} +} diff --git a/Packages/CmuxSettings/Sources/CmuxSettings/Keys/NotificationsCatalogSection.swift b/Packages/CmuxSettings/Sources/CmuxSettings/Keys/NotificationsCatalogSection.swift new file mode 100644 index 000000000000..7e581e4c5b2f --- /dev/null +++ b/Packages/CmuxSettings/Sources/CmuxSettings/Keys/NotificationsCatalogSection.swift @@ -0,0 +1,58 @@ +import Foundation + +/// Settings under the dotted-id prefix `notifications.*`. +public struct NotificationsCatalogSection: SettingCatalogSection { + public let dockBadge = DefaultsKey( + id: "notifications.dockBadge", + defaultValue: true, + userDefaultsKey: "notificationDockBadgeEnabled" + ) + + public let showInMenuBar = DefaultsKey( + id: "notifications.showInMenuBar", + defaultValue: true, + userDefaultsKey: "showMenuBarExtra" + ) + + public let unreadPaneRing = DefaultsKey( + id: "notifications.unreadPaneRing", + defaultValue: true, + userDefaultsKey: "notificationPaneRingEnabled" + ) + + public let paneFlash = DefaultsKey( + id: "notifications.paneFlash", + defaultValue: true, + userDefaultsKey: "notificationPaneFlashEnabled" + ) + + public let sound = DefaultsKey( + id: "notifications.sound", + defaultValue: "default", + userDefaultsKey: "notificationSound" + ) + + public let customSoundFilePath = DefaultsKey( + id: "notifications.customSoundFilePath", + defaultValue: "", + userDefaultsKey: "notificationSoundCustomFilePath" + ) + + public let command = DefaultsKey( + id: "notifications.command", + defaultValue: "", + userDefaultsKey: "notificationCustomCommand" + ) + + public let hooks = JSONKey<[String: String]>( + id: "notifications.hooks", + defaultValue: [:] + ) + + public let hooksMode = JSONKey( + id: "notifications.hooksMode", + defaultValue: "merge" + ) + + public init() {} +} diff --git a/Packages/CmuxSettings/Sources/CmuxSettings/Keys/SettingCatalog.swift b/Packages/CmuxSettings/Sources/CmuxSettings/Keys/SettingCatalog.swift new file mode 100644 index 000000000000..dacc221123f7 --- /dev/null +++ b/Packages/CmuxSettings/Sources/CmuxSettings/Keys/SettingCatalog.swift @@ -0,0 +1,39 @@ +import Foundation + +/// The single source of truth for every cmux setting. +/// +/// ``SettingCatalog`` is the root value-typed registry. It is composed of +/// ``SettingCatalogSection`` sub-structs grouped by dotted-id prefix +/// (`app.*`, `terminal.*`, `automation.*`, …). Construct one at app +/// startup and inject it into the stores and UI layers. +/// +/// ``all`` is derived by reflection over the catalog's stored properties +/// (the default ``SettingCatalogSection/all`` implementation), recursing +/// into nested sections. Adding a key is exactly one line in the +/// appropriate `*CatalogSection` file and it shows up in every derived +/// view — schema, migration, search — automatically. +/// +/// ```swift +/// let catalog = SettingCatalog() +/// let store = UserDefaultsSettingsStore( +/// defaults: .standard, +/// migrating: catalog.all +/// ) +/// await store.set(.dark, for: catalog.app.appearance) +/// ``` +public struct SettingCatalog: SettingCatalogSection { + public let app = AppCatalogSection() + public let terminal = TerminalCatalogSection() + public let notifications = NotificationsCatalogSection() + public let sidebar = SidebarCatalogSection() + public let sidebarAppearance = SidebarAppearanceCatalogSection() + public let workspaceColors = WorkspaceColorsCatalogSection() + public let automation = AutomationCatalogSection() + public let browser = BrowserCatalogSection() + public let betaFeatures = BetaFeaturesCatalogSection() + public let shortcuts = KeyboardShortcutsCatalogSection() + public let integrations = IntegrationsCatalogSection() + public let account = AccountCatalogSection() + + public init() {} +} diff --git a/Packages/CmuxSettings/Sources/CmuxSettings/Keys/SettingCatalogSection.swift b/Packages/CmuxSettings/Sources/CmuxSettings/Keys/SettingCatalogSection.swift new file mode 100644 index 000000000000..895fcdb9390b --- /dev/null +++ b/Packages/CmuxSettings/Sources/CmuxSettings/Keys/SettingCatalogSection.swift @@ -0,0 +1,47 @@ +import Foundation + +/// A grouping of related ``DefaultsKey`` / ``JSONKey`` declarations. +/// +/// `SettingCatalogSection` is the building block of ``SettingCatalog``. Every +/// section is a value-typed struct whose stored properties are setting keys +/// (or nested sections). Sections compose: ``SettingCatalog/all`` walks the +/// catalog with `Mirror`, descending into nested sections, and returns the +/// flat list of every key in declaration order. +/// +/// Partition the catalog along the dotted-id prefix so the file scales to +/// hundreds of settings without becoming a wall of properties: +/// +/// ```swift +/// public struct AppCatalogSection: SettingCatalogSection { +/// public let appearance = DefaultsKey( +/// id: "app.appearance", +/// defaultValue: .system, +/// userDefaultsKey: "appearanceMode" +/// ) +/// public init() {} +/// } +/// ``` +/// +/// A new section becomes a stored property on ``SettingCatalog``; reflection +/// in the default ``all`` implementation picks it up automatically. +public protocol SettingCatalogSection: Sendable { + /// Every key declared by this section and any nested sections, flattened. + var all: [AnySettingKey] { get } +} + +extension SettingCatalogSection { + /// Default implementation: walk `Mirror` over the receiver's stored + /// properties, emit setting keys directly, and recurse into nested + /// ``SettingCatalogSection`` values. + public var all: [AnySettingKey] { + Mirror(reflecting: self).children.flatMap { _, value -> [AnySettingKey] in + if let key = value as? AnySettingKeyConvertible { + return [key.asAnySettingKey] + } + if let section = value as? SettingCatalogSection { + return section.all + } + return [] + } + } +} diff --git a/Packages/CmuxSettings/Sources/CmuxSettings/Keys/SidebarAppearanceCatalogSection.swift b/Packages/CmuxSettings/Sources/CmuxSettings/Keys/SidebarAppearanceCatalogSection.swift new file mode 100644 index 000000000000..bda4e3a9b00e --- /dev/null +++ b/Packages/CmuxSettings/Sources/CmuxSettings/Keys/SidebarAppearanceCatalogSection.swift @@ -0,0 +1,72 @@ +import Foundation + +/// Settings under the dotted-id prefix `sidebarAppearance.*`. +public struct SidebarAppearanceCatalogSection: SettingCatalogSection { + public let matchTerminalBackground = DefaultsKey( + id: "sidebarAppearance.matchTerminalBackground", + defaultValue: false, + userDefaultsKey: "sidebarMatchTerminalBackground" + ) + + public let tintColorHex = DefaultsKey( + id: "sidebarAppearance.tintColor", + defaultValue: "#000000", + userDefaultsKey: "sidebarTintHex" + ) + + public let lightModeTintColorHex = DefaultsKey( + id: "sidebarAppearance.lightModeTintColor", + defaultValue: "", + userDefaultsKey: "sidebarTintHexLight" + ) + + public let darkModeTintColorHex = DefaultsKey( + id: "sidebarAppearance.darkModeTintColor", + defaultValue: "", + userDefaultsKey: "sidebarTintHexDark" + ) + + public let tintOpacity = DefaultsKey( + id: "sidebarAppearance.tintOpacity", + defaultValue: 0.18, + userDefaultsKey: "sidebarTintOpacity" + ) + + public let blurOpacity = DefaultsKey( + id: "sidebarAppearance.blurOpacity", + defaultValue: 1.0, + userDefaultsKey: "sidebarBlurOpacity" + ) + + public let cornerRadius = DefaultsKey( + id: "sidebarAppearance.cornerRadius", + defaultValue: 0.0, + userDefaultsKey: "sidebarCornerRadius" + ) + + public let preset = DefaultsKey( + id: "sidebarAppearance.preset", + defaultValue: .nativeSidebar, + userDefaultsKey: "sidebarPreset" + ) + + public let material = DefaultsKey( + id: "sidebarAppearance.material", + defaultValue: .sidebar, + userDefaultsKey: "sidebarMaterial" + ) + + public let blendMode = DefaultsKey( + id: "sidebarAppearance.blendMode", + defaultValue: .withinWindow, + userDefaultsKey: "sidebarBlendMode" + ) + + public let state = DefaultsKey( + id: "sidebarAppearance.state", + defaultValue: .followWindow, + userDefaultsKey: "sidebarState" + ) + + public init() {} +} diff --git a/Packages/CmuxSettings/Sources/CmuxSettings/Keys/SidebarCatalogSection.swift b/Packages/CmuxSettings/Sources/CmuxSettings/Keys/SidebarCatalogSection.swift new file mode 100644 index 000000000000..e3155946a48f --- /dev/null +++ b/Packages/CmuxSettings/Sources/CmuxSettings/Keys/SidebarCatalogSection.swift @@ -0,0 +1,138 @@ +import Foundation + +/// Settings under the dotted-id prefix `sidebar.*` — workspace-row +/// metadata visibility and layout. +public struct SidebarCatalogSection: SettingCatalogSection { + public let hideAllDetails = DefaultsKey( + id: "sidebar.hideAllDetails", + defaultValue: false, + userDefaultsKey: "sidebarHideAllDetails" + ) + + public let wrapWorkspaceTitles = DefaultsKey( + id: "sidebar.wrapWorkspaceTitles", + defaultValue: false, + userDefaultsKey: "sidebarWrapWorkspaceTitles" + ) + + public let showWorkspaceDescription = DefaultsKey( + id: "sidebar.showWorkspaceDescription", + defaultValue: true, + userDefaultsKey: "sidebarShowWorkspaceDescription" + ) + + /// Bool-backed to match the legacy in-app store. The on-disk key + /// `sidebarBranchVerticalLayout` is written as a Bool by every + /// shipped cmux build; using an enum here would silently revert + /// every user with a saved preference. `true` means vertical + /// (branch and directory stacked on their own lines). + public let branchVerticalLayout = DefaultsKey( + id: "sidebar.branchVerticalLayout", + defaultValue: true, + userDefaultsKey: "sidebarBranchVerticalLayout" + ) + + public let stackBranchDirectory = DefaultsKey( + id: "sidebar.stackBranchDirectory", + defaultValue: false, + userDefaultsKey: "sidebarBranchDirectoryStacked" + ) + + public let pathLastSegmentOnly = DefaultsKey( + id: "sidebar.pathLastSegmentOnly", + defaultValue: false, + userDefaultsKey: "sidebarPathLastSegmentOnly" + ) + + public let showNotificationMessage = DefaultsKey( + id: "sidebar.showNotificationMessage", + defaultValue: true, + userDefaultsKey: "sidebarShowNotificationMessage" + ) + + public let showBranchDirectory = DefaultsKey( + id: "sidebar.showBranchDirectory", + defaultValue: true, + userDefaultsKey: "sidebarShowBranchDirectory" + ) + + public let showPullRequests = DefaultsKey( + id: "sidebar.showPullRequests", + defaultValue: true, + userDefaultsKey: "sidebarShowPullRequest" + ) + + public let watchGitStatus = DefaultsKey( + id: "sidebar.watchGitStatus", + defaultValue: true, + userDefaultsKey: "sidebarWatchGitStatus" + ) + + public let makePullRequestsClickable = DefaultsKey( + id: "sidebar.makePullRequestsClickable", + defaultValue: true, + userDefaultsKey: "sidebarMakePullRequestClickable" + ) + + public let openPullRequestLinksInCmuxBrowser = DefaultsKey( + id: "sidebar.openPullRequestLinksInCmuxBrowser", + defaultValue: true, + userDefaultsKey: "browserOpenSidebarPullRequestLinksInCmuxBrowser" + ) + + public let openPortLinksInCmuxBrowser = DefaultsKey( + id: "sidebar.openPortLinksInCmuxBrowser", + defaultValue: true, + userDefaultsKey: "browserOpenSidebarPortLinksInCmuxBrowser" + ) + + public let showSSH = DefaultsKey( + id: "sidebar.showSSH", + defaultValue: true, + userDefaultsKey: "sidebarShowSSH" + ) + + public let showPorts = DefaultsKey( + id: "sidebar.showPorts", + defaultValue: true, + userDefaultsKey: "sidebarShowPorts" + ) + + public let showLog = DefaultsKey( + id: "sidebar.showLog", + defaultValue: true, + userDefaultsKey: "sidebarShowLog" + ) + + public let showProgress = DefaultsKey( + id: "sidebar.showProgress", + defaultValue: true, + userDefaultsKey: "sidebarShowProgress" + ) + + public let showCustomMetadata = DefaultsKey( + id: "sidebar.showCustomMetadata", + defaultValue: true, + userDefaultsKey: "sidebarShowStatusPills" + ) + + public let activeTabIndicatorStyle = DefaultsKey( + id: "sidebar.activeTabIndicatorStyle", + defaultValue: "leftRail", + userDefaultsKey: "sidebarActiveTabIndicatorStyle" + ) + + public let selectionColorHex = DefaultsKey( + id: "sidebar.selectionColor", + defaultValue: "", + userDefaultsKey: "sidebarSelectionColorHex" + ) + + public let notificationBadgeColorHex = DefaultsKey( + id: "sidebar.notificationBadgeColor", + defaultValue: "", + userDefaultsKey: "sidebarNotificationBadgeColorHex" + ) + + public init() {} +} diff --git a/Packages/CmuxSettings/Sources/CmuxSettings/Keys/TerminalCatalogSection.swift b/Packages/CmuxSettings/Sources/CmuxSettings/Keys/TerminalCatalogSection.swift new file mode 100644 index 000000000000..5113c02ead54 --- /dev/null +++ b/Packages/CmuxSettings/Sources/CmuxSettings/Keys/TerminalCatalogSection.swift @@ -0,0 +1,53 @@ +import Foundation + +/// Settings under the dotted-id prefix `terminal.*`. +public struct TerminalCatalogSection: SettingCatalogSection { + public let showScrollBar = DefaultsKey( + id: "terminal.showScrollBar", + defaultValue: true, + userDefaultsKey: "terminal.showScrollBar" + ) + + public let copyOnSelect = DefaultsKey( + id: "terminal.copyOnSelect", + defaultValue: false, + userDefaultsKey: "terminal.copyOnSelect" + ) + + public let autoResumeAgentSessions = DefaultsKey( + id: "terminal.autoResumeAgentSessions", + defaultValue: true, + userDefaultsKey: "terminal.autoResumeAgentSessions" + ) + + public let agentHibernationEnabled = DefaultsKey( + id: "terminal.agentHibernation.enabled", + defaultValue: false, + userDefaultsKey: "terminal.agentHibernation.enabled" + ) + + public let agentHibernationIdleSeconds = DefaultsKey( + id: "terminal.agentHibernation.idleSeconds", + defaultValue: 3600, + userDefaultsKey: "terminal.agentHibernation.idleSeconds" + ) + + public let agentHibernationMaxLiveTerminals = DefaultsKey( + id: "terminal.agentHibernation.maxLiveTerminals", + defaultValue: 12, + userDefaultsKey: "terminal.agentHibernation.maxLiveTerminals" + ) + + public let textBoxMaxLines = DefaultsKey( + id: "terminal.textBoxMaxLines", + defaultValue: 10, + userDefaultsKey: "terminal.textBoxMaxLines" + ) + + public let resumeCommands = JSONKey<[String]>( + id: "terminal.resumeCommands", + defaultValue: [] + ) + + public init() {} +} diff --git a/Packages/CmuxSettings/Sources/CmuxSettings/Keys/WorkspaceColorsCatalogSection.swift b/Packages/CmuxSettings/Sources/CmuxSettings/Keys/WorkspaceColorsCatalogSection.swift new file mode 100644 index 000000000000..3bbf921f1be0 --- /dev/null +++ b/Packages/CmuxSettings/Sources/CmuxSettings/Keys/WorkspaceColorsCatalogSection.swift @@ -0,0 +1,40 @@ +import Foundation + +/// Settings under the dotted-id prefix `workspaceColors.*`. +public struct WorkspaceColorsCatalogSection: SettingCatalogSection { + public let indicatorStyle = DefaultsKey( + id: "workspaceColors.indicatorStyle", + defaultValue: .leftRail, + userDefaultsKey: "sidebarActiveTabIndicatorStyle" + ) + + public let selectionColorHex = DefaultsKey( + id: "workspaceColors.selectionColor", + defaultValue: "", + userDefaultsKey: "sidebarSelectionColorHex" + ) + + public let notificationBadgeColorHex = DefaultsKey( + id: "workspaceColors.notificationBadgeColor", + defaultValue: "", + userDefaultsKey: "sidebarNotificationBadgeColorHex" + ) + + public let palette = DefaultsKey<[String: String]>( + id: "workspaceColors.colors", + defaultValue: [:], + userDefaultsKey: "workspaceTabColor.colors" + ) + + public let paletteOverrides = JSONKey<[String: String]>( + id: "workspaceColors.paletteOverrides", + defaultValue: [:] + ) + + public let customColors = JSONKey<[String]>( + id: "workspaceColors.customColors", + defaultValue: [] + ) + + public init() {} +} diff --git a/Packages/CmuxSettings/Sources/CmuxSettings/Stores/CmuxConfigLocation.swift b/Packages/CmuxSettings/Sources/CmuxSettings/Stores/CmuxConfigLocation.swift new file mode 100644 index 000000000000..5815aceebf96 --- /dev/null +++ b/Packages/CmuxSettings/Sources/CmuxSettings/Stores/CmuxConfigLocation.swift @@ -0,0 +1,33 @@ +import Foundation + +/// Conventional on-disk locations for the cmux JSON config. +/// +/// A small value-typed bundle of URLs. Construct one with an explicit `home` +/// directory and inject it into the parts of the app that need to know where +/// the config file lives. No shared singletons; tests use a custom `home` URL +/// pointing into a temp directory. +/// +/// ```swift +/// let locations = CmuxConfigLocation() +/// let store = JSONConfigStore(fileURL: locations.userConfigFile) +/// ``` +public struct CmuxConfigLocation: Sendable, Hashable { + /// The primary cmux config file: `/.config/cmux/cmux.json`. + public let userConfigFile: URL + + /// The legacy fallback: `/.config/cmux/settings.json`. The app's + /// settings reader checks this when the primary file is absent. + public let legacyFallbackFile: URL + + /// Creates a location bundle anchored at the given home directory. + /// + /// - Parameter home: The home directory to anchor paths to. Defaults to + /// `FileManager.default.homeDirectoryForCurrentUser`. Pass a temp URL + /// in tests. + public init(home: URL = FileManager.default.homeDirectoryForCurrentUser) { + // `URL.appending(path:)` is the modern Foundation API (macOS 13+); + // returns a non-optional URL without the legacy `isDirectory` flag. + self.userConfigFile = home.appending(path: ".config/cmux/cmux.json") + self.legacyFallbackFile = home.appending(path: ".config/cmux/settings.json") + } +} diff --git a/Packages/CmuxSettings/Sources/CmuxSettings/Stores/JSONCSanitizer.swift b/Packages/CmuxSettings/Sources/CmuxSettings/Stores/JSONCSanitizer.swift new file mode 100644 index 000000000000..33cb485c41cc --- /dev/null +++ b/Packages/CmuxSettings/Sources/CmuxSettings/Stores/JSONCSanitizer.swift @@ -0,0 +1,162 @@ +import Foundation + +/// Converts JSON-with-comments (JSONC) data into strict JSON. +/// +/// The cmux config file uses JSONC so users can leave `// inline` and +/// `/* block */` comments and trailing commas — both rejected by +/// `JSONSerialization`. `JSONCSanitizer` strips those before parsing. +/// +/// The sanitizer is a value type held by ``JSONConfigStore``. Inject a custom +/// instance for tests that want stricter or looser behavior; the default +/// initializer is enough for normal use. +public struct JSONCSanitizer: Sendable { + /// Creates a sanitizer with the default behavior: strip `//` and + /// `/* */` comments, strip trailing commas before `}` or `]`, accept UTF-8 + /// (with optional BOM), UTF-16 and UTF-32 input. + public init() {} + + /// Strips JSONC extensions from ``data`` and returns strict JSON bytes. + /// + /// - Parameter data: JSONC-encoded payload. + /// - Returns: A `Data` payload that `JSONSerialization` parses cleanly. + /// - Throws: ``Failure/invalidTextEncoding`` if the byte sequence is not + /// one of the recognized encodings; ``Failure/unterminatedBlockComment`` + /// if a `/*` block comment never closes. + public func sanitize(_ data: Data) throws -> Data { + let source = try decode(data: data) + let withoutBOM = source.hasPrefix("\u{feff}") ? String(source.dropFirst()) : source + let withoutComments = try stripComments(withoutBOM) + let withoutTrailingCommas = stripTrailingCommas(withoutComments) + return Data(withoutTrailingCommas.utf8) + } + + /// Errors produced by ``sanitize(_:)``. + public enum Failure: Error, Sendable { + /// The byte sequence is not a recognized text encoding. + case invalidTextEncoding + /// A `/*` block comment was opened but never closed. + case unterminatedBlockComment + } + + private func decode(data: Data) throws -> String { + if let encoding = detectedEncoding(for: data), let string = String(data: data, encoding: encoding) { + return string + } + if let string = String(data: data, encoding: .utf8) { return string } + throw Failure.invalidTextEncoding + } + + private func detectedEncoding(for data: Data) -> String.Encoding? { + let bytes = Array(data.prefix(4)) + if bytes.starts(with: [0x00, 0x00, 0xFE, 0xFF]) { return .utf32BigEndian } + if bytes.starts(with: [0xFF, 0xFE, 0x00, 0x00]) { return .utf32LittleEndian } + if bytes.starts(with: [0xFE, 0xFF]) { return .utf16BigEndian } + if bytes.starts(with: [0xFF, 0xFE]) { return .utf16LittleEndian } + if bytes.starts(with: [0xEF, 0xBB, 0xBF]) { return .utf8 } + return nil + } + + private func isLineTerminator(_ character: Character) -> Bool { + guard let scalar = character.unicodeScalars.first else { return false } + return scalar == "\n" || scalar == "\r" + } + + private func stripComments(_ source: String) throws -> String { + var result = "" + var index = source.startIndex + var inString = false + var isEscaped = false + while index < source.endIndex { + let character = source[index] + if inString { + result.append(character) + if isEscaped { + isEscaped = false + } else if character == "\\" { + isEscaped = true + } else if character == "\"" { + inString = false + } + index = source.index(after: index) + continue + } + if character == "\"" { + inString = true + result.append(character) + index = source.index(after: index) + continue + } + if character == "/" { + let next = source.index(after: index) + if next < source.endIndex { + if source[next] == "/" { + index = source.index(after: next) + while index < source.endIndex, !isLineTerminator(source[index]) { + index = source.index(after: index) + } + continue + } + if source[next] == "*" { + index = source.index(after: next) + var didClose = false + while index < source.endIndex { + let following = source.index(after: index) + if source[index] == "*", following < source.endIndex, source[following] == "/" { + index = source.index(after: following) + didClose = true + break + } + index = following + } + guard didClose else { throw Failure.unterminatedBlockComment } + continue + } + } + } + result.append(character) + index = source.index(after: index) + } + return result + } + + private func stripTrailingCommas(_ source: String) -> String { + var result = "" + var index = source.startIndex + var inString = false + var isEscaped = false + while index < source.endIndex { + let character = source[index] + if inString { + result.append(character) + if isEscaped { + isEscaped = false + } else if character == "\\" { + isEscaped = true + } else if character == "\"" { + inString = false + } + index = source.index(after: index) + continue + } + if character == "\"" { + inString = true + result.append(character) + index = source.index(after: index) + continue + } + if character == "," { + var lookahead = source.index(after: index) + while lookahead < source.endIndex, source[lookahead].isWhitespace { + lookahead = source.index(after: lookahead) + } + if lookahead < source.endIndex, source[lookahead] == "}" || source[lookahead] == "]" { + index = source.index(after: index) + continue + } + } + result.append(character) + index = source.index(after: index) + } + return result + } +} diff --git a/Packages/CmuxSettings/Sources/CmuxSettings/Stores/JSONConfigFileWatcher.swift b/Packages/CmuxSettings/Sources/CmuxSettings/Stores/JSONConfigFileWatcher.swift new file mode 100644 index 000000000000..7d0dfcf39985 --- /dev/null +++ b/Packages/CmuxSettings/Sources/CmuxSettings/Stores/JSONConfigFileWatcher.swift @@ -0,0 +1,140 @@ +import Foundation + +/// Watches a single file for change events and exposes them as an +/// `AsyncStream`. +/// +/// Wraps `DispatchSource.makeFileSystemObjectSource` — the kqueue-backed +/// Foundation primitive for file events on macOS — because there is no +/// async-native replacement. The dispatch sources are owned by this actor +/// and stay isolated to it. Consumers see only the actor's ``events`` +/// stream; `DispatchSource` itself is never exposed. +/// +/// Construct one per file you want to watch and inject it into the +/// consumer (e.g. ``JSONConfigStore``). The watcher also observes the +/// parent directory so its stream recovers when the file is created or +/// replaced after the watcher starts — the standard cmux config file +/// frequently does not exist on first launch. +/// +/// **Known race window.** ``init(fileURL:)`` schedules `Task { await +/// self.start() }` to attach the dispatch sources on the actor's +/// executor. Between init returning and that Task running, kqueue is not +/// yet listening, so file events in that window are missed. The window +/// is short (one actor-executor hop) and pragmatic for now; a future +/// revisit may move construction to an `async make()` factory so source +/// arming completes before the caller gets a watcher back. +/// +/// ```swift +/// let watcher = JSONConfigFileWatcher(fileURL: locations.userConfigFile) +/// for await _ in watcher.events { +/// await store.reloadFromDisk() +/// } +/// ``` +public actor JSONConfigFileWatcher { + /// Stream of change events. Yields one element per file-system event + /// affecting the watched file or its parent directory. + public nonisolated let events: AsyncStream + + private let fileURL: URL + // DispatchSource.makeFileSystemObjectSource requires a DispatchQueue. + // No async-native Foundation API exists for kqueue file events; this + // queue is internal isolation only and never exposed. + private let queue: DispatchQueue + private let continuation: AsyncStream.Continuation + // File-descriptor lifetime is owned exclusively by each source's + // `setCancelHandler`, which calls `close(fd)` exactly once when the + // source's cancel completes. We never close fds manually. + private var fileSource: DispatchSourceFileSystemObject? + private var directorySource: DispatchSourceFileSystemObject? + + /// Creates and starts a watcher for ``fileURL``. + /// + /// - Parameter fileURL: The file to watch. The parent directory is + /// also observed so the watcher recovers when the file is created + /// or replaced after the watcher starts. + public init(fileURL: URL) { + self.fileURL = fileURL + self.queue = DispatchQueue(label: "com.cmux.json-config-file-watcher") + let (stream, continuation) = AsyncStream.makeStream() + self.events = stream + self.continuation = continuation + Task { await self.start() } + } + + deinit { + // DispatchSource.cancel() is thread-safe; safe to call from deinit. + fileSource?.cancel() + directorySource?.cancel() + continuation.finish() + } + + /// Stops the watcher and finishes its ``events`` stream. + /// + /// Subsequent dispatch events are ignored. Idempotent. + public func stop() { + fileSource?.cancel() + fileSource = nil + directorySource?.cancel() + directorySource = nil + continuation.finish() + } + + // MARK: - Private + + private func start() { + attachDirectorySource() + tryAttachFileSource() + } + + /// Reacts to a parent-directory change. The file may have just been + /// created or replaced; reattach the file-level source so we observe + /// the new inode, then yield an event so consumers reread. + private func handleDirectoryEvent() { + tryAttachFileSource() + continuation.yield(()) + } + + /// Reacts to a file-level change. Yields; consumers reread. + private func handleFileEvent() { + continuation.yield(()) + } + + private func attachDirectorySource() { + let parentFD = open(fileURL.deletingLastPathComponent().path, O_EVTONLY) + guard parentFD >= 0 else { return } + let source = DispatchSource.makeFileSystemObjectSource( + fileDescriptor: parentFD, + eventMask: [.write, .rename, .delete], + queue: queue + ) + source.setEventHandler { [weak self] in + guard let self else { return } + Task { await self.handleDirectoryEvent() } + } + source.setCancelHandler { close(parentFD) } + source.resume() + directorySource = source + } + + /// Re-attaches the file-level source after a directory event. + /// + /// When we replace ``fileSource``, the previous source's + /// `setCancelHandler` is responsible for closing its own captured fd. + /// We never close fds manually here. + private func tryAttachFileSource() { + let fd = open(fileURL.path, O_EVTONLY) + guard fd >= 0 else { return } + fileSource?.cancel() + let newSource = DispatchSource.makeFileSystemObjectSource( + fileDescriptor: fd, + eventMask: [.write, .delete, .rename, .attrib], + queue: queue + ) + newSource.setEventHandler { [weak self] in + guard let self else { return } + Task { await self.handleFileEvent() } + } + newSource.setCancelHandler { close(fd) } + newSource.resume() + fileSource = newSource + } +} diff --git a/Packages/CmuxSettings/Sources/CmuxSettings/Stores/JSONConfigStore.swift b/Packages/CmuxSettings/Sources/CmuxSettings/Stores/JSONConfigStore.swift new file mode 100644 index 000000000000..61cf79133b51 --- /dev/null +++ b/Packages/CmuxSettings/Sources/CmuxSettings/Stores/JSONConfigStore.swift @@ -0,0 +1,240 @@ +import Foundation + +/// Typed read/write/observe access to settings persisted in the cmux JSON config file. +/// +/// The store is an `actor`. All reads, writes, and reset are `async`. There +/// are no locks; cross-thread access is serialized through actor isolation. +/// The store only accepts ``JSONKey``; a ``DefaultsKey`` is rejected at +/// compile time. There are no runtime store/key-mismatch traps. +/// +/// JSONC (`// line` and `/* block */` comments, trailing commas) is tolerated +/// on read via the injected ``JSONCSanitizer``. Writes round-trip through +/// `JSONSerialization` with sorted, pretty-printed output; comment-preserving +/// edits are a follow-up. +/// +/// Observation uses a single ``JSONConfigFileWatcher`` owned by the store +/// and fans out file-change events to per-subscriber `AsyncStream` +/// signals. One file event causes exactly one cache invalidation and one +/// notification per active subscriber, regardless of how many keys are being +/// observed. Each subscriber dedups on its own typed value so only real +/// changes propagate. +/// +/// ```swift +/// let catalog = SettingCatalog() +/// let store = JSONConfigStore(fileURL: CmuxConfigLocation().userConfigFile) +/// try await store.set("hunter2", for: catalog.automationSocketPassword) +/// for await password in store.values(for: catalog.automationSocketPassword) { +/// credentialsCache.apply(password) +/// } +/// ``` +public actor JSONConfigStore { + /// The on-disk location this store reads and writes. + public nonisolated let fileURL: URL + + private let sanitizer: JSONCSanitizer + private let watcher: JSONConfigFileWatcher + + private var cachedRoot: [String: Any] = [:] + private var cacheValid = false + private var subscribers: [UUID: AsyncStream.Continuation] = [:] + private var watcherTask: Task? + + /// Creates a store backed by a JSON file at the given location. + /// + /// The file may be missing; reads return the key's default value and + /// writes create the file (and any missing parent directories). + /// + /// - Parameters: + /// - fileURL: The on-disk location. Use + /// ``CmuxConfigLocation/userConfigFile`` for the standard cmux path. + /// - sanitizer: JSONC sanitizer applied to file contents on read. + /// Inject a custom one in tests; the default is enough for normal use. + public init(fileURL: URL, sanitizer: JSONCSanitizer = JSONCSanitizer()) { + self.fileURL = fileURL + self.sanitizer = sanitizer + self.watcher = JSONConfigFileWatcher(fileURL: fileURL) + } + + deinit { + watcherTask?.cancel() + } + + /// Returns the current value for the key. + public func value(for key: JSONKey) -> Value { + let root = loadedRoot() + let raw = key.path.lookup(in: root) + return Value.decodeFromJSON(raw) ?? key.defaultValue + } + + /// Writes a value for the key. + /// + /// Creates the parent directory and the file if missing. + /// + /// - Throws: Errors from `FileManager` or `JSONSerialization` writing the file. + public func set(_ value: Value, for key: JSONKey) throws { + try mutateRoot { root in + key.path.assign(value.encodeForJSON(), in: &root) + } + } + + /// Removes the key's entry from the file. Parent objects that become + /// empty are pruned. The file itself is not deleted even when no entries + /// remain. + /// + /// - Throws: Errors from `FileManager` or `JSONSerialization` writing the file. + public func reset(_ key: JSONKey) throws { + try mutateRoot { root in + key.path.remove(in: &root) + } + } + + /// Returns an `AsyncStream` that yields the current value and every later change. + /// + /// - First element is yielded as soon as the consumer starts iterating. + /// - Subsequent elements are yielded only when the typed value at this + /// key's path differs from the previously yielded value. + /// - Cancelling the consuming `Task` deregisters this subscriber. The + /// internal signal task breaks on the next suspension, calls + /// ``removeSubscriber(id:)``, and finishes the stream. Safe to cancel + /// at any time, including before the first value is yielded. + /// - The internal change-signal stream uses `.bufferingNewest(1)`; bursts + /// of file events coalesce, since we only care that *something* + /// changed and re-read the typed value on each consumed signal. + public nonisolated func values(for key: JSONKey) -> AsyncStream { + AsyncStream { continuation in + let task = Task { [weak self] in + guard let self else { + continuation.finish() + return + } + + let initial = await self.value(for: key) + continuation.yield(initial) + + let id = UUID() + // bufferingNewest(1): the signal carries no payload, so under + // burst file changes we only care that *something* changed. + // Dropping intermediate signals is correct because the typed + // value is re-read on every consumed signal and deduped below. + // Bounded buffering prevents unbounded growth under load. + let (signal, signalContinuation) = AsyncStream.makeStream( + bufferingPolicy: .bufferingNewest(1) + ) + await self.addSubscriber(id: id, continuation: signalContinuation) + + var last = initial + for await _ in signal { + if Task.isCancelled { break } + let current = await self.value(for: key) + if current != last { + last = current + continuation.yield(current) + } + } + await self.removeSubscriber(id: id) + continuation.finish() + } + continuation.onTermination = { _ in task.cancel() } + } + } + + // MARK: - Private + + private func addSubscriber(id: UUID, continuation: AsyncStream.Continuation) { + subscribers[id] = continuation + ensureWatcherTask() + } + + private func removeSubscriber(id: UUID) { + if let cont = subscribers.removeValue(forKey: id) { + cont.finish() + } + } + + /// Spawns the watcher-consumer task on the first subscribe. The task + /// drains ``JSONConfigFileWatcher/events`` and fans out to every + /// registered subscriber after invalidating the cache. + private func ensureWatcherTask() { + guard watcherTask == nil else { return } + let watcher = self.watcher + watcherTask = Task { [weak self] in + for await _ in watcher.events { + if Task.isCancelled { break } + guard let self else { break } + await self.handleFileChange() + } + } + } + + private func handleFileChange() { + cacheValid = false + for continuation in subscribers.values { + continuation.yield(()) + } + } + + private func loadedRoot() -> [String: Any] { + if cacheValid { return cachedRoot } + cachedRoot = (try? readFromDisk()) ?? [:] + cacheValid = true + return cachedRoot + } + + private func readFromDisk() throws -> [String: Any] { + let data: Data + do { + data = try Data(contentsOf: fileURL) + } catch let error as NSError where error.domain == NSCocoaErrorDomain + && error.code == NSFileReadNoSuchFileError { + return [:] + } + if data.isEmpty { return [:] } + let sanitized = try sanitizer.sanitize(data) + let object = try JSONSerialization.jsonObject(with: sanitized, options: []) + guard let dictionary = object as? [String: Any] else { + throw JSONConfigStoreReadError.notADictionary + } + return dictionary + } + + /// Computes the mutation, writes it to disk, and **only then** commits to + /// the in-memory cache. + /// + /// If the write fails (e.g. permission denied, full disk), the cache is + /// left untouched so subsequent reads still reflect what is actually on + /// disk. Without this ordering, a failed write would silently leave the + /// cache ahead of the file and reads would return phantom unsaved data. + /// + /// If the existing file on disk exists but cannot be parsed (corrupt + /// JSON, malformed JSONC, top-level non-object), the write is refused + /// — overwriting a corrupt file would silently destroy whatever real + /// content the user has in it. The error from + /// ``readFromDisk()`` is propagated to the caller. + private func mutateRoot(_ mutate: (inout [String: Any]) -> Void) throws { + var root = cacheValid ? cachedRoot : try readFromDisk() + mutate(&root) + + let parent = fileURL.deletingLastPathComponent() + try FileManager.default.createDirectory(at: parent, withIntermediateDirectories: true) + let data = try JSONSerialization.data( + withJSONObject: root, + options: [.prettyPrinted, .sortedKeys, .withoutEscapingSlashes] + ) + try data.write(to: fileURL, options: [.atomic]) + + // Only commit to cache after the file write succeeded. + cachedRoot = root + cacheValid = true + + // Notify subscribers of our own write directly rather than + // relying on the file watcher to observe it. Atomic writes + // replace the file via rename, which a vnode DispatchSource can + // miss, so self-writes must be signalled here to guarantee the + // `values(for:)` streams (and the view-models bound to them) + // reflect a change made through this store. The cache is already + // up to date, so subscribers re-read the new value immediately. + for continuation in subscribers.values { + continuation.yield(()) + } + } +} diff --git a/Packages/CmuxSettings/Sources/CmuxSettings/Stores/JSONConfigStoreReadError.swift b/Packages/CmuxSettings/Sources/CmuxSettings/Stores/JSONConfigStoreReadError.swift new file mode 100644 index 000000000000..69998e9d08f9 --- /dev/null +++ b/Packages/CmuxSettings/Sources/CmuxSettings/Stores/JSONConfigStoreReadError.swift @@ -0,0 +1,17 @@ +import Foundation + +/// Errors that ``JSONConfigStore`` raises when the on-disk file exists but +/// cannot be parsed. +/// +/// File-not-found is **not** an error — it is the legitimate empty-state +/// signal that the user has not yet written any settings. Reads in that +/// state return key defaults. Everything else (malformed JSON / JSONC, +/// top-level value that is not an object, sanitizer failure) propagates +/// to the caller, who decides whether to fall back to defaults (reads) +/// or refuse to mutate (writes). +public enum JSONConfigStoreReadError: Error, Equatable, Sendable { + /// The on-disk file decoded to JSON, but the top-level value is not + /// an object (`{ ... }`). cmux's config schema requires a top-level + /// object; arrays, strings, numbers, etc. are unrecoverable. + case notADictionary +} diff --git a/Packages/CmuxSettings/Sources/CmuxSettings/Stores/JSONPath.swift b/Packages/CmuxSettings/Sources/CmuxSettings/Stores/JSONPath.swift new file mode 100644 index 000000000000..f766decb7e93 --- /dev/null +++ b/Packages/CmuxSettings/Sources/CmuxSettings/Stores/JSONPath.swift @@ -0,0 +1,143 @@ +import Foundation + +/// A precomputed dotted-key path into a JSON object tree. +/// +/// The cmux JSON config file is a tree of nested objects +/// (`{"app": {"appearance": "dark"}}`). `JSONPath` represents one leaf +/// address (`"app.appearance"`) as a list of components, split once at +/// construction so hot read/write paths do no string-splitting per call. +/// +/// Operations preserve sibling values at every level. ``remove(in:)`` also +/// prunes parent objects that become empty after the removal. +public struct JSONPath: Sendable, Hashable { + /// The path segments, in order. For `"app.appearance"` this is + /// `["app", "appearance"]`. Constructed paths are always non-empty. + public let components: [String] + + /// Creates a `JSONPath` from a dotted string. + /// + /// - Parameter dottedPath: A non-empty dotted identifier, e.g. + /// `"app.appearance"`. The string is split on `"."` once; subsequent + /// operations reuse the precomputed components. Trapping in debug + /// builds on empty input — empty paths are a programmer error. + public init(dottedPath: String) { + precondition(!dottedPath.isEmpty, "JSONPath requires a non-empty dotted path") + // `split(separator:)` with default `omittingEmptySubsequences: true` + // would silently swallow consecutive dots; we use the non-omitting + // variant and then validate to catch malformed paths like + // "app..appearance" or ".leading" or "trailing." as programmer errors. + let segments = dottedPath.split(separator: ".", omittingEmptySubsequences: false).map(String.init) + precondition( + !segments.contains(""), + "JSONPath contains an empty component (leading/trailing dot or consecutive dots): \(dottedPath)" + ) + self.components = segments + } + + /// Returns the value at this path inside ``root``, or `nil` when any + /// segment is missing or has the wrong type. + public func lookup(in root: [String: Any]) -> Any? { + guard !components.isEmpty else { return nil } + var cursor: Any = root + for component in components { + guard let dictionary = cursor as? [String: Any], let next = dictionary[component] else { + return nil + } + cursor = next + } + return cursor + } + + /// Assigns ``value`` at this path inside ``root``, creating intermediate + /// objects as needed. Sibling values are preserved at every level. + public func assign(_ value: Any, in root: inout [String: Any]) { + guard !components.isEmpty else { return } + assignAtPath(components[...], value, in: &root) + } + + /// Removes the leaf at this path from ``root``. Parent objects that + /// become empty as a result are also removed. + public func remove(in root: inout [String: Any]) { + guard !components.isEmpty else { return } + removeAtPath(components[...], in: &root) + } +} + +// MARK: - Recursive helpers +// +// File-scope private. These are pure helpers — they have no relationship to +// any `JSONPath` instance and never read its stored ``JSONPath/components`` +// (which would be the full original path, not the sliced view that the +// recursion is processing at the current depth). File-scope private says +// "internal to this file, no type-level meaning" without the +// connotation of "type-level operation" that `private static` would carry. +// +// They take `ArraySlice` rather than `[String]` so descending one +// level is `dropFirst()` (a non-allocating view) instead of a fresh +// `Array(components.dropFirst())` copy. The cost of writing or removing a +// path of depth `n` is `O(n)`, not `O(n²)`. + +/// Writes ``value`` at the leaf identified by ``components`` inside +/// ``dictionary``, creating intermediate object levels along the way. +/// +/// Sibling values at each level are preserved. If a path segment exists but +/// maps to a non-dictionary value (e.g. the JSON had a primitive where we +/// expected an object), that segment is overwritten with a new empty object +/// before the descent continues — write wins. +/// +/// - Parameters: +/// - components: The remaining path segments at this recursion depth. +/// Empty means there is nothing to assign; recursion terminates +/// immediately. Length 1 means the current ``dictionary`` is the leaf +/// parent and we write into it directly. +/// - value: The `JSONSerialization`-compatible value to assign at the +/// leaf. +/// - dictionary: The dictionary at the current depth, mutated in place. +private func assignAtPath( + _ components: ArraySlice, + _ value: Any, + in dictionary: inout [String: Any] +) { + guard let head = components.first else { return } + if components.count == 1 { + dictionary[head] = value + return + } + var child = dictionary[head] as? [String: Any] ?? [:] + assignAtPath(components.dropFirst(), value, in: &child) + dictionary[head] = child +} + +/// Removes the leaf identified by ``components`` from ``dictionary`` and +/// prunes parent objects that become empty as a result. +/// +/// If a path segment is missing, or maps to a non-dictionary value before +/// reaching the leaf, the call is a no-op — there is nothing to remove. +/// After unwinding the recursion, each level checks whether its child +/// dictionary became empty; if so, the parent removes that child's entry +/// too. The empty-prune is what lets writes-then-reset round-trip to a +/// clean file with no orphan empty sections. +/// +/// - Parameters: +/// - components: The remaining path segments at this recursion depth. +/// Empty means there is nothing to remove. Length 1 means the current +/// ``dictionary`` is the leaf parent and the leaf key is removed +/// directly. +/// - dictionary: The dictionary at the current depth, mutated in place. +private func removeAtPath( + _ components: ArraySlice, + in dictionary: inout [String: Any] +) { + guard let head = components.first else { return } + if components.count == 1 { + dictionary.removeValue(forKey: head) + return + } + guard var child = dictionary[head] as? [String: Any] else { return } + removeAtPath(components.dropFirst(), in: &child) + if child.isEmpty { + dictionary.removeValue(forKey: head) + } else { + dictionary[head] = child + } +} diff --git a/Packages/CmuxSettings/Sources/CmuxSettings/Stores/UserDefaultsSettingsStore.swift b/Packages/CmuxSettings/Sources/CmuxSettings/Stores/UserDefaultsSettingsStore.swift new file mode 100644 index 000000000000..6cd74a8e64dc --- /dev/null +++ b/Packages/CmuxSettings/Sources/CmuxSettings/Stores/UserDefaultsSettingsStore.swift @@ -0,0 +1,131 @@ +import Foundation + +/// Typed read/write/observe access to settings persisted in `UserDefaults`. +/// +/// The store is an `actor`. Reads, writes, and reset are all `async`. There +/// are no locks; cross-thread access is serialized through actor isolation. +/// +/// The store only accepts ``DefaultsKey``; a ``JSONKey`` would be rejected at +/// compile time. There are no runtime store/key-mismatch traps. +/// +/// Observation uses `NotificationCenter.default.notifications(named:)` as the +/// modern AsyncSequence-based KVO replacement. Each ``values(for:)`` consumer +/// owns a `Task` that watches the global notification stream and yields when +/// the typed value at this key changes. +/// +/// ```swift +/// let catalog = SettingCatalog() +/// let store = UserDefaultsSettingsStore( +/// defaults: .standard, +/// migrating: catalog.all +/// ) +/// await store.set(.dark, for: catalog.appAppearance) +/// for await mode in store.values(for: catalog.appAppearance) { +/// applyAppearance(mode) +/// } +/// ``` +public actor UserDefaultsSettingsStore { + /// The `UserDefaults` suite this store reads and writes. + public let underlyingDefaults: UserDefaults + + /// Creates a store backed by the given `UserDefaults` instance. + /// + /// Keys passed in ``migrating`` run their legacy-key migration + /// synchronously inside init, before the actor is reachable. After init + /// returns, migration is complete; no per-read migration state. + /// + /// - Parameters: + /// - defaults: The defaults suite. Pass a custom suite to isolate + /// reads/writes during tests. + /// - migrating: Catalog entries whose legacy keys should be migrated. + /// Pass ``SettingCatalog/all`` from the app, or an empty array when no + /// migration is needed. + public init(defaults: UserDefaults, migrating: [AnySettingKey] = []) { + self.underlyingDefaults = defaults + // Each entry's migration closure was captured with its concrete + // Value type, so it skips legacy keys whose stored value does not + // decode as the new key's type. See AnySettingKey for details. + for key in migrating { + key.migrateUserDefaultsLegacyKeys(defaults) + } + } + + /// Returns the current value for the key. + public func value(for key: DefaultsKey) -> Value { + let raw = underlyingDefaults.object(forKey: key.userDefaultsKey) + return Value.decodeFromUserDefaults(raw) ?? key.defaultValue + } + + /// Writes a value for the key. + public func set(_ value: Value, for key: DefaultsKey) { + underlyingDefaults.set(value.encodeForUserDefaults(), forKey: key.userDefaultsKey) + } + + /// Removes the stored override for the key. After this call ``value(for:)`` + /// returns the key's default value until something writes a new override. + public func reset(_ key: DefaultsKey) { + underlyingDefaults.removeObject(forKey: key.userDefaultsKey) + } + + /// Removes the stored overrides for every UserDefaults-backed entry in + /// ``keys``. Entries whose ``AnySettingKey/kind`` is + /// ``AnySettingKey/Kind/jsonConfig`` are ignored; reset them via the + /// ``JSONConfigStore``. + /// + /// The whole operation runs inside the actor's isolation domain so + /// the caller doesn't have to send the non-`Sendable` `UserDefaults` + /// instance across boundaries. + public func resetAll(_ keys: [AnySettingKey]) { + for entry in keys { + guard case let .userDefaults(storageKey, suite, _) = entry.kind else { continue } + let defaults: UserDefaults + if let suite, let custom = UserDefaults(suiteName: suite) { + defaults = custom + } else { + defaults = underlyingDefaults + } + defaults.removeObject(forKey: storageKey) + } + } + + /// Returns an `AsyncStream` that yields the current value and every later change. + /// + /// - The first element is yielded as soon as the consumer starts iterating. + /// - Subsequent elements are yielded when `UserDefaults.didChangeNotification` + /// fires and the typed value at this key differs from the previously + /// yielded value. + /// - Cancelling the consuming `Task` cancels the underlying notification + /// loop and ends the stream. + /// - Buffering is `.bufferingNewest(1)`: a burst of writes (e.g. a + /// `ColorPicker` drag spraying a value per frame) coalesces to the + /// most recent value rather than replaying every intermediate + /// through the consumer after the consumer catches up. Only the + /// latest value matters; the stale ones are dropped. + public nonisolated func values(for key: DefaultsKey) -> AsyncStream { + AsyncStream(bufferingPolicy: .bufferingNewest(1)) { continuation in + let task = Task { [weak self] in + guard let self else { + continuation.finish() + return + } + var lastYielded = await self.value(for: key) + continuation.yield(lastYielded) + + let notifications = NotificationCenter.default.notifications( + named: UserDefaults.didChangeNotification + ) + for await _ in notifications { + if Task.isCancelled { break } + let current = await self.value(for: key) + if current != lastYielded { + lastYielded = current + continuation.yield(current) + } + } + continuation.finish() + } + continuation.onTermination = { _ in task.cancel() } + } + } + +} diff --git a/Packages/CmuxSettings/Sources/CmuxSettings/Values/AppIconMode.swift b/Packages/CmuxSettings/Sources/CmuxSettings/Values/AppIconMode.swift new file mode 100644 index 000000000000..a7a8199863e6 --- /dev/null +++ b/Packages/CmuxSettings/Sources/CmuxSettings/Values/AppIconMode.swift @@ -0,0 +1,6 @@ +import Foundation + +/// Dock icon variant. `automatic` follows the system appearance. +public enum AppIconMode: String, CaseIterable, Sendable, SettingCodable { + case automatic, light, dark +} diff --git a/Packages/CmuxSettings/Sources/CmuxSettings/Values/AppLanguage.swift b/Packages/CmuxSettings/Sources/CmuxSettings/Values/AppLanguage.swift new file mode 100644 index 000000000000..808ca5593572 --- /dev/null +++ b/Packages/CmuxSettings/Sources/CmuxSettings/Values/AppLanguage.swift @@ -0,0 +1,7 @@ +import Foundation + +/// User-selected language for the cmux UI. Raw values match the +/// `AppleLanguages` BCP-47 identifiers cmux uses on disk. +public enum AppLanguage: String, CaseIterable, Sendable, SettingCodable { + case system, en, ar, bs, zhHans = "zh-Hans", zhHant = "zh-Hant", da, de, es, fr, it, ja, ko, nb, pl, ptBR = "pt-BR", ru, th, tr, vi +} diff --git a/Packages/CmuxSettings/Sources/CmuxSettings/Values/AppearanceMode.swift b/Packages/CmuxSettings/Sources/CmuxSettings/Values/AppearanceMode.swift new file mode 100644 index 000000000000..74baf6fbbf5c --- /dev/null +++ b/Packages/CmuxSettings/Sources/CmuxSettings/Values/AppearanceMode.swift @@ -0,0 +1,14 @@ +import Foundation + +/// User-selectable appearance for the cmux app. +/// +/// Drives the SwiftUI color scheme and the Ghostty terminal theme synchronization. +/// Stored under the catalog entry ``SettingCatalog/appAppearance``. +public enum AppearanceMode: String, CaseIterable, Sendable, SettingCodable { + /// Follow the macOS system appearance. + case system + /// Always use the light variant. + case light + /// Always use the dark variant. + case dark +} diff --git a/Packages/CmuxSettings/Sources/CmuxSettings/Values/BrowserSearchEngine.swift b/Packages/CmuxSettings/Sources/CmuxSettings/Values/BrowserSearchEngine.swift new file mode 100644 index 000000000000..49966fc6c4b4 --- /dev/null +++ b/Packages/CmuxSettings/Sources/CmuxSettings/Values/BrowserSearchEngine.swift @@ -0,0 +1,7 @@ +import Foundation + +/// Default search engine the cmux browser uses for address-bar queries. +public enum BrowserSearchEngine: String, CaseIterable, Sendable, SettingCodable { + case google, duckduckgo, bing, kagi, startpage, brave, perplexity, exa, + yahoo, ecosia, qwant, mojeek, wikipedia, github, baidu, yandex, custom +} diff --git a/Packages/CmuxSettings/Sources/CmuxSettings/Values/BrowserThemeMode.swift b/Packages/CmuxSettings/Sources/CmuxSettings/Values/BrowserThemeMode.swift new file mode 100644 index 000000000000..fca16631bee7 --- /dev/null +++ b/Packages/CmuxSettings/Sources/CmuxSettings/Values/BrowserThemeMode.swift @@ -0,0 +1,6 @@ +import Foundation + +/// User-selected web-content appearance for the cmux browser. +public enum BrowserThemeMode: String, CaseIterable, Sendable, SettingCodable { + case system, light, dark +} diff --git a/Packages/CmuxSettings/Sources/CmuxSettings/Values/ConfirmQuitMode.swift b/Packages/CmuxSettings/Sources/CmuxSettings/Values/ConfirmQuitMode.swift new file mode 100644 index 000000000000..14ea932e9019 --- /dev/null +++ b/Packages/CmuxSettings/Sources/CmuxSettings/Values/ConfirmQuitMode.swift @@ -0,0 +1,8 @@ +import Foundation + +/// When to show the quit-confirmation dialog. +public enum ConfirmQuitMode: String, CaseIterable, Sendable, SettingCodable { + case always + case dirtyOnly = "dirty-only" + case never +} diff --git a/Packages/CmuxSettings/Sources/CmuxSettings/Values/FileDropDefaultBehavior.swift b/Packages/CmuxSettings/Sources/CmuxSettings/Values/FileDropDefaultBehavior.swift new file mode 100644 index 000000000000..5a1c57c8fe55 --- /dev/null +++ b/Packages/CmuxSettings/Sources/CmuxSettings/Values/FileDropDefaultBehavior.swift @@ -0,0 +1,13 @@ +import Foundation + +/// What cmux does when a file is dragged onto a terminal pane. +/// +/// Cases match the legacy app exactly so the existing on-disk +/// preference round-trips. The default is `text` (insert the file +/// path as terminal text). +public enum FileDropDefaultBehavior: String, CaseIterable, Sendable, SettingCodable { + /// Insert the file path as terminal text. Shift inverts on drop. + case text + /// Split-and-open in cmux's preview viewer. + case preview +} diff --git a/Packages/CmuxSettings/Sources/CmuxSettings/Values/PIIDisplayMode.swift b/Packages/CmuxSettings/Sources/CmuxSettings/Values/PIIDisplayMode.swift new file mode 100644 index 000000000000..ea5d84ee8148 --- /dev/null +++ b/Packages/CmuxSettings/Sources/CmuxSettings/Values/PIIDisplayMode.swift @@ -0,0 +1,14 @@ +import Foundation + +/// Whether the settings UI redacts personally-identifying account +/// metadata. +/// +/// Used by ``AccountCatalogSection`` to decide whether the user's +/// email, display name, and team affiliation render as-is or as +/// black-bar redactions in the Account section. Users who screenshot +/// settings for support / docs / videos toggle this to ``hidden`` +/// before grabbing the frame. +public enum PIIDisplayMode: String, CaseIterable, Sendable, SettingCodable { + case visible + case hidden +} diff --git a/Packages/CmuxSettings/Sources/CmuxSettings/Values/ShortcutAction+Defaults.swift b/Packages/CmuxSettings/Sources/CmuxSettings/Values/ShortcutAction+Defaults.swift new file mode 100644 index 000000000000..5f3ff0beb91c --- /dev/null +++ b/Packages/CmuxSettings/Sources/CmuxSettings/Values/ShortcutAction+Defaults.swift @@ -0,0 +1,92 @@ +import Foundation + +extension ShortcutAction { + /// The factory-default ``ShortcutStroke`` for this action. + /// + /// Mirrors the table in + /// `Sources/KeyboardShortcutSettings.swift` so the package's + /// settings UI can show "(default: ⌘N)" instead of "(default)" + /// next to unbound rows, and so the Reset action in the Settings + /// UI can restore a row by writing the default stroke through + /// the JSON store. + public var defaultStroke: ShortcutStroke? { + switch self { + case .openSettings: return ShortcutStroke(key: ",", command: true) + case .reloadConfiguration: return ShortcutStroke(key: ",", command: true, shift: true) + case .showHideAllWindows: return ShortcutStroke(key: ".", command: true, option: true, control: true) + case .globalSearch: return ShortcutStroke(key: "f", command: true, option: true) + case .newWindow: return ShortcutStroke(key: "n", command: true, shift: true) + case .closeWindow: return ShortcutStroke(key: "w", command: true, control: true) + case .toggleFullScreen: return ShortcutStroke(key: "f", command: true, control: true) + case .quit: return ShortcutStroke(key: "q", command: true) + case .toggleSidebar: return ShortcutStroke(key: "b", command: true) + case .newTab: return ShortcutStroke(key: "n", command: true) + case .openFolder: return ShortcutStroke(key: "o", command: true) + case .reopenPreviousSession: return ShortcutStroke(key: "o", command: true, shift: true) + case .goToWorkspace: return ShortcutStroke(key: "p", command: true) + case .commandPalette: return ShortcutStroke(key: "p", command: true, shift: true) + case .commandPaletteNext: return ShortcutStroke(key: "n", control: true) + case .commandPalettePrevious: return ShortcutStroke(key: "p", control: true) + case .sendFeedback: return nil + case .showNotifications: return ShortcutStroke(key: "i", command: true) + case .jumpToUnread: return ShortcutStroke(key: "u", command: true, shift: true) + case .toggleUnread: return ShortcutStroke(key: "u", command: true, option: true) + case .markOldestUnreadAndJumpNext: return ShortcutStroke(key: "u", command: true, control: true) + case .focusRightSidebar: return ShortcutStroke(key: "e", command: true, shift: true) + case .switchRightSidebarToFiles: return ShortcutStroke(key: "1", control: true) + case .switchRightSidebarToFind: return ShortcutStroke(key: "2", control: true) + case .switchRightSidebarToSessions: return ShortcutStroke(key: "3", control: true) + case .switchRightSidebarToFeed: return ShortcutStroke(key: "4", control: true) + case .switchRightSidebarToDock: return ShortcutStroke(key: "5", control: true) + case .triggerFlash: return ShortcutStroke(key: "h", command: true, shift: true) + case .nextSidebarTab: return ShortcutStroke(key: "]", command: true, control: true) + case .prevSidebarTab: return ShortcutStroke(key: "[", command: true, control: true) + case .focusHistoryBack: return ShortcutStroke(key: "[", command: true) + case .focusHistoryForward: return ShortcutStroke(key: "]", command: true) + case .renameTab: return ShortcutStroke(key: "r", command: true) + case .renameWorkspace: return ShortcutStroke(key: "r", command: true, shift: true) + case .editWorkspaceDescription: return ShortcutStroke(key: "e", command: true, option: true) + case .closeTab: return ShortcutStroke(key: "w", command: true) + case .closeOtherTabsInPane: return ShortcutStroke(key: "t", command: true, option: true) + case .closeWorkspace: return ShortcutStroke(key: "w", command: true, shift: true) + case .reopenClosedBrowserPanel: return ShortcutStroke(key: "t", command: true, shift: true) + case .focusLeft: return ShortcutStroke(key: "←", command: true, option: true) + case .focusRight: return ShortcutStroke(key: "→", command: true, option: true) + case .focusUp: return ShortcutStroke(key: "↑", command: true, option: true) + case .focusDown: return ShortcutStroke(key: "↓", command: true, option: true) + case .splitRight: return ShortcutStroke(key: "d", command: true) + case .splitDown: return ShortcutStroke(key: "d", command: true, shift: true) + case .toggleSplitZoom: return ShortcutStroke(key: "\r", command: true, shift: true) + case .equalizeSplits: return ShortcutStroke(key: "=", command: true, control: true) + case .splitBrowserRight: return ShortcutStroke(key: "d", command: true, option: true) + case .splitBrowserDown: return ShortcutStroke(key: "d", command: true, shift: true, option: true) + case .nextSurface: return ShortcutStroke(key: "]", command: true, shift: true) + case .prevSurface: return ShortcutStroke(key: "[", command: true, shift: true) + case .selectSurfaceByNumber: return ShortcutStroke(key: "1", control: true) + case .selectWorkspaceByNumber: return ShortcutStroke(key: "1", command: true) + case .newSurface: return ShortcutStroke(key: "t", command: true) + case .toggleTerminalCopyMode: return ShortcutStroke(key: "m", command: true, shift: true) + case .focusTextBoxInput: return ShortcutStroke(key: "a", command: true, shift: true) + case .attachTextBoxFile: return ShortcutStroke(key: "a", command: true, shift: true, option: true) + case .toggleRightSidebar: return ShortcutStroke(key: "b", command: true, option: true) + case .saveFilePreview: return ShortcutStroke(key: "s", command: true) + case .openBrowser: return ShortcutStroke(key: "l", command: true, shift: true) + case .focusBrowserAddressBar: return ShortcutStroke(key: "l", command: true) + case .browserBack: return ShortcutStroke(key: "[", command: true) + case .browserForward: return ShortcutStroke(key: "]", command: true) + case .browserReload: return ShortcutStroke(key: "r", command: true) + case .browserZoomIn: return ShortcutStroke(key: "=", command: true) + case .browserZoomOut: return ShortcutStroke(key: "-", command: true) + case .browserZoomReset: return ShortcutStroke(key: "0", command: true) + case .find: return ShortcutStroke(key: "f", command: true) + case .findInDirectory: return ShortcutStroke(key: "f", command: true, shift: true) + case .findNext: return ShortcutStroke(key: "g", command: true) + case .findPrevious: return ShortcutStroke(key: "g", command: true, option: true) + case .hideFind: return ShortcutStroke(key: "f", command: true, shift: true, option: true) + case .useSelectionForFind: return ShortcutStroke(key: "e", command: true) + case .toggleBrowserDeveloperTools: return ShortcutStroke(key: "i", command: true, option: true) + case .showBrowserJavaScriptConsole: return ShortcutStroke(key: "c", command: true, option: true) + case .toggleReactGrab: return ShortcutStroke(key: "g", command: true, shift: true) + } + } +} diff --git a/Packages/CmuxSettings/Sources/CmuxSettings/Values/ShortcutAction.swift b/Packages/CmuxSettings/Sources/CmuxSettings/Values/ShortcutAction.swift new file mode 100644 index 000000000000..c504aa3e2909 --- /dev/null +++ b/Packages/CmuxSettings/Sources/CmuxSettings/Values/ShortcutAction.swift @@ -0,0 +1,235 @@ +import Foundation + +/// The stable, user-customisable shortcut actions cmux exposes. +/// +/// Each case is a one-line identifier that maps to one user-facing +/// behavior. The set is intentionally flat (rather than nested by +/// category) so the JSON config representation stays readable +/// (`"shortcuts.bindings": { "openSettings": "cmd+,", ... }`). +/// +/// Display names + group categorization are metadata derived from the +/// enum case in extensions below; the raw value is the stable +/// identifier persisted in cmux.json. +public enum ShortcutAction: String, CaseIterable, Sendable, Hashable, SettingCodable { + // MARK: App + case openSettings + case reloadConfiguration + case showHideAllWindows + case globalSearch + case newWindow + case closeWindow + case toggleFullScreen + case quit + + // MARK: Workspace + case toggleSidebar + case newTab + case openFolder + case reopenPreviousSession + case goToWorkspace + case commandPalette + case commandPaletteNext + case commandPalettePrevious + case sendFeedback + case showNotifications + case jumpToUnread + case toggleUnread + case markOldestUnreadAndJumpNext + case focusRightSidebar + case switchRightSidebarToFiles + case switchRightSidebarToFind + case switchRightSidebarToSessions + case switchRightSidebarToFeed + case switchRightSidebarToDock + case triggerFlash + + // MARK: Navigation + case nextSurface + case prevSurface + case selectSurfaceByNumber + case nextSidebarTab + case prevSidebarTab + case focusHistoryBack + case focusHistoryForward + case selectWorkspaceByNumber + case renameTab + case renameWorkspace + case editWorkspaceDescription + case closeTab + case closeOtherTabsInPane + case closeWorkspace + case reopenClosedBrowserPanel + case newSurface + case toggleTerminalCopyMode + case focusTextBoxInput + case attachTextBoxFile + + // MARK: Panes + case focusLeft + case focusRight + case focusUp + case focusDown + case splitRight + case splitDown + case toggleSplitZoom + case equalizeSplits + case splitBrowserRight + case splitBrowserDown + case toggleRightSidebar = "toggleFileExplorer" + + // MARK: Browser & Find + case saveFilePreview + case openBrowser + case focusBrowserAddressBar + case browserBack + case browserForward + case browserReload + case browserZoomIn + case browserZoomOut + case browserZoomReset + case find + case findInDirectory + case findNext + case findPrevious + case hideFind + case useSelectionForFind + case toggleBrowserDeveloperTools + case showBrowserJavaScriptConsole + case toggleReactGrab +} + +extension ShortcutAction { + /// Logical grouping used for sectioning the shortcuts pane. + public enum Group: String, CaseIterable, Sendable, Hashable { + case app + case workspace + case navigation + case panes + case browser + + public var title: String { + switch self { + case .app: return "App" + case .workspace: return "Workspace" + case .navigation: return "Navigation" + case .panes: return "Panes" + case .browser: return "Browser & Find" + } + } + } + + /// Which group this action belongs to in the settings pane. + public var group: Group { + switch self { + case .openSettings, .reloadConfiguration, .showHideAllWindows, .globalSearch, + .newWindow, .closeWindow, .toggleFullScreen, .quit: + return .app + case .toggleSidebar, .newTab, .openFolder, .reopenPreviousSession, .goToWorkspace, + .commandPalette, .commandPaletteNext, .commandPalettePrevious, .sendFeedback, + .showNotifications, .jumpToUnread, .toggleUnread, .markOldestUnreadAndJumpNext, + .focusRightSidebar, .switchRightSidebarToFiles, .switchRightSidebarToFind, + .switchRightSidebarToSessions, .switchRightSidebarToFeed, + .switchRightSidebarToDock, .triggerFlash: + return .workspace + case .nextSurface, .prevSurface, .selectSurfaceByNumber, .nextSidebarTab, + .prevSidebarTab, .focusHistoryBack, .focusHistoryForward, + .selectWorkspaceByNumber, .renameTab, .renameWorkspace, + .editWorkspaceDescription, .closeTab, .closeOtherTabsInPane, .closeWorkspace, + .reopenClosedBrowserPanel, .newSurface, .toggleTerminalCopyMode, + .focusTextBoxInput, .attachTextBoxFile: + return .navigation + case .focusLeft, .focusRight, .focusUp, .focusDown, .splitRight, .splitDown, + .toggleSplitZoom, .equalizeSplits, .splitBrowserRight, .splitBrowserDown, + .toggleRightSidebar: + return .panes + case .saveFilePreview, .openBrowser, .focusBrowserAddressBar, .browserBack, + .browserForward, .browserReload, .browserZoomIn, .browserZoomOut, + .browserZoomReset, .find, .findInDirectory, .findNext, .findPrevious, + .hideFind, .useSelectionForFind, .toggleBrowserDeveloperTools, + .showBrowserJavaScriptConsole, .toggleReactGrab: + return .browser + } + } + + /// User-facing display name shown in the Settings UI. + public var displayName: String { + switch self { + case .openSettings: return "Settings…" + case .reloadConfiguration: return "Reload Configuration" + case .showHideAllWindows: return "Show/Hide All Windows" + case .globalSearch: return "Global Search" + case .newWindow: return "New Window" + case .closeWindow: return "Close Window" + case .toggleFullScreen: return "Toggle Full Screen" + case .quit: return "Quit cmux" + case .toggleSidebar: return "Toggle Left Sidebar" + case .newTab: return "New Workspace" + case .openFolder: return "Open Folder" + case .reopenPreviousSession: return "Restore Previous App Launch" + case .goToWorkspace: return "Go to Workspace…" + case .commandPalette: return "Command Palette…" + case .commandPaletteNext: return "Command Palette: Next" + case .commandPalettePrevious: return "Command Palette: Previous" + case .sendFeedback: return "Send Feedback" + case .showNotifications: return "Show Notifications" + case .jumpToUnread: return "Jump to Latest Unread" + case .toggleUnread: return "Toggle Unread" + case .markOldestUnreadAndJumpNext: return "Mark as Oldest Unread and Jump to Next Latest Unread" + case .focusRightSidebar: return "Toggle Right Sidebar Focus" + case .switchRightSidebarToFiles: return "Show Sidebar Files" + case .switchRightSidebarToFind: return "Show Sidebar Find" + case .switchRightSidebarToSessions: return "Show Sidebar Vault" + case .switchRightSidebarToFeed: return "Show Sidebar Feed" + case .switchRightSidebarToDock: return "Show Sidebar Dock" + case .triggerFlash: return "Flash Focused Panel" + case .nextSurface: return "Next Surface" + case .prevSurface: return "Previous Surface" + case .selectSurfaceByNumber: return "Select Surface 1…9" + case .nextSidebarTab: return "Next Workspace" + case .prevSidebarTab: return "Previous Workspace" + case .focusHistoryBack: return "Focus Back" + case .focusHistoryForward: return "Focus Forward" + case .selectWorkspaceByNumber: return "Select Workspace 1…9" + case .renameTab: return "Rename Tab" + case .renameWorkspace: return "Rename Workspace" + case .editWorkspaceDescription: return "Edit Workspace Description" + case .closeTab: return "Close Tab" + case .closeOtherTabsInPane: return "Close Other Tabs in Pane" + case .closeWorkspace: return "Close Workspace" + case .reopenClosedBrowserPanel: return "Reopen Last Closed" + case .newSurface: return "New Surface" + case .toggleTerminalCopyMode: return "Toggle Terminal Copy Mode" + case .focusTextBoxInput: return "Focus TextBox Input" + case .attachTextBoxFile: return "Attach File to TextBox Input" + case .focusLeft: return "Focus Pane Left" + case .focusRight: return "Focus Pane Right" + case .focusUp: return "Focus Pane Up" + case .focusDown: return "Focus Pane Down" + case .splitRight: return "Split Right" + case .splitDown: return "Split Down" + case .toggleSplitZoom: return "Toggle Pane Zoom" + case .equalizeSplits: return "Equalize Splits" + case .splitBrowserRight: return "Split Browser Right" + case .splitBrowserDown: return "Split Browser Down" + case .toggleRightSidebar: return "Toggle Right Sidebar" + case .saveFilePreview: return "Save File Preview" + case .openBrowser: return "Open Browser" + case .focusBrowserAddressBar: return "Focus Address Bar" + case .browserBack: return "Back" + case .browserForward: return "Forward" + case .browserReload: return "Reload Page" + case .browserZoomIn: return "Zoom In" + case .browserZoomOut: return "Zoom Out" + case .browserZoomReset: return "Actual Size" + case .find: return "Find…" + case .findInDirectory: return "Find in Directory…" + case .findNext: return "Find Next" + case .findPrevious: return "Find Previous" + case .hideFind: return "Hide Find Bar" + case .useSelectionForFind: return "Use Selection for Find" + case .toggleBrowserDeveloperTools: return "Toggle Browser Developer Tools" + case .showBrowserJavaScriptConsole: return "Show Browser JavaScript Console" + case .toggleReactGrab: return "Toggle React Grab" + } + } +} diff --git a/Packages/CmuxSettings/Sources/CmuxSettings/Values/ShortcutStroke.swift b/Packages/CmuxSettings/Sources/CmuxSettings/Values/ShortcutStroke.swift new file mode 100644 index 000000000000..d3f852e4b528 --- /dev/null +++ b/Packages/CmuxSettings/Sources/CmuxSettings/Values/ShortcutStroke.swift @@ -0,0 +1,37 @@ +import Foundation + +/// One keystroke in a (possibly chorded) shortcut. +/// +/// `key` is the platform-canonical lower-case character or named token +/// (e.g. `"a"`, `"space"`, `"return"`, `"f5"`, `"←"`). `keyCode` is the +/// optional macOS virtual key code, captured when the user records a +/// shortcut so we can re-match the same physical key after a layout +/// change. Modifier flags are flat booleans because the cmux JSON +/// config encodes them that way for easy hand-editing. +public struct ShortcutStroke: Sendable, Equatable, Hashable, Codable { + public let key: String + public let command: Bool + public let shift: Bool + public let option: Bool + public let control: Bool + public let keyCode: UInt16? + + public init( + key: String, + command: Bool = false, + shift: Bool = false, + option: Bool = false, + control: Bool = false, + keyCode: UInt16? = nil + ) { + self.key = key + self.command = command + self.shift = shift + self.option = option + self.control = control + self.keyCode = keyCode + } + + /// True when at least one of `cmd`, `shift`, `opt`, or `ctrl` is set. + public var hasAnyModifier: Bool { command || shift || option || control } +} diff --git a/Packages/CmuxSettings/Sources/CmuxSettings/Values/SidebarBlendModeOption.swift b/Packages/CmuxSettings/Sources/CmuxSettings/Values/SidebarBlendModeOption.swift new file mode 100644 index 000000000000..ff665d28ee6a --- /dev/null +++ b/Packages/CmuxSettings/Sources/CmuxSettings/Values/SidebarBlendModeOption.swift @@ -0,0 +1,7 @@ +import Foundation + +/// AppKit `NSVisualEffectView.BlendingMode` choice for the sidebar. +public enum SidebarBlendModeOption: String, CaseIterable, Sendable, SettingCodable { + case behindWindow + case withinWindow +} diff --git a/Packages/CmuxSettings/Sources/CmuxSettings/Values/SidebarMaterialOption.swift b/Packages/CmuxSettings/Sources/CmuxSettings/Values/SidebarMaterialOption.swift new file mode 100644 index 000000000000..1bd8877bbe6c --- /dev/null +++ b/Packages/CmuxSettings/Sources/CmuxSettings/Values/SidebarMaterialOption.swift @@ -0,0 +1,19 @@ +import Foundation + +/// AppKit `NSVisualEffectView.Material` choice for the sidebar. +public enum SidebarMaterialOption: String, CaseIterable, Sendable, SettingCodable { + case sidebar + case titlebar + case selection + case menu + case popover + case headerView + case sheet + case windowBackground + case hudWindow + case fullScreenUI + case toolTip + case contentBackground + case underWindowBackground + case underPageBackground +} diff --git a/Packages/CmuxSettings/Sources/CmuxSettings/Values/SidebarPresetOption.swift b/Packages/CmuxSettings/Sources/CmuxSettings/Values/SidebarPresetOption.swift new file mode 100644 index 000000000000..55f26882a538 --- /dev/null +++ b/Packages/CmuxSettings/Sources/CmuxSettings/Values/SidebarPresetOption.swift @@ -0,0 +1,14 @@ +import Foundation + +/// User-selectable preset bundle for the sidebar's appearance. +/// Selecting a preset writes a coordinated set of material/blend/state/ +/// tint values; choosing `custom` keeps whatever the user has already +/// dialed in. +public enum SidebarPresetOption: String, CaseIterable, Sendable, SettingCodable { + case nativeSidebar + case nativeTitlebar + case translucent + case opaqueDark + case opaqueLight + case custom +} diff --git a/Packages/CmuxSettings/Sources/CmuxSettings/Values/SidebarStateOption.swift b/Packages/CmuxSettings/Sources/CmuxSettings/Values/SidebarStateOption.swift new file mode 100644 index 000000000000..470647cb0a28 --- /dev/null +++ b/Packages/CmuxSettings/Sources/CmuxSettings/Values/SidebarStateOption.swift @@ -0,0 +1,8 @@ +import Foundation + +/// AppKit `NSVisualEffectView.State` choice for the sidebar. +public enum SidebarStateOption: String, CaseIterable, Sendable, SettingCodable { + case active + case inactive + case followWindow = "followsWindowActiveState" +} diff --git a/Packages/CmuxSettings/Sources/CmuxSettings/Values/SocketControlMode.swift b/Packages/CmuxSettings/Sources/CmuxSettings/Values/SocketControlMode.swift new file mode 100644 index 000000000000..ac196f932694 --- /dev/null +++ b/Packages/CmuxSettings/Sources/CmuxSettings/Values/SocketControlMode.swift @@ -0,0 +1,21 @@ +import Foundation + +/// Controls which clients may connect to the cmux automation socket. +/// +/// Stored under the catalog entry ``SettingCatalog/automationSocketControlMode``. +/// The cases mirror the on-disk strings used by `~/.config/cmux/cmux.json` and +/// the legacy UserDefaults value, so the raw values must not be renamed without +/// a migration. +public enum SocketControlMode: String, CaseIterable, Sendable, SettingCodable { + /// The automation socket is not exposed. + case off + /// Only the bundled `cmux` CLI may connect. + case cmuxOnly + /// Automation tools (e.g. hooks for Claude, Cursor, Gemini) may connect. + case automation + /// Clients must present a password configured under + /// ``SettingCatalog/automationSocketPassword``. + case password + /// Any local client may connect. Treat as developer-only. + case allowAll +} diff --git a/Packages/CmuxSettings/Sources/CmuxSettings/Values/StoredShortcut.swift b/Packages/CmuxSettings/Sources/CmuxSettings/Values/StoredShortcut.swift new file mode 100644 index 000000000000..54a782fd6df0 --- /dev/null +++ b/Packages/CmuxSettings/Sources/CmuxSettings/Values/StoredShortcut.swift @@ -0,0 +1,59 @@ +import Foundation + +/// A shortcut binding as it lives on disk: one or two ``ShortcutStroke``s. +/// +/// A single-stroke binding fires when the user presses the recorded +/// modifiers + key. A two-stroke ("chord") binding is the tmux-style +/// prefix pattern: press the first stroke, then the second within a +/// short window. ``isUnbound`` represents an explicit "no shortcut for +/// this action" assignment so users can suppress an inherited default. +public struct StoredShortcut: Sendable, Equatable, Hashable, Codable, SettingCodable { + /// The primary stroke. Empty `key` means the shortcut is unbound. + public let first: ShortcutStroke + + /// Optional second stroke (chord). `nil` means single-stroke. + public let second: ShortcutStroke? + + /// A binding that explicitly clears any inherited default. + public static let unbound = StoredShortcut( + first: ShortcutStroke(key: "") + ) + + public init(first: ShortcutStroke, second: ShortcutStroke? = nil) { + self.first = first + self.second = second + } + + /// True when this binding is the explicit "no shortcut" marker. + public var isUnbound: Bool { first.key.isEmpty && second == nil } + + /// True when the binding fires on two consecutive strokes. + public var hasChord: Bool { second != nil } + + // MARK: - SettingCodable + + public static func decodeFromUserDefaults(_ raw: Any?) -> StoredShortcut? { + guard let data = raw as? Data else { return nil } + return try? JSONDecoder().decode(StoredShortcut.self, from: data) + } + + public func encodeForUserDefaults() -> Any { + (try? JSONEncoder().encode(self)) ?? Data() + } + + public static func decodeFromJSON(_ raw: Any?) -> StoredShortcut? { + guard let raw, !(raw is NSNull) else { return nil } + guard let data = try? JSONSerialization.data(withJSONObject: raw, options: .fragmentsAllowed) else { + return nil + } + return try? JSONDecoder().decode(StoredShortcut.self, from: data) + } + + public func encodeForJSON() -> Any { + guard let data = try? JSONEncoder().encode(self), + let object = try? JSONSerialization.jsonObject(with: data, options: [.fragmentsAllowed]) else { + return NSNull() + } + return object + } +} diff --git a/Packages/CmuxSettings/Sources/CmuxSettings/Values/WorkspaceIndicatorStyle.swift b/Packages/CmuxSettings/Sources/CmuxSettings/Values/WorkspaceIndicatorStyle.swift new file mode 100644 index 000000000000..ebf43d0111e2 --- /dev/null +++ b/Packages/CmuxSettings/Sources/CmuxSettings/Values/WorkspaceIndicatorStyle.swift @@ -0,0 +1,6 @@ +import Foundation + +/// Visual style for the active-workspace indicator in the sidebar. +public enum WorkspaceIndicatorStyle: String, CaseIterable, Sendable, SettingCodable { + case leftRail, solidFill +} diff --git a/Packages/CmuxSettings/Sources/CmuxSettings/Values/WorkspacePlacement.swift b/Packages/CmuxSettings/Sources/CmuxSettings/Values/WorkspacePlacement.swift new file mode 100644 index 000000000000..1d2c20af59a3 --- /dev/null +++ b/Packages/CmuxSettings/Sources/CmuxSettings/Values/WorkspacePlacement.swift @@ -0,0 +1,6 @@ +import Foundation + +/// Where a newly-created workspace lands in the sidebar. +public enum WorkspacePlacement: String, CaseIterable, Sendable, SettingCodable { + case top, end, afterCurrent +} diff --git a/Packages/CmuxSettings/Sources/CmuxSettings/Values/WorkspacePresentationMode.swift b/Packages/CmuxSettings/Sources/CmuxSettings/Values/WorkspacePresentationMode.swift new file mode 100644 index 000000000000..083a59ebd90d --- /dev/null +++ b/Packages/CmuxSettings/Sources/CmuxSettings/Values/WorkspacePresentationMode.swift @@ -0,0 +1,6 @@ +import Foundation + +/// Chrome density for the main workspace window. +public enum WorkspacePresentationMode: String, CaseIterable, Sendable, SettingCodable { + case standard, minimal +} diff --git a/Packages/CmuxSettings/Tests/CmuxSettingsTests/JSONConfigStoreTests.swift b/Packages/CmuxSettings/Tests/CmuxSettingsTests/JSONConfigStoreTests.swift new file mode 100644 index 000000000000..772ffb570286 --- /dev/null +++ b/Packages/CmuxSettings/Tests/CmuxSettingsTests/JSONConfigStoreTests.swift @@ -0,0 +1,99 @@ +import Foundation +import Testing +@testable import CmuxSettings + +@Suite("JSONConfigStore") +struct JSONConfigStoreTests { + private func makeStore() -> (JSONConfigStore, URL, SettingCatalog) { + let tempDir = FileManager.default.temporaryDirectory + .appendingPathComponent("cmux-settings-tests-\(UUID().uuidString)", isDirectory: true) + try? FileManager.default.createDirectory(at: tempDir, withIntermediateDirectories: true) + let fileURL = tempDir.appendingPathComponent("cmux.json", isDirectory: false) + return (JSONConfigStore(fileURL: fileURL), fileURL, SettingCatalog()) + } + + @Test func readsDefaultWhenFileMissing() async { + let (store, _, catalog) = makeStore() + let value = await store.value(for: catalog.automation.socketPassword) + #expect(value == "") + } + + @Test func roundTripsNestedKey() async throws { + let (store, fileURL, catalog) = makeStore() + try await store.set("hunter2", for: catalog.automation.socketPassword) + let value = await store.value(for: catalog.automation.socketPassword) + #expect(value == "hunter2") + + let data = try Data(contentsOf: fileURL) + let parsed = try JSONSerialization.jsonObject(with: data) as? [String: Any] + let automation = parsed?["automation"] as? [String: Any] + #expect(automation?["socketPassword"] as? String == "hunter2") + } + + @Test func resetRemovesEntryAndPrunesEmptyParents() async throws { + let (store, fileURL, catalog) = makeStore() + try await store.set("hunter2", for: catalog.automation.socketPassword) + try await store.reset(catalog.automation.socketPassword) + let value = await store.value(for: catalog.automation.socketPassword) + #expect(value == "") + let data = try Data(contentsOf: fileURL) + let parsed = try JSONSerialization.jsonObject(with: data) as? [String: Any] + #expect(parsed?["automation"] == nil) + } + + @Test func toleratesJSONCComments() async throws { + let (store, fileURL, catalog) = makeStore() + let json = """ + { + // commented + "automation": { + "socketPassword": "test", + } + } + """ + try Data(json.utf8).write(to: fileURL) + let value = await store.value(for: catalog.automation.socketPassword) + #expect(value == "test") + } + + @Test func observesExternalEdit() async throws { + let (store, fileURL, catalog) = makeStore() + try Data("{}".utf8).write(to: fileURL) + + let observed = Task<[String], Never> { + var collected: [String] = [] + for await value in store.values(for: catalog.automation.socketPassword) { + collected.append(value) + if collected.count == 2 { break } + } + return collected + } + + // Give the subscriber Task time to register before the file change; + // DispatchSource also coalesces events, so wait for delivery to settle. + try? await Task.sleep(nanoseconds: 100_000_000) + let payload = #"{"automation":{"socketPassword":"injected"}}"# + try Data(payload.utf8).write(to: fileURL) + + let collected = await withTimeout(seconds: 3) { await observed.value } + #expect(collected.first == "") + #expect(collected.last == "injected") + } +} + +private func withTimeout(seconds: Double, _ work: @escaping @Sendable () async -> T) async -> T { + await withTaskGroup(of: T?.self) { group in + group.addTask { await work() } + group.addTask { + try? await Task.sleep(nanoseconds: UInt64(seconds * 1_000_000_000)) + return nil + } + for await result in group { + if let result { + group.cancelAll() + return result + } + } + fatalError("timed out without producing a value") + } +} diff --git a/Packages/CmuxSettings/Tests/CmuxSettingsTests/SettingCatalogTests.swift b/Packages/CmuxSettings/Tests/CmuxSettingsTests/SettingCatalogTests.swift new file mode 100644 index 000000000000..c00272d708e8 --- /dev/null +++ b/Packages/CmuxSettings/Tests/CmuxSettingsTests/SettingCatalogTests.swift @@ -0,0 +1,44 @@ +import Foundation +import Testing +@testable import CmuxSettings + +@Suite("SettingCatalog") +struct SettingCatalogTests { + @Test func eachKeyHasUniqueId() { + let ids = SettingCatalog().all.map(\.id) + #expect(ids.count == Set(ids).count) + } + + @Test func userDefaultsStorageKeysAreUnique() { + let keys = SettingCatalog().all.compactMap { entry -> String? in + if case let .userDefaults(key, _, _) = entry.kind { return key } + return nil + } + #expect(keys.count == Set(keys).count) + } + + @Test func jsonBackedKeysUseTheirIdAsPath() { + for entry in SettingCatalog().all where entry.kind == .jsonConfig { + #expect(!entry.id.isEmpty) + #expect(entry.id.contains(".")) + } + } + + @Test func allReachesEverySection() { + // Sanity check: the recursive Mirror walk picks up keys from every + // nested section. Concretely, both `app.appearance` and + // `automation.socketPassword` must appear in `all`. + let ids = Set(SettingCatalog().all.map(\.id)) + #expect(ids.contains("app.appearance")) + #expect(ids.contains("automation.socketControlMode")) + #expect(ids.contains("automation.socketPassword")) + } + + @Test func keyIdsMatchTheirSectionPrefix() { + // Each key's dotted id must start with its section's prefix; this is + // the convention that lets the JSON store use `id` as the JSON path. + let catalog = SettingCatalog() + for key in catalog.app.all { #expect(key.id.hasPrefix("app.")) } + for key in catalog.automation.all { #expect(key.id.hasPrefix("automation.")) } + } +} diff --git a/Packages/CmuxSettings/Tests/CmuxSettingsTests/SettingCodableTests.swift b/Packages/CmuxSettings/Tests/CmuxSettingsTests/SettingCodableTests.swift new file mode 100644 index 000000000000..81f4a90c51a8 --- /dev/null +++ b/Packages/CmuxSettings/Tests/CmuxSettingsTests/SettingCodableTests.swift @@ -0,0 +1,40 @@ +import Foundation +import Testing +@testable import CmuxSettings + +@Suite("SettingCodable") +struct SettingCodableTests { + @Test func boolDecodesFromNSNumberBoolean() { + #expect(Bool.decodeFromUserDefaults(NSNumber(value: true)) == true) + // JSON keeps the bool/int distinction; UserDefaults does not. + #expect(Bool.decodeFromJSON(NSNumber(value: 1)) == nil) + } + + @Test func intDistinguishesBooleanFromIntInJSON() { + #expect(Int.decodeFromJSON(NSNumber(value: true)) == nil) + #expect(Int.decodeFromJSON(NSNumber(value: 42)) == 42) + } + + @Test func intFromJSONRejectsFractional() { + #expect(Int.decodeFromJSON(NSNumber(value: 1.5)) == nil) + #expect(Int.decodeFromJSON(NSNumber(value: 7)) == 7) + } + + @Test func rawRepresentableEnumRoundTrips() { + let encoded = AppearanceMode.dark.encodeForJSON() + #expect(encoded as? String == "dark") + #expect(AppearanceMode.decodeFromJSON(encoded) == .dark) + } + + @Test func arrayRoundTrip() { + let value: [String] = ["a", "b"] + let encoded = value.encodeForJSON() + #expect([String].decodeFromJSON(encoded) == value) + } + + @Test func dictionaryRoundTrip() { + let value: [String: Int] = ["x": 1, "y": 2] + let encoded = value.encodeForJSON() + #expect([String: Int].decodeFromJSON(encoded) == value) + } +} diff --git a/Packages/CmuxSettings/Tests/CmuxSettingsTests/UserDefaultsSettingsStoreTests.swift b/Packages/CmuxSettings/Tests/CmuxSettingsTests/UserDefaultsSettingsStoreTests.swift new file mode 100644 index 000000000000..7a89244d3394 --- /dev/null +++ b/Packages/CmuxSettings/Tests/CmuxSettingsTests/UserDefaultsSettingsStoreTests.swift @@ -0,0 +1,111 @@ +import Foundation +import Testing +@testable import CmuxSettings + +@Suite("UserDefaultsSettingsStore") +struct UserDefaultsSettingsStoreTests { + private func makeStore() -> (UserDefaultsSettingsStore, SettingCatalog) { + let suiteName = "cmux.tests.\(UUID().uuidString)" + let store = UserDefaultsSettingsStore(defaults: UserDefaults(suiteName: suiteName)!) + return (store, SettingCatalog()) + } + + @Test func readsDefaultWhenUnset() async { + let (store, catalog) = makeStore() + let value = await store.value(for: catalog.app.appearance) + #expect(value == .system) + } + + @Test func roundTripsTypedEnum() async { + let (store, catalog) = makeStore() + await store.set(.dark, for: catalog.app.appearance) + let value = await store.value(for: catalog.app.appearance) + #expect(value == .dark) + } + + @Test func resetReturnsToDefault() async { + let (store, catalog) = makeStore() + await store.set(.light, for: catalog.app.appearance) + await store.reset(catalog.app.appearance) + let value = await store.value(for: catalog.app.appearance) + #expect(value == .system) + } + + @Test func valuesStreamYieldsInitialThenChanges() async { + let (store, catalog) = makeStore() + await store.set(.light, for: catalog.app.appearance) + + let observed = Task<[AppearanceMode], Never> { + var collected: [AppearanceMode] = [] + for await mode in store.values(for: catalog.app.appearance) { + collected.append(mode) + if collected.count == 3 { break } + } + return collected + } + + try? await Task.sleep(nanoseconds: 50_000_000) + await store.set(.dark, for: catalog.app.appearance) + try? await Task.sleep(nanoseconds: 50_000_000) + await store.set(.system, for: catalog.app.appearance) + + let collected = await observed.value + #expect(collected == [.light, .dark, .system]) + } + + @Test func migratesLegacyKey() async { + let suiteName = "cmux.tests.\(UUID().uuidString)" + do { + let setup = UserDefaults(suiteName: suiteName)! + setup.set("dark", forKey: "legacyAppearance") + } + + let migrating = DefaultsKey( + id: "app.appearance", + defaultValue: .system, + userDefaultsKey: "appearanceMode", + legacyUserDefaultsKeys: ["legacyAppearance"] + ) + + let store = UserDefaultsSettingsStore( + defaults: UserDefaults(suiteName: suiteName)!, + migrating: [AnySettingKey(migrating)] + ) + + let value = await store.value(for: migrating) + #expect(value == .dark) + } + + @Test func skipsLegacyMigrationOnTypeMismatch() async { + // Legacy value is a Bool, but the new key expects an enum (String). + // Migration must NOT copy the Bool into the new key; otherwise reads + // would silently fall back to default and the legacy data would be + // both unreadable AND removed. Skipping leaves the legacy data in + // place for manual recovery. + let suiteName = "cmux.tests.\(UUID().uuidString)" + do { + let setup = UserDefaults(suiteName: suiteName)! + setup.set(true, forKey: "legacyAppearance") + } + + let migrating = DefaultsKey( + id: "app.appearance", + defaultValue: .system, + userDefaultsKey: "appearanceMode", + legacyUserDefaultsKeys: ["legacyAppearance"] + ) + + let store = UserDefaultsSettingsStore( + defaults: UserDefaults(suiteName: suiteName)!, + migrating: [AnySettingKey(migrating)] + ) + + let value = await store.value(for: migrating) + #expect(value == .system, "type-incompatible legacy value must not be migrated") + + // The legacy key should remain untouched so admins can recover. + let verify = UserDefaults(suiteName: suiteName)! + #expect(verify.object(forKey: "legacyAppearance") as? Bool == true) + #expect(verify.object(forKey: "appearanceMode") == nil) + } +} diff --git a/Packages/CmuxSettingsUI/Package.swift b/Packages/CmuxSettingsUI/Package.swift new file mode 100644 index 000000000000..b8f6c50a55c6 --- /dev/null +++ b/Packages/CmuxSettingsUI/Package.swift @@ -0,0 +1,31 @@ +// swift-tools-version: 6.0 + +import PackageDescription + +let package = Package( + name: "CmuxSettingsUI", + platforms: [ + .macOS(.v14), + ], + products: [ + .library( + name: "CmuxSettingsUI", + targets: ["CmuxSettingsUI"] + ), + ], + dependencies: [ + .package(path: "../CmuxSettings"), + ], + targets: [ + .target( + name: "CmuxSettingsUI", + dependencies: [ + .product(name: "CmuxSettings", package: "CmuxSettings"), + ] + ), + .testTarget( + name: "CmuxSettingsUITests", + dependencies: ["CmuxSettingsUI"] + ), + ] +) diff --git a/Packages/CmuxSettingsUI/Sources/CmuxSettingsUI/Account/AccountFlow.swift b/Packages/CmuxSettingsUI/Sources/CmuxSettingsUI/Account/AccountFlow.swift new file mode 100644 index 000000000000..ec62bfe416ba --- /dev/null +++ b/Packages/CmuxSettingsUI/Sources/CmuxSettingsUI/Account/AccountFlow.swift @@ -0,0 +1,43 @@ +import Foundation + +/// Host-supplied dependency the package's ``AccountSection`` uses to +/// render and drive the sign-in / sign-out flow. +/// +/// The package deliberately does not link the host app's auth library +/// (`CMUXAuthCore`) so it stays buildable in isolation. The host wraps +/// its own auth surface in an `AccountFlow` implementation and injects +/// it via ``SettingsRuntime/init(catalog:userDefaultsStore:jsonStore:errorLog:accountFlow:)``. +/// +/// Implementations must be `@MainActor`-safe because the package reads +/// `currentIdentity` / `availableTeams` / `selectedTeamID` from view +/// bodies. Use `@Observable` so SwiftUI tracks changes. +@MainActor +public protocol AccountFlow: AnyObject { + /// The currently signed-in user, or `nil` if signed out. + var currentIdentity: AccountIdentity? { get } + + /// Teams the current user belongs to. Empty when the user is + /// signed out or the host hasn't loaded them yet. + var availableTeams: [AccountTeamSummary] { get } + + /// Identifier of the currently selected team, or `nil` if none. + var selectedTeamID: String? { get set } + + /// Whether the host is currently in the middle of a sign-in or + /// sign-out network round trip. The UI disables interaction while + /// this is `true`. + var isWorkingOnAuth: Bool { get } + + /// Launches the host's sign-in flow. The package shows the user a + /// progress indicator while ``isWorkingOnAuth`` is `true` and + /// re-reads ``currentIdentity`` when the flow resolves. + func startSignIn() + + /// Signs out and clears any cached identity. After this returns, + /// ``currentIdentity`` reads as `nil`. + func signOut() async + + /// Re-fetches the current user from the backend, refreshing the + /// identity card without forcing the user through sign-in again. + func refreshCurrentUser() async +} diff --git a/Packages/CmuxSettingsUI/Sources/CmuxSettingsUI/Account/AccountIdentity.swift b/Packages/CmuxSettingsUI/Sources/CmuxSettingsUI/Account/AccountIdentity.swift new file mode 100644 index 000000000000..496fd9383609 --- /dev/null +++ b/Packages/CmuxSettingsUI/Sources/CmuxSettingsUI/Account/AccountIdentity.swift @@ -0,0 +1,35 @@ +import Foundation + +/// Display-only view of the currently signed-in cmux user. +/// +/// Surface for the package's ``AccountSection`` to render an identity +/// card without depending on the host's auth package. The host wraps +/// its own user type (e.g. `CMUXAuthCore.CMUXAuthUser`) in an +/// ``AccountIdentity`` when it constructs the ``AccountFlow`` +/// delegate. +public struct AccountIdentity: Sendable, Hashable { + /// Stable identifier for the user (e.g. backend user id). + public let id: String + + /// User-facing display name. May be empty. + public let displayName: String + + /// Primary email address. May be empty if the auth provider + /// doesn't surface one. + public let email: String + + /// Optional avatar URL the host has resolved to a fetchable image. + public let avatarURL: URL? + + public init( + id: String, + displayName: String, + email: String, + avatarURL: URL? = nil + ) { + self.id = id + self.displayName = displayName + self.email = email + self.avatarURL = avatarURL + } +} diff --git a/Packages/CmuxSettingsUI/Sources/CmuxSettingsUI/Account/AccountIdentityCard.swift b/Packages/CmuxSettingsUI/Sources/CmuxSettingsUI/Account/AccountIdentityCard.swift new file mode 100644 index 000000000000..e5b92e6712ec --- /dev/null +++ b/Packages/CmuxSettingsUI/Sources/CmuxSettingsUI/Account/AccountIdentityCard.swift @@ -0,0 +1,79 @@ +import CmuxSettings +import SwiftUI + +/// Identity row rendered at the top of the Account section. +/// +/// Mirrors the legacy in-app `AuthSettingsRow`: a primary email title +/// (13pt medium), a display-name subtitle (11pt secondary), an +/// optional inline `ProgressView` while auth is in flight, and a +/// trailing Sign In / Sign Out button. No avatar, no redaction — +/// matches the legacy layout exactly. +@MainActor +struct AccountIdentityCard: View { + let flow: AccountFlow? + + init(flow: AccountFlow?) { + self.flow = flow + } + + var body: some View { + HStack(alignment: .center, spacing: 12) { + VStack(alignment: .leading, spacing: 2) { + Text(titleText) + .font(.system(size: 13, weight: .medium)) + if let subtitle = subtitleText { + Text(subtitle) + .font(.system(size: 11)) + .foregroundColor(.secondary) + } + } + Spacer(minLength: 12) + if flow?.isWorkingOnAuth == true { + ProgressView().controlSize(.small) + } + Button(action: buttonAction) { + Text(buttonTitle) + } + .controlSize(.small) + .disabled(flow?.isWorkingOnAuth == true) + } + .padding(.horizontal, 14) + .padding(.vertical, 10) + } + + private var titleText: String { + if let identity = flow?.currentIdentity { + if !identity.email.isEmpty { + return identity.email + } + return String(localized: "settings.account.signedIn.title", defaultValue: "Signed in") + } + return String(localized: "settings.account.signedOut.title", defaultValue: "Not signed in") + } + + private var subtitleText: String? { + if let identity = flow?.currentIdentity { + // Legacy AuthSettingsRow returns authManager.currentUser?.displayName + // directly. Pass the raw value through (including empty strings) so + // the row shape matches when displayName is set to "". + return identity.displayName + } + return String(localized: "settings.account.signedOut.subtitle", defaultValue: "Sign in with your cmux account to enable sync across devices.") + } + + private var buttonTitle: String { + if flow?.currentIdentity != nil { + return String(localized: "settings.account.signOut", defaultValue: "Sign Out") + } + return String(localized: "settings.account.signIn", defaultValue: "Sign In…") + } + + private func buttonAction() { + guard let flow else { return } + if flow.currentIdentity != nil { + Task { await flow.signOut() } + } else { + flow.startSignIn() + } + } +} diff --git a/Packages/CmuxSettingsUI/Sources/CmuxSettingsUI/Account/AccountTeamPicker.swift b/Packages/CmuxSettingsUI/Sources/CmuxSettingsUI/Account/AccountTeamPicker.swift new file mode 100644 index 000000000000..3c9317cd83a3 --- /dev/null +++ b/Packages/CmuxSettingsUI/Sources/CmuxSettingsUI/Account/AccountTeamPicker.swift @@ -0,0 +1,25 @@ +import SwiftUI + +/// Picker bound to the host's ``AccountFlow/selectedTeamID`` so the +/// user can switch between teams without leaving Settings. +@MainActor +struct AccountTeamPicker: View { + let flow: AccountFlow + + var body: some View { + Picker( + String(localized: "settings.account.activeTeam", defaultValue: "Active Team"), + selection: Binding( + get: { flow.selectedTeamID ?? "" }, + set: { newValue in + flow.selectedTeamID = newValue.isEmpty ? nil : newValue + } + ) + ) { + Text(String(localized: "settings.account.activeTeam.none", defaultValue: "None")).tag("") + ForEach(flow.availableTeams) { team in + Text(team.displayName).tag(team.id) + } + } + } +} diff --git a/Packages/CmuxSettingsUI/Sources/CmuxSettingsUI/Account/AccountTeamSummary.swift b/Packages/CmuxSettingsUI/Sources/CmuxSettingsUI/Account/AccountTeamSummary.swift new file mode 100644 index 000000000000..39271325fe0a --- /dev/null +++ b/Packages/CmuxSettingsUI/Sources/CmuxSettingsUI/Account/AccountTeamSummary.swift @@ -0,0 +1,17 @@ +import Foundation + +/// Display-only view of a cmux team the current user belongs to. +/// +/// Surface for the package's ``AccountSection`` to render the team +/// picker. The host derives this from its own team type. +public struct AccountTeamSummary: Sendable, Hashable, Identifiable { + public let id: String + public let displayName: String + public let slug: String? + + public init(id: String, displayName: String, slug: String? = nil) { + self.id = id + self.displayName = displayName + self.slug = slug + } +} diff --git a/Packages/CmuxSettingsUI/Sources/CmuxSettingsUI/Bindings/DefaultsValueModel.swift b/Packages/CmuxSettingsUI/Sources/CmuxSettingsUI/Bindings/DefaultsValueModel.swift new file mode 100644 index 000000000000..19579b08150f --- /dev/null +++ b/Packages/CmuxSettingsUI/Sources/CmuxSettingsUI/Bindings/DefaultsValueModel.swift @@ -0,0 +1,71 @@ +import CmuxSettings +import Foundation +import Observation + +/// `@Observable` view-model that projects one ``DefaultsKey`` value into +/// SwiftUI-bindable state. +/// +/// SwiftUI views need synchronous reads against a `Binding` in +/// their body. The ``UserDefaultsSettingsStore`` API is `async`, so we +/// can't bind directly. ``DefaultsValueModel`` is the bridge: +/// +/// 1. On construction it seeds ``current`` from a synchronous store read +/// and subscribes to ``UserDefaultsSettingsStore/values(for:)`` for +/// later changes. That stream is `.bufferingNewest(1)`, so a burst of +/// writes (e.g. a `ColorPicker` drag) coalesces to the latest value +/// instead of replaying every intermediate back through ``current``. +/// 2. SwiftUI views read ``current`` synchronously and write via ``set(_:)``. +/// 3. ``set(_:)`` updates ``current`` optimistically (immediate UI) and +/// persists the write in a fire-and-forget `Task`. +/// +/// Lifecycle: the observation `Task` captures `self` weakly and exits +/// when the model is deallocated, so no explicit cancellation is needed. +@MainActor +@Observable +public final class DefaultsValueModel { + /// The most recently observed value. SwiftUI views read this synchronously. + public private(set) var current: Value + + private let store: UserDefaultsSettingsStore + private let key: DefaultsKey + + /// Creates a model bound to ``key`` in ``store``. + /// + /// - Parameters: + /// - store: The UserDefaults store to read from and write to. + /// - key: The setting to observe. + public init(store: UserDefaultsSettingsStore, key: DefaultsKey) { + self.store = store + self.key = key + // Seed with the key default; the observation stream's first + // element (the actual stored value) lands immediately after and + // is the sole writer of `current` thereafter. + self.current = key.defaultValue + Task { [weak self, store, key] in + for await value in store.values(for: key) { + guard let self else { return } + if Task.isCancelled { break } + self.current = value + } + } + } + + /// Persists the value. The observation stream is the single writer of + /// ``current``, so the UI reflects the change once the write lands and + /// the stream yields it back (a small storage round-trip). Synchronous + /// because SwiftUI `Binding` setters can't `await`; the write itself + /// runs in a fire-and-forget `Task`. + public func set(_ value: Value) { + Task { [store, key] in + await store.set(value, for: key) + } + } + + /// Removes the override; ``current`` updates when the stream observes + /// the reset. + public func reset() { + Task { [store, key] in + await store.reset(key) + } + } +} diff --git a/Packages/CmuxSettingsUI/Sources/CmuxSettingsUI/Bindings/JSONValueModel.swift b/Packages/CmuxSettingsUI/Sources/CmuxSettingsUI/Bindings/JSONValueModel.swift new file mode 100644 index 000000000000..860f315feb31 --- /dev/null +++ b/Packages/CmuxSettingsUI/Sources/CmuxSettingsUI/Bindings/JSONValueModel.swift @@ -0,0 +1,91 @@ +import CmuxSettings +import Foundation +import Observation + +/// `@Observable` view-model that projects one ``JSONKey`` value into +/// SwiftUI-bindable state. +/// +/// Same shape as ``DefaultsValueModel`` but bound to a ``JSONConfigStore``. +/// Set / reset failures populate the model's ``lastWriteError`` *and* are +/// pushed into the optional injected ``SettingsErrorLog`` so the UI can +/// surface them centrally; the model never silently swallows a failure. +/// +/// Lifecycle: the observation `Task` captures `self` weakly inside its +/// loop body and exits naturally when the model is deallocated. +@MainActor +@Observable +public final class JSONValueModel { + /// The most recently observed value. Updated by the JSON store's file + /// watcher. + public private(set) var current: Value + + /// Error from the most recent set/reset attempt, or `nil`. + public private(set) var lastWriteError: Error? + + private let store: JSONConfigStore + private let key: JSONKey + private let errorLog: SettingsErrorLog + + /// Creates a model bound to ``key`` in ``store``. + /// + /// - Parameters: + /// - store: The JSON config store to read from and write to. + /// - key: The setting to observe. + /// - errorLog: Global log that write failures are pushed into so + /// they surface centrally. The runtime always provides one; see + /// ``SettingsRuntime/errorLog``. + public init( + store: JSONConfigStore, + key: JSONKey, + errorLog: SettingsErrorLog + ) { + self.store = store + self.key = key + self.errorLog = errorLog + self.current = key.defaultValue + Task { [weak self, store, key] in + for await value in store.values(for: key) { + guard let self else { return } + if Task.isCancelled { break } + self.current = value + } + } + } + + /// Persists the value. The observation stream is the single writer of + /// ``current``, which updates once the write lands and the store + /// yields it back. On failure ``lastWriteError`` is populated and + /// recorded in the error log. Synchronous because SwiftUI `Binding` + /// setters can't `await`. + public func set(_ value: Value) { + let keyID = key.id + Task { [weak self, store, key] in + do { + try await store.set(value, for: key) + await MainActor.run { self?.lastWriteError = nil } + } catch { + await MainActor.run { + self?.lastWriteError = error + self?.errorLog.record(error, keyID: keyID) + } + } + } + } + + /// Removes the JSON entry (parents that become empty are pruned). + /// ``current`` updates when the stream observes the reset. + public func reset() { + let keyID = key.id + Task { [weak self, store, key] in + do { + try await store.reset(key) + await MainActor.run { self?.lastWriteError = nil } + } catch { + await MainActor.run { + self?.lastWriteError = error + self?.errorLog.record(error, keyID: keyID) + } + } + } + } +} diff --git a/Packages/CmuxSettingsUI/Sources/CmuxSettingsUI/Bindings/Setting.swift b/Packages/CmuxSettingsUI/Sources/CmuxSettingsUI/Bindings/Setting.swift new file mode 100644 index 000000000000..d7bc35c0b3c0 --- /dev/null +++ b/Packages/CmuxSettingsUI/Sources/CmuxSettingsUI/Bindings/Setting.swift @@ -0,0 +1,116 @@ +import CmuxSettings +import Observation +import SwiftUI + +/// Property wrapper that binds a SwiftUI view to one setting in the +/// catalog, regardless of which store backs it. +/// +/// Resolves its store + catalog from the injected ``SettingsRuntime`` in +/// the environment, so views never have to take a runtime as an `init` +/// parameter. The keypath determines which store (UserDefaults vs JSON): +/// overloaded inits match `DefaultsKey` and `JSONKey` separately. +/// +/// ```swift +/// struct AppearancePicker: View { +/// @Setting(\.app.appearance) var mode +/// +/// var body: some View { +/// Picker("Appearance", selection: $mode) { +/// ForEach(AppearanceMode.allCases, id: \.self) { mode in +/// Text(String(describing: mode)).tag(mode) +/// } +/// } +/// } +/// } +/// ``` +/// +/// JSON-write failures are routed into the runtime's +/// ``SettingsErrorLog`` so the UI can surface them centrally; the +/// `Binding` setter itself never throws. +@MainActor +@propertyWrapper +public struct Setting: @preconcurrency DynamicProperty { + @Environment(\.settingsRuntime) private var runtime + @State private var resolved: ResolvedModel = .pending + + private let kind: Kind + + private enum Kind { + case defaults(KeyPath>) + case json(KeyPath>) + } + + /// Binds to a UserDefaults-backed setting. + public init(_ keyPath: KeyPath>) { + self.kind = .defaults(keyPath) + } + + /// Binds to a JSON-config-backed setting. + public init(_ keyPath: KeyPath>) { + self.kind = .json(keyPath) + } + + public var wrappedValue: Value { + get { + switch resolved { + case .pending: return fallbackDefault + case .defaults(let model): return model.current + case .json(let model): return model.current + } + } + nonmutating set { + switch resolved { + case .pending: return + case .defaults(let model): model.set(newValue) + case .json(let model): model.set(newValue) + } + } + } + + public var projectedValue: Binding { + Binding(get: { wrappedValue }, set: { wrappedValue = $0 }) + } + + public func update() { + guard case .pending = resolved, let runtime else { return } + switch kind { + case .defaults(let keyPath): + let key = runtime.catalog[keyPath: keyPath] + resolved = .defaults(DefaultsValueModel( + store: runtime.userDefaultsStore, + key: key + )) + case .json(let keyPath): + let key = runtime.catalog[keyPath: keyPath] + resolved = .json(JSONValueModel( + store: runtime.jsonStore, + key: key, + errorLog: runtime.errorLog + )) + } + } + + private var fallbackDefault: Value { + // Used only when no runtime is injected (previews, tests). + guard let runtime else { + // Re-derive from the keypath against a fresh catalog instance. + let catalog = SettingCatalog() + switch kind { + case .defaults(let kp): return catalog[keyPath: kp].defaultValue + case .json(let kp): return catalog[keyPath: kp].defaultValue + } + } + switch kind { + case .defaults(let kp): return runtime.catalog[keyPath: kp].defaultValue + case .json(let kp): return runtime.catalog[keyPath: kp].defaultValue + } + } +} + +/// Backing storage for ``Setting``. Boxes whichever model variant the +/// keypath resolved to. +private enum ResolvedModel: Sendable { + case pending + case defaults(DefaultsValueModel) + case json(JSONValueModel) +} diff --git a/Packages/CmuxSettingsUI/Sources/CmuxSettingsUI/Chrome/SettingsCard.swift b/Packages/CmuxSettingsUI/Sources/CmuxSettingsUI/Chrome/SettingsCard.swift new file mode 100644 index 000000000000..29bcff85d5c9 --- /dev/null +++ b/Packages/CmuxSettingsUI/Sources/CmuxSettingsUI/Chrome/SettingsCard.swift @@ -0,0 +1,41 @@ +import AppKit +import SwiftUI + +/// Rounded grouped container for a vertical stack of +/// ``SettingsCardRow``s separated by ``SettingsCardDivider``s. +/// +/// Mirrors the legacy in-app chrome: a 13pt-corner-radius rectangle +/// filled with translucent control background and a half-opacity +/// separator stroke. Hosts use one ``SettingsCard`` per subsection +/// of the settings page. +/// +/// ```swift +/// SettingsSectionHeader("Appearance") +/// SettingsCard { +/// SettingsCardRow(...) +/// SettingsCardDivider() +/// SettingsCardRow(...) +/// } +/// ``` +@MainActor +public struct SettingsCard: View { + @ViewBuilder let content: Content + + public init(@ViewBuilder content: () -> Content) { + self.content = content() + } + + public var body: some View { + VStack(alignment: .leading, spacing: 0) { + content + } + .background( + RoundedRectangle(cornerRadius: 13, style: .continuous) + .fill(Color(nsColor: NSColor.controlBackgroundColor).opacity(0.76)) + .overlay( + RoundedRectangle(cornerRadius: 13, style: .continuous) + .stroke(Color(nsColor: NSColor.separatorColor).opacity(0.5), lineWidth: 1) + ) + ) + } +} diff --git a/Packages/CmuxSettingsUI/Sources/CmuxSettingsUI/Chrome/SettingsCardDivider.swift b/Packages/CmuxSettingsUI/Sources/CmuxSettingsUI/Chrome/SettingsCardDivider.swift new file mode 100644 index 000000000000..27d3d1cc2e2d --- /dev/null +++ b/Packages/CmuxSettingsUI/Sources/CmuxSettingsUI/Chrome/SettingsCardDivider.swift @@ -0,0 +1,15 @@ +import AppKit +import SwiftUI + +/// 1pt half-opacity separator drawn between two ``SettingsCardRow``s +/// inside a ``SettingsCard``. +@MainActor +public struct SettingsCardDivider: View { + public init() {} + + public var body: some View { + Rectangle() + .fill(Color(nsColor: NSColor.separatorColor).opacity(0.5)) + .frame(height: 1) + } +} diff --git a/Packages/CmuxSettingsUI/Sources/CmuxSettingsUI/Chrome/SettingsCardNote.swift b/Packages/CmuxSettingsUI/Sources/CmuxSettingsUI/Chrome/SettingsCardNote.swift new file mode 100644 index 000000000000..3c62fe284a6f --- /dev/null +++ b/Packages/CmuxSettingsUI/Sources/CmuxSettingsUI/Chrome/SettingsCardNote.swift @@ -0,0 +1,22 @@ +import SwiftUI + +/// Small caption-style note rendered inside a ``SettingsCard`` — +/// typically used after a row to explain a setting's effect in +/// secondary-colored text. Mirrors the legacy in-app chrome. +@MainActor +public struct SettingsCardNote: View { + let text: String + + public init(_ text: String) { + self.text = text + } + + public var body: some View { + Text(text) + .font(.caption) + .foregroundColor(.secondary) + .padding(.horizontal, 14) + .padding(.vertical, 8) + .frame(maxWidth: .infinity, alignment: .leading) + } +} diff --git a/Packages/CmuxSettingsUI/Sources/CmuxSettingsUI/Chrome/SettingsCardRow.swift b/Packages/CmuxSettingsUI/Sources/CmuxSettingsUI/Chrome/SettingsCardRow.swift new file mode 100644 index 000000000000..811cbfe19372 --- /dev/null +++ b/Packages/CmuxSettingsUI/Sources/CmuxSettingsUI/Chrome/SettingsCardRow.swift @@ -0,0 +1,63 @@ +import SwiftUI + +/// One row inside a ``SettingsCard``: a left-aligned title (and +/// optional subtitle), the row's control on the right, and an +/// optional ``configurationReview`` annotation that exposes the +/// underlying cmux.json path next to the row when the host enables +/// "show config paths". +/// +/// Mirrors the legacy in-app `SettingsCardRow`: 13pt medium title, +/// 11pt secondary subtitle, 14pt horizontal padding, 9pt vertical +/// padding. The trailing slot accepts any SwiftUI view; common +/// patterns are a `Toggle`, a `Picker`, a `Stepper`, or a custom +/// `HStack` of the control plus secondary affordances. +@MainActor +public struct SettingsCardRow: View { + let configurationReview: SettingsConfigurationReview + let title: String + let subtitle: String? + let controlWidth: CGFloat? + @ViewBuilder let trailing: Trailing + + public init( + configurationReview: SettingsConfigurationReview = .action, + _ title: String, + subtitle: String? = nil, + controlWidth: CGFloat? = nil, + @ViewBuilder trailing: () -> Trailing + ) { + self.configurationReview = configurationReview + self.title = title + self.subtitle = subtitle + self.controlWidth = controlWidth + self.trailing = trailing() + } + + public var body: some View { + HStack(alignment: .center, spacing: 12) { + VStack(alignment: .leading, spacing: subtitle == nil ? 0 : 3) { + Text(title) + .font(.system(size: 13, weight: .medium)) + if let subtitle { + Text(subtitle) + .font(.caption) + .foregroundColor(.secondary) + .lineLimit(2) + } + } + .frame(maxWidth: .infinity, alignment: .leading) + + Group { + if let controlWidth { + trailing.frame(width: controlWidth, alignment: .trailing) + } else { + trailing + } + } + .layoutPriority(1) + } + .padding(.horizontal, 14) + .padding(.vertical, 9) + .frame(maxWidth: .infinity, alignment: .leading) + } +} diff --git a/Packages/CmuxSettingsUI/Sources/CmuxSettingsUI/Chrome/SettingsConfigurationReview.swift b/Packages/CmuxSettingsUI/Sources/CmuxSettingsUI/Chrome/SettingsConfigurationReview.swift new file mode 100644 index 000000000000..107e277e7b35 --- /dev/null +++ b/Packages/CmuxSettingsUI/Sources/CmuxSettingsUI/Chrome/SettingsConfigurationReview.swift @@ -0,0 +1,27 @@ +import Foundation + +/// Metadata annotation attached to a ``SettingsCardRow`` that +/// describes how the row's value persists. +/// +/// Mirrors the legacy enum: tells "Show JSON paths" UIs which +/// dotted cmux.json paths the row writes through, distinguishes +/// rows that are pure UserDefaults (`.settingsOnly`), rows that +/// trigger a one-shot action (`.action`), and rows that only +/// surface in debug builds (`.debugOnly`). +public enum SettingsConfigurationReview: Equatable, Sendable { + case settingsFile([String]) + case settingsOnly + case action + case debugOnly + + public static func json(_ paths: String...) -> Self { + .settingsFile(paths) + } + + /// Dotted cmux.json paths this row touches. Empty for + /// non-settings rows. + public var paths: [String] { + if case .settingsFile(let paths) = self { return paths } + return [] + } +} diff --git a/Packages/CmuxSettingsUI/Sources/CmuxSettingsUI/Chrome/SettingsSectionHeader.swift b/Packages/CmuxSettingsUI/Sources/CmuxSettingsUI/Chrome/SettingsSectionHeader.swift new file mode 100644 index 000000000000..cd1c81fb6cea --- /dev/null +++ b/Packages/CmuxSettingsUI/Sources/CmuxSettingsUI/Chrome/SettingsSectionHeader.swift @@ -0,0 +1,25 @@ +import SwiftUI + +/// Section header rendered above a ``SettingsCard``. +/// +/// Mirrors the legacy in-app chrome: small secondary-colored title +/// nudged 2pt right of the card, intentionally tucked close to the +/// card below it. Use ``settingsSearchAnchor(_:)`` on the header +/// when callers should be able to scroll-to it from the sidebar +/// or the search hit list. +@MainActor +public struct SettingsSectionHeader: View { + let title: String + + public init(_ title: String) { + self.title = title + } + + public var body: some View { + Text(title) + .font(.system(size: 13, weight: .semibold)) + .foregroundColor(.secondary) + .padding(.leading, 2) + .padding(.bottom, -2) + } +} diff --git a/Packages/CmuxSettingsUI/Sources/CmuxSettingsUI/Environment/SettingsHostActions.swift b/Packages/CmuxSettingsUI/Sources/CmuxSettingsUI/Environment/SettingsHostActions.swift new file mode 100644 index 000000000000..3b534ada355d --- /dev/null +++ b/Packages/CmuxSettingsUI/Sources/CmuxSettingsUI/Environment/SettingsHostActions.swift @@ -0,0 +1,89 @@ +import Foundation + +/// Host-supplied callbacks the package's section views invoke for +/// actions that live outside the catalog — clearing browser history, +/// opening the user's editor on cmux.json, sending feedback, posting +/// test notifications, restarting the app after a language change, +/// and launching the browser import workflow. +/// +/// The package doesn't carry these implementations because they +/// depend on host-app services that have no place in a +/// Foundation-only settings package. The host implements this +/// protocol once and injects it via ``SettingsRuntime``; sections +/// check for `nil` callbacks and hide the corresponding buttons +/// when no host action is available. +@MainActor +public protocol SettingsHostActions: AnyObject { + /// Deletes the user's browser history (visited-page suggestions, + /// omnibar autocomplete cache). Idempotent. + func clearBrowserHistory() + + /// Opens the cmux JSON config file in the user's preferred + /// external editor. The package's inline editor still works; + /// this is the escape hatch for users who prefer their own + /// editor. + func openConfigInExternalEditor() + + /// Launches the host's feedback flow (typically a "Send Feedback" + /// URL or in-app form). + func sendFeedback() + + /// Posts a synthetic test notification so the user can confirm + /// the configured sound + behavior. + func sendTestNotification() + + /// Opens System Settings to the cmux notifications pane so the + /// user can grant / revoke OS-level notification permission. + func openSystemNotificationSettings() + + /// Restarts the cmux app. Used after the user changes the + /// language picker, which requires a full process restart. + func restartApp() + + /// Launches the host's browser-import flow (Safari / Chrome / + /// Firefox source picker + profile selection + cookie prompt). + func openBrowserImportFlow() + + /// Asks the OS for notification authorization. No-op if the user + /// has already responded (granted or denied). + func requestNotificationAuthorization() + + /// Opens the cmux terminal config preview window (the legacy + /// "Open Config" row in the App section). The host owns the + /// window scene so the package can't open it directly. + func openTerminalConfigWindow() + + /// Plays the currently configured notification sound so the user + /// can preview it from the Settings UI. + func previewNotificationSound() + + /// Returns the current number of saved browser-history entries, or + /// `nil` if the host hasn't loaded the history store yet. The + /// Browser section uses this to render a dynamic "N saved pages" + /// subtitle next to the Clear History button. + func browserHistoryEntryCount() -> Int? +} + +public extension SettingsHostActions { + func browserHistoryEntryCount() -> Int? { nil } +} + +/// No-op ``SettingsHostActions`` for previews, tests, and any context +/// that renders settings without a live host. Lets the runtime expose a +/// non-optional ``SettingsRuntime/hostActions`` so section views never +/// have to branch on an optional host. +@MainActor +public final class NoopSettingsHostActions: SettingsHostActions { + public init() {} + public func clearBrowserHistory() {} + public func openConfigInExternalEditor() {} + public func sendFeedback() {} + public func sendTestNotification() {} + public func openSystemNotificationSettings() {} + public func restartApp() {} + public func openBrowserImportFlow() {} + public func requestNotificationAuthorization() {} + public func openTerminalConfigWindow() {} + public func previewNotificationSound() {} + public func browserHistoryEntryCount() -> Int? { nil } +} diff --git a/Packages/CmuxSettingsUI/Sources/CmuxSettingsUI/Environment/SettingsRuntime.swift b/Packages/CmuxSettingsUI/Sources/CmuxSettingsUI/Environment/SettingsRuntime.swift new file mode 100644 index 000000000000..cbb5b5f1143c --- /dev/null +++ b/Packages/CmuxSettingsUI/Sources/CmuxSettingsUI/Environment/SettingsRuntime.swift @@ -0,0 +1,61 @@ +import CmuxSettings +import SwiftUI + +/// The app-side bundle of catalog + stores + error log + account +/// flow delegate, injected into the SwiftUI environment so views can +/// resolve settings dependencies without threading each piece through +/// every `init`. +/// +/// `SettingsRuntime` is a value-typed handle: the stores are actors, +/// the error log is a `@MainActor` class, the account flow is a +/// `@MainActor` protocol existential — the bundle itself is +/// `Sendable`. Construct one at app startup and pass it via +/// ``View/settingsRuntime(_:)``. +public struct SettingsRuntime: @unchecked Sendable { + public let catalog: SettingCatalog + public let userDefaultsStore: UserDefaultsSettingsStore + public let jsonStore: JSONConfigStore + public let errorLog: SettingsErrorLog + public let accountFlow: AccountFlow? + public let hostActions: SettingsHostActions + + @MainActor + public init( + catalog: SettingCatalog, + userDefaultsStore: UserDefaultsSettingsStore, + jsonStore: JSONConfigStore, + errorLog: SettingsErrorLog, + accountFlow: AccountFlow? = nil, + hostActions: SettingsHostActions = NoopSettingsHostActions() + ) { + self.catalog = catalog + self.userDefaultsStore = userDefaultsStore + self.jsonStore = jsonStore + self.errorLog = errorLog + self.accountFlow = accountFlow + self.hostActions = hostActions + } +} + +private struct SettingsRuntimeKey: EnvironmentKey { + static let defaultValue: SettingsRuntime? = nil +} + +extension EnvironmentValues { + /// The settings runtime visible to views via `@Environment`. `nil` + /// when no runtime has been injected — typically only during + /// previews and unit tests that don't exercise settings code paths. + public var settingsRuntime: SettingsRuntime? { + get { self[SettingsRuntimeKey.self] } + set { self[SettingsRuntimeKey.self] = newValue } + } +} + +extension View { + /// Injects ``runtime`` into the view tree so any descendant + /// `@Setting` property wrapper or section can resolve its store, + /// catalog, and account flow. + public func settingsRuntime(_ runtime: SettingsRuntime) -> some View { + environment(\.settingsRuntime, runtime) + } +} diff --git a/Packages/CmuxSettingsUI/Sources/CmuxSettingsUI/Errors/SettingsErrorAlertModifier.swift b/Packages/CmuxSettingsUI/Sources/CmuxSettingsUI/Errors/SettingsErrorAlertModifier.swift new file mode 100644 index 000000000000..8e39a71c7eeb --- /dev/null +++ b/Packages/CmuxSettingsUI/Sources/CmuxSettingsUI/Errors/SettingsErrorAlertModifier.swift @@ -0,0 +1,45 @@ +import CmuxSettings +import SwiftUI + +/// View modifier that presents the most recent ``SettingsErrorLog`` +/// entry as a standard macOS alert. +/// +/// Matches the behavior cmux's existing settings UI uses for write +/// failures — a modal alert with OK to dismiss, *not* an inline banner. +/// The modifier is `internal`; views attach it via +/// ``SwiftUI/View/settingsErrorAlert(log:)``. +struct SettingsErrorAlertModifier: ViewModifier { + @Bindable var log: SettingsErrorLog + + func body(content: Content) -> some View { + content + .alert( + Text(String(localized: "settings.error.alert.title", defaultValue: "Couldn't save setting")), + isPresented: Binding( + get: { log.entries.last != nil }, + set: { newValue in + if !newValue, let last = log.entries.last { + log.dismiss(last.id) + } + } + ), + presenting: log.entries.last + ) { _ in + Button(String(localized: "settings.error.alert.dismiss", defaultValue: "OK")) { + if let last = log.entries.last { + log.dismiss(last.id) + } + } + } message: { entry in + Text("\(entry.keyID): \(entry.message)") + } + } +} + +extension View { + /// Attaches the standard settings error alert. The most recent + /// unacknowledged entry in ``log`` surfaces as a modal alert. + public func settingsErrorAlert(log: SettingsErrorLog) -> some View { + modifier(SettingsErrorAlertModifier(log: log)) + } +} diff --git a/Packages/CmuxSettingsUI/Sources/CmuxSettingsUI/Errors/SettingsErrorLog.swift b/Packages/CmuxSettingsUI/Sources/CmuxSettingsUI/Errors/SettingsErrorLog.swift new file mode 100644 index 000000000000..e5285368fcaf --- /dev/null +++ b/Packages/CmuxSettingsUI/Sources/CmuxSettingsUI/Errors/SettingsErrorLog.swift @@ -0,0 +1,61 @@ +import CmuxSettings +import Foundation +import Observation + +/// Bounded ring-buffer of settings-write failures, surfaced to the UI. +/// +/// Models call ``record(_:keyID:)`` whenever a write to a backing store +/// fails. The settings window renders the recent entries as a banner so +/// users see the failure instead of having the UI silently revert on the +/// next read. `@Observable`, lives on the runtime, retained by +/// ``EnvironmentValues/settingsErrorLog``. +@MainActor +@Observable +public final class SettingsErrorLog { + /// One recorded failure. + public struct Entry: Identifiable, Hashable, Sendable { + public let id: UUID + /// The dotted ``AnySettingKey/id`` of the setting that failed. + public let keyID: String + /// Localized error description. + public let message: String + /// When the failure was recorded. + public let timestamp: Date + + public init(keyID: String, message: String, timestamp: Date = Date()) { + self.id = UUID() + self.keyID = keyID + self.message = message + self.timestamp = timestamp + } + } + + /// Maximum number of entries kept. Older entries are discarded. + public let capacity: Int + + public private(set) var entries: [Entry] = [] + + public init(capacity: Int = 32) { + self.capacity = capacity + } + + /// Pushes a new entry. If the buffer is at ``capacity``, the oldest + /// entry is dropped. + public func record(_ error: Error, keyID: String) { + let entry = Entry(keyID: keyID, message: error.localizedDescription) + entries.append(entry) + if entries.count > capacity { + entries.removeFirst(entries.count - capacity) + } + } + + /// Removes the entry with the given id. No-op if not present. + public func dismiss(_ id: UUID) { + entries.removeAll { $0.id == id } + } + + /// Clears all entries. + public func clear() { + entries.removeAll() + } +} diff --git a/Packages/CmuxSettingsUI/Sources/CmuxSettingsUI/Navigation/CuratedSettingEntry+Default.swift b/Packages/CmuxSettingsUI/Sources/CmuxSettingsUI/Navigation/CuratedSettingEntry+Default.swift new file mode 100644 index 000000000000..99a842cc0b04 --- /dev/null +++ b/Packages/CmuxSettingsUI/Sources/CmuxSettingsUI/Navigation/CuratedSettingEntry+Default.swift @@ -0,0 +1,139 @@ +import CmuxSettings +import Foundation + +extension Array where Element == CuratedSettingEntry { + /// The cmux-shipped curated search entries. + /// + /// Ported from the legacy `SettingsSearchIndex.settingEntries` + + /// `SettingsSearchAliasIndex.settingAliases` tables in + /// `Sources/SettingsNavigation.swift` and + /// `Sources/SettingsSearchAliases.swift`. Roughly one row per + /// high-signal setting; the surface area is meant to mirror what + /// users actually search for, not the full catalog. Catalog keys + /// not present here still fall back to a dotted-id index entry in + /// ``SettingsSearchIndex``. + /// + /// Strings are English-only until the package ships an + /// `xcstrings` catalog. Tests and hosts that want a different set + /// of entries pass their own array via + /// ``SettingsSearchIndex/init(catalog:curatedEntries:)``. + public static var cmuxDefault: [CuratedSettingEntry] { + [ + // Account / integrations + .init(section: .account, id: "account", title: "Account", synonyms: "auth authentication login logout signin sign-in signout sign-out email user profile stack team"), + .init(section: .account, id: "claude-code", title: "Claude Code Integration", synonyms: "automation.claudeCodeIntegration claude code hooks agent integration status notifications"), + .init(section: .account, id: "claude-path", title: "Claude Binary Path", synonyms: "automation.claudeBinaryPath claude binary executable path cli command custom"), + .init(section: .account, id: "ripgrep-path", title: "Ripgrep Binary Path", synonyms: "ripgrep rg binary executable path search find nix custom"), + .init(section: .account, id: "subagent-notifications", title: "Suppress Subagent Notifications", synonyms: "subagent nested child agent codex claude hooks notifications"), + .init(section: .account, id: "cursor", title: "Cursor Integration", synonyms: "cursor ide agent hooks notifications"), + .init(section: .account, id: "gemini", title: "Gemini CLI Integration", synonyms: "gemini cli google agent hooks notifications"), + + // App + .init(section: .app, id: "language", title: "Language", synonyms: "app.language locale l10n localization translation japanese english ja en nihongo restart"), + .init(section: .app, id: "appearance", title: "Appearance", synonyms: "app.appearance theme color scheme light mode dark mode system mode"), + .init(section: .app, id: "app-icon", title: "App Icon", synonyms: "app.appIcon dock icon application icon app switcher alternate icon"), + .init(section: .app, id: "new-workspace-placement", title: "New Workspace Placement", synonyms: "app.newWorkspacePlacement new tab insert position order top bottom end"), + .init(section: .app, id: "workspace-inherit-working-directory", title: "Inherit Workspace Working Directory", synonyms: "workspace cwd directory inherit current focused working-directory"), + .init(section: .app, id: "minimal-mode", title: "Workspace Presentation Mode", synonyms: "presentation compact chrome layout simple titlebar controls"), + .init(section: .app, id: "keep-workspace-open", title: "Keep Workspace Open After Last Pane Closes", synonyms: "close last pane surface keep tab workspace"), + .init(section: .app, id: "focus-pane-first-click", title: "Focus Pane on First Click", synonyms: "click to focus focus follows mouse first click mouse activation"), + .init(section: .app, id: "file-drops", title: "File Drops Default Behavior", synonyms: "drag drop files finder path text terminal editor split preview shift"), + .init(section: .app, id: "preferred-editor", title: "Preferred Editor Command", synonyms: "editor open file code vscode visual studio zed sublime subl cursor"), + .init(section: .app, id: "supported-file-previews", title: "Open Supported Files in cmux", synonyms: "cmd click file preview pdf image video audio quicklook quick look editor external"), + .init(section: .app, id: "markdown-viewer", title: "Open Markdown in cmux Viewer", synonyms: "md markdown mdx viewer preview readme"), + .init(section: .app, id: "imessage-mode", title: "iMessage Mode", synonyms: "imessage message messages chat prompt prompts submitted texting reorder move workspace top agent send"), + .init(section: .app, id: "reorder-notification", title: "Reorder Workspaces on Notification", synonyms: "notification reorder move workspace top unread sort"), + .init(section: .app, id: "menu-bar-only", title: "Menu Bar Only", synonyms: "menubar menu bar dockless hide dock app switcher cmd-tab command-tab"), + .init(section: .app, id: "telemetry", title: "Send Anonymous Telemetry", synonyms: "analytics crash reports sentry posthog usage anonymous privacy"), + .init(section: .app, id: "warn-before-quit", title: "Warn Before Quitting", synonyms: "quit confirmation command-q cmd-q exit close app"), + .init(section: .app, id: "warn-before-closing-tab", title: "Warn Before Closing Tab", synonyms: "close tab confirmation command-w cmd-w terminal surface"), + .init(section: .app, id: "warn-before-closing-tab-x-button", title: "Warn Before Closing Tab via X Button", synonyms: "x button close tab confirmation terminal surface"), + .init(section: .app, id: "hide-tab-close-button", title: "Hide Tab Close Button", synonyms: "hide x button close tab terminal surface"), + .init(section: .app, id: "rename-selects-name", title: "Rename Selects Existing Name", synonyms: "rename select all existing title command palette workspace name"), + .init(section: .app, id: "palette-search-all", title: "Command Palette Searches All Surfaces", synonyms: "command palette search all surfaces cmd-p terminal browser markdown"), + .init(section: .app, id: "dock-badge", title: "Dock Badge", synonyms: "notifications.dockBadge badge dock unread count icon notifications red bubble"), + .init(section: .app, id: "show-menu-bar", title: "Show Menu Bar Extra", synonyms: "menubar menu bar status item tray extra"), + .init(section: .app, id: "unread-pane-ring", title: "Unread Pane Ring", synonyms: "notifications.unreadPaneRing blue border unread ring notification pane outline"), + .init(section: .app, id: "pane-flash", title: "Pane Flash", synonyms: "notifications.paneFlash flash blink highlight pane notification pulse"), + .init(section: .app, id: "notification-sound", title: "Notification Sound", synonyms: "notifications.sound sound audio alert chime beep custom file wav mp3 caf aiff"), + .init(section: .app, id: "notification-command", title: "Notification Command", synonyms: "notifications.command shell command hook script env environment variable done agent"), + + // Terminal + .init(section: .terminal, id: "scrollbar", title: "Show Terminal Scroll Bar", synonyms: "terminal.showScrollBar scrollback scrollbar scroll bar right edge alternate screen tui"), + .init(section: .terminal, id: "textbox-max-lines", title: "Text Box Max Lines", synonyms: "textbox text box rich input prompt max height lines grow scroll"), + .init(section: .terminal, id: "copy-on-select", title: "Copy on Selection", synonyms: "terminal.copyOnSelect copy on selection select clipboard mouse double click triple click iterm"), + .init(section: .terminal, id: "agent-auto-resume", title: "Resume Agent Sessions on Reopen", synonyms: "terminal.autoResumeAgentSessions auto resume restore reopen relaunch quit sessions agents claude code codex opencode rovo dev rovodev toggle"), + .init(section: .terminal, id: "agent-hibernation", title: "Agent Hibernation", synonyms: "terminal.agentHibernation idle hibernate suspend background agents claude code codex opencode live terminals"), + .init(section: .terminal, id: "resume-commands", title: "Resume Commands", synonyms: "surface resume command approvals prefixes auto restore prompt manual tmux hibernation"), + + // Sidebar appearance + sidebar workspace row details + .init(section: .sidebarAppearance, id: "match-terminal", title: "Match Terminal Background", synonyms: "sidebarAppearance.matchTerminalBackground transparent background material terminal background sync"), + .init(section: .sidebarAppearance, id: "hide-sidebar-details", title: "Hide All Sidebar Details", synonyms: "sidebar.hideAllDetails compact sidebar hide details only title minimal left rail"), + .init(section: .sidebarAppearance, id: "wrap-workspace-titles", title: "Wrap Workspace Titles in Sidebar", synonyms: "sidebar.wrapWorkspaceTitles workspace title wrap multiline pr pull request"), + .init(section: .sidebarAppearance, id: "show-workspace-description", title: "Show Workspace Description in Sidebar", synonyms: "sidebar.showWorkspaceDescription workspace description notes markdown sidebar"), + .init(section: .sidebarAppearance, id: "sidebar-branch-layout", title: "Sidebar Branch Layout", synonyms: "sidebar.branchVerticalLayout git branch layout vertical inline cwd directory"), + .init(section: .sidebarAppearance, id: "stack-branch-directory", title: "Stack Branch and Directory", synonyms: "sidebar.stackBranchDirectory git branch directory cwd path stack stacked separate lines two rows"), + .init(section: .sidebarAppearance, id: "path-last-segment-only", title: "Truncate Path From Start", synonyms: "sidebar.pathLastSegmentOnly cwd path directory last segment basename short truncate folder repo"), + .init(section: .sidebarAppearance, id: "show-notification-message", title: "Show Notification Message in Sidebar", synonyms: "sidebar.showNotificationMessage latest message unread notification text sidebar"), + .init(section: .sidebarAppearance, id: "show-branch-directory", title: "Show Branch + Directory in Sidebar", synonyms: "sidebar.showBranchDirectory git branch cwd path directory folder repo sidebar"), + .init(section: .sidebarAppearance, id: "show-pull-requests", title: "Show Pull Requests in Sidebar", synonyms: "sidebar.showPullRequests pr mr review github gitlab bitbucket pull request merge request"), + .init(section: .sidebarAppearance, id: "watch-git-status", title: "Watch Git Status in Sidebar", synonyms: "sidebar.watchGitStatus git status branch watcher index lock"), + .init(section: .sidebarAppearance, id: "make-pr-clickable", title: "Make Sidebar PR Clickable", synonyms: "sidebar.makePullRequestsClickable clickable pull requests pr mr reviews links select workspace row"), + .init(section: .sidebarAppearance, id: "open-pr-links", title: "Open Sidebar PR Links in cmux Browser", synonyms: "sidebar.openPullRequestLinksInCmuxBrowser pr links github browser default external embedded"), + .init(section: .sidebarAppearance, id: "open-port-links", title: "Open Sidebar Port Links in cmux Browser", synonyms: "sidebar.openPortLinksInCmuxBrowser ports localhost links browser default external embedded"), + .init(section: .sidebarAppearance, id: "show-ssh", title: "Show SSH in Sidebar", synonyms: "sidebar.showSSH remote host target ssh server"), + .init(section: .sidebarAppearance, id: "show-ports", title: "Show Listening Ports in Sidebar", synonyms: "sidebar.showPorts localhost port listener dev server url"), + .init(section: .sidebarAppearance, id: "show-log", title: "Show Latest Log in Sidebar", synonyms: "sidebar.showLog log status latest message imperative"), + .init(section: .sidebarAppearance, id: "show-progress", title: "Show Progress in Sidebar", synonyms: "sidebar.showProgress progress bar percent status set_progress"), + .init(section: .sidebarAppearance, id: "show-metadata", title: "Show Custom Metadata in Sidebar", synonyms: "sidebar.showCustomMetadata metadata meta report_meta status custom block"), + + // Beta + .init(section: .betaFeatures, id: "dock", title: "Right-Sidebar Dock (Beta)", synonyms: "dock right sidebar terminal controls tui beta unstable"), + + // Automation + .init(section: .automation, id: "socket-mode", title: "Socket Control Mode", synonyms: "automation.socketControlMode api socket unix domain control server auth allow password disabled"), + .init(section: .automation, id: "socket-password", title: "Socket Password", synonyms: "automation.socketPassword auth token credential secret password access key"), + .init(section: .automation, id: "port-base", title: "Port Base", synonyms: "automation.portBase cmux_port start first base env environment variable"), + .init(section: .automation, id: "port-range", title: "Port Range Size", synonyms: "automation.portRange cmux_port_end range size count env ports"), + + // Browser + .init(section: .browser, id: "enable-browser", title: "Disable cmux Browser", synonyms: "browser.disabled enable disable webview embedded browser tabs links"), + .init(section: .browser, id: "search-engine", title: "Default Search Engine", synonyms: "browser.defaultSearchEngine omnibar address bar google duckduckgo bing kagi brave startpage perplexity exa yahoo ecosia qwant mojeek wikipedia github baidu yandex custom search provider"), + .init(section: .browser, id: "search-suggestions", title: "Show Search Suggestions", synonyms: "browser.showSearchSuggestions suggest autocomplete address bar search suggestions"), + .init(section: .browser, id: "theme", title: "Browser Theme", synonyms: "browser.theme web page theme color scheme light dark system"), + .init(section: .browser, id: "hidden-webview-discard", title: "Discard Hidden Browser WebViews", synonyms: "browser.discardHiddenWebViews memory hidden tabs webview discard unload reclaim"), + .init(section: .browser, id: "hidden-webview-discard-delay", title: "Hidden WebView Discard Delay", synonyms: "browser.hiddenWebViewDiscardDelaySeconds memory hidden tabs delay seconds discard unload"), + .init(section: .browser, id: "terminal-links", title: "Open Terminal Links in cmux Browser", synonyms: "browser.openTerminalLinksInCmuxBrowser click url terminal links open in browser href"), + .init(section: .browser, id: "intercept-open", title: "Intercept open http(s) in Terminal", synonyms: "browser.interceptTerminalOpenCommandInCmuxBrowser open command http https url terminal intercept"), + .init(section: .browser, id: "host-whitelist", title: "Hosts to Open in Embedded Browser", synonyms: "browser.hostsToOpenInEmbeddedBrowser allowlist whitelist host wildcard domain embedded browser"), + .init(section: .browser, id: "external-patterns", title: "URLs to Always Open Externally", synonyms: "browser.urlsToAlwaysOpenExternally denylist blocklist regex rules external default browser"), + .init(section: .browser, id: "http-allowlist", title: "HTTP Hosts Allowed in Embedded Browser", synonyms: "browser.insecureHttpHostsAllowedInEmbeddedBrowser insecure http allowlist localhost localtest non-https warning"), + .init(section: .browser, id: "react-grab", title: "React Grab Version", synonyms: "browser.reactGrabVersion react grab npm version toolbar cmd-shift-g inspect component"), + + // Browser import + .init(section: .browserImport, id: "import-data", title: "Import Browser Data", synonyms: "chrome safari firefox brave edge arc bookmarks history cookies profiles migration"), + .init(section: .browserImport, id: "import-hint", title: "Show Import Hint on Blank Tabs", synonyms: "browser.showImportHintOnBlankTabs blank tab onboarding hint import prompt dismiss"), + + // Global hotkey + .init(section: .globalHotkey, id: "enable-hotkey", title: "Enable System-Wide Hotkey", synonyms: "app.systemWideHotkeyEnabled global hotkey enable system wide show hide all windows"), + .init(section: .globalHotkey, id: "shortcut", title: "Global Hotkey", synonyms: "global hotkey shortcut recorder key command option control"), + + // Keyboard shortcuts + .init(section: .keyboardShortcuts, id: "shortcuts", title: "Keyboard Shortcuts", synonyms: "shortcuts.bindings hotkeys keybindings key bindings commands keyboard accelerators chords cmux json"), + .init(section: .keyboardShortcuts, id: "shortcut-chords", title: "Shortcut Chords", synonyms: "tmux prefix ctrl-b control-b multi key sequence chord cmux json"), + .init(section: .keyboardShortcuts, id: "reset-defaults", title: "Reset Default Shortcuts", synonyms: "reset restore default defaults built in builtin shortcuts hotkeys keybindings commands"), + + // Workspace colors + .init(section: .workspaceColors, id: "indicator", title: "Workspace Color Indicator", synonyms: "workspaceColors.indicatorStyle tab indicator active workspace style color stripe dot"), + .init(section: .workspaceColors, id: "selection", title: "Selection Highlight", synonyms: "workspaceColors.selectionColor selected workspace color highlight background active tab"), + .init(section: .workspaceColors, id: "badge", title: "Notification Badge Color", synonyms: "workspaceColors.notificationBadgeColor unread notification badge color dot count"), + + // cmux.json + .init(section: .settingsJSON, id: "open-file", title: "Open Config File", synonyms: "open config file json jsonc config editor ~/.config cmux preferences"), + .init(section: .settingsJSON, id: "documentation", title: "Configuration Documentation", synonyms: "docs documentation schema reference cmux json keys configuration"), + + // Reset + .init(section: .reset, id: "reset-all", title: "Reset All Settings", synonyms: "factory reset restore defaults clear preferences"), + ] + } +} diff --git a/Packages/CmuxSettingsUI/Sources/CmuxSettingsUI/Navigation/CuratedSettingEntry.swift b/Packages/CmuxSettingsUI/Sources/CmuxSettingsUI/Navigation/CuratedSettingEntry.swift new file mode 100644 index 000000000000..c315c627d770 --- /dev/null +++ b/Packages/CmuxSettingsUI/Sources/CmuxSettingsUI/Navigation/CuratedSettingEntry.swift @@ -0,0 +1,45 @@ +import CmuxSettings +import Foundation + +/// A single user-curated search entry surfaced by ``SettingsSearchIndex``. +/// +/// Each entry pairs a navigable ``SettingsSectionID`` with a stable id, +/// a localized title, and a search synonym string mined from how users +/// actually refer to the setting (the legacy `SettingsSearchAliasIndex` +/// table is the source). +/// +/// Hosts that want to expose extra settings in search can build +/// additional ``CuratedSettingEntry`` values and pass them to +/// ``SettingsSearchIndex/init(catalog:curatedEntries:)``; the +/// package-shipped default table is the value of the +/// `[CuratedSettingEntry].cmuxDefault` constant. +public struct CuratedSettingEntry: Sendable, Hashable { + /// Section that will be selected in the sidebar when the user + /// clicks this search hit. + public let section: SettingsSectionID + + /// Stable identifier for this entry within its section. Used to + /// dedupe entries and to build the index's stable entry id. + public let id: String + + /// User-facing title rendered in the search result row. + public let title: String + + /// Space-separated synonym tokens. The search index folds these + /// case- and diacritic-insensitively before matching, so a query + /// of "copy on select" finds an entry with synonyms + /// `"terminal.copyOnSelect copy on selection clipboard"`. + public let synonyms: String + + public init( + section: SettingsSectionID, + id: String, + title: String, + synonyms: String + ) { + self.section = section + self.id = id + self.title = title + self.synonyms = synonyms + } +} diff --git a/Packages/CmuxSettingsUI/Sources/CmuxSettingsUI/Navigation/SettingsSearchIndex.swift b/Packages/CmuxSettingsUI/Sources/CmuxSettingsUI/Navigation/SettingsSearchIndex.swift new file mode 100644 index 000000000000..3ba1af6c25f4 --- /dev/null +++ b/Packages/CmuxSettingsUI/Sources/CmuxSettingsUI/Navigation/SettingsSearchIndex.swift @@ -0,0 +1,157 @@ +import CmuxSettings +import Foundation + +/// Fuzzy-match index over ``SettingsSectionID`` titles and the curated +/// per-setting entries in ``CuratedSettingEntries``. +/// +/// Three classes of entries are indexed: +/// +/// 1. Section entries — one per ``SettingsSectionID`` case — surfaced +/// in the sidebar by default (empty query). +/// 2. Curated setting entries from ``CuratedSettingEntries/entries`` — +/// one per high-signal setting, with the user-facing localized +/// title plus a synonym string mined from the legacy index. This is +/// what makes search useful: typing "copy on select" finds the +/// `terminal.copyOnSelect` row even though that's an internal id. +/// 3. Fallback dotted-id entries built from ``SettingCatalog/all`` for +/// catalog keys that are *not* covered by the curated table. They +/// only match on the dotted id, which is a usable last-resort path +/// for power users who know the underlying key. +/// +/// Diacritic-insensitive matching via +/// `String.folding(options:locale:)`. Matching is per-token AND: every +/// whitespace/punct-separated token in the query must appear somewhere +/// in the entry's normalized search text. +public struct SettingsSearchIndex: Sendable { + public struct Entry: Sendable, Identifiable, Hashable { + public enum Kind: Sendable, Hashable { + case section + case setting(parent: SettingsSectionID) + } + + public let id: String + public let kind: Kind + public let title: String + public let symbolName: String + public let normalizedSearchText: String + } + + public let entries: [Entry] + + /// Builds an index from the section list, the supplied curated + /// entries, and any remaining ``SettingCatalog/all`` keys not + /// already covered by the curated table. + /// + /// - Parameters: + /// - catalog: The settings catalog whose dotted-id keys back-fill + /// the index for entries not in ``curatedEntries``. + /// - curatedEntries: High-signal entries with curated titles + + /// synonyms. Defaults to ``Swift/Array/cmuxDefault`` — the + /// table the cmux app ships with. Tests pass an empty array + /// or a focused subset to exercise specific behavior; hosts + /// can append their own entries to the default to add new + /// searchable surfaces without forking. + public init( + catalog: SettingCatalog, + curatedEntries: [CuratedSettingEntry] = .cmuxDefault + ) { + var built: [Entry] = [] + + for section in SettingsSectionID.allCases { + built.append(Entry( + id: "section:\(section.rawValue)", + kind: .section, + title: section.title, + symbolName: section.symbolName, + normalizedSearchText: Self.normalize( + "\(section.title) \(section.searchKeywords)" + ) + )) + } + + for entry in curatedEntries { + built.append(Entry( + id: "setting:\(entry.section.rawValue):\(entry.id)", + kind: .setting(parent: entry.section), + title: entry.title, + symbolName: entry.section.symbolName, + normalizedSearchText: Self.normalize( + "\(entry.title) \(entry.synonyms)" + ) + )) + } + + for key in catalog.all + where !Self.isCovered(by: curatedEntries, keyID: key.id) { + // Catalog keys that the curated table already covers + // surface there with their natural title; this back-fill + // is for keys nobody has written a curated entry for yet. + let parent = Self.inferParent(fromKeyID: key.id) ?? .app + built.append(Entry( + id: "setting:\(key.id)", + kind: .setting(parent: parent), + title: key.id, + symbolName: parent.symbolName, + normalizedSearchText: Self.normalize(key.id) + )) + } + + self.entries = built + } + + public func match(_ query: String) -> [Entry] { + let tokens = Self.tokens(in: query) + if tokens.isEmpty { + return entries.filter { if case .section = $0.kind { return true } else { return false } } + } + return entries.filter { entry in + tokens.allSatisfy { entry.normalizedSearchText.contains($0) } + } + } + + private static func normalize(_ text: String) -> String { + text.folding(options: [.caseInsensitive, .diacriticInsensitive], locale: .current) + } + + private static func tokens(in query: String) -> [String] { + normalize(query) + .split { character in + character.unicodeScalars.allSatisfy { scalar in + CharacterSet.whitespacesAndNewlines.contains(scalar) + || CharacterSet.punctuationCharacters.contains(scalar) + } + } + .map(String.init) + } + + /// A curated entry covers a catalog key when the entry's synonym + /// string contains the catalog key's dotted id. Avoids surfacing + /// the same setting twice (once with a curated title, once with + /// the raw dotted id). + private static func isCovered( + by curatedEntries: [CuratedSettingEntry], + keyID: String + ) -> Bool { + for entry in curatedEntries where entry.synonyms.contains(keyID) { + return true + } + return false + } + + private static func inferParent(fromKeyID id: String) -> SettingsSectionID? { + guard let prefix = id.split(separator: ".").first else { return nil } + switch String(prefix) { + case "app": return .app + case "terminal": return .terminal + case "sidebar", "sidebarAppearance": return .sidebarAppearance + case "workspaceColors": return .workspaceColors + case "automation": return .automation + case "browser": return .browser + case "notifications": return .app + case "shortcuts": return .keyboardShortcuts + case "integrations": return .account + case "rightSidebar", "betaFeatures": return .betaFeatures + default: return nil + } + } +} diff --git a/Packages/CmuxSettingsUI/Sources/CmuxSettingsUI/Navigation/SettingsSectionID.swift b/Packages/CmuxSettingsUI/Sources/CmuxSettingsUI/Navigation/SettingsSectionID.swift new file mode 100644 index 000000000000..4967eb5e34ec --- /dev/null +++ b/Packages/CmuxSettingsUI/Sources/CmuxSettingsUI/Navigation/SettingsSectionID.swift @@ -0,0 +1,87 @@ +import Foundation + +/// Top-level navigation targets for the settings window. +/// +/// The cmux app exposes a fixed set of section panes. Each section gets +/// its own SwiftUI view in `Sections/`; the sidebar lists them in +/// declaration order, the search index filters across all of them. +/// +/// Adding a section means: add a case here, add its title and icon in +/// the `SettingsSectionID` extension below, and add a view file in +/// `Sections/`. +public enum SettingsSectionID: String, CaseIterable, Identifiable, Sendable, Hashable { + case account + case app + case terminal + case sidebarAppearance + case betaFeatures + case automation + case browser + case browserImport + case globalHotkey + case keyboardShortcuts + case workspaceColors + case settingsJSON + case reset + + public var id: Self { self } + + /// User-facing section title shown in the sidebar. + public var title: String { + switch self { + case .account: return "Account" + case .app: return "App" + case .terminal: return "Terminal" + case .sidebarAppearance: return "Sidebar" + case .betaFeatures: return "Beta Features" + case .automation: return "Automation" + case .browser: return "Browser" + case .browserImport: return "Import Browser Data" + case .globalHotkey: return "Global Hotkey" + case .keyboardShortcuts: return "Keyboard Shortcuts" + case .workspaceColors: return "Workspace Colors" + case .settingsJSON: return "cmux.json" + case .reset: return "Reset" + } + } + + /// SF Symbol shown alongside the title in the sidebar. + public var symbolName: String { + switch self { + case .account: return "person.crop.circle" + case .app: return "gearshape" + case .terminal: return "terminal" + case .sidebarAppearance: return "sidebar.left" + case .betaFeatures: return "exclamationmark.triangle" + 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 .workspaceColors: return "paintpalette" + case .settingsJSON: return "doc.text" + case .reset: return "arrow.counterclockwise" + } + } + + /// Space-separated keywords used by the settings search index. Each + /// section can advertise additional terms here so users find sections + /// by capability rather than only by title. + public var searchKeywords: String { + switch self { + case .account: return "sign in team sync user profile" + case .app: return "appearance language workspace notifications menu bar telemetry" + case .terminal: return "scrollbar copy on select agent resume hibernation" + case .sidebarAppearance: return "sidebar details branches material terminal background" + case .betaFeatures: return "beta experimental unstable dock right sidebar" + case .automation: return "socket integrations hooks ports claude cursor gemini" + case .browser: return "search engine links history theme" + case .browserImport: return "browser import bookmarks history cookies" + case .globalHotkey: return "system wide shortcut" + case .keyboardShortcuts: return "keybindings commands chords" + case .workspaceColors: return "palette tabs indicator" + case .settingsJSON: return "config file preferences editor schema jsonc reload" + case .reset: return "defaults reset" + } + } +} diff --git a/Packages/CmuxSettingsUI/Sources/CmuxSettingsUI/Navigation/SettingsSidebarEntryRow.swift b/Packages/CmuxSettingsUI/Sources/CmuxSettingsUI/Navigation/SettingsSidebarEntryRow.swift new file mode 100644 index 000000000000..2ee8968ce0a4 --- /dev/null +++ b/Packages/CmuxSettingsUI/Sources/CmuxSettingsUI/Navigation/SettingsSidebarEntryRow.swift @@ -0,0 +1,36 @@ +import SwiftUI + +/// Sidebar row used inside the settings window's `List`. +/// +/// Mirrors the legacy in-app `SettingsSidebarEntryRow`: 16pt SF +/// Symbol icon left-aligned in a fixed-width slot, then a left-stacked +/// title with an optional subtitle in caption / secondary style. Both +/// lines are single-line-clipped so long entries get truncated rather +/// than wrap and inflate row height. +@MainActor +struct SettingsSidebarEntryRow: View { + let title: String + let symbolName: String + let subtitle: String? + + var body: some View { + HStack(spacing: 10) { + Image(systemName: symbolName) + .foregroundStyle(.secondary) + .frame(width: 16) + + VStack(alignment: .leading, spacing: 2) { + Text(title) + .lineLimit(1) + + if let subtitle { + Text(subtitle) + .font(.caption) + .foregroundStyle(.secondary) + .lineLimit(1) + } + } + } + .frame(maxWidth: .infinity, alignment: .leading) + } +} diff --git a/Packages/CmuxSettingsUI/Sources/CmuxSettingsUI/Rows/AppIconPickerRow.swift b/Packages/CmuxSettingsUI/Sources/CmuxSettingsUI/Rows/AppIconPickerRow.swift new file mode 100644 index 000000000000..48c5513aefe9 --- /dev/null +++ b/Packages/CmuxSettingsUI/Sources/CmuxSettingsUI/Rows/AppIconPickerRow.swift @@ -0,0 +1,111 @@ +import CmuxSettings +import SwiftUI + +/// Visual three-up App Icon picker row. +/// +/// Mirrors the legacy in-app `AppIconPickerRow`: a leading title + +/// subtitle and a trailing row of three tappable icon tiles +/// (Automatic / Light / Dark). The Automatic tile shows the two +/// concrete icons overlapping; the others show the real raster icon +/// from the host app's asset catalog. +/// +/// The icon images live in the host app target (not the package), so +/// they're resolved through `Bundle.main` via the asset name. +@MainActor +struct AppIconPickerRow: View { + let selectedMode: AppIconMode + let onSelect: (AppIconMode) -> Void + + private let iconSize: CGFloat = 48 + private let autoIconSize: CGFloat = 36 + + var body: some View { + HStack(alignment: .top, spacing: 12) { + VStack(alignment: .leading, spacing: 3) { + Text(String(localized: "settings.app.appIcon", defaultValue: "App Icon")) + .font(.system(size: 13, weight: .medium)) + Text(String(localized: "settings.app.appIcon.subtitle", defaultValue: "Dock and app switcher")) + .font(.caption) + .foregroundColor(.secondary) + } + .frame(maxWidth: .infinity, alignment: .leading) + + HStack(spacing: 8) { + ForEach(AppIconMode.allCases, id: \.self) { mode in + let isSelected = selectedMode == mode + Button { + onSelect(mode) + } label: { + VStack(spacing: 4) { + Group { + if mode == .automatic { + ZStack { + Image("AppIconLight", bundle: .main) + .resizable() + .interpolation(.high) + .frame(width: autoIconSize, height: autoIconSize) + .clipShape(RoundedRectangle(cornerRadius: autoIconSize * 0.22, style: .continuous)) + .offset(x: -10) + Image("AppIconDark", bundle: .main) + .resizable() + .interpolation(.high) + .frame(width: autoIconSize, height: autoIconSize) + .clipShape(RoundedRectangle(cornerRadius: autoIconSize * 0.22, style: .continuous)) + .offset(x: 10) + } + .frame(width: iconSize, height: iconSize) + } else { + Image(iconAssetName(for: mode), bundle: .main) + .resizable() + .interpolation(.high) + .frame(width: iconSize, height: iconSize) + .clipShape(RoundedRectangle(cornerRadius: iconSize * 0.22, style: .continuous)) + } + } + + Text(iconDisplayName(mode)) + .font(.system(size: 10)) + .foregroundColor(isSelected ? .primary : .secondary) + } + .padding(.vertical, 8) + .padding(.horizontal, 10) + .contentShape(Rectangle()) + .background( + RoundedRectangle(cornerRadius: 8, style: .continuous) + .fill(isSelected + ? Color.accentColor.opacity(0.12) + : Color.clear) + ) + .overlay( + RoundedRectangle(cornerRadius: 8, style: .continuous) + .stroke(isSelected ? Color.accentColor : Color.clear, lineWidth: 2) + ) + } + .buttonStyle(.plain) + .focusable(false) + .accessibilityAddTraits(isSelected ? .isSelected : []) + } + } + .layoutPriority(1) + } + .padding(.horizontal, 14) + .padding(.vertical, 9) + .frame(maxWidth: .infinity, alignment: .leading) + } + + private func iconAssetName(for mode: AppIconMode) -> String { + switch mode { + case .automatic: return "AppIconLight" + case .light: return "AppIconLight" + case .dark: return "AppIconDark" + } + } + + private func iconDisplayName(_ mode: AppIconMode) -> String { + switch mode { + case .automatic: return String(localized: "appIcon.automatic", defaultValue: "Automatic") + case .light: return String(localized: "appIcon.light", defaultValue: "Light") + case .dark: return String(localized: "appIcon.dark", defaultValue: "Dark") + } + } +} diff --git a/Packages/CmuxSettingsUI/Sources/CmuxSettingsUI/Rows/Color+CmuxHex.swift b/Packages/CmuxSettingsUI/Sources/CmuxSettingsUI/Rows/Color+CmuxHex.swift new file mode 100644 index 000000000000..5e0850c31323 --- /dev/null +++ b/Packages/CmuxSettingsUI/Sources/CmuxSettingsUI/Rows/Color+CmuxHex.swift @@ -0,0 +1,28 @@ +import AppKit +import SwiftUI + +/// `#RRGGBB` hex conversion for `Color`, used by the Workspace Colors +/// pickers. +public extension Color { + /// Creates a color from a `#RRGGBB` (or `RRGGBB`) hex string, or + /// returns `nil` when the string isn't a 6-digit hex color. + init?(cmuxHex hex: String) { + var trimmed = hex + if trimmed.hasPrefix("#") { trimmed.removeFirst() } + guard trimmed.count == 6, let intVal = UInt32(trimmed, radix: 16) else { return nil } + let r = Double((intVal >> 16) & 0xFF) / 255 + let g = Double((intVal >> 8) & 0xFF) / 255 + let b = Double(intVal & 0xFF) / 255 + self.init(red: r, green: g, blue: b) + } + + /// The color rendered as a `#RRGGBB` string in the sRGB color space. + var cmuxHexString: String { + let nsColor = NSColor(self) + guard let rgb = nsColor.usingColorSpace(.sRGB) else { return "#000000" } + let r = Int((rgb.redComponent * 255).rounded()) + let g = Int((rgb.greenComponent * 255).rounded()) + let b = Int((rgb.blueComponent * 255).rounded()) + return String(format: "#%02X%02X%02X", r, g, b) + } +} diff --git a/Packages/CmuxSettingsUI/Sources/CmuxSettingsUI/Rows/SettingsDefaultsTextFieldRow.swift b/Packages/CmuxSettingsUI/Sources/CmuxSettingsUI/Rows/SettingsDefaultsTextFieldRow.swift new file mode 100644 index 000000000000..5e6cfa77df71 --- /dev/null +++ b/Packages/CmuxSettingsUI/Sources/CmuxSettingsUI/Rows/SettingsDefaultsTextFieldRow.swift @@ -0,0 +1,61 @@ +import CmuxSettings +import SwiftUI + +/// A labeled `TextField` bound to a `String` UserDefaults-backed setting +/// via ``DefaultsValueModel``. +/// +/// Sibling of ``SettingsTextFieldRow`` (which binds to a +/// JSON-config-backed setting); the two are split because the underlying +/// value model types differ (``DefaultsValueModel`` vs. +/// ``JSONValueModel``) and unifying them would require +/// existential erasure that adds nothing at the call site. +@MainActor +public struct SettingsDefaultsTextFieldRow: View { + private let model: DefaultsValueModel + private let title: String + private let placeholder: String + private let subtitle: String? + + @State private var draft: String = "" + @State private var loaded: Bool = false + + public init( + model: DefaultsValueModel, + title: String, + placeholder: String = "", + subtitle: String? = nil + ) { + self.model = model + self.title = title + self.placeholder = placeholder + self.subtitle = subtitle + } + + public var body: some View { + VStack(alignment: .leading, spacing: 4) { + HStack { + Text(title) + TextField(placeholder, text: $draft, onCommit: { + model.set(draft) + }) + .textFieldStyle(.roundedBorder) + } + if let subtitle { + Text(subtitle) + .font(.caption) + .foregroundStyle(.secondary) + } + } + .task { + if !loaded { + draft = model.current + loaded = true + } + } + .onChange(of: model.current) { _, newValue in + if draft != newValue { + draft = newValue + } + } + } +} diff --git a/Packages/CmuxSettingsUI/Sources/CmuxSettingsUI/Rows/SettingsDoubleStepperRow.swift b/Packages/CmuxSettingsUI/Sources/CmuxSettingsUI/Rows/SettingsDoubleStepperRow.swift new file mode 100644 index 000000000000..01a4a6ad301a --- /dev/null +++ b/Packages/CmuxSettingsUI/Sources/CmuxSettingsUI/Rows/SettingsDoubleStepperRow.swift @@ -0,0 +1,46 @@ +import CmuxSettings +import SwiftUI + +/// A labeled `Stepper` bound to a `Double` ``DefaultsKey``. +/// +/// Used for bounded floating-point settings (agent hibernation idle +/// seconds, etc.). The caller supplies the inclusive range and a step +/// granularity. The current value is rendered with the supplied +/// ``format`` so callers can show it as percent, seconds, points, etc. +@MainActor +public struct SettingsDoubleStepperRow: View { + private let model: DefaultsValueModel + private let title: String + private let range: ClosedRange + private let step: Double + private let format: (Double) -> String + + public init( + model: DefaultsValueModel, + title: String, + range: ClosedRange, + step: Double = 1, + format: @escaping (Double) -> String = { String(format: "%.0f", $0) } + ) { + self.model = model + self.title = title + self.range = range + self.step = step + self.format = format + } + + public var body: some View { + Stepper(value: Binding( + get: { model.current }, + set: { model.set($0) } + ), in: range, step: step) { + HStack { + Text(title) + Spacer() + Text(format(model.current)) + .foregroundStyle(.secondary) + .monospacedDigit() + } + } + } +} diff --git a/Packages/CmuxSettingsUI/Sources/CmuxSettingsUI/Rows/SettingsPickerRow.swift b/Packages/CmuxSettingsUI/Sources/CmuxSettingsUI/Rows/SettingsPickerRow.swift new file mode 100644 index 000000000000..1362e1a0829c --- /dev/null +++ b/Packages/CmuxSettingsUI/Sources/CmuxSettingsUI/Rows/SettingsPickerRow.swift @@ -0,0 +1,51 @@ +import CmuxSettings +import SwiftUI + +/// A labeled SwiftUI `Picker` bound to an enum ``DefaultsKey`` via a +/// ``DefaultsValueModel``. +/// +/// `Value` is constrained to `CaseIterable & Hashable` so the picker can +/// enumerate options. The caller supplies a display-label function so the +/// raw value's name isn't bare in the UI. +/// +/// ```swift +/// SettingsPickerRow( +/// model: DefaultsValueModel(store: defaults, key: catalog.app.appearance), +/// title: "Appearance", +/// label: { mode in +/// switch mode { +/// case .system: return "Follow System" +/// case .light: return "Light" +/// case .dark: return "Dark" +/// } +/// } +/// ) +/// ``` +@MainActor +public struct SettingsPickerRow: View +where Value.AllCases: RandomAccessCollection { + private let model: DefaultsValueModel + private let title: String + private let label: (Value) -> String + + public init( + model: DefaultsValueModel, + title: String, + label: @escaping (Value) -> String + ) { + self.model = model + self.title = title + self.label = label + } + + public var body: some View { + Picker(title, selection: Binding( + get: { model.current }, + set: { model.set($0) } + )) { + ForEach(Value.allCases, id: \.self) { value in + Text(label(value)).tag(value) + } + } + } +} diff --git a/Packages/CmuxSettingsUI/Sources/CmuxSettingsUI/Rows/SettingsStepperRow.swift b/Packages/CmuxSettingsUI/Sources/CmuxSettingsUI/Rows/SettingsStepperRow.swift new file mode 100644 index 000000000000..725772821c73 --- /dev/null +++ b/Packages/CmuxSettingsUI/Sources/CmuxSettingsUI/Rows/SettingsStepperRow.swift @@ -0,0 +1,38 @@ +import CmuxSettings +import SwiftUI + +/// A labeled `Stepper` bound to an `Int` ``DefaultsKey``. +/// +/// Used for bounded integer settings (port base, max live terminals, +/// etc.). The caller supplies the inclusive range. +@MainActor +public struct SettingsStepperRow: View { + private let model: DefaultsValueModel + private let title: String + private let range: ClosedRange + + public init( + model: DefaultsValueModel, + title: String, + range: ClosedRange + ) { + self.model = model + self.title = title + self.range = range + } + + public var body: some View { + Stepper(value: Binding( + get: { model.current }, + set: { model.set($0) } + ), in: range) { + HStack { + Text(title) + Spacer() + Text("\(model.current)") + .foregroundStyle(.secondary) + .monospacedDigit() + } + } + } +} diff --git a/Packages/CmuxSettingsUI/Sources/CmuxSettingsUI/Rows/SettingsTextFieldRow.swift b/Packages/CmuxSettingsUI/Sources/CmuxSettingsUI/Rows/SettingsTextFieldRow.swift new file mode 100644 index 000000000000..d347b4192652 --- /dev/null +++ b/Packages/CmuxSettingsUI/Sources/CmuxSettingsUI/Rows/SettingsTextFieldRow.swift @@ -0,0 +1,53 @@ +import CmuxSettings +import SwiftUI + +/// A labeled `TextField` bound to a `String` JSON-backed setting. +/// +/// Used for free-form text settings persisted in the cmux JSON config +/// file (e.g. ``SettingCatalog/automation/socketPassword``). Writes round +/// through the actor on every commit; transient typing state lives in +/// local `@State`. +@MainActor +public struct SettingsTextFieldRow: View { + private let model: JSONValueModel + private let title: String + private let placeholder: String + + @State private var draft: String = "" + @State private var loaded: Bool = false + + public init( + model: JSONValueModel, + title: String, + placeholder: String = "" + ) { + self.model = model + self.title = title + self.placeholder = placeholder + } + + public var body: some View { + HStack { + Text(title) + TextField(placeholder, text: $draft, onCommit: { + model.set(draft) + }) + .textFieldStyle(.roundedBorder) + } + .task { + // Hydrate the draft from the model on first appearance and + // keep it in sync with externally-driven changes. + if !loaded { + draft = model.current + loaded = true + } + } + .onChange(of: model.current) { _, newValue in + // External change (file watcher) — refresh the draft only if + // the user isn't actively editing. + if draft != newValue { + draft = newValue + } + } + } +} diff --git a/Packages/CmuxSettingsUI/Sources/CmuxSettingsUI/Rows/SettingsToggleRow.swift b/Packages/CmuxSettingsUI/Sources/CmuxSettingsUI/Rows/SettingsToggleRow.swift new file mode 100644 index 000000000000..2b34ed82e7b0 --- /dev/null +++ b/Packages/CmuxSettingsUI/Sources/CmuxSettingsUI/Rows/SettingsToggleRow.swift @@ -0,0 +1,49 @@ +import CmuxSettings +import SwiftUI + +/// A labeled SwiftUI `Toggle` bound to a `Bool` ``DefaultsKey`` via a +/// ``DefaultsValueModel``. +/// +/// Encapsulates the per-row layout cmux uses across its settings panes: +/// title on the left, optional subtitle/secondary text underneath, +/// `Toggle` on the right. +/// +/// ```swift +/// SettingsToggleRow( +/// model: DefaultsValueModel(store: defaultsStore, key: catalog.app.warnBeforeQuit), +/// title: "Warn before quitting", +/// subtitle: "Show a confirmation when ⌘Q is pressed with active workspaces." +/// ) +/// ``` +@MainActor +public struct SettingsToggleRow: View { + private let model: DefaultsValueModel + private let title: String + private let subtitle: String? + + public init( + model: DefaultsValueModel, + title: String, + subtitle: String? = nil + ) { + self.model = model + self.title = title + self.subtitle = subtitle + } + + public var body: some View { + Toggle(isOn: Binding( + get: { model.current }, + set: { model.set($0) } + )) { + VStack(alignment: .leading, spacing: 2) { + Text(title) + if let subtitle { + Text(subtitle) + .font(.caption) + .foregroundStyle(.secondary) + } + } + } + } +} diff --git a/Packages/CmuxSettingsUI/Sources/CmuxSettingsUI/Rows/ShortcutRecorderView.swift b/Packages/CmuxSettingsUI/Sources/CmuxSettingsUI/Rows/ShortcutRecorderView.swift new file mode 100644 index 000000000000..fd994988b33d --- /dev/null +++ b/Packages/CmuxSettingsUI/Sources/CmuxSettingsUI/Rows/ShortcutRecorderView.swift @@ -0,0 +1,399 @@ +import AppKit +import CmuxSettings +import SwiftUI + +/// AppKit-backed SwiftUI control that records a ``StoredShortcut``. +/// +/// SwiftUI does not surface raw key-down events with modifier flags +/// usable for shortcut recording, so this view wraps an +/// `NSViewRepresentable` over an `NSButton` subclass that captures +/// `keyDown` and `performKeyEquivalent` while focused. Click the button +/// to enter recording mode; the button title changes to +/// "Press shortcut…" and the next keystroke is captured and yielded +/// via ``onStroke``. +/// +/// When ``chordsEnabled`` is `true`, the recorder collects two +/// keystrokes in sequence — the second is the "chord" stroke, +/// modeled after tmux-style `Ctrl-B + p` bindings — and yields a +/// chorded ``StoredShortcut`` via ``onChord``. Pressing Escape during +/// the chord-pending state aborts the recording. +/// +/// Mirrors the legacy `ShortcutRecorderButton` (NSButton with +/// `.rounded` bezel) used in the in-app Settings keyboard shortcuts +/// section so the package recorder is visually indistinguishable from +/// the legacy control. The recorder installs a local NSEvent monitor +/// while active so modifier combinations that would normally trigger +/// app menu items (⌘W, ⌘Q, ⌘N, etc.) are captured for recording +/// instead of firing the menu action. +public struct ShortcutRecorderView: NSViewRepresentable { + private let onStroke: (ShortcutStroke) -> Void + private let onChord: ((StoredShortcut) -> Void)? + private let onBareKeyRejected: (() -> Void)? + private let placeholder: String + private let chordsEnabled: Bool + private let hasPendingRejection: Bool + + /// Creates a single-stroke recorder. + /// + /// The default `placeholder` is the legacy empty label + /// (`shortcut.unbound.displayValue`, "None"), matching + /// `StoredShortcut.displayString` for an unbound binding. When the + /// action is unbound and the recorder is not focused, the box shows + /// this label rather than a recording prompt — mirroring legacy + /// `ShortcutRecorderNSButton` resting state. + public init( + placeholder: String = String(localized: "shortcut.unbound.displayValue", defaultValue: "None"), + hasPendingRejection: Bool = false, + onStroke: @escaping (ShortcutStroke) -> Void, + onBareKeyRejected: (() -> Void)? = nil + ) { + self.placeholder = placeholder + self.hasPendingRejection = hasPendingRejection + self.onStroke = onStroke + self.onChord = nil + self.onBareKeyRejected = onBareKeyRejected + self.chordsEnabled = false + } + + /// Creates a recorder that can capture either a single stroke or + /// a two-stroke chord. When the user enters chord mode, the + /// recorder waits for a second keystroke and yields it via + /// ``onChord``. Plain single-key recordings still fire + /// ``onStroke``. + public init( + placeholder: String = String(localized: "shortcut.unbound.displayValue", defaultValue: "None"), + chordsEnabled: Bool, + hasPendingRejection: Bool = false, + onStroke: @escaping (ShortcutStroke) -> Void, + onChord: @escaping (StoredShortcut) -> Void, + onBareKeyRejected: (() -> Void)? = nil + ) { + self.placeholder = placeholder + self.hasPendingRejection = hasPendingRejection + self.onStroke = onStroke + self.onChord = onChord + self.onBareKeyRejected = onBareKeyRejected + self.chordsEnabled = chordsEnabled + } + + public func makeNSView(context: Context) -> RecorderHostButton { + let button = RecorderHostButton() + button.placeholder = placeholder + button.chordsEnabled = chordsEnabled + button.onStroke = onStroke + button.onChord = onChord + button.onBareKeyRejected = onBareKeyRejected + button.refreshTitle() + return button + } + + public func updateNSView(_ nsView: RecorderHostButton, context: Context) { + nsView.placeholder = placeholder + nsView.chordsEnabled = chordsEnabled + nsView.onStroke = onStroke + nsView.onChord = onChord + nsView.onBareKeyRejected = onBareKeyRejected + if !hasPendingRejection { + nsView.clearPendingRejection() + } + nsView.refreshTitle() + } +} + +/// Focusable AppKit `NSButton` host for ``ShortcutRecorderView``. +/// +/// Mirrors the legacy `ShortcutRecorderNSButton` shape: a rounded +/// `NSButton` whose title swaps between the recorded shortcut, a +/// "Press shortcut…" prompt while recording, and a `" …"` +/// preview while waiting for the second stroke of a chord. Installs +/// an `NSEvent.addLocalMonitorForEvents` monitor while recording so +/// menu-equivalent keystrokes (⌘W, ⌘Q, ⌘N, etc.) are captured for +/// recording instead of firing menu items. +public final class RecorderHostButton: NSButton { + /// Tracks the recorder that is currently capturing keystrokes so a + /// click on a different recorder can stop the previous one. Mirrors + /// legacy `ShortcutRecorderNSButton.activeRecorder` / + /// `KeyboardShortcutRecorderActivity.stopAllRecording()` — without + /// it, clicking a second recorder leaves the first still + /// installing event monitors and racing for keystrokes. + private static weak var activeRecorder: RecorderHostButton? + + public var placeholder: String = "" + public var chordsEnabled: Bool = false + public var onStroke: ((ShortcutStroke) -> Void)? + public var onChord: ((StoredShortcut) -> Void)? + public var onBareKeyRejected: (() -> Void)? + + private var isRecording = false + private var pendingFirst: ShortcutStroke? + private var hasPendingRejection = false + // `deinit` is nonisolated and must remove the local event monitor; the + // token is set/cleared only on the main thread (this is a main-thread + // AppKit view), so reading it from the nonisolated deinit is safe. + private nonisolated(unsafe) var eventMonitor: Any? + + public override init(frame frameRect: NSRect) { + super.init(frame: frameRect) + configure() + } + + public required init?(coder: NSCoder) { + super.init(coder: coder) + configure() + } + + deinit { + if let monitor = eventMonitor { + NSEvent.removeMonitor(monitor) + } + } + + private func configure() { + bezelStyle = .rounded + setButtonType(.momentaryPushIn) + // Match legacy `ShortcutRecorderNSButton`, which rendered the + // recorded chord in the default `.regular` system control font + // for a `.rounded` bezel NSButton. When this button is hosted + // inside SwiftUI via `NSViewRepresentable`, the ambient + // `controlSize` environment can shrink the button to `.small`, + // which swaps in the small system font and makes the shortcut + // text visibly smaller/lighter than the legacy in-app control. + // Pin both the control size and the font explicitly so the + // package recorder renders byte-for-byte like legacy regardless + // of the surrounding SwiftUI environment. + controlSize = .regular + font = NSFont.systemFont(ofSize: NSFont.systemFontSize(for: .regular)) + target = self + action = #selector(buttonClicked) + } + + public override var acceptsFirstResponder: Bool { true } + + public override func becomeFirstResponder() -> Bool { + let became = super.becomeFirstResponder() + if became { + startRecording() + } + return became + } + + public override func resignFirstResponder() -> Bool { + let result = super.resignFirstResponder() + stopRecording() + return result + } + + @objc private func buttonClicked() { + if isRecording { + stopRecording() + } else if window?.firstResponder === self { + // Already first responder (e.g. the user just clicked to + // cancel an in-progress recording, which stops recording but + // keeps focus). `makeFirstResponder(self)` would be a no-op + // here and never call `becomeFirstResponder`, so start + // recording directly — otherwise a third click can't + // re-enter recording mode. + startRecording() + } else { + window?.makeFirstResponder(self) + } + } + + private func startRecording() { + guard !isRecording else { return } + // Stop any other recorder before claiming the active slot so + // only one button is consuming keystrokes at a time. Matches + // legacy `KeyboardShortcutRecorderActivity.stopAllRecording()` + // behavior invoked from `ShortcutRecorderNSButton.startRecording`. + if let previous = Self.activeRecorder, previous !== self { + previous.stopRecording() + } + isRecording = true + Self.activeRecorder = self + pendingFirst = nil + hasPendingRejection = false + installEventMonitor() + refreshTitle() + } + + private func stopRecording() { + guard isRecording else { return } + isRecording = false + if Self.activeRecorder === self { + Self.activeRecorder = nil + } + pendingFirst = nil + removeEventMonitor() + refreshTitle() + } + + public override func performKeyEquivalent(with event: NSEvent) -> Bool { + guard isRecording else { return super.performKeyEquivalent(with: event) } + handleRecordingEvent(event) + return true + } + + public override func keyDown(with event: NSEvent) { + guard isRecording else { + super.keyDown(with: event) + return + } + handleRecordingEvent(event) + } + + /// Installs a local NSEvent monitor that drains key-down (and + /// system-defined media-key) events to this recorder while it is + /// active. Without `.keyDown`, ⌘W / ⌘Q / ⌘N and similar + /// menu-equivalent strokes fire the app menu before reaching the + /// button, so the user cannot bind them. Without `.systemDefined`, + /// media keys (Play/Pause, Volume, Brightness, Next/Previous Track) + /// are never delivered, so they cannot be recorded either — + /// matching the legacy `ShortcutRecorderNSButton` monitor mask. + private func installEventMonitor() { + guard eventMonitor == nil else { return } + eventMonitor = NSEvent.addLocalMonitorForEvents(matching: [.keyDown, .systemDefined]) { [weak self] event in + guard let self, self.isRecording, self.window?.firstResponder === self else { return event } + self.handleRecordingEvent(event) + return nil + } + } + + private func removeEventMonitor() { + if let monitor = eventMonitor { + NSEvent.removeMonitor(monitor) + eventMonitor = nil + } + } + + private func handleRecordingEvent(_ event: NSEvent) { + // Escape aborts a chord-in-progress without committing. + if event.keyCode == 53 /* Escape */ { + pendingFirst = nil + stopRecording() + return + } + + guard let chars = event.charactersIgnoringModifiers, !chars.isEmpty else { return } + + let hasModifier = event.modifierFlags.contains(.command) + || event.modifierFlags.contains(.option) + || event.modifierFlags.contains(.control) + || event.modifierFlags.contains(.shift) + + let stroke = ShortcutStroke( + key: chars.lowercased(), + command: event.modifierFlags.contains(.command), + shift: event.modifierFlags.contains(.shift), + option: event.modifierFlags.contains(.option), + control: event.modifierFlags.contains(.control), + keyCode: event.keyCode + ) + + // Legacy ShortcutRecorderNSButton requires a modifier on the + // first stroke so users cannot accidentally bind a bare letter + // as a global keyboard shortcut. The chord-pending second + // stroke does not require a modifier (matching legacy). + if pendingFirst == nil, !hasModifier { + hasPendingRejection = true + refreshTitle() + onBareKeyRejected?() + return + } + + if chordsEnabled, let first = pendingFirst { + pendingFirst = nil + hasPendingRejection = false + let chord = StoredShortcut(first: first, second: stroke) + onChord?(chord) + stopRecording() + return + } + + if chordsEnabled, pendingFirst == nil { + pendingFirst = stroke + hasPendingRejection = false + refreshTitle() + return + } + + hasPendingRejection = false + onStroke?(stroke) + stopRecording() + } + + /// Clears the internal "pending rejection" state so the button + /// stops displaying the "Press shortcut…" prompt after the user + /// dismisses the validation banner via Undo. Mirrors the legacy + /// `ShortcutRecorderNSButton.clearPendingRejection` flow used by + /// `ShortcutRecorderButton.updateNSView` in legacy code. + public func clearPendingRejection() { + guard hasPendingRejection else { return } + hasPendingRejection = false + refreshTitle() + } + + /// Recomputes the button title from the current recording / pending + /// state. Called automatically on every state transition and by the + /// SwiftUI `updateNSView` path when the placeholder changes. + public func refreshTitle() { + if isRecording { + if let pendingFirst { + let format = String(localized: "shortcut.recorder.pendingChord", defaultValue: "%@ …") + title = String.localizedStringWithFormat(format, display(pendingFirst)) + } else { + title = String(localized: "shortcut.pressShortcut.prompt", defaultValue: "Press shortcut…") + } + } else if hasPendingRejection { + title = String(localized: "shortcut.pressShortcut.prompt", defaultValue: "Press shortcut…") + } else { + title = placeholder + } + } + + /// Formats a single stroke with modifier symbols + the + /// human-readable key label. Mirrors legacy + /// `ShortcutStroke.displayString` so the chord-pending preview + /// shows e.g. "⌃B" or "⌘Space" instead of "⌃B" + raw key tokens + /// like "space" or "media.next". + private func display(_ stroke: ShortcutStroke) -> String { + var result = "" + if stroke.control { result.append("⌃") } + if stroke.option { result.append("⌥") } + if stroke.shift { result.append("⇧") } + if stroke.command { result.append("⌘") } + result.append(Self.keyDisplayString(stroke.key)) + return result + } + + /// Mirrors legacy `ShortcutStroke.keyDisplayString`. Returns the + /// localized friendly label for named keys (Tab, Space, media keys) + /// and the uppercased raw character otherwise. + private static func keyDisplayString(_ key: String) -> String { + switch key { + case "\t": + return String(localized: "shortcut.key.tab", defaultValue: "Tab") + case "space": + return String(localized: "shortcut.key.space", defaultValue: "Space") + case "\r": + return "↩" + case "media.brightnessDown": + return String(localized: "shortcut.key.mediaBrightnessDown", defaultValue: "Brightness Down") + case "media.brightnessUp": + return String(localized: "shortcut.key.mediaBrightnessUp", defaultValue: "Brightness Up") + case "media.mute": + return String(localized: "shortcut.key.mediaMute", defaultValue: "Mute") + case "media.next": + return String(localized: "shortcut.key.mediaNext", defaultValue: "Next Track") + case "media.playPause": + return String(localized: "shortcut.key.mediaPlayPause", defaultValue: "Play/Pause") + case "media.previous": + return String(localized: "shortcut.key.mediaPrevious", defaultValue: "Previous Track") + case "media.volumeDown": + return String(localized: "shortcut.key.mediaVolumeDown", defaultValue: "Volume Down") + case "media.volumeUp": + return String(localized: "shortcut.key.mediaVolumeUp", defaultValue: "Volume Up") + default: + return key.uppercased() + } + } +} diff --git a/Packages/CmuxSettingsUI/Sources/CmuxSettingsUI/Rows/ThemePickerRow.swift b/Packages/CmuxSettingsUI/Sources/CmuxSettingsUI/Rows/ThemePickerRow.swift new file mode 100644 index 000000000000..dbe3994a7da5 --- /dev/null +++ b/Packages/CmuxSettingsUI/Sources/CmuxSettingsUI/Rows/ThemePickerRow.swift @@ -0,0 +1,102 @@ +import CmuxSettings +import SwiftUI + +/// Visual three-up Theme picker row. +/// +/// Mirrors the legacy in-app `ThemePickerRow`: a leading "Theme" title +/// and a trailing row of three tappable thumbnails (System / Light / +/// Dark) backed by ``ThemeWindowThumbnail``. The System tile shows a +/// split light/dark composition with a hairline divider. The selected +/// tile gets an accent border and tinted background. +@MainActor +struct ThemePickerRow: View { + let selectedMode: AppearanceMode + let onSelect: (AppearanceMode) -> Void + + private let thumbWidth: CGFloat = 76 + private let thumbHeight: CGFloat = 50 + + var body: some View { + HStack(alignment: .top, spacing: 12) { + Text(String(localized: "settings.app.theme", defaultValue: "Theme")) + .font(.system(size: 13, weight: .medium)) + .frame(maxWidth: .infinity, alignment: .leading) + + HStack(spacing: 8) { + ForEach(AppearanceMode.allCases, id: \.self) { mode in + let isSelected = selectedMode == mode + Button { + onSelect(mode) + } label: { + VStack(spacing: 4) { + Group { + if mode == .system { + ZStack { + ThemeWindowThumbnail(isDark: false) + .mask( + GeometryReader { geo in + Rectangle() + .frame(width: geo.size.width / 2, height: geo.size.height) + .position(x: geo.size.width / 4, y: geo.size.height / 2) + } + ) + ThemeWindowThumbnail(isDark: true) + .mask( + GeometryReader { geo in + Rectangle() + .frame(width: geo.size.width / 2, height: geo.size.height) + .position(x: geo.size.width * 0.75, y: geo.size.height / 2) + } + ) + GeometryReader { geo in + Rectangle() + .fill(Color.primary.opacity(0.15)) + .frame(width: 1, height: geo.size.height) + .position(x: geo.size.width / 2, y: geo.size.height / 2) + } + } + } else { + ThemeWindowThumbnail(isDark: mode == .dark) + } + } + .frame(width: thumbWidth, height: thumbHeight) + + Text(themeDisplayName(mode)) + .font(.system(size: 10)) + .fontWeight(isSelected ? .semibold : .regular) + .foregroundColor(isSelected ? .primary : .secondary) + } + .padding(.vertical, 8) + .padding(.horizontal, 10) + .contentShape(Rectangle()) + .background( + RoundedRectangle(cornerRadius: 8, style: .continuous) + .fill(isSelected + ? Color.accentColor.opacity(0.12) + : Color.clear) + ) + .overlay( + RoundedRectangle(cornerRadius: 8, style: .continuous) + .stroke(isSelected ? Color.accentColor : Color.clear, lineWidth: 2) + ) + } + .buttonStyle(.plain) + .focusable(false) + .accessibilityAddTraits(isSelected ? .isSelected : []) + } + } + .layoutPriority(1) + } + .padding(.horizontal, 14) + .padding(.vertical, 9) + .frame(maxWidth: .infinity, alignment: .leading) + } + + private func themeDisplayName(_ mode: AppearanceMode) -> String { + switch mode { + case .system: return String(localized: "appearance.system", defaultValue: "System") + case .light: return String(localized: "appearance.light", defaultValue: "Light") + case .dark: return String(localized: "appearance.dark", defaultValue: "Dark") + } + } +} diff --git a/Packages/CmuxSettingsUI/Sources/CmuxSettingsUI/Rows/ThemeWindowThumbnail.swift b/Packages/CmuxSettingsUI/Sources/CmuxSettingsUI/Rows/ThemeWindowThumbnail.swift new file mode 100644 index 000000000000..f5e308b8016e --- /dev/null +++ b/Packages/CmuxSettingsUI/Sources/CmuxSettingsUI/Rows/ThemeWindowThumbnail.swift @@ -0,0 +1,112 @@ +import SwiftUI + +/// Tiny stylized desktop+windows thumbnail used by ``ThemePickerRow``. +/// +/// Ported byte-for-byte from `Sources/cmuxApp.swift`. Draws a +/// wallpaper gradient, a translucent menu bar, a backing window with +/// an accent-color title bar, and a front window with traffic-light +/// dots — switching its palette between the light and dark variant +/// based on ``isDark``. +@MainActor +struct ThemeWindowThumbnail: View { + let isDark: Bool + + var body: some View { + GeometryReader { geo in + let width = geo.size.width + let height = geo.size.height + + ZStack { + // Wallpaper background + if isDark { + LinearGradient( + colors: [Color(red: 0.1, green: 0.1, blue: 0.3), Color(red: 0.05, green: 0.05, blue: 0.1)], + startPoint: .topLeading, + endPoint: .bottomTrailing + ) + Path { path in + path.move(to: CGPoint(x: 0, y: height * 0.5)) + path.addQuadCurve(to: CGPoint(x: width, y: height), control: CGPoint(x: width * 0.5, y: height * 0.2)) + path.addLine(to: CGPoint(x: width, y: 0)) + path.addLine(to: CGPoint(x: 0, y: 0)) + } + .fill(LinearGradient(colors: [Color(red: 0.2, green: 0.2, blue: 0.6).opacity(0.5), .clear], startPoint: .topLeading, endPoint: .bottomTrailing)) + } else { + LinearGradient( + colors: [Color(red: 0.6, green: 0.8, blue: 0.95), Color(red: 0.2, green: 0.4, blue: 0.8)], + startPoint: .topLeading, + endPoint: .bottomTrailing + ) + Path { path in + path.move(to: CGPoint(x: 0, y: height * 0.5)) + path.addQuadCurve(to: CGPoint(x: width, y: height), control: CGPoint(x: width * 0.5, y: height * 0.2)) + path.addLine(to: CGPoint(x: width, y: 0)) + path.addLine(to: CGPoint(x: 0, y: 0)) + } + .fill(LinearGradient(colors: [Color(red: 0.8, green: 0.9, blue: 1.0).opacity(0.6), .clear], startPoint: .topLeading, endPoint: .bottomTrailing)) + } + + // Menu bar + VStack(spacing: 0) { + HStack { + Image(systemName: "applelogo") + .font(.system(size: max(height * 0.08, 6))) + .foregroundColor(isDark ? .white : .black) + .opacity(0.8) + Spacer() + } + .padding(.horizontal, max(width * 0.04, 4)) + .frame(height: max(height * 0.12, 8)) + .background(.ultraThinMaterial) + Spacer() + } + + // Back window + VStack(spacing: 0) { + Rectangle() + .fill(isDark ? Color(white: 0.2) : Color(white: 0.9)) + .frame(height: max(height * 0.15, 8)) + ZStack(alignment: .top) { + Rectangle() + .fill(isDark ? Color(white: 0.15) : Color(white: 0.98)) + RoundedRectangle(cornerRadius: max(width * 0.02, 2), style: .continuous) + .fill(Color.accentColor) + .frame(height: max(height * 0.12, 6)) + .padding(max(width * 0.04, 4)) + } + } + .clipShape(RoundedRectangle(cornerRadius: max(width * 0.04, 4), style: .continuous)) + .frame(width: width * 0.65, height: height * 0.45) + .shadow(color: .black.opacity(isDark ? 0.4 : 0.15), radius: 4, x: 0, y: 2) + .offset(x: -width * 0.08, y: -height * 0.1) + + // Front window with traffic lights + VStack(spacing: 0) { + ZStack { + Rectangle() + .fill(isDark ? Color(white: 0.18) : Color(white: 0.92)) + HStack(spacing: max(width * 0.025, 2)) { + Circle().fill(Color(red: 1.0, green: 0.37, blue: 0.34)).frame(width: max(width * 0.04, 3)) + Circle().fill(Color(red: 1.0, green: 0.74, blue: 0.18)).frame(width: max(width * 0.04, 3)) + Circle().fill(Color(red: 0.15, green: 0.79, blue: 0.25)).frame(width: max(width * 0.04, 3)) + Spacer() + } + .padding(.horizontal, max(width * 0.04, 4)) + } + .frame(height: max(height * 0.18, 10)) + Rectangle() + .fill(isDark ? Color(white: 0.1) : .white) + } + .clipShape(RoundedRectangle(cornerRadius: max(width * 0.05, 5), style: .continuous)) + .shadow(color: .black.opacity(isDark ? 0.5 : 0.2), radius: 6, x: 0, y: 3) + .frame(width: width * 0.75, height: height * 0.55) + .offset(x: width * 0.12, y: height * 0.2) + } + } + .clipShape(RoundedRectangle(cornerRadius: 14, style: .continuous)) + .overlay( + RoundedRectangle(cornerRadius: 14, style: .continuous) + .stroke(Color.primary.opacity(0.1), lineWidth: 1) + ) + } +} diff --git a/Packages/CmuxSettingsUI/Sources/CmuxSettingsUI/Scene/SettingsWindowScene.swift b/Packages/CmuxSettingsUI/Sources/CmuxSettingsUI/Scene/SettingsWindowScene.swift new file mode 100644 index 000000000000..ef2094d0a3ae --- /dev/null +++ b/Packages/CmuxSettingsUI/Sources/CmuxSettingsUI/Scene/SettingsWindowScene.swift @@ -0,0 +1,478 @@ +import CmuxSettings +import SwiftUI + +/// The settings window SwiftUI `Scene`. +/// +/// Composes a single tall `ScrollView` of stacked sections — the +/// legacy in-app layout — with a left sidebar that scrolls to a +/// section's anchor on click. This mirrors what cmux's settings +/// window has historically looked like; using a `NavigationSplitView` +/// with one-pane-at-a-time selection was a previous (now reverted) +/// architecture choice. +@MainActor +public struct SettingsWindowScene: Scene { + private let runtime: SettingsRuntime + + public init(runtime: SettingsRuntime) { + self.runtime = runtime + } + + public var body: some Scene { + Window(String(localized: "settings.title", defaultValue: "Settings"), id: "cmux.settings") { + SettingsWindowRoot(runtime: runtime) + .settingsRuntime(runtime) + } + .defaultSize(width: 980, height: 680) + .windowResizability(.contentMinSize) + .commands { SidebarCommands() } + } +} + +/// Root view of the settings window. Owns the search query, the +/// scroll proxy, and the section anchors. Renders sidebar + tall +/// scrolling content side-by-side. +@MainActor +public struct SettingsWindowRoot: View { + let runtime: SettingsRuntime + + public init(runtime: SettingsRuntime) { + self.runtime = runtime + } + + @State private var searchText: String = "" + // Legacy SettingsRootView persists two distinct pieces of state: + // `selectedSettingsSection` (the top-level section pane shown in + // the detail) and `selectedSettingsSidebarEntry` (the specific + // sidebar row that's highlighted — a section row, a setting hit + // from the search index, etc.). Keeping them separate matters + // because under search the user can click an individual setting + // hit and we still want the section pane to follow, but two + // sibling hits inside one section must each be selectable. + @SceneStorage("selectedSettingsSection") private var selectedSectionRaw: String = SettingsSectionID.account.rawValue + @SceneStorage("selectedSettingsSidebarEntry") private var selectedSidebarEntryID: String = "section:\(SettingsSectionID.account.rawValue)" + // Legacy `SettingsRootView` binds `NavigationSplitView`'s + // `columnVisibility` so the user can collapse the sidebar via the + // toolbar button (or the SidebarCommands menu) and have that state + // persist for the lifetime of the window. Without a binding, + // `NavigationSplitView` is locked to whatever its initial layout + // resolved to, which makes the chevron toggle a no-op in the + // package window. Keep this in @State (not @SceneStorage) because + // legacy stores it on the transient `SettingsDraftState`, not in + // SceneStorage. + @State private var columnVisibility: NavigationSplitViewVisibility = .all + // Mirrors legacy SettingsView.settingsNavigationGeneration. When + // multiple navigation requests fire in quick succession (e.g. the + // sidebar selection changes plus an external app.cmux.settings + // navigation post), each scheduled `proxy.scrollTo(...)` runs on + // the next main-queue tick. Without a generation guard, a stale + // earlier request can win and snap the scroll back to a section + // the user has already moved past. The counter is incremented in + // `applyScrollNavigation` and re-checked inside `DispatchQueue.main.async`, + // so only the most recent request actually scrolls. + @State private var settingsNavigationGeneration: Int = 0 + + private var defaultsStore: UserDefaultsSettingsStore { runtime.userDefaultsStore } + private var jsonStore: JSONConfigStore { runtime.jsonStore } + private var catalog: SettingCatalog { runtime.catalog } + private var hostActions: SettingsHostActions { runtime.hostActions } + private var accountFlow: AccountFlow? { runtime.accountFlow } + + private var searchIndex: SettingsSearchIndex { + SettingsSearchIndex(catalog: catalog) + } + + /// Resolves the selected section pane from the persisted raw value, + /// defaulting to ``SettingsSectionID/account`` when the stored value + /// is unrecognized (e.g., after dropping a case). + private var selectedSection: SettingsSectionID { + SettingsSectionID(rawValue: selectedSectionRaw) ?? .account + } + + /// Whether the user currently has a non-empty search query. When + /// false the sidebar should track section selection only; when true + /// the per-entry selection survives. + private var isSearching: Bool { + !searchText.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty + } + + // Legacy uses a non-optional `Binding` because a sidebar + // selection always points at *some* entry (section row or setting + // hit). Mirroring that here lets List's selection semantics behave + // identically — particularly that clicking the same row again + // doesn't transiently nil-out the selection and break SceneStorage + // round-trips. + private var sidebarSelectionBinding: Binding { + Binding( + get: { self.selectedSidebarEntryID }, + set: { newValue in + self.selectSidebarEntry(newValue) + } + ) + } + + public var body: some View { + NavigationSplitView(columnVisibility: $columnVisibility) { + sidebar + } detail: { + detailScroll + } + .navigationSplitViewStyle(.balanced) + // Legacy SettingsRootView pins the window minimum to + // SettingsWindowPresenter.minimumSize (820 x 540); mirror that + // so the package window can shrink to the same lower bound. + .frame(minWidth: 820, minHeight: 540) + .settingsErrorAlert(log: runtime.errorLog) + .onReceive(NotificationCenter.default.publisher(for: Self.navigationRequestName)) { notification in + applyNavigationRequest(notification) + } + .onChange(of: searchText) { _, newValue in + // Legacy SettingsRootView resyncs the sidebar entry to the + // section row whenever the search text is cleared, so + // typing then clearing doesn't leave a stale "deep" entry + // selected. + guard newValue.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty else { return } + selectedSidebarEntryID = sectionEntryID(for: selectedSection) + } + } + + public static let navigationRequestName = Notification.Name("cmux.settings.navigate") + + /// Legacy `SettingsRootView.onReceive` only updates the selection + /// state (sidebar entry + section pane) in response to an external + /// navigation request. The actual scroll-to is owned by + /// `SettingsView`, which listens to the same notification and + /// translates it into `proxy.scrollTo(...)` calls. The package + /// follows the same split: state changes happen here; the detail + /// scroll picks up the notification on its own and scrolls. + private func applyNavigationRequest(_ notification: Notification) { + guard + let rawValue = notification.userInfo?["target"] as? String, + let target = SettingsSectionID(rawValue: rawValue) + else { return } + // Legacy preserves the highlighted search hit when an external + // navigation request resolves to the same section the currently + // selected sidebar entry already lives in. Without this, typing + // a search query and clicking a setting hit would have the + // sidebar selection collapsed back to the section row whenever + // anyone (re)posted a navigation request to that section. + let selectedEntry = searchIndex.entries.first { $0.id == selectedSidebarEntryID } + let selectedEntryTarget = parentSection(for: selectedSidebarEntryID) + let shouldPreserveSearchSelection = isSearching + && selectedEntry != nil + && selectedEntryTarget == target + navigate(to: target, preferSectionSelection: !shouldPreserveSearchSelection) + } + + @ViewBuilder + private var sidebar: some View { + List(selection: sidebarSelectionBinding) { + let matches = searchIndex.match(searchText) + if matches.isEmpty { + Text(String(localized: "settings.search.noResults", defaultValue: "No Results")) + .foregroundStyle(.secondary) + } else { + ForEach(matches) { entry in + SettingsSidebarEntryRow( + title: entry.title, + symbolName: entry.symbolName, + subtitle: subtitle(for: 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) + } + + /// Legacy `SettingsSearchEntry` populates `subtitle` with the + /// parent section's title for setting-type hits and `nil` for + /// section-type hits, so `SettingsSidebarEntryRow` renders the + /// section name underneath each search hit but keeps section + /// rows single-line. Mirror that here. + private func subtitle(for entry: SettingsSearchIndex.Entry) -> String? { + switch entry.kind { + case .section: + return nil + case .setting(let parent): + return parent.title + } + } + + /// Updates both the sidebar entry selection and the underlying + /// section pane based on the clicked sidebar row. Setting-hit + /// clicks keep the deep entry selected (so the row stays + /// highlighted) while still moving the detail pane to the parent + /// section. + /// + /// Mirrors legacy `SettingsRootView.selectSidebarEntry`: in + /// addition to updating selection state, it posts a settings + /// navigation notification so any external listeners (host-side + /// code, other windows) and the package's own detail scroll + /// receive a consistent stream of navigation events. The detail + /// scroll picks up the same notification and turns it into a + /// `proxy.scrollTo(...)` so every click — including repeat clicks + /// or sibling search hits — drives a scroll. + private func selectSidebarEntry(_ entryID: String) { + // Mirror legacy `SettingsRootView.selectSidebarEntry`: bail if + // the entry id doesn't resolve to a known search-index entry, + // so stale SceneStorage values or out-of-band selection writes + // can't corrupt the section pane. The lookup also resolves the + // entry's target section in one place rather than re-parsing + // the id string. + let index = searchIndex + guard let entry = index.entries.first(where: { $0.id == entryID }) else { return } + selectedSidebarEntryID = entry.id + let section = parentSection(for: entry) + if selectedSectionRaw != section.rawValue { + selectedSectionRaw = section.rawValue + } + postNavigationRequest(target: section, anchorID: entry.id, highlight: isSearching) + } + + /// Maps a resolved search-index entry to its target section, + /// matching legacy `SettingsSearchEntry.target` semantics. Section + /// entries decode their target from the canonical "section:" + /// id; setting entries carry their parent directly on the kind. + private func parentSection(for entry: SettingsSearchIndex.Entry) -> SettingsSectionID { + switch entry.kind { + case .section: + return parentSection(for: entry.id) + case .setting(let parent): + return parent + } + } + + /// Posts a `cmux.settings.navigate` notification with the same + /// userInfo shape legacy `SettingsNavigationRequest.post` uses, + /// so host-side listeners and the package's own detail scroll + /// receive a consistent stream of navigation events. + private func postNavigationRequest( + target: SettingsSectionID, + anchorID: String, + highlight: Bool + ) { + NotificationCenter.default.post( + name: Self.navigationRequestName, + object: nil, + userInfo: [ + "target": target.rawValue, + "anchor": anchorID, + "highlight": highlight + ] + ) + } + + /// Navigates from outside (e.g., a `cmux.settings.navigate` + /// notification) to a top-level section, also resetting the sidebar + /// row to that section's header row when `preferSectionSelection` + /// is true. Legacy passes `false` when the navigation request + /// arrives while the user is searching and the request target + /// matches the currently selected setting hit — so the highlighted + /// sidebar row stays put while the detail pane snaps to the + /// section. + private func navigate(to target: SettingsSectionID, preferSectionSelection: Bool = true) { + if selectedSectionRaw != target.rawValue { selectedSectionRaw = target.rawValue } + if preferSectionSelection { + let sectionEntry = sectionEntryID(for: target) + if selectedSidebarEntryID != sectionEntry { selectedSidebarEntryID = sectionEntry } + } + } + + /// The canonical entry ID the search index uses for section header + /// rows ("section:"). Mirrors ``SettingsSearchIndex``'s + /// internal id scheme. + private func sectionEntryID(for section: SettingsSectionID) -> String { + "section:\(section.rawValue)" + } + + /// Decodes an entry ID back to the section pane that should be + /// scrolled into view. Section rows resolve to themselves; setting + /// hits resolve to their parent section. + private func parentSection(for entryID: String) -> SettingsSectionID { + if entryID.hasPrefix("section:") { + let raw = String(entryID.dropFirst("section:".count)) + return SettingsSectionID(rawValue: raw) ?? .account + } + if let entry = searchIndex.entries.first(where: { $0.id == entryID }) { + if case .setting(let parent) = entry.kind { return parent } + } + return .account + } + + @ViewBuilder + private var detailScroll: some View { + GeometryReader { _ in + ScrollViewReader { proxy in + ScrollView { + // LazyVStack so the ~12 settings sections build their + // bodies on demand as they scroll into view instead of + // eagerly on first render. The eager plain VStack made + // opening the window and the first scroll janky because + // every section (and its nested cards/controls) was + // instantiated up front. Each section keeps its + // `.id(anchorID(for:))` so `proxy.scrollTo(...)` from + // the sidebar/search navigation still resolves anchors. + LazyVStack(alignment: .leading, spacing: 14) { + sectionStack + } + // Legacy SettingsView only pads the inner VStack; it + // does not pin maxWidth. Adding an outer frame would + // change the alignment math the legacy layout assumes + // (SettingsCard widths come from the ScrollView, not + // from a parent VStack stretched to topLeading). + .padding(.horizontal, 20) + .padding(.top, 20) + .padding(.bottom, 20) + } + .toggleStyle(.switch) + .onAppear { + // Legacy SettingsView.onAppear scrolls to the restored + // section so reopening the Settings window lands on + // the last-viewed pane rather than always at Account. + // Posting through the navigation notification keeps a + // single scroll path (legacy `applySettingsNavigation`) + // and lets the SceneStorage-restored sidebar entry + // drive a deep scroll if it was a setting hit. + let section = selectedSection + let anchor = selectedSidebarEntryID.isEmpty + ? sectionEntryID(for: section) + : selectedSidebarEntryID + postNavigationRequest( + target: section, + anchorID: anchor, + highlight: false + ) + } + .onReceive(NotificationCenter.default.publisher(for: Self.navigationRequestName)) { notification in + applyScrollNavigation(notification, proxy: proxy) + } + } + } + } + + /// Mirrors legacy `SettingsView.applySettingsNavigation`: scrolls + /// to the section header first, then — when the navigation request + /// carries a deep anchor and `highlight` is set — scrolls that + /// specific anchor into the vertical center of the viewport. + /// + /// Section-level navigation posts (e.g. external `navigate(to:)` + /// calls that don't carry a meaningful highlight) only get the + /// section-top scroll, matching the legacy snap-to-top behavior. + /// + /// A monotonically increasing `settingsNavigationGeneration` + /// guards against stale scrolls when navigation requests pile up: + /// each call captures the current generation, increments it, and + /// the dispatched scroll only runs if the captured generation is + /// still the latest — otherwise an earlier request would clobber + /// the user's most recent navigation. + private func applyScrollNavigation(_ notification: Notification, proxy: ScrollViewProxy) { + guard + let rawValue = notification.userInfo?["target"] as? String, + let target = SettingsSectionID(rawValue: rawValue) + else { return } + let anchorID = (notification.userInfo?["anchor"] as? String) ?? self.anchorID(for: target) + let shouldHighlight = (notification.userInfo?["highlight"] as? Bool) ?? false + let sectionID = self.anchorID(for: target) + settingsNavigationGeneration += 1 + let navigationGeneration = settingsNavigationGeneration + DispatchQueue.main.async { + guard navigationGeneration == settingsNavigationGeneration else { return } + proxy.scrollTo(sectionID, anchor: .top) + if shouldHighlight && anchorID != sectionID { + proxy.scrollTo(anchorID, anchor: .center) + } + } + } + + @ViewBuilder + private var sectionStack: some View { + // Order matches the legacy in-app SettingsView scroll order: + // Account, App, Terminal, Sidebar, Beta Features, Automation, + // Browser (with embedded Import), Global Hotkey, Keyboard + // Shortcuts, Workspace Colors, cmux.json, Reset. + AccountSection( + defaultsStore: defaultsStore, + catalog: catalog, + accountFlow: accountFlow + ) + .id(anchorID(for: .account)) + + AppSection( + defaultsStore: defaultsStore, + catalog: catalog, + hostActions: hostActions + ) + .id(anchorID(for: .app)) + + TerminalSection( + defaultsStore: defaultsStore, + jsonStore: jsonStore, + catalog: catalog, + hostActions: hostActions + ) + .id(anchorID(for: .terminal)) + + SidebarSection(defaultsStore: defaultsStore, catalog: catalog) + .id(anchorID(for: .sidebarAppearance)) + + BetaFeaturesSection(defaultsStore: defaultsStore, catalog: catalog) + .id(anchorID(for: .betaFeatures)) + + AutomationSection( + defaultsStore: defaultsStore, + jsonStore: jsonStore, + catalog: catalog, + errorLog: runtime.errorLog + ) + .id(anchorID(for: .automation)) + + BrowserSection( + defaultsStore: defaultsStore, + catalog: catalog, + hostActions: hostActions, + importAnchorID: anchorID(for: .browserImport) + ) + .id(anchorID(for: .browser)) + + GlobalHotkeySection( + defaultsStore: defaultsStore, + jsonStore: jsonStore, + catalog: catalog, + errorLog: runtime.errorLog + ) + .id(anchorID(for: .globalHotkey)) + + KeyboardShortcutsSection( + jsonStore: jsonStore, + catalog: catalog, + errorLog: runtime.errorLog, + hostActions: hostActions + ) + .id(anchorID(for: .keyboardShortcuts)) + + WorkspaceColorsSection( + defaultsStore: defaultsStore, + jsonStore: jsonStore, + catalog: catalog, + errorLog: runtime.errorLog + ) + .id(anchorID(for: .workspaceColors)) + + SettingsJSONSection(jsonStore: jsonStore, hostActions: hostActions) + .id(anchorID(for: .settingsJSON)) + + ResetSection( + defaultsStore: defaultsStore, + jsonStore: jsonStore, + catalog: catalog + ) + .id(anchorID(for: .reset)) + } + + private func anchorID(for section: SettingsSectionID) -> String { + "section:\(section.rawValue)" + } +} diff --git a/Packages/CmuxSettingsUI/Sources/CmuxSettingsUI/Sections/AccountSection.swift b/Packages/CmuxSettingsUI/Sources/CmuxSettingsUI/Sections/AccountSection.swift new file mode 100644 index 000000000000..77a5ff063050 --- /dev/null +++ b/Packages/CmuxSettingsUI/Sources/CmuxSettingsUI/Sections/AccountSection.swift @@ -0,0 +1,35 @@ +import CmuxSettings +import SwiftUI + +/// **Account** section — mirrors the legacy in-app section: a single +/// `SettingsCard` containing the identity row (primary email title + +/// display name subtitle + Sign In / Sign Out button, no avatar). The +/// integration toggles (Claude Code, Cursor, Gemini, ripgrep, subagent +/// suppression) live under **Automation** to match legacy ordering. +@MainActor +public struct AccountSection: View { + private let accountFlow: AccountFlow? + + public init( + defaultsStore: UserDefaultsSettingsStore, + catalog: SettingCatalog, + accountFlow: AccountFlow? + ) { + // `defaultsStore` and `catalog` are part of the section's shared + // init shape (every section takes them) but the Account card binds + // no defaults-backed values, so neither is stored. The identity row + // is driven entirely by `accountFlow`. + _ = defaultsStore + _ = catalog + self.accountFlow = accountFlow + } + + public var body: some View { + Group { + SettingsSectionHeader(String(localized: "settings.section.account", defaultValue: "Account")) + SettingsCard { + AccountIdentityCard(flow: accountFlow) + } + } + } +} diff --git a/Packages/CmuxSettingsUI/Sources/CmuxSettingsUI/Sections/AppSection.swift b/Packages/CmuxSettingsUI/Sources/CmuxSettingsUI/Sections/AppSection.swift new file mode 100644 index 000000000000..a8e845c60096 --- /dev/null +++ b/Packages/CmuxSettingsUI/Sources/CmuxSettingsUI/Sections/AppSection.swift @@ -0,0 +1,767 @@ +import AppKit +import CmuxSettings +import SwiftUI +import UniformTypeIdentifiers + +/// **App** section — mirrors the legacy in-app section row-for-row +/// inside a single `SettingsCard`: Language, Appearance, App Icon, +/// New Workspace Placement, Inherit Working Directory, Minimal Mode, +/// Keep Workspace Open When Closing Last Surface, Focus Pane on +/// First Click, File Drops, Open Files With, Open Supported Files in +/// cmux, Terminal Config link, Open Markdown in cmux Viewer, +/// iMessage Mode, Reorder on Notification, Dock Badge, Menu Bar +/// Only, Show in Menu Bar, Unread Pane Ring, Pane Flash, Desktop +/// Notifications, Notification Sound, Notification Command, Send +/// anonymous telemetry, Warn Before Quit, Warn Before Closing Tab / +/// X Button / Hide Tab Close Button, Rename Selects Existing Name, +/// Command Palette Searches All Surfaces. +@MainActor +public struct AppSection: View { + private let catalog: SettingCatalog + private let hostActions: SettingsHostActions + + // Every bound value-model lives here as view state, constructed once + // and persisted across renders so the @Observable change tracking + // actually drives invalidation. + @State private var language: DefaultsValueModel + @State private var appearance: DefaultsValueModel + @State private var appIcon: DefaultsValueModel + @State private var placement: DefaultsValueModel + @State private var inheritDir: DefaultsValueModel + @State private var minimalMode: DefaultsValueModel + @State private var keepWorkspaceOpen: DefaultsValueModel + @State private var firstClick: DefaultsValueModel + @State private var fileDrop: DefaultsValueModel + @State private var preferredEditor: DefaultsValueModel + @State private var openSupported: DefaultsValueModel + @State private var openMarkdown: DefaultsValueModel + @State private var iMessage: DefaultsValueModel + @State private var reorder: DefaultsValueModel + @State private var dockBadge: DefaultsValueModel + @State private var menuBarOnly: DefaultsValueModel + @State private var showInMenuBar: DefaultsValueModel + @State private var paneRing: DefaultsValueModel + @State private var paneFlash: DefaultsValueModel + @State private var soundName: DefaultsValueModel + @State private var soundCommand: DefaultsValueModel + @State private var customSoundFile: DefaultsValueModel + @State private var telemetry: DefaultsValueModel + @State private var confirmQuit: DefaultsValueModel + @State private var warnCloseTab: DefaultsValueModel + @State private var warnCloseX: DefaultsValueModel + @State private var hideCloseButton: DefaultsValueModel + @State private var renameSelects: DefaultsValueModel + @State private var paletteAllSurfaces: DefaultsValueModel + + @State private var languageAtAppear: AppLanguage? + @State private var telemetryAtAppear: Bool? + + public init( + defaultsStore: UserDefaultsSettingsStore, + catalog: SettingCatalog, + hostActions: SettingsHostActions + ) { + self.catalog = catalog + self.hostActions = hostActions + _language = State(initialValue: DefaultsValueModel(store: defaultsStore, key: catalog.app.language)) + _appearance = State(initialValue: DefaultsValueModel(store: defaultsStore, key: catalog.app.appearance)) + _appIcon = State(initialValue: DefaultsValueModel(store: defaultsStore, key: catalog.app.appIcon)) + _placement = State(initialValue: DefaultsValueModel(store: defaultsStore, key: catalog.app.newWorkspacePlacement)) + _inheritDir = State(initialValue: DefaultsValueModel(store: defaultsStore, key: catalog.app.workspaceInheritWorkingDirectory)) + _minimalMode = State(initialValue: DefaultsValueModel(store: defaultsStore, key: catalog.app.presentationMode)) + _keepWorkspaceOpen = State(initialValue: DefaultsValueModel(store: defaultsStore, key: catalog.app.keepWorkspaceOpenWhenClosingLastSurface)) + _firstClick = State(initialValue: DefaultsValueModel(store: defaultsStore, key: catalog.app.focusPaneOnFirstClick)) + _fileDrop = State(initialValue: DefaultsValueModel(store: defaultsStore, key: catalog.app.fileDropDefaultBehavior)) + _preferredEditor = State(initialValue: DefaultsValueModel(store: defaultsStore, key: catalog.app.preferredEditor)) + _openSupported = State(initialValue: DefaultsValueModel(store: defaultsStore, key: catalog.app.openSupportedFilesInCmux)) + _openMarkdown = State(initialValue: DefaultsValueModel(store: defaultsStore, key: catalog.app.openMarkdownInCmuxViewer)) + _iMessage = State(initialValue: DefaultsValueModel(store: defaultsStore, key: catalog.app.iMessageMode)) + _reorder = State(initialValue: DefaultsValueModel(store: defaultsStore, key: catalog.app.reorderOnNotification)) + _dockBadge = State(initialValue: DefaultsValueModel(store: defaultsStore, key: catalog.notifications.dockBadge)) + _menuBarOnly = State(initialValue: DefaultsValueModel(store: defaultsStore, key: catalog.app.menuBarOnly)) + _showInMenuBar = State(initialValue: DefaultsValueModel(store: defaultsStore, key: catalog.notifications.showInMenuBar)) + _paneRing = State(initialValue: DefaultsValueModel(store: defaultsStore, key: catalog.notifications.unreadPaneRing)) + _paneFlash = State(initialValue: DefaultsValueModel(store: defaultsStore, key: catalog.notifications.paneFlash)) + _soundName = State(initialValue: DefaultsValueModel(store: defaultsStore, key: catalog.notifications.sound)) + _soundCommand = State(initialValue: DefaultsValueModel(store: defaultsStore, key: catalog.notifications.command)) + _customSoundFile = State(initialValue: DefaultsValueModel(store: defaultsStore, key: catalog.notifications.customSoundFilePath)) + _telemetry = State(initialValue: DefaultsValueModel(store: defaultsStore, key: catalog.app.sendAnonymousTelemetry)) + _confirmQuit = State(initialValue: DefaultsValueModel(store: defaultsStore, key: catalog.app.confirmQuitMode)) + _warnCloseTab = State(initialValue: DefaultsValueModel(store: defaultsStore, key: catalog.app.warnBeforeClosingTab)) + _warnCloseX = State(initialValue: DefaultsValueModel(store: defaultsStore, key: catalog.app.warnBeforeClosingTabXButton)) + _hideCloseButton = State(initialValue: DefaultsValueModel(store: defaultsStore, key: catalog.app.hideTabCloseButton)) + _renameSelects = State(initialValue: DefaultsValueModel(store: defaultsStore, key: catalog.app.renameSelectsExistingName)) + _paletteAllSurfaces = State(initialValue: DefaultsValueModel(store: defaultsStore, key: catalog.app.commandPaletteSearchesAllSurfaces)) + } + + private static let columnWidth: CGFloat = 196 + private static let notificationSoundControlWidth: CGFloat = 280 + + /// Languages legacy `AppLanguage` exposes (cmuxApp.swift line + /// 4338). The shared `CmuxSettings.AppLanguage` adds `.vi` for a + /// future Vietnamese localization that the legacy in-app picker + /// doesn't surface yet; filter it out here so the Settings UI + /// matches the legacy menu shape exactly. + private static let legacyLanguageCases: [AppLanguage] = [ + .system, .en, .ar, .bs, .zhHans, .zhHant, .da, .de, .es, .fr, + .it, .ja, .ko, .nb, .pl, .ptBR, .ru, .th, .tr, + ] + + public var body: some View { + Group { + SettingsSectionHeader(String(localized: "settings.section.app", defaultValue: "App")) + .accessibilityIdentifier("SettingsAppSection") + mainCard + } + .task { + if languageAtAppear == nil { languageAtAppear = language.current } + if telemetryAtAppear == nil { telemetryAtAppear = telemetry.current } + } + } + + @ViewBuilder + private var mainCard: some View { + SettingsCard { + // Language + SettingsCardRow( + configurationReview: .json("app.language"), + String(localized: "settings.app.language", defaultValue: "Language"), + subtitle: languageAtAppear != nil && language.current != languageAtAppear + ? String(localized: "settings.app.language.restartSubtitle", defaultValue: "Restart cmux to apply") + : nil, + controlWidth: Self.columnWidth + ) { + Picker("", selection: Binding(get: { language.current }, set: { language.set($0) })) { + ForEach(Self.legacyLanguageCases, id: \.self) { lang in + Text(languageDisplayName(lang)).tag(lang) + } + } + .labelsHidden() + .pickerStyle(.menu) + } + SettingsCardDivider() + + // Theme — three-up visual picker mirroring legacy + ThemePickerRow( + selectedMode: appearance.current, + onSelect: { appearance.set($0) } + ) + SettingsCardDivider() + + // App Icon — three-up visual picker mirroring legacy + AppIconPickerRow( + selectedMode: appIcon.current, + onSelect: { appIcon.set($0) } + ) + SettingsCardDivider() + + // New Workspace Placement + SettingsCardRow( + configurationReview: .json("app.newWorkspacePlacement"), + String(localized: "settings.app.newWorkspacePlacement", defaultValue: "New Workspace Placement"), + subtitle: workspacePlacementSubtitle(placement.current), + controlWidth: Self.columnWidth + ) { + // Order matches legacy NewWorkspacePlacement.allCases: + // top, afterCurrent, end. + Picker("", selection: Binding(get: { placement.current }, set: { placement.set($0) })) { + Text(String(localized: "workspace.placement.top", defaultValue: "Top")).tag(WorkspacePlacement.top) + Text(String(localized: "workspace.placement.afterCurrent", defaultValue: "After current")).tag(WorkspacePlacement.afterCurrent) + Text(String(localized: "workspace.placement.end", defaultValue: "End")).tag(WorkspacePlacement.end) + } + .labelsHidden() + .pickerStyle(.menu) + } + SettingsCardDivider() + + // Inherit Working Directory + SettingsCardRow( + configurationReview: .json("app.workspaceInheritWorkingDirectory"), + String(localized: "settings.app.workspaceInheritWorkingDirectory", defaultValue: "Inherit Workspace Working Directory"), + subtitle: inheritDir.current + ? String(localized: "settings.app.workspaceInheritWorkingDirectory.subtitleOn", defaultValue: "New workspaces start in the focused workspace's working directory.") + : String(localized: "settings.app.workspaceInheritWorkingDirectory.subtitleOff", defaultValue: "New workspaces leave their working directory unset so Ghostty's working-directory setting can apply.") + ) { + Toggle("", isOn: Binding(get: { inheritDir.current }, set: { inheritDir.set($0) })) + .labelsHidden() + .controlSize(.small) + .accessibilityIdentifier("SettingsWorkspaceInheritWorkingDirectoryToggle") + } + SettingsCardDivider() + + // Minimal Mode + SettingsCardRow( + configurationReview: .json("app.minimalMode"), + String(localized: "settings.app.minimalMode", defaultValue: "Minimal Mode"), + subtitle: minimalMode.current == .minimal + ? String(localized: "settings.app.minimalMode.subtitleOn", defaultValue: "Hide the workspace title bar and move workspace controls into the sidebar.") + : String(localized: "settings.app.minimalMode.subtitleOff", defaultValue: "Use the standard workspace title bar and controls.") + ) { + Toggle("", isOn: Binding( + get: { minimalMode.current == .minimal }, + set: { minimalMode.set($0 ? .minimal : .standard) } + )) + .labelsHidden() + .controlSize(.small) + .accessibilityIdentifier("SettingsMinimalModeToggle") + } + SettingsCardDivider() + + // Keep Workspace Open. The stored value carries close-on-last- + // surface semantics (true = close), so the "Keep Open" toggle + // and its subtitle bind to the inverse, matching legacy. + SettingsCardRow( + configurationReview: .json("app.keepWorkspaceOpenWhenClosingLastSurface"), + String(localized: "settings.app.closeWorkspaceOnLastSurfaceShortcut", defaultValue: "Keep Workspace Open When Closing Last Surface"), + subtitle: !keepWorkspaceOpen.current + ? String(localized: "settings.app.closeWorkspaceOnLastSurfaceShortcut.subtitleOn", defaultValue: "When the focused surface is the last one in its workspace, the close-surface shortcut closes only the surface and keeps the workspace open. Use the close-workspace shortcut to close the workspace explicitly.") + : String(localized: "settings.app.closeWorkspaceOnLastSurfaceShortcut.subtitleOff", defaultValue: "When the focused surface is the last one in its workspace, the close-surface shortcut also closes the workspace.") + ) { + Toggle("", isOn: Binding(get: { !keepWorkspaceOpen.current }, set: { keepWorkspaceOpen.set(!$0) })) + .labelsHidden() + .controlSize(.small) + } + SettingsCardDivider() + + // Focus Pane on First Click + SettingsCardRow( + configurationReview: .json("app.focusPaneOnFirstClick"), + String(localized: "settings.app.paneFirstClickFocus", defaultValue: "Focus Pane on First Click"), + subtitle: firstClick.current + ? String(localized: "settings.app.paneFirstClickFocus.subtitleOn", defaultValue: "When cmux is inactive, clicking a pane activates the window and focuses that pane in one click.") + : String(localized: "settings.app.paneFirstClickFocus.subtitleOff", defaultValue: "When cmux is inactive, the first click only activates the window. Click again to focus the pane.") + ) { + Toggle("", isOn: Binding(get: { firstClick.current }, set: { firstClick.set($0) })) + .labelsHidden() + .controlSize(.small) + } + SettingsCardDivider() + + // File Drops + SettingsCardRow( + configurationReview: .settingsOnly, + String(localized: "settings.app.fileDrop.defaultBehavior", defaultValue: "File Drops"), + subtitle: fileDropSubtitle(fileDrop.current), + controlWidth: Self.columnWidth + ) { + Picker("", selection: Binding(get: { fileDrop.current }, set: { fileDrop.set($0) })) { + Text(String(localized: "settings.app.fileDrop.defaultBehavior.text", defaultValue: "Drop path text")).tag(FileDropDefaultBehavior.text) + Text(String(localized: "settings.app.fileDrop.defaultBehavior.preview", defaultValue: "Open file preview")).tag(FileDropDefaultBehavior.preview) + } + .labelsHidden() + .pickerStyle(.menu) + } + SettingsCardDivider() + + // Preferred Editor + SettingsCardRow( + configurationReview: .json("app.preferredEditor"), + String(localized: "settings.app.preferredEditor", defaultValue: "Open Files With"), + subtitle: String(localized: "settings.app.preferredEditor.subtitle", defaultValue: "Command used when Cmd-click file previews are disabled or a file is unsupported. Leave empty for system default.") + ) { + TextField( + String(localized: "settings.app.preferredEditor.placeholder", defaultValue: "e.g. code, zed, subl"), + text: Binding(get: { preferredEditor.current }, set: { preferredEditor.set($0) }) + ) + .textFieldStyle(.roundedBorder) + .frame(width: 200) + } + SettingsCardDivider() + + // Open Supported Files in cmux + SettingsCardRow( + configurationReview: .json("app.openSupportedFilesInCmux"), + String(localized: "settings.app.openSupportedFilesInCmux", defaultValue: "Open Supported Files in cmux"), + subtitle: String(localized: "settings.app.openSupportedFilesInCmux.subtitle", defaultValue: "Cmd-clicking readable files opens text, code, PDFs, images, audio, video, and Quick Look previews in cmux.") + ) { + Toggle("", isOn: Binding(get: { openSupported.current }, set: { openSupported.set($0) })) + .labelsHidden() + .controlSize(.small) + } + SettingsCardDivider() + + // Terminal Config (host action) + SettingsCardRow( + configurationReview: .action, + String(localized: "settings.app.configWindow", defaultValue: "Terminal Config"), + subtitle: String(localized: "settings.app.configWindow.subtitle", defaultValue: "Open the cmux terminal config and generated preview in one utility window."), + controlWidth: Self.columnWidth + ) { + Button(String(localized: "settings.app.configWindow.openButton", defaultValue: "Open Config")) { + hostActions.openTerminalConfigWindow() + } + .buttonStyle(.bordered) + .controlSize(.small) + } + SettingsCardDivider() + + // Open Markdown in cmux Viewer + SettingsCardRow( + configurationReview: .json("app.openMarkdownInCmuxViewer"), + String(localized: "settings.app.openMarkdownInCmuxViewer", defaultValue: "Open Markdown in cmux Viewer"), + subtitle: String(localized: "settings.app.openMarkdownInCmuxViewer.subtitle", defaultValue: "When supported file routing is on, Cmd-clicking Markdown files opens the rendered cmux markdown viewer instead of the generic file preview.") + ) { + Toggle("", isOn: Binding(get: { openMarkdown.current }, set: { openMarkdown.set($0) })) + .labelsHidden() + .controlSize(.small) + } + SettingsCardDivider() + + // iMessage Mode + SettingsCardRow( + configurationReview: .json("app.iMessageMode"), + String(localized: "settings.app.iMessageMode", defaultValue: "iMessage Mode"), + subtitle: String(localized: "settings.app.iMessageMode.subtitle", defaultValue: "Move a workspace to the top and show the submitted message when you send an agent prompt.") + ) { + Toggle("", isOn: Binding(get: { iMessage.current }, set: { iMessage.set($0) })) + .labelsHidden() + .controlSize(.small) + } + SettingsCardDivider() + + // Reorder on Notification + SettingsCardRow( + configurationReview: .json("app.reorderOnNotification"), + String(localized: "settings.app.reorderOnNotification", defaultValue: "Reorder on Notification"), + subtitle: String(localized: "settings.app.reorderOnNotification.subtitle", defaultValue: "Move workspaces to the top when they receive a notification. Disable for stable shortcut positions.") + ) { + Toggle("", isOn: Binding(get: { reorder.current }, set: { reorder.set($0) })) + .labelsHidden() + .controlSize(.small) + } + SettingsCardDivider() + + // Dock Badge + SettingsCardRow( + configurationReview: .json("notifications.dockBadge"), + String(localized: "settings.app.dockBadge", defaultValue: "Dock Badge"), + subtitle: String(localized: "settings.app.dockBadge.subtitle", defaultValue: "Show unread count on app icon (Dock and Cmd+Tab).") + ) { + Toggle("", isOn: Binding(get: { dockBadge.current }, set: { dockBadge.set($0) })) + .labelsHidden() + .controlSize(.small) + } + SettingsCardDivider() + + // Menu Bar Only + SettingsCardRow( + configurationReview: .json("app.menuBarOnly"), + String(localized: "settings.app.menuBarOnly", defaultValue: "Menu Bar Only"), + subtitle: String(localized: "settings.app.menuBarOnly.subtitle", defaultValue: "Hide the Dock icon and Cmd+Tab entry. Use the menu bar item to show cmux.") + ) { + Toggle("", isOn: Binding(get: { menuBarOnly.current }, set: { menuBarOnly.set($0) })) + .labelsHidden() + .controlSize(.small) + .accessibilityIdentifier("SettingsMenuBarOnlyToggle") + } + SettingsCardDivider() + + // Show in Menu Bar + SettingsCardRow( + configurationReview: .json("notifications.showInMenuBar"), + String(localized: "settings.app.showInMenuBar", defaultValue: "Show in Menu Bar"), + subtitle: String(localized: "settings.app.showInMenuBar.subtitle", defaultValue: "Keep cmux in the menu bar for unread notifications and quick actions.") + ) { + Toggle("", isOn: Binding(get: { showInMenuBar.current }, set: { showInMenuBar.set($0) })) + .labelsHidden() + .controlSize(.small) + } + .disabled(menuBarOnly.current) + SettingsCardDivider() + + // Unread Pane Ring + SettingsCardRow( + configurationReview: .json("notifications.unreadPaneRing"), + String(localized: "settings.notifications.paneRing.title", defaultValue: "Unread Pane Ring"), + subtitle: String(localized: "settings.notifications.paneRing.subtitle", defaultValue: "Show a blue ring around panes with unread notifications.") + ) { + Toggle("", isOn: Binding(get: { paneRing.current }, set: { paneRing.set($0) })) + .labelsHidden() + .controlSize(.small) + } + SettingsCardDivider() + + // Pane Flash + SettingsCardRow( + configurationReview: .json("notifications.paneFlash"), + String(localized: "settings.notifications.paneFlash.title", defaultValue: "Pane Flash"), + subtitle: String(localized: "settings.notifications.paneFlash.subtitle", defaultValue: "Briefly flash a blue outline when cmux highlights a pane.") + ) { + Toggle("", isOn: Binding(get: { paneFlash.current }, set: { paneFlash.set($0) })) + .labelsHidden() + .controlSize(.small) + } + + // Desktop Notifications — legacy renders this row + // unconditionally with a permission-state status text + + // one dynamic action button + Send Test. Without a host + // signal for the permission state, the package falls + // back to the .notDetermined baseline: subtitle "Desktop + // notifications are not enabled yet.", "Enable" action + // (which maps to requestNotificationAuthorization), and + // Send Test. Buttons disable when no host is wired. + SettingsCardDivider() + SettingsCardRow( + configurationReview: .action, + String(localized: "settings.notifications.desktop", defaultValue: "Desktop Notifications"), + subtitle: String(localized: "settings.notifications.desktop.subtitle.notDetermined", defaultValue: "Desktop notifications are not enabled yet.") + ) { + HStack(spacing: 6) { + Text(String(localized: "settings.notifications.desktop.status.unknown", defaultValue: "Permission unknown")) + .font(.system(size: 11, weight: .semibold)) + .foregroundStyle(.secondary) + .frame(width: 98, alignment: .trailing) + Button(String(localized: "settings.notifications.desktop.action.enable", defaultValue: "Enable")) { + hostActions.requestNotificationAuthorization() + } + .controlSize(.small) + Button(String(localized: "settings.notifications.desktop.sendTest", defaultValue: "Send Test")) { + hostActions.sendTestNotification() + } + .controlSize(.small) + } + } + SettingsCardDivider() + + // Notification Sound — Picker over NSSound names with + // Preview button. Custom-file path field appears when the + // user selects "custom". + notificationSoundRow(model: soundName) + SettingsCardDivider() + + // Notification Command + SettingsCardRow( + configurationReview: .json("notifications.command"), + 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( + String(localized: "settings.notifications.command.placeholder", defaultValue: "say \"done\""), + text: Binding(get: { soundCommand.current }, set: { soundCommand.set($0) }) + ) + .textFieldStyle(.roundedBorder) + .frame(width: 200) + } + SettingsCardDivider() + + // Telemetry + SettingsCardRow( + configurationReview: .json("app.sendAnonymousTelemetry"), + String(localized: "settings.app.telemetry", defaultValue: "Send anonymous telemetry"), + subtitle: (telemetryAtAppear != nil && telemetry.current != telemetryAtAppear) + ? String(localized: "settings.app.telemetry.subtitleChanged", defaultValue: "Change takes effect on next launch.") + : String(localized: "settings.app.telemetry.subtitle", defaultValue: "Share anonymized crash and usage data to help improve cmux.") + ) { + Toggle("", isOn: Binding(get: { telemetry.current }, set: { telemetry.set($0) })) + .labelsHidden() + .controlSize(.small) + } + SettingsCardDivider() + + // Warn Before Quit + SettingsCardRow( + configurationReview: .json("app.confirmQuit", "app.warnBeforeQuit"), + String(localized: "settings.app.warnBeforeQuit", defaultValue: "Warn Before Quit"), + subtitle: confirmQuitSubtitle(confirmQuit.current), + controlWidth: Self.columnWidth + ) { + Picker("", selection: Binding(get: { confirmQuit.current }, set: { confirmQuit.set($0) })) { + // Labels mirror legacy QuitConfirmationMode.localizedSettingsTitle. + Text(String(localized: "settings.app.confirmQuit.always", defaultValue: "Always")).tag(ConfirmQuitMode.always) + Text(String(localized: "settings.app.confirmQuit.dirtyOnly", defaultValue: "Dirty Only")).tag(ConfirmQuitMode.dirtyOnly) + Text(String(localized: "settings.app.confirmQuit.never", defaultValue: "Never")).tag(ConfirmQuitMode.never) + } + .labelsHidden() + .pickerStyle(.segmented) + .controlSize(.small) + } + SettingsCardDivider() + + // Warn Before Closing Tab + SettingsCardRow( + configurationReview: .json("app.warnBeforeClosingTab"), + String(localized: "settings.app.warnBeforeClosingTab", defaultValue: "Warn Before Closing Tab"), + subtitle: warnCloseTab.current + ? String(localized: "settings.app.warnBeforeClosingTab.subtitleOn", defaultValue: "Show a confirmation before closing a tab.") + : String(localized: "settings.app.warnBeforeClosingTab.subtitleOff", defaultValue: "Tabs close immediately without confirmation.") + ) { + Toggle("", isOn: Binding(get: { warnCloseTab.current }, set: { warnCloseTab.set($0) })) + .labelsHidden() + .controlSize(.small) + } + SettingsCardDivider() + + // Warn Before Tab Close Button + SettingsCardRow( + configurationReview: .json("app.warnBeforeClosingTabXButton"), + String(localized: "settings.app.warnBeforeClosingTabXButton", defaultValue: "Warn Before Tab Close Button"), + subtitle: warnCloseXSubtitle(hideCloseButton: hideCloseButton.current, warnEnabled: warnCloseX.current) + ) { + Toggle("", isOn: Binding(get: { warnCloseX.current }, set: { warnCloseX.set($0) })) + .labelsHidden() + .controlSize(.small) + .disabled(hideCloseButton.current) + } + SettingsCardDivider() + + // Hide Tab Close Button + SettingsCardRow( + configurationReview: .json("app.hideTabCloseButton"), + String(localized: "settings.app.hideTabCloseButton", defaultValue: "Hide Tab Close Button"), + subtitle: hideCloseButton.current + ? String(localized: "settings.app.hideTabCloseButton.subtitleOn", defaultValue: "Tab close buttons are hidden.") + : String(localized: "settings.app.hideTabCloseButton.subtitleOff", defaultValue: "Tab close buttons appear on hover and on the active tab.") + ) { + Toggle("", isOn: Binding(get: { hideCloseButton.current }, set: { hideCloseButton.set($0) })) + .labelsHidden() + .controlSize(.small) + } + SettingsCardDivider() + + // Rename Selects Existing Name + SettingsCardRow( + configurationReview: .json("app.renameSelectsExistingName"), + String(localized: "settings.app.renameSelectsName", defaultValue: "Rename Selects Existing Name"), + subtitle: renameSelects.current + ? String(localized: "settings.app.renameSelectsName.subtitleOn", defaultValue: "Command Palette rename starts with all text selected.") + : String(localized: "settings.app.renameSelectsName.subtitleOff", defaultValue: "Command Palette rename keeps the caret at the end.") + ) { + Toggle("", isOn: Binding(get: { renameSelects.current }, set: { renameSelects.set($0) })) + .labelsHidden() + .controlSize(.small) + } + SettingsCardDivider() + + // Command Palette Searches All Surfaces + SettingsCardRow( + configurationReview: .json("app.commandPaletteSearchesAllSurfaces"), + String(localized: "settings.app.commandPaletteSearchAllSurfaces", defaultValue: "Command Palette Searches All Surfaces"), + subtitle: paletteAllSurfaces.current + ? String(localized: "settings.app.commandPaletteSearchAllSurfaces.subtitleOn", defaultValue: "Cmd+P also matches panel surfaces across workspaces.") + : String(localized: "settings.app.commandPaletteSearchAllSurfaces.subtitleOff", defaultValue: "Cmd+P matches workspace rows only.") + ) { + Toggle("", isOn: Binding(get: { paletteAllSurfaces.current }, set: { paletteAllSurfaces.set($0) })) + .labelsHidden() + .controlSize(.small) + .accessibilityIdentifier("CommandPaletteSearchAllSurfacesToggle") + } + } + } + + /// Standard macOS notification sound names plus cmux-specific + /// sentinels for default / none / custom-file. Matches the + /// legacy `NotificationSoundSettings.systemSounds` list shape + /// (order, labels, and the `custom_file` sentinel value). + private static let customSoundFileValue = "custom_file" + private static let systemSoundOptions: [(value: String, label: String)] = [ + ("default", "Default"), + ("Basso", "Basso"), + ("Blow", "Blow"), + ("Bottle", "Bottle"), + ("Frog", "Frog"), + ("Funk", "Funk"), + ("Glass", "Glass"), + ("Hero", "Hero"), + ("Morse", "Morse"), + ("Ping", "Ping"), + ("Pop", "Pop"), + ("Purr", "Purr"), + ("Sosumi", "Sosumi"), + ("Submarine", "Submarine"), + ("Tink", "Tink"), + (customSoundFileValue, "Custom File..."), + ("none", "None"), + ] + + @ViewBuilder + private func notificationSoundRow(model: DefaultsValueModel) -> some View { + let customFile = customSoundFile + SettingsCardRow( + configurationReview: .json("notifications.sound", "notifications.customSoundFilePath"), + String(localized: "settings.notifications.sound.title", defaultValue: "Notification Sound"), + subtitle: String(localized: "settings.notifications.sound.subtitle", defaultValue: "Sound played when a notification arrives."), + controlWidth: Self.notificationSoundControlWidth + ) { + VStack(alignment: .trailing, spacing: 6) { + HStack(spacing: 6) { + Picker("", selection: Binding(get: { model.current }, set: { model.set($0) })) { + ForEach(Self.systemSoundOptions, id: \.value) { option in + Text(option.label).tag(option.value) + } + } + .labelsHidden() + Button { + hostActions.previewNotificationSound() + } label: { + Image(systemName: "play.fill") + .font(.system(size: 9)) + } + .buttonStyle(.bordered) + .controlSize(.small) + .disabled(!canPreviewNotificationSound(soundValue: model.current, customFilePath: customFile.current)) + } + if model.current == Self.customSoundFileValue { + HStack(spacing: 6) { + // Legacy AppSection always renders the file + // display name slot, with a "No file selected" + // fallback when the path is empty. + Text(customSoundFileDisplayName(path: customFile.current)) + .font(.system(size: 11)) + .foregroundStyle(.secondary) + .lineLimit(1) + .truncationMode(.middle) + .frame(width: 170, alignment: .trailing) + Button(String(localized: "settings.notifications.sound.custom.choose.button", defaultValue: "Choose...")) { + chooseCustomNotificationSound(into: customFile) + } + .controlSize(.small) + Button(String(localized: "settings.notifications.sound.custom.clear.button", defaultValue: "Clear")) { + customFile.reset() + } + .controlSize(.small) + .disabled(customFile.current.isEmpty) + } + } + } + .frame(maxWidth: .infinity, alignment: .trailing) + } + } + + private func chooseCustomNotificationSound(into model: DefaultsValueModel) { + let panel = NSOpenPanel() + panel.canChooseDirectories = false + panel.canChooseFiles = true + panel.allowsMultipleSelection = false + panel.allowedContentTypes = [ + UTType(filenameExtension: "aiff"), + UTType(filenameExtension: "wav"), + UTType(filenameExtension: "caf"), + UTType(filenameExtension: "m4a"), + UTType(filenameExtension: "mp3"), + ].compactMap { $0 } + panel.title = String(localized: "settings.notifications.sound.custom.panelTitle", defaultValue: "Choose Notification Sound") + if panel.runModal() == .OK, let url = panel.url { + model.set(url.path) + } + } + + private func languageDisplayName(_ language: AppLanguage) -> String { + // Mirrors legacy AppLanguage.displayName: native name plus an + // English suffix in parentheses, except for English and + // Portuguese (Brasil) which already carry the locale name. + switch language { + case .system: return String(localized: "language.system", defaultValue: "System") + case .en: return "English" + case .ar: return "\u{200E}العربية (Arabic)" + case .bs: return "Bosanski (Bosnian)" + case .zhHans: return "简体中文 (Chinese Simplified)" + case .zhHant: return "繁體中文 (Chinese Traditional)" + case .da: return "Dansk (Danish)" + case .de: return "Deutsch (German)" + case .es: return "Español (Spanish)" + case .fr: return "Français (French)" + case .it: return "Italiano (Italian)" + case .ja: return "日本語 (Japanese)" + case .ko: return "한국어 (Korean)" + case .nb: return "Norsk (Norwegian)" + case .pl: return "Polski (Polish)" + case .ptBR: return "Português (Brasil)" + case .ru: return "Русский (Russian)" + case .th: return "ไทย (Thai)" + case .tr: return "Türkçe (Turkish)" + case .vi: return "Tiếng Việt (Vietnamese)" + } + } + + private func workspacePlacementSubtitle(_ placement: WorkspacePlacement) -> String { + // Mirrors legacy NewWorkspacePlacement.description verbatim + // (Sources/TabManager.swift, "workspace.placement.*.description"). + switch placement { + case .top: + return String( + localized: "workspace.placement.top.description", + defaultValue: "Insert new workspaces at the top of the list." + ) + case .afterCurrent: + return String( + localized: "workspace.placement.afterCurrent.description", + defaultValue: "Insert new workspaces directly after the active workspace." + ) + case .end: + return String( + localized: "workspace.placement.end.description", + defaultValue: "Append new workspaces to the bottom of the list." + ) + } + } + + private func fileDropSubtitle(_ behavior: FileDropDefaultBehavior) -> String { + switch behavior { + case .text: + return String( + localized: "settings.app.fileDrop.defaultBehavior.text.subtitle", + defaultValue: "Over terminals and editors, dragging files inserts shell-escaped paths. Hold Shift to open a file preview or split." + ) + case .preview: + return String( + localized: "settings.app.fileDrop.defaultBehavior.preview.subtitle", + defaultValue: "Dragging files opens previews or split panes. Hold Shift over terminals and editors to insert path text." + ) + } + } + + private func confirmQuitSubtitle(_ mode: ConfirmQuitMode) -> String { + // Mirrors legacy confirmQuitModeSubtitle keys/text. + switch mode { + case .always: return String(localized: "settings.app.warnBeforeQuit.subtitleOn", defaultValue: "Show a confirmation before quitting with Cmd+Q.") + case .dirtyOnly: return String(localized: "settings.app.confirmQuit.subtitleDirtyOnly", defaultValue: "Show a confirmation only when a workspace needs close confirmation.") + case .never: return String(localized: "settings.app.warnBeforeQuit.subtitleOff", defaultValue: "Cmd+Q quits immediately without confirmation.") + } + } + + /// Mirrors legacy `notificationSoundCustomFileDisplayName`. + private func customSoundFileDisplayName(path: String) -> String { + let trimmed = path.trimmingCharacters(in: .whitespacesAndNewlines) + if trimmed.isEmpty { + return String( + localized: "settings.notifications.sound.custom.file.none", + defaultValue: "No file selected" + ) + } + return URL(fileURLWithPath: trimmed).lastPathComponent + } + + /// Mirrors legacy `canPreviewNotificationSound`. Custom-file mode + /// can only preview when a path is present. + private func canPreviewNotificationSound(soundValue: String, customFilePath: String) -> Bool { + switch soundValue { + case "none": + return false + case Self.customSoundFileValue: + return !customFilePath.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty + default: + return true + } + } + + private func warnCloseXSubtitle(hideCloseButton: Bool, warnEnabled: Bool) -> String { + // Mirrors legacy warnBeforeClosingTabXButtonSubtitle: hidden override + // takes priority, then on/off wording. + if hideCloseButton { + return String( + localized: "settings.app.warnBeforeClosingTabXButton.subtitleHidden", + defaultValue: "Tab close buttons are hidden, so this warning is inactive." + ) + } + if warnEnabled { + return String( + localized: "settings.app.warnBeforeClosingTabXButton.subtitleOn", + defaultValue: "The tab close button asks for confirmation before closing." + ) + } + return String( + localized: "settings.app.warnBeforeClosingTabXButton.subtitleOff", + defaultValue: "The tab close button closes tabs immediately." + ) + } +} diff --git a/Packages/CmuxSettingsUI/Sources/CmuxSettingsUI/Sections/AutomationSection.swift b/Packages/CmuxSettingsUI/Sources/CmuxSettingsUI/Sections/AutomationSection.swift new file mode 100644 index 000000000000..f9b3a85d6f59 --- /dev/null +++ b/Packages/CmuxSettingsUI/Sources/CmuxSettingsUI/Sections/AutomationSection.swift @@ -0,0 +1,370 @@ +import CmuxSettings +import SwiftUI + +/// **Automation** section — mirrors the legacy in-app section +/// row-for-row: Socket Control (mode picker, password subrow when +/// .password, warnings, overrides note), then separate cards for +/// Claude Code Integration, Claude Binary Path, Ripgrep Binary Path, +/// Suppress Subagent Notifications, Cursor Integration, Gemini +/// Integration, and Port Base / Port Range Size. +@MainActor +public struct AutomationSection: View { + private let catalog: SettingCatalog + + @State private var socketPasswordModel: JSONValueModel + @State private var modeModel: DefaultsValueModel + @State private var claudeCodeModel: DefaultsValueModel + @State private var claudePathModel: DefaultsValueModel + @State private var ripgrepPathModel: DefaultsValueModel + @State private var suppressSubagentModel: DefaultsValueModel + @State private var cursorModel: DefaultsValueModel + @State private var geminiModel: DefaultsValueModel + @State private var portBaseModel: DefaultsValueModel + @State private var portRangeModel: DefaultsValueModel + @State private var socketPasswordDraft: String = "" + @State private var socketPasswordStatus: SocketPasswordStatus? + @State private var showOpenAccessConfirmation: Bool = false + @State private var pendingOpenAccessMode: SocketControlMode? + @State private var modeBeforePendingOpenAccess: SocketControlMode? + + private struct SocketPasswordStatus: Equatable { + let message: String + let isError: Bool + } + + public init( + defaultsStore: UserDefaultsSettingsStore, + jsonStore: JSONConfigStore, + catalog: SettingCatalog, + errorLog: SettingsErrorLog + ) { + self.catalog = catalog + _socketPasswordModel = State(initialValue: JSONValueModel( + store: jsonStore, + key: catalog.automation.socketPassword, + errorLog: errorLog + )) + _modeModel = State(initialValue: DefaultsValueModel(store: defaultsStore, key: catalog.automation.socketControlMode)) + _claudeCodeModel = State(initialValue: DefaultsValueModel(store: defaultsStore, key: catalog.integrations.claudeCodeHooksEnabled)) + _claudePathModel = State(initialValue: DefaultsValueModel(store: defaultsStore, key: catalog.integrations.claudeCodeCustomClaudePath)) + _ripgrepPathModel = State(initialValue: DefaultsValueModel(store: defaultsStore, key: catalog.integrations.ripgrepCustomBinaryPath)) + _suppressSubagentModel = State(initialValue: DefaultsValueModel(store: defaultsStore, key: catalog.integrations.suppressSubagentNotifications)) + _cursorModel = State(initialValue: DefaultsValueModel(store: defaultsStore, key: catalog.integrations.cursorHooksEnabled)) + _geminiModel = State(initialValue: DefaultsValueModel(store: defaultsStore, key: catalog.integrations.geminiHooksEnabled)) + _portBaseModel = State(initialValue: DefaultsValueModel(store: defaultsStore, key: catalog.automation.portBase)) + _portRangeModel = State(initialValue: DefaultsValueModel(store: defaultsStore, key: catalog.automation.portRange)) + } + + private static let columnWidth: CGFloat = 196 + + public var body: some View { + Group { + SettingsSectionHeader(String(localized: "settings.section.automation", defaultValue: "Automation")) + + socketControlCard + claudeCodeCard + claudePathCard + ripgrepPathCard + suppressSubagentCard + cursorCard + geminiCard + portCard + } + .confirmationDialog( + String(localized: "settings.automation.openAccess.dialog.title", defaultValue: "Enable full open access?"), + isPresented: $showOpenAccessConfirmation, + titleVisibility: .visible + ) { + Button( + String(localized: "settings.automation.openAccess.dialog.confirm", defaultValue: "Enable Full Open Access"), + role: .destructive + ) { + if let pending = pendingOpenAccessMode { + modeModel.set(pending) + } + pendingOpenAccessMode = nil + modeBeforePendingOpenAccess = nil + } + Button( + String(localized: "settings.automation.openAccess.dialog.cancel", defaultValue: "Cancel"), + role: .cancel + ) { + pendingOpenAccessMode = nil + modeBeforePendingOpenAccess = nil + } + } message: { + Text(String( + localized: "settings.automation.openAccess.dialog.message", + defaultValue: "This disables ancestry and password checks and opens the socket to all local users. Only enable when you understand the risk." + )) + } + } + + @ViewBuilder + private var socketControlCard: some View { + let isPassword = modeModel.current == .password + let isAllowAll = modeModel.current == .allowAll + let hasPassword = !socketPasswordModel.current.isEmpty + + SettingsCard { + SettingsCardRow( + configurationReview: .json("automation.socketControlMode"), + String(localized: "settings.automation.socketMode", defaultValue: "Socket Control Mode"), + subtitle: modeModel.current.description, + controlWidth: Self.columnWidth + ) { + Picker("", selection: Binding( + get: { modeModel.current }, + set: { newValue in + if newValue == .allowAll && modeModel.current != .allowAll { + modeBeforePendingOpenAccess = modeModel.current + pendingOpenAccessMode = newValue + showOpenAccessConfirmation = true + return + } + modeModel.set(newValue) + if newValue != .password { + socketPasswordStatus = nil + } + } + )) { + ForEach(SocketControlMode.uiCases, id: \.self) { mode in + Text(mode.displayName).tag(mode) + } + } + .labelsHidden() + .pickerStyle(.menu) + .accessibilityIdentifier("AutomationSocketModePicker") + } + SettingsCardDivider() + SettingsCardNote(String(localized: "settings.automation.socketMode.note", defaultValue: "Controls access to the local Unix socket for programmatic control. Choose a mode that matches your threat model.")) + + if isPassword { + SettingsCardDivider() + SettingsCardRow( + configurationReview: .json("automation.socketPassword"), + String(localized: "settings.automation.socketPassword", defaultValue: "Socket Password"), + subtitle: hasPassword + ? String(localized: "settings.automation.socketPassword.subtitleSet", defaultValue: "Stored in Application Support.") + : String(localized: "settings.automation.socketPassword.subtitleUnset", defaultValue: "No password set. External clients will be blocked until one is configured.") + ) { + HStack(spacing: 8) { + SecureField( + String(localized: "settings.automation.socketPassword.placeholder", defaultValue: "Password"), + text: $socketPasswordDraft + ) + .textFieldStyle(.roundedBorder) + .frame(width: 170) + Button( + hasPassword + ? String(localized: "settings.automation.socketPassword.change", defaultValue: "Change") + : String(localized: "settings.automation.socketPassword.set", defaultValue: "Set") + ) { + saveSocketPassword() + } + .buttonStyle(.bordered) + .controlSize(.small) + .disabled(socketPasswordDraft.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty) + if hasPassword { + Button(String(localized: "settings.automation.socketPassword.clear", defaultValue: "Clear")) { + clearSocketPassword() + } + .buttonStyle(.bordered) + .controlSize(.small) + } + } + } + if let status = socketPasswordStatus { + Text(status.message) + .font(.caption) + .foregroundStyle(status.isError ? Color.red : Color.secondary) + .padding(.horizontal, 14) + .padding(.bottom, 8) + } + } + + if isAllowAll { + SettingsCardDivider() + Text(String(localized: "settings.automation.openAccessWarning", defaultValue: "Warning: Full open access makes the control socket world-readable/writable on this Mac and disables auth checks. Use only for local debugging.")) + .font(.caption) + .foregroundStyle(.red) + .padding(.horizontal, 14) + .padding(.vertical, 8) + } + + SettingsCardNote(String(localized: "settings.automation.socketOverrides.note", defaultValue: "Overrides: CMUX_SOCKET_ENABLE, CMUX_SOCKET_MODE, and CMUX_SOCKET_PATH (set CMUX_ALLOW_SOCKET_OVERRIDE=1 for stable/nightly builds).")) + } + } + + @ViewBuilder + private var claudeCodeCard: some View { + SettingsCard { + SettingsCardRow( + configurationReview: .json("automation.claudeCodeIntegration"), + String(localized: "settings.automation.claudeCode", defaultValue: "Claude Code Integration"), + subtitle: claudeCodeModel.current + ? String(localized: "settings.automation.claudeCode.subtitleOn", defaultValue: "Sidebar shows Claude session status and notifications.") + : String(localized: "settings.automation.claudeCode.subtitleOff", defaultValue: "Claude Code runs without cmux integration.") + ) { + Toggle("", isOn: Binding(get: { claudeCodeModel.current }, set: { claudeCodeModel.set($0) })) + .labelsHidden() + .controlSize(.small) + .accessibilityIdentifier("SettingsClaudeCodeHooksToggle") + } + SettingsCardDivider() + SettingsCardNote(String(localized: "settings.automation.claudeCode.note", defaultValue: "When enabled, cmux wraps the claude command to inject session tracking and notification hooks. Disable if you prefer to manage Claude Code hooks yourself.")) + } + } + + @ViewBuilder + private var claudePathCard: some View { + SettingsCard { + SettingsCardRow( + configurationReview: .json("automation.claudeBinaryPath"), + String(localized: "settings.automation.claudeCode.customPath", defaultValue: "Claude Binary Path"), + subtitle: String(localized: "settings.automation.claudeCode.customPath.subtitle", defaultValue: "Custom path to the claude binary. Leave empty to use PATH.") + ) { + TextField( + String(localized: "settings.automation.claudeCode.customPath.placeholder", defaultValue: "e.g. /usr/local/bin/claude"), + text: Binding(get: { claudePathModel.current }, set: { claudePathModel.set($0) }) + ) + .textFieldStyle(.roundedBorder) + .frame(width: 200) + } + } + } + + @ViewBuilder + private var ripgrepPathCard: some View { + SettingsCard { + SettingsCardRow( + configurationReview: .json("automation.ripgrepBinaryPath"), + String(localized: "settings.automation.ripgrep.customPath", defaultValue: "Ripgrep Binary Path"), + subtitle: String(localized: "settings.automation.ripgrep.customPath.subtitle", defaultValue: "Custom path to the rg binary used by Find. Leave empty to use common install locations and PATH.") + ) { + TextField( + String(localized: "settings.automation.ripgrep.customPath.placeholder", defaultValue: "e.g. /etc/profiles/per-user/you/bin/rg"), + text: Binding(get: { ripgrepPathModel.current }, set: { ripgrepPathModel.set($0) }) + ) + .textFieldStyle(.roundedBorder) + .frame(width: 200) + } + } + } + + @ViewBuilder + private var suppressSubagentCard: some View { + SettingsCard { + SettingsCardRow( + configurationReview: .json("automation.suppressSubagentNotifications"), + String(localized: "settings.automation.suppressSubagentNotifications", defaultValue: "Suppress Subagent Notifications"), + subtitle: suppressSubagentModel.current + ? String(localized: "settings.automation.suppressSubagentNotifications.subtitleOn", defaultValue: "Child agent completions stay in Feed without notifications.") + : String(localized: "settings.automation.suppressSubagentNotifications.subtitleOff", defaultValue: "Child agent completions notify like top-level agents.") + ) { + Toggle("", isOn: Binding(get: { suppressSubagentModel.current }, set: { suppressSubagentModel.set($0) })) + .labelsHidden() + .controlSize(.small) + .accessibilityIdentifier("SettingsSuppressSubagentNotificationsToggle") + } + SettingsCardDivider() + SettingsCardNote(String(localized: "settings.automation.suppressSubagentNotifications.note", defaultValue: "Uses process ancestry from hook processes. Disable if nested Codex or Claude sessions should trigger completion notifications.")) + } + } + + @ViewBuilder + private var cursorCard: some View { + SettingsCard { + SettingsCardRow( + configurationReview: .json("automation.cursorIntegration"), + String(localized: "settings.automation.cursor", defaultValue: "Cursor Integration"), + subtitle: cursorModel.current + ? String(localized: "settings.automation.cursor.subtitleOn", defaultValue: "Sidebar shows Cursor agent status and notifications.") + : String(localized: "settings.automation.cursor.subtitleOff", defaultValue: "Cursor runs without cmux integration.") + ) { + Toggle("", isOn: Binding(get: { cursorModel.current }, set: { cursorModel.set($0) })) + .labelsHidden() + .controlSize(.small) + .accessibilityIdentifier("SettingsCursorHooksToggle") + } + SettingsCardDivider() + SettingsCardNote(String(localized: "settings.automation.cursor.note", defaultValue: "Hooks must be installed with `cmux hooks cursor install`. They no-op outside cmux terminals.")) + } + } + + @ViewBuilder + private var geminiCard: some View { + SettingsCard { + SettingsCardRow( + configurationReview: .json("automation.geminiIntegration"), + String(localized: "settings.automation.gemini", defaultValue: "Gemini CLI Integration"), + subtitle: geminiModel.current + ? String(localized: "settings.automation.gemini.subtitleOn", defaultValue: "Sidebar shows Gemini session status and notifications.") + : String(localized: "settings.automation.gemini.subtitleOff", defaultValue: "Gemini runs without cmux integration.") + ) { + Toggle("", isOn: Binding(get: { geminiModel.current }, set: { geminiModel.set($0) })) + .labelsHidden() + .controlSize(.small) + .accessibilityIdentifier("SettingsGeminiHooksToggle") + } + SettingsCardDivider() + SettingsCardNote(String(localized: "settings.automation.gemini.note", defaultValue: "Hooks must be installed with `cmux hooks gemini install`. They no-op outside cmux terminals.")) + } + } + + @ViewBuilder + private var portCard: some View { + SettingsCard { + SettingsCardRow( + configurationReview: .json("automation.portBase"), + String(localized: "settings.automation.portBase", defaultValue: "Port Base"), + subtitle: String(localized: "settings.automation.portBase.subtitle", defaultValue: "Starting port for CMUX_PORT env var."), + controlWidth: Self.columnWidth + ) { + TextField("", value: Binding(get: { portBaseModel.current }, set: { portBaseModel.set($0) }), format: .number) + .textFieldStyle(.roundedBorder) + .multilineTextAlignment(.trailing) + } + SettingsCardDivider() + SettingsCardRow( + configurationReview: .json("automation.portRange"), + String(localized: "settings.automation.portRange", defaultValue: "Port Range Size"), + subtitle: String(localized: "settings.automation.portRange.subtitle", defaultValue: "Number of ports per workspace."), + controlWidth: Self.columnWidth + ) { + TextField("", value: Binding(get: { portRangeModel.current }, set: { portRangeModel.set($0) }), format: .number) + .textFieldStyle(.roundedBorder) + .multilineTextAlignment(.trailing) + } + SettingsCardDivider() + SettingsCardNote(String(localized: "settings.automation.port.note", defaultValue: "Each workspace gets CMUX_PORT and CMUX_PORT_END env vars with a dedicated port range. New terminals inherit these values.")) + } + } + + private func saveSocketPassword() { + let trimmed = socketPasswordDraft.trimmingCharacters(in: .whitespacesAndNewlines) + guard !trimmed.isEmpty else { + socketPasswordStatus = SocketPasswordStatus( + message: String(localized: "settings.automation.socketPassword.empty", defaultValue: "Enter a password first."), + isError: true + ) + return + } + socketPasswordModel.set(trimmed) + socketPasswordDraft = "" + socketPasswordStatus = SocketPasswordStatus( + message: String(localized: "settings.automation.socketPassword.saved", defaultValue: "Saved."), + isError: false + ) + } + + private func clearSocketPassword() { + socketPasswordModel.reset() + socketPasswordDraft = "" + socketPasswordStatus = SocketPasswordStatus( + message: String(localized: "settings.automation.socketPassword.cleared", defaultValue: "Cleared."), + isError: false + ) + } + +} diff --git a/Packages/CmuxSettingsUI/Sources/CmuxSettingsUI/Sections/BetaFeaturesSection.swift b/Packages/CmuxSettingsUI/Sources/CmuxSettingsUI/Sections/BetaFeaturesSection.swift new file mode 100644 index 000000000000..70dd8063042f --- /dev/null +++ b/Packages/CmuxSettingsUI/Sources/CmuxSettingsUI/Sections/BetaFeaturesSection.swift @@ -0,0 +1,72 @@ +import CmuxSettings +import SwiftUI + +/// **Beta Features** section — mirrors the legacy +/// `BetaFeaturesSettingsView`: warning note followed by a single +/// `Dock` toggle. +@MainActor +public struct BetaFeaturesSection: View { + @State private var dock: DefaultsValueModel + + public init(defaultsStore: UserDefaultsSettingsStore, catalog: SettingCatalog) { + _dock = State(initialValue: DefaultsValueModel(store: defaultsStore, key: catalog.betaFeatures.rightSidebarDock)) + } + + public var body: some View { + Group { + SettingsSectionHeader(String(localized: "settings.section.betaFeatures", defaultValue: "Beta Features")) + SettingsCard { + BetaFeaturesWarningNote( + String(localized: "settings.betaFeatures.warning", defaultValue: "Dock is unstable and may change or break. Enable it only when you are testing it.") + ) + SettingsCardDivider() + dockRow + } + } + } + + @ViewBuilder + private var dockRow: some View { + SettingsCardRow( + configurationReview: .settingsOnly, + String(localized: "settings.betaFeatures.dock", defaultValue: "Dock"), + subtitle: dock.current + ? String(localized: "settings.betaFeatures.dock.subtitleOn", defaultValue: "Shows Dock in the right sidebar mode switcher for custom terminal controls.") + : String(localized: "settings.betaFeatures.dock.subtitleOff", defaultValue: "Hides Dock from the right sidebar until you enable it here.") + ) { + Toggle("", isOn: Binding(get: { dock.current }, set: { dock.set($0) })) + .labelsHidden() + .controlSize(.small) + .accessibilityIdentifier("SettingsBetaDockToggle") + } + } +} + +/// Small warning callout with a yellow triangle, used at the top of +/// the Beta Features card to remind users the toggles below are +/// unstable. Mirrors the legacy `BetaFeaturesWarningNote`. +@MainActor +private struct BetaFeaturesWarningNote: View { + let text: String + + init(_ text: String) { + self.text = text + } + + var body: some View { + HStack(alignment: .top, spacing: 8) { + Image(systemName: "exclamationmark.triangle.fill") + .font(.system(size: 12, weight: .semibold)) + .foregroundStyle(.yellow) + .accessibilityHidden(true) + + Text(text) + .font(.caption) + .foregroundColor(.secondary) + .fixedSize(horizontal: false, vertical: true) + } + .padding(.horizontal, 14) + .padding(.vertical, 8) + .frame(maxWidth: .infinity, alignment: .leading) + } +} diff --git a/Packages/CmuxSettingsUI/Sources/CmuxSettingsUI/Sections/BrowserImportSection.swift b/Packages/CmuxSettingsUI/Sources/CmuxSettingsUI/Sections/BrowserImportSection.swift new file mode 100644 index 000000000000..eb1b6bf665cf --- /dev/null +++ b/Packages/CmuxSettingsUI/Sources/CmuxSettingsUI/Sections/BrowserImportSection.swift @@ -0,0 +1,25 @@ +import CmuxSettings +import SwiftUI + +/// **Import Browser Data** anchor — in the legacy layout the import +/// block is rendered inline inside the Browser section's card. This +/// section exists only as a navigation deeplink target; sidebar +/// clicks for `.browserImport` resolve to this view, which renders +/// nothing but the legacy import block (so deeplinks land near the +/// same content). +@MainActor +public struct BrowserImportSection: View { + public init( + defaultsStore: UserDefaultsSettingsStore, + catalog: SettingCatalog, + hostActions: SettingsHostActions + ) { + _ = defaultsStore + _ = catalog + _ = hostActions + } + + public var body: some View { + EmptyView() + } +} diff --git a/Packages/CmuxSettingsUI/Sources/CmuxSettingsUI/Sections/BrowserSection.swift b/Packages/CmuxSettingsUI/Sources/CmuxSettingsUI/Sections/BrowserSection.swift new file mode 100644 index 000000000000..0188ac6a4540 --- /dev/null +++ b/Packages/CmuxSettingsUI/Sources/CmuxSettingsUI/Sections/BrowserSection.swift @@ -0,0 +1,546 @@ +import AppKit +import CmuxSettings +import SwiftUI + +/// **Browser** section — mirrors the legacy in-app section +/// row-for-row inside a single `SettingsCard`: Enable cmux Browser, +/// Default Search Engine, conditional Custom Search Engine fields, +/// Show Search Suggestions, Browser Theme, Browser Memory Saver + +/// Memory Saver Delay, Open Terminal Links / Intercept open, +/// conditional Hosts / External Patterns text editors, HTTP Hosts +/// Allowed in Embedded Browser editor, Import Browser Data +/// subsection, React Grab Version, Browsing History. +@MainActor +public struct BrowserSection: View { + private let catalog: SettingCatalog + private let hostActions: SettingsHostActions + private let importAnchorID: String? + + @State private var disabled: DefaultsValueModel + @State private var engine: DefaultsValueModel + @State private var customName: DefaultsValueModel + @State private var customURL: DefaultsValueModel + @State private var suggestions: DefaultsValueModel + @State private var theme: DefaultsValueModel + @State private var discardEnabled: DefaultsValueModel + @State private var discardDelay: DefaultsValueModel + @State private var openTermLinks: DefaultsValueModel + @State private var interceptOpen: DefaultsValueModel + @State private var hosts: DefaultsValueModel + @State private var external: DefaultsValueModel + @State private var httpAllowlist: DefaultsValueModel + @State private var importHint: DefaultsValueModel + @State private var reactGrab: DefaultsValueModel + + @State private var confirmClearHistory: Bool = false + @State private var httpAllowlistDraft: String = "" + @State private var httpAllowlistSyncedValue: String = "" + @State private var httpAllowlistLoaded: Bool = false + + public init( + defaultsStore: UserDefaultsSettingsStore, + catalog: SettingCatalog, + hostActions: SettingsHostActions, + importAnchorID: String? = nil + ) { + self.catalog = catalog + self.hostActions = hostActions + self.importAnchorID = importAnchorID + _disabled = State(initialValue: DefaultsValueModel(store: defaultsStore, key: catalog.browser.disabled)) + _engine = State(initialValue: DefaultsValueModel(store: defaultsStore, key: catalog.browser.defaultSearchEngine)) + _customName = State(initialValue: DefaultsValueModel(store: defaultsStore, key: catalog.browser.customSearchEngineName)) + _customURL = State(initialValue: DefaultsValueModel(store: defaultsStore, key: catalog.browser.customSearchEngineURLTemplate)) + _suggestions = State(initialValue: DefaultsValueModel(store: defaultsStore, key: catalog.browser.showSearchSuggestions)) + _theme = State(initialValue: DefaultsValueModel(store: defaultsStore, key: catalog.browser.theme)) + _discardEnabled = State(initialValue: DefaultsValueModel(store: defaultsStore, key: catalog.browser.discardHiddenWebViews)) + _discardDelay = State(initialValue: DefaultsValueModel(store: defaultsStore, key: catalog.browser.hiddenWebViewDiscardDelaySeconds)) + _openTermLinks = State(initialValue: DefaultsValueModel(store: defaultsStore, key: catalog.browser.openTerminalLinksInCmuxBrowser)) + _interceptOpen = State(initialValue: DefaultsValueModel(store: defaultsStore, key: catalog.browser.interceptTerminalOpenCommandInCmuxBrowser)) + _hosts = State(initialValue: DefaultsValueModel(store: defaultsStore, key: catalog.browser.hostsToOpenInEmbeddedBrowser)) + _external = State(initialValue: DefaultsValueModel(store: defaultsStore, key: catalog.browser.urlsToAlwaysOpenExternally)) + _httpAllowlist = State(initialValue: DefaultsValueModel(store: defaultsStore, key: catalog.browser.insecureHttpHostsAllowedInEmbeddedBrowser)) + _importHint = State(initialValue: DefaultsValueModel(store: defaultsStore, key: catalog.browser.showImportHintOnBlankTabs)) + _reactGrab = State(initialValue: DefaultsValueModel(store: defaultsStore, key: catalog.browser.reactGrabVersion)) + } + + private static let columnWidth: CGFloat = 196 + + public var body: some View { + Group { + SettingsSectionHeader(String(localized: "settings.section.browser", defaultValue: "Browser")) + .accessibilityIdentifier("SettingsBrowserSection") + mainCard + } + .confirmationDialog( + String(localized: "settings.browser.history.clearDialog.title", defaultValue: "Clear browser history?"), + isPresented: $confirmClearHistory, + titleVisibility: .visible + ) { + Button(String(localized: "settings.browser.history.clearDialog.confirm", defaultValue: "Clear History"), role: .destructive) { + hostActions.clearBrowserHistory() + } + Button(String(localized: "settings.browser.history.clearDialog.cancel", defaultValue: "Cancel"), role: .cancel) {} + } message: { + Text(String(localized: "settings.browser.history.clearDialog.message", defaultValue: "This removes visited-page suggestions from the browser omnibar.")) + } + } + + @ViewBuilder + private var mainCard: some View { + SettingsCard { + // Enable cmux Browser + SettingsCardRow( + configurationReview: .settingsOnly, + String(localized: "settings.browser.enabled", defaultValue: "Enable cmux Browser"), + subtitle: !disabled.current + ? String(localized: "settings.browser.enabled.subtitleOn", defaultValue: "Browser tabs, terminal link clicks, and intercepted open commands can use the embedded browser.") + : String(localized: "settings.browser.enabled.subtitleOff", defaultValue: "Browser tabs and link interception are disabled. Links open in your default browser.") + ) { + Toggle("", isOn: Binding(get: { !disabled.current }, set: { disabled.set(!$0) })) + .labelsHidden() + .controlSize(.small) + .accessibilityIdentifier("BrowserEnabledToggle") + } + SettingsCardDivider() + + // Default Search Engine + SettingsCardRow( + configurationReview: .json("browser.defaultSearchEngine"), + String(localized: "settings.browser.searchEngine", defaultValue: "Default Search Engine"), + subtitle: String(localized: "settings.browser.searchEngine.subtitle", defaultValue: "Used by the browser address bar when input is not a URL."), + controlWidth: Self.columnWidth + ) { + Picker("", selection: Binding(get: { engine.current }, set: { engine.set($0) })) { + ForEach(BrowserSearchEngine.allCases, id: \.self) { value in + Text(searchEngineLabel(value)).tag(value) + } + } + .labelsHidden() + .pickerStyle(.menu) + } + SettingsCardDivider() + + // Custom Search Engine Name + URL (only when custom) + if engine.current == .custom { + SettingsCardRow( + configurationReview: .json("browser.customSearchEngineName"), + String(localized: "settings.browser.customSearchEngineName", defaultValue: "Custom Search Engine Name"), + subtitle: String(localized: "settings.browser.customSearchEngineName.subtitle", defaultValue: "Shown in browser address bar search suggestions."), + controlWidth: Self.columnWidth + ) { + TextField("", text: Binding(get: { customName.current }, set: { customName.set($0) })) + .textFieldStyle(.roundedBorder) + } + SettingsCardDivider() + SettingsCardRow( + configurationReview: .json("browser.customSearchEngineURLTemplate"), + String(localized: "settings.browser.customSearchEngineURLTemplate", defaultValue: "Custom Search URL"), + subtitle: String(localized: "settings.browser.customSearchEngineURLTemplate.subtitle", defaultValue: "Use {query} or %s for the search terms. Without a placeholder, cmux appends q=."), + controlWidth: 330 + ) { + TextField("", text: Binding(get: { customURL.current }, set: { customURL.set($0) })) + .textFieldStyle(.roundedBorder) + } + SettingsCardDivider() + } + + // Show Search Suggestions + SettingsCardRow( + configurationReview: .json("browser.showSearchSuggestions"), + String(localized: "settings.browser.searchSuggestions", defaultValue: "Show Search Suggestions") + ) { + Toggle("", isOn: Binding(get: { suggestions.current }, set: { suggestions.set($0) })) + .labelsHidden() + .controlSize(.small) + } + SettingsCardDivider() + + // Browser Theme + SettingsCardRow( + configurationReview: .json("browser.theme"), + String(localized: "settings.browser.theme", defaultValue: "Browser Theme"), + subtitle: browserThemeSubtitle(theme.current), + controlWidth: Self.columnWidth + ) { + Picker("", selection: Binding(get: { theme.current }, set: { theme.set($0) })) { + ForEach(BrowserThemeMode.allCases, id: \.self) { mode in + Text(themeDisplayName(mode)).tag(mode) + } + } + .labelsHidden() + .pickerStyle(.menu) + } + SettingsCardDivider() + + // Browser Memory Saver + SettingsCardRow( + configurationReview: .json("browser.discardHiddenWebViews"), + String(localized: "settings.browser.hiddenWebViewDiscard", defaultValue: "Browser Memory Saver"), + subtitle: discardEnabled.current + ? String(localized: "settings.browser.hiddenWebViewDiscard.subtitleOn", defaultValue: "Hidden browser tabs release page memory after the delay below, then restore when shown again.") + : String(localized: "settings.browser.hiddenWebViewDiscard.subtitleOff", defaultValue: "Hidden browser tabs keep page memory until closed.") + ) { + Toggle("", isOn: Binding(get: { discardEnabled.current }, set: { discardEnabled.set($0) })) + .labelsHidden() + .controlSize(.small) + .accessibilityIdentifier("SettingsBrowserHiddenWebViewDiscardToggle") + } + SettingsCardDivider() + + // Memory Saver Delay + SettingsCardRow( + configurationReview: .json("browser.hiddenWebViewDiscardDelaySeconds"), + String(localized: "settings.browser.hiddenWebViewDiscardDelay", defaultValue: "Memory Saver Delay"), + subtitle: String(localized: "settings.browser.hiddenWebViewDiscardDelay.subtitle", defaultValue: "How long a browser tab must stay hidden before cmux frees its page memory. Active downloads, popups, developer tools, fullscreen, and loading pages are skipped."), + controlWidth: Self.columnWidth + ) { + HStack(spacing: 8) { + Text(formatDiscardDelay(discardDelay.current)) + .font(.system(.body, design: .monospaced)) + .monospacedDigit() + .frame(width: 56, alignment: .trailing) + Stepper( + "", + value: Binding(get: { discardDelay.current }, set: { discardDelay.set($0) }), + in: 0...3_600, + step: 30 + ) + .labelsHidden() + } + .disabled(!discardEnabled.current) + .accessibilityIdentifier("SettingsBrowserHiddenWebViewDiscardDelayStepper") + } + SettingsCardDivider() + + // Open Terminal Links + SettingsCardRow( + configurationReview: .json("browser.openTerminalLinksInCmuxBrowser"), + String(localized: "settings.browser.openTerminalLinks", defaultValue: "Open Terminal Links in cmux Browser"), + subtitle: String(localized: "settings.browser.openTerminalLinks.subtitle", defaultValue: "When off, links clicked in terminal output open in your default browser.") + ) { + Toggle("", isOn: Binding(get: { openTermLinks.current }, set: { openTermLinks.set($0) })) + .labelsHidden() + .controlSize(.small) + } + SettingsCardDivider() + + // Intercept open + SettingsCardRow( + configurationReview: .json("browser.interceptTerminalOpenCommandInCmuxBrowser"), + String(localized: "settings.browser.interceptOpen", defaultValue: "Intercept open http(s) in Terminal"), + subtitle: String(localized: "settings.browser.interceptOpen.subtitle", defaultValue: "When off, `open https://...` and `open http://...` always use your default browser.") + ) { + Toggle("", isOn: Binding(get: { interceptOpen.current }, set: { interceptOpen.set($0) })) + .labelsHidden() + .controlSize(.small) + } + + // Hosts + External Patterns (only when relevant) + if openTermLinks.current || interceptOpen.current { + SettingsCardDivider() + hostnameEditor( + title: String(localized: "settings.browser.hostWhitelist", defaultValue: "Hosts to Open in Embedded Browser"), + subtitle: String(localized: "settings.browser.hostWhitelist.subtitle", defaultValue: "Applies to terminal link clicks and intercepted `open https://...` calls. Only these hosts open in cmux. Others open in your default browser. One host or wildcard per line (for example: example.com, *.internal.example). Leave empty to open all hosts in cmux."), + json: "browser.hostsToOpenInEmbeddedBrowser", + model: hosts + ) + SettingsCardDivider() + hostnameEditor( + title: String(localized: "settings.browser.externalPatterns", defaultValue: "URLs to Always Open Externally"), + subtitle: String(localized: "settings.browser.externalPatterns.subtitle", defaultValue: "Applies to terminal link clicks and intercepted `open https://...` calls. One rule per line. Plain text matches any URL substring, or prefix with `re:` for regex (for example: openai.com/usage, re:^https?://[^/]*\\.example\\.com/(billing|usage))."), + json: "browser.urlsToAlwaysOpenExternally", + model: external + ) + } + SettingsCardDivider() + + // HTTP Hosts Allowed in Embedded Browser + httpAllowlistRow(model: httpAllowlist) + + SettingsCardDivider() + + // Import Browser Data subsection — tagged with the + // browserImport anchor id so sidebar deeplinks for that + // navigation target scroll the user to this inline block. + importBrowserDataBlock( + importHintModel: importHint, + onImport: { hostActions.openBrowserImportFlow() } + ) + .id(importAnchorID ?? "section:browserImport.inline") + SettingsCardDivider() + + // React Grab Version + SettingsCardRow( + configurationReview: .json("browser.reactGrabVersion"), + String(localized: "settings.browser.reactGrabVersion", defaultValue: "React Grab Version"), + subtitle: String(localized: "settings.browser.reactGrabVersion.subtitle", defaultValue: "Pinned npm version of react-grab injected by the toolbar button (Cmd+Shift+G). Only versions with a known integrity hash are accepted.") + ) { + TextField("", text: Binding(get: { reactGrab.current }, set: { reactGrab.set($0) })) + .textFieldStyle(.roundedBorder) + .frame(width: 100) + .font(.system(.body, design: .monospaced)) + .accessibilityIdentifier("SettingsReactGrabVersionField") + } + + // Browsing History — legacy renders this row unconditionally. + // When the host has no history store wired in, the count is + // nil so the subtitle falls back to the generic instruction + // and the Clear button is disabled. + let historyCount = hostActions.browserHistoryEntryCount() + SettingsCardDivider() + SettingsCardRow( + configurationReview: .action, + String(localized: "settings.browser.history", defaultValue: "Browsing History"), + subtitle: historySubtitle(count: historyCount) + ) { + Button(String(localized: "settings.browser.history.clearButton", defaultValue: "Clear History…")) { + confirmClearHistory = true + } + .buttonStyle(.bordered) + .controlSize(.small) + .disabled(historyCount == 0) + } + } + } + + @ViewBuilder + private func hostnameEditor(title: String, subtitle: String, json: String, model: DefaultsValueModel) -> some View { + VStack(alignment: .leading, spacing: 6) { + SettingsCardRow(configurationReview: .json(json), title, subtitle: subtitle) { + EmptyView() + } + TextEditor(text: Binding(get: { model.current }, set: { model.set($0) })) + .font(.system(.body, design: .monospaced)) + .frame(minHeight: 60, maxHeight: 120) + .scrollContentBackground(.hidden) + .padding(6) + .background(Color(nsColor: .controlBackgroundColor)) + .cornerRadius(6) + .overlay( + RoundedRectangle(cornerRadius: 6) + .stroke(Color(nsColor: .separatorColor), lineWidth: 0.5) + ) + .padding(.horizontal, 16) + .padding(.bottom, 12) + } + } + + @ViewBuilder + private func httpAllowlistRow(model: DefaultsValueModel) -> some View { + VStack(alignment: .leading, spacing: 8) { + Text(String(localized: "settings.browser.httpAllowlist", defaultValue: "HTTP Hosts Allowed in Embedded Browser")) + .font(.system(size: 13, weight: .semibold)) + Text(String(localized: "settings.browser.httpAllowlist.description", defaultValue: "Controls which HTTP (non-HTTPS) hosts can open in cmux without a warning prompt. Defaults include localhost, *.localhost, 127.0.0.1, ::1, 0.0.0.0, and *.localtest.me.")) + .font(.caption) + .foregroundStyle(.secondary) + TextEditor(text: $httpAllowlistDraft) + .font(.system(size: 12, weight: .regular, design: .monospaced)) + .frame(minHeight: 86) + .padding(6) + .background( + RoundedRectangle(cornerRadius: 8, style: .continuous) + .fill(Color(nsColor: .textBackgroundColor)) + ) + .overlay( + RoundedRectangle(cornerRadius: 8, style: .continuous) + .stroke(Color(nsColor: .separatorColor), lineWidth: 1) + ) + .accessibilityIdentifier("SettingsBrowserHTTPAllowlistField") + ViewThatFits(in: .horizontal) { + HStack(alignment: .center, spacing: 10) { + Text(String(localized: "settings.browser.httpAllowlist.hint", defaultValue: "One host or wildcard per line (for example: localhost, *.localhost, 127.0.0.1, ::1, 0.0.0.0, *.localtest.me).")) + .font(.caption) + .foregroundStyle(.secondary) + .fixedSize(horizontal: false, vertical: true) + Spacer(minLength: 0) + Button(String(localized: "settings.browser.httpAllowlist.save", defaultValue: "Save")) { + model.set(httpAllowlistDraft) + httpAllowlistSyncedValue = httpAllowlistDraft + } + .buttonStyle(.bordered) + .controlSize(.small) + .disabled(httpAllowlistDraft == model.current) + .accessibilityIdentifier("SettingsBrowserHTTPAllowlistSaveButton") + } + + VStack(alignment: .leading, spacing: 8) { + Text(String(localized: "settings.browser.httpAllowlist.hint", defaultValue: "One host or wildcard per line (for example: localhost, *.localhost, 127.0.0.1, ::1, 0.0.0.0, *.localtest.me).")) + .font(.caption) + .foregroundStyle(.secondary) + HStack { + Spacer(minLength: 0) + Button(String(localized: "settings.browser.httpAllowlist.save", defaultValue: "Save")) { + model.set(httpAllowlistDraft) + httpAllowlistSyncedValue = httpAllowlistDraft + } + .buttonStyle(.bordered) + .controlSize(.small) + .disabled(httpAllowlistDraft == model.current) + .accessibilityIdentifier("SettingsBrowserHTTPAllowlistSaveButton") + } + } + } + } + .padding(.horizontal, 14) + .padding(.vertical, 10) + .task { + if !httpAllowlistLoaded { + httpAllowlistDraft = model.current + httpAllowlistSyncedValue = model.current + httpAllowlistLoaded = true + } + } + .onChange(of: model.current) { _, newValue in + // Mirrors SettingsDraftState.syncBrowserInsecureHTTPAllowlistFromSavedValue: + // only refresh the draft when the user hasn't edited it + // since the last sync. Otherwise keep their in-progress + // edits intact across external store updates. + if !httpAllowlistLoaded { + httpAllowlistDraft = newValue + httpAllowlistSyncedValue = newValue + httpAllowlistLoaded = true + return + } + if httpAllowlistDraft == httpAllowlistSyncedValue { + httpAllowlistDraft = newValue + } + httpAllowlistSyncedValue = newValue + } + } + + @ViewBuilder + private func importBrowserDataBlock(importHintModel: DefaultsValueModel, onImport: @escaping () -> Void) -> some View { + VStack(alignment: .leading, spacing: 12) { + VStack(alignment: .leading, spacing: 8) { + Text(String(localized: "settings.browser.import", defaultValue: "Import Browser Data")) + .font(.system(size: 13, weight: .semibold)) + VStack(alignment: .leading, spacing: 6) { + Text(String(localized: "browser.import.hint.title", defaultValue: "Import browser data")) + .font(.system(size: 12.5, weight: .semibold)) + Text(String(localized: "browser.import.hint.subtitle", defaultValue: "Import bookmarks, history, and cookies from Safari, Chrome, Firefox, Brave, Edge, or Arc. Already-imported entries are deduped automatically.")) + .font(.caption) + .foregroundStyle(.secondary) + .fixedSize(horizontal: false, vertical: true) + .accessibilityIdentifier("SettingsBrowserImportSummary") + Text(String(localized: "browser.import.hint.settingsFootnote", defaultValue: "You can always find this in Settings > Browser.")) + .font(.system(size: 10.5)) + .foregroundStyle(.tertiary) + .fixedSize(horizontal: false, vertical: true) + } + .padding(.horizontal, 12) + .padding(.vertical, 10) + .frame(maxWidth: .infinity, alignment: .leading) + .background( + RoundedRectangle(cornerRadius: 10, style: .continuous) + .fill(Color(nsColor: .controlBackgroundColor)) + ) + .overlay( + RoundedRectangle(cornerRadius: 10, style: .continuous) + .stroke(Color(nsColor: .separatorColor).opacity(0.4), lineWidth: 1) + ) + } + HStack(spacing: 8) { + Button(String(localized: "settings.browser.import.choose", defaultValue: "Choose…")) { onImport() } + .buttonStyle(.bordered) + .controlSize(.small) + .accessibilityIdentifier("SettingsBrowserImportChooseButton") + Button(String(localized: "settings.browser.import.refresh", defaultValue: "Refresh")) {} + .buttonStyle(.bordered) + .controlSize(.small) + .disabled(true) + } + .accessibilityIdentifier("SettingsBrowserImportActions") + Toggle( + String(localized: "settings.browser.import.hint.show", defaultValue: "Show import hint on blank browser tabs"), + isOn: Binding(get: { importHintModel.current }, set: { importHintModel.set($0) }) + ) + .controlSize(.small) + .accessibilityIdentifier("SettingsBrowserImportHintToggle") + Text(String(localized: "settings.browser.import.hint.settingsNote", defaultValue: "Shown until you import or dismiss it on a blank tab.")) + .font(.caption) + .foregroundStyle(.secondary) + .fixedSize(horizontal: false, vertical: true) + } + .padding(.horizontal, 14) + .padding(.vertical, 10) + .accessibilityIdentifier("SettingsBrowserImportSection") + } + + private func browserThemeSubtitle(_ mode: BrowserThemeMode) -> String { + if mode == .system { + return String(localized: "settings.browser.theme.subtitleSystem", defaultValue: "System follows app and macOS appearance.") + } + let name = themeDisplayName(mode) + return String(localized: "settings.browser.theme.subtitleForced", defaultValue: "\(name) forces that color scheme for compatible pages.") + } + + private func themeDisplayName(_ mode: BrowserThemeMode) -> String { + switch mode { + case .system: + return String(localized: "theme.system", defaultValue: "System") + case .light: + return String(localized: "theme.light", defaultValue: "Light") + case .dark: + return String(localized: "theme.dark", defaultValue: "Dark") + } + } + + private func searchEngineLabel(_ engine: BrowserSearchEngine) -> String { + switch engine { + case .google: return String(localized: "settings.browser.searchEngine.google", defaultValue: "Google") + case .duckduckgo: return String(localized: "settings.browser.searchEngine.duckduckgo", defaultValue: "DuckDuckGo") + case .bing: return String(localized: "settings.browser.searchEngine.bing", defaultValue: "Bing") + case .kagi: return String(localized: "settings.browser.searchEngine.kagi", defaultValue: "Kagi") + case .startpage: return String(localized: "settings.browser.searchEngine.startpage", defaultValue: "Startpage") + case .brave: return String(localized: "settings.browser.searchEngine.brave", defaultValue: "Brave Search") + case .perplexity: return String(localized: "settings.browser.searchEngine.perplexity", defaultValue: "Perplexity") + case .exa: return String(localized: "settings.browser.searchEngine.exa", defaultValue: "Exa") + case .yahoo: return String(localized: "settings.browser.searchEngine.yahoo", defaultValue: "Yahoo") + case .ecosia: return String(localized: "settings.browser.searchEngine.ecosia", defaultValue: "Ecosia") + case .qwant: return String(localized: "settings.browser.searchEngine.qwant", defaultValue: "Qwant") + case .mojeek: return String(localized: "settings.browser.searchEngine.mojeek", defaultValue: "Mojeek") + case .wikipedia: return String(localized: "settings.browser.searchEngine.wikipedia", defaultValue: "Wikipedia") + case .github: return String(localized: "settings.browser.searchEngine.github", defaultValue: "GitHub") + case .baidu: return String(localized: "settings.browser.searchEngine.baidu", defaultValue: "Baidu") + case .yandex: return String(localized: "settings.browser.searchEngine.yandex", defaultValue: "Yandex") + case .custom: return String(localized: "settings.browser.searchEngine.custom", defaultValue: "Custom") + } + } + + /// Builds the Browsing History row subtitle, matching the legacy + /// `browserHistorySubtitle`: a loading message when the host has + /// not loaded the history store yet, then dynamic phrasing once + /// the count is known. Keys mirror legacy + /// `settings.browser.history.subtitleLoading/Empty/One/Many`. + private func historySubtitle(count: Int?) -> String { + switch count { + case .none: + return String(localized: "settings.browser.history.subtitleLoading", defaultValue: "Checking browsing history...") + case .some(0): + return String(localized: "settings.browser.history.subtitleEmpty", defaultValue: "No saved pages yet.") + case .some(1): + return String(localized: "settings.browser.history.subtitleOne", defaultValue: "1 saved page appears in omnibar suggestions.") + case .some(let n): + return String(localized: "settings.browser.history.subtitleMany", defaultValue: "\(n) saved pages appear in omnibar suggestions.") + } + } + + /// Formats the Memory Saver Delay value as `Xm Ys` (or `Ys`) so + /// the stepper readout reads naturally for delays measured in + /// minutes. Matches the legacy + /// `browserHiddenWebViewDiscardDelayLabel` formatter, including + /// the localized format strings. + private func formatDiscardDelay(_ seconds: Double) -> String { + let total = max(0, Int(seconds.rounded())) + if total < 60 { + let format = String(localized: "settings.browser.hiddenWebViewDiscardDelay.seconds", defaultValue: "%llds") + return String.localizedStringWithFormat(format, Int64(total)) + } + if total % 60 == 0 { + let format = String(localized: "settings.browser.hiddenWebViewDiscardDelay.minutes", defaultValue: "%lldm") + return String.localizedStringWithFormat(format, Int64(total / 60)) + } + let format = String(localized: "settings.browser.hiddenWebViewDiscardDelay.minutesSeconds", defaultValue: "%lldm %llds") + return String.localizedStringWithFormat(format, Int64(total / 60), Int64(total % 60)) + } +} diff --git a/Packages/CmuxSettingsUI/Sources/CmuxSettingsUI/Sections/GlobalHotkeySection.swift b/Packages/CmuxSettingsUI/Sources/CmuxSettingsUI/Sections/GlobalHotkeySection.swift new file mode 100644 index 000000000000..8d885972892d --- /dev/null +++ b/Packages/CmuxSettingsUI/Sources/CmuxSettingsUI/Sections/GlobalHotkeySection.swift @@ -0,0 +1,208 @@ +import CmuxSettings +import SwiftUI + +/// **Global Hotkey** section — mirrors the legacy in-app section: +/// one card with an Enable toggle and the system-wide chord recorder, +/// followed by a card note explaining macOS permissions. +/// +/// The recorder reads and writes the same JSON-backed shortcut binding +/// the legacy app uses — `shortcuts.bindings["showHideAllWindows"]` — +/// so keystrokes persist immediately and round-trip with the rest of +/// the Keyboard Shortcuts section. +@MainActor +public struct GlobalHotkeySection: View { + private let jsonStore: JSONConfigStore + private let catalog: SettingCatalog + private let errorLog: SettingsErrorLog + + @State private var enabled: DefaultsValueModel + @State private var bindings: [String: StoredShortcut] = [:] + @State private var bindingsTask: Task? + @State private var restoreShortcut: StoredShortcut? + + private let hotkeyAction: ShortcutAction = .showHideAllWindows + + public init( + defaultsStore: UserDefaultsSettingsStore, + jsonStore: JSONConfigStore, + catalog: SettingCatalog, + errorLog: SettingsErrorLog + ) { + self.jsonStore = jsonStore + self.catalog = catalog + self.errorLog = errorLog + _enabled = State(initialValue: DefaultsValueModel(store: defaultsStore, key: catalog.app.systemWideHotkeyEnabled)) + } + + public var body: some View { + Group { + SettingsSectionHeader(String(localized: "settings.section.globalHotkey", defaultValue: "Global Hotkey")) + .accessibilityIdentifier("SettingsGlobalHotkeySection") + mainCard + SettingsCardNote( + String(localized: "settings.globalHotkey.note", defaultValue: "Use Command, Option, or Control with another key. No extra macOS permission is required.") + ) + .accessibilityIdentifier("SettingsGlobalHotkeyNote") + } + .task { await streamBindings() } + .onDisappear { bindingsTask?.cancel() } + } + + @ViewBuilder + private var mainCard: some View { + SettingsCard { + SettingsCardRow( + configurationReview: .settingsOnly, + String(localized: "settings.globalHotkey.enable", defaultValue: "Enable System-Wide Hotkey"), + subtitle: enabled.current + ? String(localized: "settings.globalHotkey.enable.subtitleOn", defaultValue: "Press the shortcut from any app to show or hide all cmux windows.") + : String(localized: "settings.globalHotkey.enable.subtitleOff", defaultValue: "Turn this on to show or hide all cmux windows from any app.") + ) { + Toggle("", isOn: Binding(get: { enabled.current }, set: { enabled.set($0) })) + .labelsHidden() + .controlSize(.small) + .accessibilityIdentifier("SettingsGlobalHotkeyToggle") + } + SettingsCardDivider() + recorderRow + } + } + + @ViewBuilder + private var recorderRow: some View { + let effective = currentShortcut + let isUnbound = effective?.isUnbound ?? true + let canRestore = isUnbound && restoreShortcut != nil + VStack(alignment: .leading, spacing: 4) { + HStack(alignment: .center, spacing: 12) { + VStack(alignment: .leading, spacing: 2) { + Text(String(localized: "settings.globalHotkey.shortcut", defaultValue: "Show/Hide All Windows")) + } + Spacer() + ShortcutRecorderView( + placeholder: placeholderText(for: effective) + ) { stroke in + Task { await assign(stroke: stroke) } + } + .frame(width: 160) + .accessibilityIdentifier("SettingsGlobalHotkeyRecorder") + + Button { + if canRestore, let restore = restoreShortcut { + Task { await write(updating: restore) } + restoreShortcut = nil + } else if let effective, !effective.isUnbound { + restoreShortcut = effective + Task { await write(updating: .unbound) } + } + } label: { + Image(systemName: canRestore ? "arrow.counterclockwise.circle.fill" : "xmark.circle.fill") + .imageScale(.medium) + } + .buttonStyle(.borderless) + .disabled(isUnbound && !canRestore) + .help( + canRestore + ? String(localized: "shortcut.recorder.restore.help", defaultValue: "Restore previous shortcut") + : String(localized: "shortcut.recorder.clear.help", defaultValue: "Unbind shortcut") + ) + .accessibilityLabel( + canRestore + ? String(localized: "shortcut.recorder.restore", defaultValue: "Restore") + : String(localized: "shortcut.recorder.clear", defaultValue: "Unbind") + ) + .accessibilityIdentifier("ShortcutRecorderClearRestoreButton") + } + } + .padding(.horizontal, 14) + .padding(.vertical, 9) + } + + private func write(updating shortcut: StoredShortcut) async { + var updated = bindings + updated[hotkeyAction.rawValue] = shortcut + await write(updated) + } + + private var currentShortcut: StoredShortcut? { + if let override = bindings[hotkeyAction.rawValue] { return override } + return hotkeyAction.defaultStroke.map { StoredShortcut(first: $0) } + } + + private func placeholderText(for shortcut: StoredShortcut?) -> String { + guard let shortcut, !shortcut.isUnbound else { + // Matches the legacy recorder's unbound resting label. + return String(localized: "shortcut.unbound.displayValue", defaultValue: "None") + } + return format(shortcut) + } + + private func format(_ shortcut: StoredShortcut) -> String { + var parts: [String] = [] + if shortcut.first.control { parts.append("⌃") } + if shortcut.first.option { parts.append("⌥") } + if shortcut.first.shift { parts.append("⇧") } + if shortcut.first.command { parts.append("⌘") } + parts.append(Self.keyDisplayString(shortcut.first.key)) + return parts.joined() + } + + /// Mirrors legacy `ShortcutStroke.keyDisplayString` so named keys + /// (Tab, Space, return, media/brightness/volume) render as the + /// friendly label rather than an uppercased token. Plain letters + /// and digits fall back to `.uppercased()`. + private static func keyDisplayString(_ key: String) -> String { + switch key { + case "\t": + return String(localized: "shortcut.key.tab", defaultValue: "Tab") + case "space": + return String(localized: "shortcut.key.space", defaultValue: "Space") + case "\r": + return "↩" + case "media.brightnessDown": + return String(localized: "shortcut.key.mediaBrightnessDown", defaultValue: "Brightness Down") + case "media.brightnessUp": + return String(localized: "shortcut.key.mediaBrightnessUp", defaultValue: "Brightness Up") + case "media.mute": + return String(localized: "shortcut.key.mediaMute", defaultValue: "Mute") + case "media.next": + return String(localized: "shortcut.key.mediaNext", defaultValue: "Next Track") + case "media.playPause": + return String(localized: "shortcut.key.mediaPlayPause", defaultValue: "Play/Pause") + case "media.previous": + return String(localized: "shortcut.key.mediaPrevious", defaultValue: "Previous Track") + case "media.volumeDown": + return String(localized: "shortcut.key.mediaVolumeDown", defaultValue: "Volume Down") + case "media.volumeUp": + return String(localized: "shortcut.key.mediaVolumeUp", defaultValue: "Volume Up") + default: + return key.uppercased() + } + } + + private func streamBindings() async { + bindingsTask?.cancel() + let task = Task { + for await dictionary in jsonStore.values(for: catalog.shortcuts.bindings) { + if Task.isCancelled { break } + bindings = dictionary + } + } + bindingsTask = task + await task.value + } + + private func assign(stroke: ShortcutStroke) async { + var updated = bindings + updated[hotkeyAction.rawValue] = StoredShortcut(first: stroke) + await write(updated) + } + + private func write(_ updated: [String: StoredShortcut]) async { + do { + try await jsonStore.set(updated, for: catalog.shortcuts.bindings) + } catch { + errorLog.record(error, keyID: catalog.shortcuts.bindings.id) + } + } +} diff --git a/Packages/CmuxSettingsUI/Sources/CmuxSettingsUI/Sections/KeyboardShortcutsSection.swift b/Packages/CmuxSettingsUI/Sources/CmuxSettingsUI/Sections/KeyboardShortcutsSection.swift new file mode 100644 index 000000000000..af33c2f67df8 --- /dev/null +++ b/Packages/CmuxSettingsUI/Sources/CmuxSettingsUI/Sections/KeyboardShortcutsSection.swift @@ -0,0 +1,493 @@ +import CmuxSettings +import SwiftUI + +/// **Keyboard Shortcuts** section — mirrors the legacy in-app +/// section: one `SettingsCard` containing the chord docs link, +/// the Reset Defaults action, and a per-action recorder row for +/// every `ShortcutAction` (using the new package recorder). +@MainActor +public struct KeyboardShortcutsSection: View { + private let jsonStore: JSONConfigStore + private let catalog: SettingCatalog + private let errorLog: SettingsErrorLog + private let hostActions: SettingsHostActions + + @State private var bindings: [String: StoredShortcut] = [:] + @State private var streamTask: Task? + @State private var chordModeActions: Set = [] + @State private var restoreShortcuts: [String: StoredShortcut] = [:] + @State private var bareKeyRejections: Set = [] + /// Per-action "rejected attempt" snapshot used to drive the red + /// validation banner. Legacy `ShortcutRecorderSettingsControl` + /// stores `rejectedAttempt` and never writes the conflicting + /// shortcut; the package previously wrote first then detected the + /// conflict at render time, which meant the conflict actually + /// persisted on disk and the Undo button could not roll it back. + /// This state captures the conflicting action so the banner can + /// render without persisting the bad binding. + @State private var conflictRejections: [String: ShortcutAction] = [:] + + public init( + jsonStore: JSONConfigStore, + catalog: SettingCatalog, + errorLog: SettingsErrorLog, + hostActions: SettingsHostActions + ) { + self.jsonStore = jsonStore + self.catalog = catalog + self.errorLog = errorLog + self.hostActions = hostActions + } + + public var body: some View { + Group { + SettingsSectionHeader(String(localized: "settings.section.keyboardShortcuts", defaultValue: "Keyboard Shortcuts")) + .accessibilityIdentifier("SettingsKeyboardShortcutsSection") + SettingsCard { + chordsRow + SettingsCardDivider() + resetDefaultsRow + SettingsCardDivider() + // Filter out actions that live in their own section + // (Global Hotkey owns the system-wide chord), then + // re-order so the colocated right-sidebar/find actions + // sit next to the unread navigation actions — matches + // legacy `KeyboardShortcutSettings.settingsVisibleActions` + // / `orderedSettingsVisibleActions`. + let actions = Self.settingsVisibleActions + ForEach(Array(actions.enumerated()), id: \.element) { index, action in + actionRow(action) + if index < actions.count - 1 { + SettingsCardDivider() + } + } + } + Text(String(localized: "settings.shortcuts.recordHint", defaultValue: "Click a shortcut value to record. Use X to unbind; it changes to restore after a clear.")) + .font(.caption) + .foregroundColor(.secondary) + .padding(.leading, 2) + .accessibilityIdentifier("ShortcutRecordingHint") + } + .task { await streamBindings() } + .onDisappear { streamTask?.cancel() } + } + + @ViewBuilder + private var chordsRow: some View { + 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 cmux.json, for example [\"ctrl+b\", \"c\"].") + ) { + HStack(spacing: 8) { + Link( + String(localized: "settings.shortcuts.chords.docsButton", defaultValue: "Chord docs"), + destination: URL(string: "https://cmux.com/docs/keyboard-shortcuts#shortcut-chords")! + ) + .font(.caption) + .accessibilityIdentifier("SettingsKeyboardShortcutsChordDocsLink") + + Button(String(localized: "settings.app.settingsFile.openButton", defaultValue: "Open cmux.json")) { + hostActions.openConfigInExternalEditor() + } + .buttonStyle(.bordered) + .controlSize(.small) + .accessibilityIdentifier("SettingsKeyboardShortcutsOpenSettingsFileButton") + } + } + } + + @ViewBuilder + private var resetDefaultsRow: some View { + SettingsCardRow( + configurationReview: .settingsOnly, + String(localized: "settings.shortcuts.resetDefaults", defaultValue: "Reset Default Shortcuts"), + subtitle: String(localized: "settings.shortcuts.resetDefaults.subtitle", defaultValue: "Restore built-in shortcut values for shortcuts managed in app settings.") + ) { + Button { + Task { await resetAll() } + } label: { + Label( + String(localized: "settings.shortcuts.resetDefaults.button", defaultValue: "Reset Defaults"), + systemImage: "arrow.counterclockwise" + ) + } + .buttonStyle(.bordered) + .controlSize(.small) + .accessibilityIdentifier("SettingsKeyboardShortcutsResetDefaultsButton") + } + } + + @ViewBuilder + private func actionRow(_ action: ShortcutAction) -> some View { + let override = bindings[action.rawValue] + let effective = override ?? action.defaultStroke.map { StoredShortcut(first: $0) } + let isUnbound = effective?.isUnbound ?? true + let canRestore = isUnbound && restoreShortcuts[action.rawValue] != nil + let bareKeyRejected = bareKeyRejections.contains(action.rawValue) + // Drive the validation banner off the explicit rejection state, + // not off live bindings. Legacy never persists a conflicting + // shortcut: `ShortcutRecorderSettingsControl` captures the + // rejected attempt and shows the banner without ever writing + // the bad value, so Undo simply clears `rejectedAttempt`. + let conflict = conflictRejections[action.rawValue] + let validationMessage: String? = { + if bareKeyRejected { + return String( + localized: "shortcut.recorder.error.bareKeyNotAllowed", + defaultValue: "Shortcuts must include ⌘ ⌥ ⌃ or ⇧" + ) + } + if let conflict { + // Mirror legacy `ShortcutRecorderValidationPresentation.message` + // wording: include both the conflicting action label AND its + // displayed shortcut string in parentheses so the user can + // identify which existing shortcut is in the way. + let conflictOverride = bindings[conflict.rawValue] + let conflictEffective = conflictOverride ?? conflict.defaultStroke.map { StoredShortcut(first: $0) } + let conflictShortcutString = conflictEffective.map { format($0) } ?? "" + let messageFormat = String( + localized: "shortcut.recorder.error.conflictsWithAction", + defaultValue: "This shortcut conflicts with %@ (%@)." + ) + return String.localizedStringWithFormat(messageFormat, conflict.displayName, conflictShortcutString) + } + return nil + }() + + let subtitle: String? = nil + VStack(alignment: .leading, spacing: 4) { + HStack(alignment: subtitle == nil ? .center : .top, spacing: 12) { + VStack(alignment: .leading, spacing: 2) { + Text(action.displayName) + if let subtitle { + Text(subtitle) + .font(.caption) + .foregroundStyle(.secondary) + } + } + + Spacer() + + ShortcutRecorderView( + placeholder: formatPlaceholder(effective: effective), + chordsEnabled: chordModeActions.contains(action.rawValue), + hasPendingRejection: bareKeyRejected, + onStroke: { stroke in Task { await assign(stroke: stroke, to: action) } }, + onChord: { chord in Task { await assignChord(chord, to: action) } }, + onBareKeyRejected: { bareKeyRejections.insert(action.rawValue) } + ) + .frame(width: 160) + + Button { + bareKeyRejections.remove(action.rawValue) + conflictRejections.removeValue(forKey: action.rawValue) + if canRestore, let restore = restoreShortcuts[action.rawValue] { + Task { await restoreBinding(restore, for: action) } + } else if let effective, !effective.isUnbound { + restoreShortcuts[action.rawValue] = effective + Task { await clearBinding(for: action) } + } + } label: { + Image(systemName: canRestore ? "arrow.counterclockwise.circle.fill" : "xmark.circle.fill") + .imageScale(.medium) + } + .buttonStyle(.borderless) + .disabled(isUnbound && !canRestore) + .help( + canRestore + ? String(localized: "shortcut.recorder.restore.help", defaultValue: "Restore previous shortcut") + : String(localized: "shortcut.recorder.clear.help", defaultValue: "Unbind shortcut") + ) + .accessibilityLabel( + canRestore + ? String(localized: "shortcut.recorder.restore", defaultValue: "Restore") + : String(localized: "shortcut.recorder.clear", defaultValue: "Unbind") + ) + .accessibilityIdentifier("ShortcutRecorderClearRestoreButton") + } + + if let validationMessage { + HStack(alignment: .firstTextBaseline, spacing: 8) { + Image(systemName: "exclamationmark.triangle.fill") + .font(.caption) + .foregroundStyle(.red) + + Text(validationMessage) + .font(.caption) + .foregroundStyle(.red) + .fixedSize(horizontal: false, vertical: true) + + // Legacy `KeyboardShortcutRecorder` always renders an + // Undo button when `onUndoButtonPressed` is set, which + // `ShortcutRecorderSettingsControl` wires up for every + // rejected attempt (both bare-key and conflict). Match + // that so users can dismiss the conflict banner without + // having to record a different shortcut. + Button(String(localized: "shortcut.recorder.undo", defaultValue: "Undo")) { + bareKeyRejections.remove(action.rawValue) + conflictRejections.removeValue(forKey: action.rawValue) + } + .buttonStyle(.link) + .font(.caption) + } + .padding(.horizontal, 8) + .padding(.vertical, 6) + .frame(maxWidth: .infinity, alignment: .leading) + .background { + RoundedRectangle(cornerRadius: 6) + .fill(Color.red.opacity(0.12)) + } + .overlay { + RoundedRectangle(cornerRadius: 6) + .stroke(Color.red.opacity(0.35), lineWidth: 1) + } + .accessibilityIdentifier("ShortcutRecorderValidationMessage") + } + } + .padding(.horizontal, 14) + .padding(.vertical, 9) + } + + /// Mirrors legacy `KeyboardShortcutSettings.settingsVisibleActions`: + /// filters out `.showHideAllWindows` (owned by Global Hotkey section) + /// then re-orders so `focusRightSidebar`, `toggleRightSidebar`, and + /// `findInDirectory` sit immediately after `markOldestUnreadAndJumpNext` + /// or `jumpToUnread` (the unread navigation cluster), so colocated + /// sidebar/find shortcuts appear together in the settings UI. + private static var settingsVisibleActions: [ShortcutAction] { + let base = ShortcutAction.allCases.filter { $0 != .showHideAllWindows } + let colocated: [ShortcutAction] = [ + .focusRightSidebar, + .toggleRightSidebar, + .findInDirectory, + ].filter(base.contains) + let colocatedSet = Set(colocated) + let remaining = base.filter { !colocatedSet.contains($0) } + + guard let anchorIndex = remaining.firstIndex(of: .markOldestUnreadAndJumpNext) + ?? remaining.firstIndex(of: .jumpToUnread) else { + return colocated + remaining + } + + var ordered = remaining + ordered.insert(contentsOf: colocated, at: anchorIndex + 1) + return ordered + } + + // MARK: - Conflict helpers + + /// Mirrors legacy `KeyboardShortcutSettings.Action.conflicts(with:proposedAction:configuredShortcut:)` + /// at a coarser grain: only treat two actions as conflicting when the + /// *configured* (effective) shortcut of the other action is not + /// unbound and shares the same first stroke. The legacy implementation + /// has per-action overrides (e.g. number-stack actions allow sharing), + /// but we don't have that catalog data here, so the conservative + /// "same first stroke && both bound" check is the best we can do. + private func formatPlaceholder(effective: StoredShortcut?) -> String { + let unboundLabel = String(localized: "shortcut.unbound.displayValue", defaultValue: "None") + guard let effective else { return unboundLabel } + if effective.isUnbound { return unboundLabel } + return format(effective) + } + + private func detectConflict(for action: ShortcutAction, stroke: StoredShortcut) -> ShortcutAction? { + for other in ShortcutAction.allCases where other != action { + let override = bindings[other.rawValue] + let effective = override ?? other.defaultStroke.map { StoredShortcut(first: $0) } + guard let effective, !effective.isUnbound else { continue } + if stroke.first == effective.first { return other } + } + return nil + } + + /// Mirrors legacy `StoredShortcut.displayString`: returns localized + /// "None" for unbound, formats the first stroke (and optional chord + /// second stroke) via ``Self/strokeDisplayString(_:)`` so named keys + /// like Tab/Space/arrows/media render with their friendly labels + /// instead of raw `"\t"` / `"space"` / `"media.next"`. + private func format(_ shortcut: StoredShortcut) -> String { + if shortcut.isUnbound { + return String(localized: "shortcut.unbound.displayValue", defaultValue: "None") + } + if let chord = shortcut.second { + return Self.strokeDisplayString(shortcut.first) + + " " + + Self.strokeDisplayString(chord) + } + return Self.strokeDisplayString(shortcut.first) + } + + /// Formats a single ``ShortcutStroke`` with the same symbol order + /// and named-key labels as legacy `ShortcutStroke.displayString` + /// (modifier symbols ⌃⌥⇧⌘ followed by ``Self/keyDisplayString(_:)``). + private static func strokeDisplayString(_ stroke: ShortcutStroke) -> String { + var result = "" + if stroke.control { result.append("⌃") } + if stroke.option { result.append("⌥") } + if stroke.shift { result.append("⇧") } + if stroke.command { result.append("⌘") } + result.append(keyDisplayString(stroke.key)) + return result + } + + /// Mirrors legacy `ShortcutStroke.keyDisplayString` for the common + /// named-key tokens we may see in stored shortcuts. Falls back to + /// the uppercased raw key for plain letters/digits. + private static func keyDisplayString(_ key: String) -> String { + switch key { + case "\t": + return String(localized: "shortcut.key.tab", defaultValue: "Tab") + case "space": + return String(localized: "shortcut.key.space", defaultValue: "Space") + case "\r": + return "↩" + case "media.brightnessDown": + return String(localized: "shortcut.key.mediaBrightnessDown", defaultValue: "Brightness Down") + case "media.brightnessUp": + return String(localized: "shortcut.key.mediaBrightnessUp", defaultValue: "Brightness Up") + case "media.mute": + return String(localized: "shortcut.key.mediaMute", defaultValue: "Mute") + case "media.next": + return String(localized: "shortcut.key.mediaNext", defaultValue: "Next Track") + case "media.playPause": + return String(localized: "shortcut.key.mediaPlayPause", defaultValue: "Play/Pause") + case "media.previous": + return String(localized: "shortcut.key.mediaPrevious", defaultValue: "Previous Track") + case "media.volumeDown": + return String(localized: "shortcut.key.mediaVolumeDown", defaultValue: "Volume Down") + case "media.volumeUp": + return String(localized: "shortcut.key.mediaVolumeUp", defaultValue: "Volume Up") + default: + return key.uppercased() + } + } + + private func streamBindings() async { + streamTask?.cancel() + let task = Task { + for await dictionary in jsonStore.values(for: catalog.shortcuts.bindings) { + if Task.isCancelled { break } + bindings = dictionary + // Mirror legacy `KeyboardShortcutRecorder.onChange(of: + // shortcut)`: when an action's effective shortcut becomes + // non-unbound (e.g. the user edited cmux.json directly to + // restore a previously-cleared binding), drop the cached + // restore stroke so the X/restore button flips back to + // "Unbind" instead of stale "Restore previous shortcut". + pruneRestoreShortcuts() + // Mirror legacy `ShortcutRecorderSettingsControl.onChange(of: shortcut)` + // which clears `rejectedAttempt = nil` whenever the + // shortcut changes. Externally-edited cmux.json should + // dismiss a stale rejection banner for that action. + pruneConflictRejections() + } + } + streamTask = task + await task.value + } + + private func pruneConflictRejections() { + guard !conflictRejections.isEmpty else { return } + // Drop banners for actions whose binding now resolves cleanly. + // Legacy `ShortcutRecorderSettingsControl` clears `rejectedAttempt` + // on `.onChange(of: shortcut)`, so an externally-edited binding + // (cmux.json reload) dismisses the validation banner too. + for key in Array(conflictRejections.keys) { + guard let action = ShortcutAction(rawValue: key) else { + conflictRejections.removeValue(forKey: key) + continue + } + let effective = bindings[action.rawValue] ?? action.defaultStroke.map { StoredShortcut(first: $0) } + if let effective, detectConflict(for: action, stroke: effective) == nil { + conflictRejections.removeValue(forKey: key) + } else if effective == nil { + conflictRejections.removeValue(forKey: key) + } + } + } + + private func pruneRestoreShortcuts() { + guard !restoreShortcuts.isEmpty else { return } + for (key, _) in restoreShortcuts { + let override = bindings[key] + // If there is no override at all, the action is back to its + // default stroke (also non-unbound for most actions), so the + // restore cache is no longer meaningful. + if let override, override.isUnbound { continue } + restoreShortcuts.removeValue(forKey: key) + } + } + + private func assign(stroke: ShortcutStroke, to action: ShortcutAction) async { + let proposed = StoredShortcut(first: stroke) + if let conflict = detectConflict(for: action, stroke: proposed) { + // Mirror legacy `KeyboardShortcutSettings.Action.normalizedRecordedShortcutResult`: + // never write a conflicting binding. Surface the rejection + // through `conflictRejections` so the banner + Undo button + // can drive the user back to a usable state. + conflictRejections[action.rawValue] = conflict + bareKeyRejections.remove(action.rawValue) + return + } + var updated = bindings + updated[action.rawValue] = proposed + restoreShortcuts.removeValue(forKey: action.rawValue) + bareKeyRejections.remove(action.rawValue) + conflictRejections.removeValue(forKey: action.rawValue) + await write(updated) + } + + private func assignChord(_ chord: StoredShortcut, to action: ShortcutAction) async { + if let conflict = detectConflict(for: action, stroke: chord) { + conflictRejections[action.rawValue] = conflict + chordModeActions.remove(action.rawValue) + bareKeyRejections.remove(action.rawValue) + return + } + var updated = bindings + updated[action.rawValue] = chord + chordModeActions.remove(action.rawValue) + restoreShortcuts.removeValue(forKey: action.rawValue) + bareKeyRejections.remove(action.rawValue) + conflictRejections.removeValue(forKey: action.rawValue) + await write(updated) + } + + private func clearBinding(for action: ShortcutAction) async { + var updated = bindings + updated[action.rawValue] = StoredShortcut.unbound + await write(updated) + } + + private func restoreBinding(_ shortcut: StoredShortcut, for action: ShortcutAction) async { + var updated = bindings + updated[action.rawValue] = shortcut + restoreShortcuts.removeValue(forKey: action.rawValue) + conflictRejections.removeValue(forKey: action.rawValue) + await write(updated) + } + + private func resetToDefault(action: ShortcutAction) async { + var updated = bindings + updated.removeValue(forKey: action.rawValue) + restoreShortcuts.removeValue(forKey: action.rawValue) + conflictRejections.removeValue(forKey: action.rawValue) + await write(updated) + } + + private func resetAll() async { + restoreShortcuts.removeAll() + bareKeyRejections.removeAll() + conflictRejections.removeAll() + await write([:]) + } + + private func write(_ updated: [String: StoredShortcut]) async { + do { + try await jsonStore.set(updated, for: catalog.shortcuts.bindings) + } catch { + errorLog.record(error, keyID: catalog.shortcuts.bindings.id) + } + } +} diff --git a/Packages/CmuxSettingsUI/Sources/CmuxSettingsUI/Sections/ResetSection.swift b/Packages/CmuxSettingsUI/Sources/CmuxSettingsUI/Sections/ResetSection.swift new file mode 100644 index 000000000000..2459d8dcfc23 --- /dev/null +++ b/Packages/CmuxSettingsUI/Sources/CmuxSettingsUI/Sections/ResetSection.swift @@ -0,0 +1,45 @@ +import CmuxSettings +import SwiftUI + +/// **Reset** section — mirrors the legacy in-app section: a single +/// centered "Reset All Settings" button wrapped in a `SettingsCard`. +/// Matches legacy behavior: the action fires immediately on click, +/// without a confirmation dialog. +@MainActor +public struct ResetSection: View { + private let defaultsStore: UserDefaultsSettingsStore + private let jsonStore: JSONConfigStore + private let catalog: SettingCatalog + + public init(defaultsStore: UserDefaultsSettingsStore, jsonStore: JSONConfigStore, catalog: SettingCatalog) { + self.defaultsStore = defaultsStore + self.jsonStore = jsonStore + self.catalog = catalog + } + + public var body: some View { + Group { + SettingsSectionHeader(String(localized: "settings.section.reset", defaultValue: "Reset")) + SettingsCard { + HStack { + Spacer(minLength: 0) + Button(String(localized: "settings.reset.resetAll", defaultValue: "Reset All Settings")) { + Task { await resetAll() } + } + .buttonStyle(.bordered) + .controlSize(.regular) + Spacer(minLength: 0) + } + .padding(.horizontal, 14) + .padding(.vertical, 10) + } + } + } + + private func resetAll() async { + await defaultsStore.resetAll(catalog.all) + for key in catalog.all { + await key.resetInJSON(jsonStore) + } + } +} diff --git a/Packages/CmuxSettingsUI/Sources/CmuxSettingsUI/Sections/SettingsJSONSection.swift b/Packages/CmuxSettingsUI/Sources/CmuxSettingsUI/Sections/SettingsJSONSection.swift new file mode 100644 index 000000000000..5d5a27ea1b69 --- /dev/null +++ b/Packages/CmuxSettingsUI/Sources/CmuxSettingsUI/Sections/SettingsJSONSection.swift @@ -0,0 +1,80 @@ +import CmuxSettings +import SwiftUI + +/// **cmux.json** section — mirrors the legacy in-app section: a card +/// containing the User Config File row (display path + Open button) +/// and the Documentation row (Open Docs link). +@MainActor +public struct SettingsJSONSection: View { + private let jsonStore: JSONConfigStore + private let hostActions: SettingsHostActions + + public init(jsonStore: JSONConfigStore, hostActions: SettingsHostActions) { + self.jsonStore = jsonStore + self.hostActions = hostActions + } + + public var body: some View { + Group { + SettingsSectionHeader(String(localized: "settings.section.settingsJSON", defaultValue: "cmux.json")) + .accessibilityIdentifier("SettingsJSONSection") + SettingsCard { + userConfigFileRow + SettingsCardDivider() + documentationRow + } + } + } + + @ViewBuilder + private var userConfigFileRow: some View { + SettingsCardRow( + configurationReview: .action, + String(localized: "settings.settingsJSON.file", defaultValue: "User config file"), + subtitle: String(localized: "settings.settingsJSON.file.subtitle", defaultValue: "Edit cmux-owned app settings, shortcuts, automation, sidebar, notifications, and browser behavior."), + controlWidth: 330 + ) { + HStack(spacing: 8) { + Text(displayPath) + .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")) { + hostActions.openConfigInExternalEditor() + } + .buttonStyle(.bordered) + .controlSize(.small) + .accessibilityIdentifier("SettingsJSONOpenButton") + } + .accessibilityIdentifier("SettingsJSONOpenFileRowActions") + } + } + + @ViewBuilder + private var documentationRow: some View { + 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.") + ) { + Link( + String(localized: "settings.settingsJSON.docsButton", defaultValue: "Open Docs"), + destination: URL(string: "https://cmux.com/docs/configuration#cmux-json")! + ) + .font(.caption) + .accessibilityIdentifier("SettingsJSONDocsLink") + } + } + + private var displayPath: String { + let homePath = NSHomeDirectory() + let fullPath = jsonStore.fileURL.path + if fullPath.hasPrefix(homePath) { + return "~" + String(fullPath.dropFirst(homePath.count)) + } + return fullPath + } +} diff --git a/Packages/CmuxSettingsUI/Sources/CmuxSettingsUI/Sections/SidebarSection.swift b/Packages/CmuxSettingsUI/Sources/CmuxSettingsUI/Sections/SidebarSection.swift new file mode 100644 index 000000000000..fb1e3b41d30a --- /dev/null +++ b/Packages/CmuxSettingsUI/Sources/CmuxSettingsUI/Sections/SidebarSection.swift @@ -0,0 +1,321 @@ +import CmuxSettings +import SwiftUI + +/// **Sidebar** section — mirrors the legacy in-app section +/// row-for-row. Single card containing match-terminal-background +/// followed by every workspace-row detail toggle. Rows that depend +/// on a parent toggle (`hideAllDetails`, PR visibility, PR +/// clickability) are disabled accordingly. +@MainActor +public struct SidebarSection: View { + private let catalog: SettingCatalog + + @State private var matchTerminal: DefaultsValueModel + @State private var hideAll: DefaultsValueModel + @State private var wrapTitles: DefaultsValueModel + @State private var showDesc: DefaultsValueModel + @State private var branchVerticalLayout: DefaultsValueModel + @State private var stackBranchDir: DefaultsValueModel + @State private var pathLastOnly: DefaultsValueModel + @State private var showNotification: DefaultsValueModel + @State private var showBranchDir: DefaultsValueModel + @State private var showPR: DefaultsValueModel + @State private var watchGit: DefaultsValueModel + @State private var prClickable: DefaultsValueModel + @State private var prLinks: DefaultsValueModel + @State private var portLinks: DefaultsValueModel + @State private var showSSH: DefaultsValueModel + @State private var showPorts: DefaultsValueModel + @State private var showLog: DefaultsValueModel + @State private var showProgress: DefaultsValueModel + @State private var showMetadata: DefaultsValueModel + + public init(defaultsStore: UserDefaultsSettingsStore, catalog: SettingCatalog) { + self.catalog = catalog + _matchTerminal = State(initialValue: DefaultsValueModel(store: defaultsStore, key: catalog.sidebarAppearance.matchTerminalBackground)) + _hideAll = State(initialValue: DefaultsValueModel(store: defaultsStore, key: catalog.sidebar.hideAllDetails)) + _wrapTitles = State(initialValue: DefaultsValueModel(store: defaultsStore, key: catalog.sidebar.wrapWorkspaceTitles)) + _showDesc = State(initialValue: DefaultsValueModel(store: defaultsStore, key: catalog.sidebar.showWorkspaceDescription)) + _branchVerticalLayout = State(initialValue: DefaultsValueModel(store: defaultsStore, key: catalog.sidebar.branchVerticalLayout)) + _stackBranchDir = State(initialValue: DefaultsValueModel(store: defaultsStore, key: catalog.sidebar.stackBranchDirectory)) + _pathLastOnly = State(initialValue: DefaultsValueModel(store: defaultsStore, key: catalog.sidebar.pathLastSegmentOnly)) + _showNotification = State(initialValue: DefaultsValueModel(store: defaultsStore, key: catalog.sidebar.showNotificationMessage)) + _showBranchDir = State(initialValue: DefaultsValueModel(store: defaultsStore, key: catalog.sidebar.showBranchDirectory)) + _showPR = State(initialValue: DefaultsValueModel(store: defaultsStore, key: catalog.sidebar.showPullRequests)) + _watchGit = State(initialValue: DefaultsValueModel(store: defaultsStore, key: catalog.sidebar.watchGitStatus)) + _prClickable = State(initialValue: DefaultsValueModel(store: defaultsStore, key: catalog.sidebar.makePullRequestsClickable)) + _prLinks = State(initialValue: DefaultsValueModel(store: defaultsStore, key: catalog.sidebar.openPullRequestLinksInCmuxBrowser)) + _portLinks = State(initialValue: DefaultsValueModel(store: defaultsStore, key: catalog.sidebar.openPortLinksInCmuxBrowser)) + _showSSH = State(initialValue: DefaultsValueModel(store: defaultsStore, key: catalog.sidebar.showSSH)) + _showPorts = State(initialValue: DefaultsValueModel(store: defaultsStore, key: catalog.sidebar.showPorts)) + _showLog = State(initialValue: DefaultsValueModel(store: defaultsStore, key: catalog.sidebar.showLog)) + _showProgress = State(initialValue: DefaultsValueModel(store: defaultsStore, key: catalog.sidebar.showProgress)) + _showMetadata = State(initialValue: DefaultsValueModel(store: defaultsStore, key: catalog.sidebar.showCustomMetadata)) + } + + public var body: some View { + Group { + SettingsSectionHeader(String(localized: "settings.section.sidebarAppearance", defaultValue: "Sidebar")) + mainCard + } + } + + @ViewBuilder + private var mainCard: some View { + SettingsCard { + SettingsCardRow( + configurationReview: .json("sidebarAppearance.matchTerminalBackground"), + String(localized: "settings.sidebarAppearance.matchTerminalBackground", defaultValue: "Match Terminal Background"), + subtitle: String(localized: "settings.sidebarAppearance.matchTerminalBackground.subtitle", defaultValue: "Use the same background color and transparency as the terminal.") + ) { + Toggle("", isOn: Binding(get: { matchTerminal.current }, set: { matchTerminal.set($0) })) + .labelsHidden() + .toggleStyle(.switch) + .controlSize(.small) + } + SettingsCardDivider() + + SettingsCardRow( + configurationReview: .json("sidebar.hideAllDetails"), + String(localized: "settings.app.hideAllSidebarDetails", defaultValue: "Hide All Sidebar Details"), + subtitle: hideAll.current + ? String(localized: "settings.app.hideAllSidebarDetails.subtitleOn", defaultValue: "Show only the workspace title row. Overrides the detail toggles below.") + : String(localized: "settings.app.hideAllSidebarDetails.subtitleOff", defaultValue: "Show secondary workspace details as controlled by the toggles below.") + ) { + Toggle("", isOn: Binding(get: { hideAll.current }, set: { hideAll.set($0) })) + .labelsHidden() + .controlSize(.small) + } + SettingsCardDivider() + + SettingsCardRow( + configurationReview: .json("sidebar.wrapWorkspaceTitles"), + String(localized: "settings.app.wrapWorkspaceTitles", defaultValue: "Wrap Workspace Titles in Sidebar"), + subtitle: wrapTitles.current + ? String(localized: "settings.app.wrapWorkspaceTitles.subtitleOn", defaultValue: "Long workspace titles can use as many lines as they need.") + : String(localized: "settings.app.wrapWorkspaceTitles.subtitleOff", defaultValue: "Workspace titles stay on one line and truncate at the end.") + ) { + Toggle("", isOn: Binding(get: { wrapTitles.current }, set: { wrapTitles.set($0) })) + .labelsHidden() + .controlSize(.small) + } + SettingsCardDivider() + + SettingsCardRow( + configurationReview: .json("sidebar.showWorkspaceDescription"), + String(localized: "settings.app.showWorkspaceDescription", defaultValue: "Show Workspace Description in Sidebar"), + subtitle: String(localized: "settings.app.showWorkspaceDescription.subtitle", defaultValue: "Display custom workspace descriptions below the workspace title.") + ) { + Toggle("", isOn: Binding(get: { showDesc.current }, set: { showDesc.set($0) })) + .labelsHidden() + .controlSize(.small) + } + .disabled(hideAll.current) + SettingsCardDivider() + + SettingsCardRow( + configurationReview: .json("sidebar.branchLayout"), + String(localized: "settings.app.sidebarBranchLayout", defaultValue: "Sidebar Branch Layout"), + subtitle: branchVerticalLayout.current + ? String(localized: "settings.app.sidebarBranchLayout.subtitleVertical", defaultValue: "Vertical: each branch appears on its own line.") + : String(localized: "settings.app.sidebarBranchLayout.subtitleInline", defaultValue: "Inline: all branches share one line."), + controlWidth: 196 + ) { + Picker("", selection: Binding(get: { branchVerticalLayout.current }, set: { branchVerticalLayout.set($0) })) { + Text(String(localized: "settings.app.sidebarBranchLayout.vertical", defaultValue: "Vertical")).tag(true) + Text(String(localized: "settings.app.sidebarBranchLayout.inline", defaultValue: "Inline")).tag(false) + } + .labelsHidden() + .pickerStyle(.menu) + } + .disabled(hideAll.current) + SettingsCardDivider() + + SettingsCardRow( + configurationReview: .json("sidebar.stackBranchDirectory"), + String(localized: "settings.app.stackBranchDirectory", defaultValue: "Stack Branch and Directory"), + subtitle: stackBranchDir.current + ? String(localized: "settings.app.stackBranchDirectory.subtitleOn", defaultValue: "Branch and directory render on separate lines.") + : String(localized: "settings.app.stackBranchDirectory.subtitleOff", defaultValue: "Branch and directory share a single line.") + ) { + Toggle("", isOn: Binding(get: { stackBranchDir.current }, set: { stackBranchDir.set($0) })) + .labelsHidden() + .controlSize(.small) + } + .disabled(hideAll.current) + SettingsCardDivider() + + SettingsCardRow( + configurationReview: .json("sidebar.pathLastSegmentOnly"), + String(localized: "settings.app.pathLastSegmentOnly", defaultValue: "Truncate Path From Start"), + subtitle: pathLastOnly.current + ? String(localized: "settings.app.pathLastSegmentOnly.subtitleOn", defaultValue: "Show as much of the trailing path as fits; shorter forms are prefixed with …/.") + : String(localized: "settings.app.pathLastSegmentOnly.subtitleOff", defaultValue: "Render full paths abbreviated with ~/.") + ) { + Toggle("", isOn: Binding(get: { pathLastOnly.current }, set: { pathLastOnly.set($0) })) + .labelsHidden() + .controlSize(.small) + } + .disabled(hideAll.current) + SettingsCardDivider() + + SettingsCardRow( + configurationReview: .json("sidebar.showNotificationMessage"), + String(localized: "settings.app.showNotificationMessage", defaultValue: "Show Notification Message in Sidebar"), + subtitle: String(localized: "settings.app.showNotificationMessage.subtitle", defaultValue: "Display the latest notification message below the workspace title.") + ) { + Toggle("", isOn: Binding(get: { showNotification.current }, set: { showNotification.set($0) })) + .labelsHidden() + .controlSize(.small) + } + .disabled(hideAll.current) + SettingsCardDivider() + + SettingsCardRow( + configurationReview: .json("sidebar.showBranchDirectory"), + String(localized: "settings.app.showBranchDirectory", defaultValue: "Show Branch + Directory in Sidebar"), + subtitle: String(localized: "settings.app.showBranchDirectory.subtitle", defaultValue: "Display the built-in git branch and working-directory row.") + ) { + Toggle("", isOn: Binding(get: { showBranchDir.current }, set: { showBranchDir.set($0) })) + .labelsHidden() + .controlSize(.small) + } + .disabled(hideAll.current) + SettingsCardDivider() + + SettingsCardRow( + configurationReview: .json("sidebar.showPullRequests"), + String(localized: "settings.app.showPullRequests", defaultValue: "Show Pull Requests in Sidebar"), + subtitle: String(localized: "settings.app.showPullRequests.subtitle", defaultValue: "Display review items (PR/MR/etc.) with status and number.") + ) { + Toggle("", isOn: Binding(get: { showPR.current }, set: { showPR.set($0) })) + .labelsHidden() + .controlSize(.small) + } + .disabled(hideAll.current) + SettingsCardDivider() + + SettingsCardRow( + configurationReview: .json("sidebar.watchGitStatus"), + String(localized: "settings.app.watchGitStatus", defaultValue: "Watch Git Status in Sidebar"), + subtitle: String(localized: "settings.app.watchGitStatus.subtitle", defaultValue: "Update sidebar branch and PR metadata from repository file changes without polling git.") + ) { + Toggle("", isOn: Binding(get: { watchGit.current }, set: { watchGit.set($0) })) + .labelsHidden() + .controlSize(.small) + } + .disabled(hideAll.current) + SettingsCardDivider() + + SettingsCardRow( + configurationReview: .json("sidebar.makePullRequestsClickable"), + String(localized: "settings.app.makeSidebarPullRequestClickable", defaultValue: "Make Sidebar PR Clickable"), + subtitle: String(localized: "settings.app.makeSidebarPullRequestClickable.subtitle", defaultValue: "Review items stay visible as plain text, and clicks in that area select the workspace row.") + ) { + Toggle("", isOn: Binding(get: { prClickable.current }, set: { prClickable.set($0) })) + .labelsHidden() + .controlSize(.small) + .accessibilityIdentifier("SettingsSidebarPullRequestClickableToggle") + } + .disabled(hideAll.current || !showPR.current) + SettingsCardDivider() + + SettingsCardRow( + configurationReview: .json("sidebar.openPullRequestLinksInCmuxBrowser"), + String(localized: "settings.app.openSidebarPRLinks", defaultValue: "Open Sidebar PR Links in cmux Browser"), + subtitle: prLinksSubtitle(prVisible: showPR.current, prClickable: prClickable.current, openInCmux: prLinks.current) + ) { + Toggle("", isOn: Binding(get: { prLinks.current }, set: { prLinks.set($0) })) + .labelsHidden() + .controlSize(.small) + } + .disabled(hideAll.current || !showPR.current || !prClickable.current) + SettingsCardDivider() + + SettingsCardRow( + configurationReview: .json("sidebar.openPortLinksInCmuxBrowser"), + String(localized: "settings.app.openSidebarPortLinks", defaultValue: "Open Sidebar Port Links in cmux Browser"), + subtitle: portLinks.current + ? String(localized: "settings.app.openSidebarPortLinks.subtitleOn", defaultValue: "Port clicks open inside cmux browser.") + : String(localized: "settings.app.openSidebarPortLinks.subtitleOff", defaultValue: "Port clicks open in your default browser.") + ) { + Toggle("", isOn: Binding(get: { portLinks.current }, set: { portLinks.set($0) })) + .labelsHidden() + .controlSize(.small) + } + .disabled(hideAll.current) + SettingsCardDivider() + + SettingsCardRow( + configurationReview: .json("sidebar.showSSH"), + String(localized: "settings.app.showSSH", defaultValue: "Show SSH in Sidebar"), + subtitle: String(localized: "settings.app.showSSH.subtitle", defaultValue: "Display the SSH target for remote workspaces in its own row.") + ) { + Toggle("", isOn: Binding(get: { showSSH.current }, set: { showSSH.set($0) })) + .labelsHidden() + .controlSize(.small) + } + .disabled(hideAll.current) + SettingsCardDivider() + + SettingsCardRow( + configurationReview: .json("sidebar.showPorts"), + String(localized: "settings.app.showPorts", defaultValue: "Show Listening Ports in Sidebar"), + subtitle: String(localized: "settings.app.showPorts.subtitle", defaultValue: "Display detected listening ports for the active workspace.") + ) { + Toggle("", isOn: Binding(get: { showPorts.current }, set: { showPorts.set($0) })) + .labelsHidden() + .controlSize(.small) + } + .disabled(hideAll.current) + SettingsCardDivider() + + SettingsCardRow( + configurationReview: .json("sidebar.showLog"), + String(localized: "settings.app.showLog", defaultValue: "Show Latest Log in Sidebar"), + subtitle: String(localized: "settings.app.showLog.subtitle", defaultValue: "Display the latest imperative log/status message.") + ) { + Toggle("", isOn: Binding(get: { showLog.current }, set: { showLog.set($0) })) + .labelsHidden() + .controlSize(.small) + } + .disabled(hideAll.current) + SettingsCardDivider() + + SettingsCardRow( + configurationReview: .json("sidebar.showProgress"), + String(localized: "settings.app.showProgress", defaultValue: "Show Progress in Sidebar"), + subtitle: String(localized: "settings.app.showProgress.subtitle", defaultValue: "Display the built-in progress bar from set_progress.") + ) { + Toggle("", isOn: Binding(get: { showProgress.current }, set: { showProgress.set($0) })) + .labelsHidden() + .controlSize(.small) + } + .disabled(hideAll.current) + SettingsCardDivider() + + SettingsCardRow( + configurationReview: .json("sidebar.showCustomMetadata"), + String(localized: "settings.app.showMetadata", defaultValue: "Show Custom Metadata in Sidebar"), + subtitle: String(localized: "settings.app.showMetadata.subtitle", defaultValue: "Display custom metadata from report_meta/set_status and report_meta_block.") + ) { + Toggle("", isOn: Binding(get: { showMetadata.current }, set: { showMetadata.set($0) })) + .labelsHidden() + .controlSize(.small) + } + .disabled(hideAll.current) + } + } + + private func prLinksSubtitle(prVisible: Bool, prClickable: Bool, openInCmux: Bool) -> String { + if !prVisible { + return String(localized: "settings.app.openSidebarPRLinks.subtitleHidden", defaultValue: "Enable sidebar PR visibility to choose where PR links open.") + } + if !prClickable { + return String(localized: "settings.app.openSidebarPRLinks.subtitleDisabled", defaultValue: "Enable sidebar PR clickability to choose where PR links open.") + } + return openInCmux + ? String(localized: "settings.app.openSidebarPRLinks.subtitleOn", defaultValue: "Clicks open inside cmux browser.") + : String(localized: "settings.app.openSidebarPRLinks.subtitleOff", defaultValue: "Clicks open in your default browser.") + } +} diff --git a/Packages/CmuxSettingsUI/Sources/CmuxSettingsUI/Sections/TerminalSection.swift b/Packages/CmuxSettingsUI/Sources/CmuxSettingsUI/Sections/TerminalSection.swift new file mode 100644 index 000000000000..67de65fa54ee --- /dev/null +++ b/Packages/CmuxSettingsUI/Sources/CmuxSettingsUI/Sections/TerminalSection.swift @@ -0,0 +1,179 @@ +import CmuxSettings +import SwiftUI + +/// **Terminal** section — mirrors the legacy in-app section +/// row-for-row: scroll bar, text-box max lines, copy on selection, +/// resume agent sessions, agent hibernation enable + idle seconds + +/// max live terminals, plus the JSON-backed Resume Commands editor. +@MainActor +public struct TerminalSection: View { + private let jsonStore: JSONConfigStore + private let catalog: SettingCatalog + private let hostActions: SettingsHostActions + + @State private var scrollBar: DefaultsValueModel + @State private var textBoxLines: DefaultsValueModel + @State private var copyOnSelect: DefaultsValueModel + @State private var autoResume: DefaultsValueModel + @State private var hibernation: DefaultsValueModel + @State private var idleSeconds: DefaultsValueModel + @State private var maxLive: DefaultsValueModel + + public init( + defaultsStore: UserDefaultsSettingsStore, + jsonStore: JSONConfigStore, + catalog: SettingCatalog, + hostActions: SettingsHostActions + ) { + self.jsonStore = jsonStore + self.catalog = catalog + self.hostActions = hostActions + _scrollBar = State(initialValue: DefaultsValueModel(store: defaultsStore, key: catalog.terminal.showScrollBar)) + _textBoxLines = State(initialValue: DefaultsValueModel(store: defaultsStore, key: catalog.terminal.textBoxMaxLines)) + _copyOnSelect = State(initialValue: DefaultsValueModel(store: defaultsStore, key: catalog.terminal.copyOnSelect)) + _autoResume = State(initialValue: DefaultsValueModel(store: defaultsStore, key: catalog.terminal.autoResumeAgentSessions)) + _hibernation = State(initialValue: DefaultsValueModel(store: defaultsStore, key: catalog.terminal.agentHibernationEnabled)) + _idleSeconds = State(initialValue: DefaultsValueModel(store: defaultsStore, key: catalog.terminal.agentHibernationIdleSeconds)) + _maxLive = State(initialValue: DefaultsValueModel(store: defaultsStore, key: catalog.terminal.agentHibernationMaxLiveTerminals)) + } + + public var body: some View { + Group { + SettingsSectionHeader(String(localized: "settings.section.terminal", defaultValue: "Terminal")) + mainCard + resumeCommandsCard + } + } + + @ViewBuilder + private var resumeCommandsCard: some View { + SettingsCard { + SettingsCardRow( + configurationReview: .json("terminal.resumeCommands"), + String(localized: "settings.terminal.resumeCommands", defaultValue: "Resume Commands"), + subtitle: String( + localized: "settings.terminal.resumeCommands.subtitle", + defaultValue: "Review signed command prefixes that can restore non-agent terminal surfaces." + ), + controlWidth: 170 + ) { + HStack(spacing: 8) { + Text(verbatim: "0") + .font(.caption.monospacedDigit()) + .foregroundColor(.secondary) + Button(String(localized: "settings.settingsJSON.openButton", defaultValue: "Open")) { + hostActions.openConfigInExternalEditor() + } + .buttonStyle(.bordered) + .controlSize(.small) + } + } + } + } + + @ViewBuilder + private var mainCard: some View { + SettingsCard { + SettingsCardRow( + configurationReview: .json("terminal.showScrollBar"), + String(localized: "settings.terminal.scrollBar", defaultValue: "Show Terminal Scroll Bar"), + subtitle: scrollBar.current + ? String(localized: "settings.terminal.scrollBar.subtitleOn", defaultValue: "Shows the right-edge terminal scroll bar in shell scrollback. cmux hides it automatically for alternate-screen style TUI surfaces and you can also disable it per workspace.") + : String(localized: "settings.terminal.scrollBar.subtitleOff", defaultValue: "Hides the right-edge terminal scroll bar everywhere. Changes apply immediately and persist across relaunches.") + ) { + Toggle("", isOn: Binding(get: { scrollBar.current }, set: { scrollBar.set($0) })) + .labelsHidden() + .controlSize(.small) + .accessibilityIdentifier("SettingsTerminalScrollBarToggle") + } + SettingsCardDivider() + SettingsCardRow( + configurationReview: .json("terminal.textBoxMaxLines"), + String(localized: "settings.terminal.textBoxMaxLines", defaultValue: "TextBox Max Lines"), + subtitle: String(localized: "settings.terminal.textBoxMaxLines.subtitle", defaultValue: "Limits how tall the rich terminal input can grow before it scrolls."), + controlWidth: 196 + ) { + Stepper( + value: Binding(get: { textBoxLines.current }, set: { textBoxLines.set($0) }), + in: 1...20 + ) { + Text(verbatim: "\(textBoxLines.current)") + .monospacedDigit() + .frame(width: 28, alignment: .trailing) + } + .controlSize(.small) + .accessibilityIdentifier("SettingsTerminalTextBoxMaxLinesStepper") + } + SettingsCardDivider() + SettingsCardRow( + configurationReview: .json("terminal.copyOnSelect"), + String(localized: "settings.terminal.copyOnSelect", defaultValue: "Copy on Selection"), + subtitle: copyOnSelect.current + ? String(localized: "settings.terminal.copyOnSelect.subtitleOn", defaultValue: "Selected terminal text is copied to the system clipboard when the selection is committed.") + : String(localized: "settings.terminal.copyOnSelect.subtitleOff", defaultValue: "Terminal selections do not replace the system clipboard. Use Cmd+C to copy manually.") + ) { + Toggle("", isOn: Binding(get: { copyOnSelect.current }, set: { copyOnSelect.set($0) })) + .labelsHidden() + .controlSize(.small) + .accessibilityIdentifier("SettingsTerminalCopyOnSelectToggle") + } + SettingsCardDivider() + SettingsCardRow( + configurationReview: .json("terminal.autoResumeAgentSessions"), + String(localized: "settings.terminal.agentAutoResume", defaultValue: "Resume Agent Sessions on Reopen"), + subtitle: autoResume.current + ? String(localized: "settings.terminal.agentAutoResume.subtitleOn", defaultValue: "When cmux reopens after quit, restored agent terminals automatically run their resume command.") + : String(localized: "settings.terminal.agentAutoResume.subtitleOff", defaultValue: "When cmux reopens after quit, restored agent terminals stay idle until you resume them manually.") + ) { + Toggle("", isOn: Binding(get: { autoResume.current }, set: { autoResume.set($0) })) + .labelsHidden() + .controlSize(.small) + .accessibilityIdentifier("SettingsTerminalAgentAutoResumeToggle") + } + SettingsCardDivider() + SettingsCardRow( + configurationReview: .json("terminal.agentHibernation.enabled"), + String(localized: "settings.terminal.agentHibernation", defaultValue: "Agent Hibernation"), + subtitle: hibernation.current + ? String(localized: "settings.terminal.agentHibernation.subtitleOn", defaultValue: "Idle background agent terminals can be suspended when the live-terminal limit is exceeded.") + : String(localized: "settings.terminal.agentHibernation.subtitleOff", defaultValue: "Agent terminals stay live until you close them or quit cmux.") + ) { + Toggle("", isOn: Binding(get: { hibernation.current }, set: { hibernation.set($0) })) + .labelsHidden() + .controlSize(.small) + .accessibilityIdentifier("SettingsTerminalAgentHibernationToggle") + } + SettingsCardDivider() + SettingsCardRow( + configurationReview: .json("terminal.agentHibernation.idleSeconds"), + String(localized: "settings.terminal.agentHibernation.idleSeconds", defaultValue: "Hibernate After Idle Seconds"), + subtitle: String(localized: "settings.terminal.agentHibernation.idleSeconds.subtitle", defaultValue: "A terminal must have no output and report an idle agent lifecycle for this long before it can be suspended."), + controlWidth: 140 + ) { + Stepper( + "\(Int(idleSeconds.current))", + value: Binding(get: { idleSeconds.current }, set: { idleSeconds.set($0) }), + in: 5...604_800, + step: 60 + ) + .accessibilityIdentifier("SettingsTerminalAgentHibernationIdleSecondsStepper") + } + SettingsCardDivider() + SettingsCardRow( + configurationReview: .json("terminal.agentHibernation.maxLiveTerminals"), + String(localized: "settings.terminal.agentHibernation.maxLiveTerminals", defaultValue: "Max Live Agent Terminals"), + subtitle: String(localized: "settings.terminal.agentHibernation.maxLiveTerminals.subtitle", defaultValue: "Visible terminals stay live. Extra idle background agent terminals hibernate oldest first."), + controlWidth: 120 + ) { + Stepper( + "\(maxLive.current)", + value: Binding(get: { maxLive.current }, set: { maxLive.set($0) }), + in: 1...256, + step: 1 + ) + .accessibilityIdentifier("SettingsTerminalAgentHibernationMaxLiveStepper") + } + } + } + +} diff --git a/Packages/CmuxSettingsUI/Sources/CmuxSettingsUI/Sections/WorkspaceColorsSection.swift b/Packages/CmuxSettingsUI/Sources/CmuxSettingsUI/Sections/WorkspaceColorsSection.swift new file mode 100644 index 000000000000..f3a82c945008 --- /dev/null +++ b/Packages/CmuxSettingsUI/Sources/CmuxSettingsUI/Sections/WorkspaceColorsSection.swift @@ -0,0 +1,271 @@ +import AppKit +import CmuxSettings +import SwiftUI + +/// **Workspace Colors** section — mirrors the legacy in-app section: +/// indicator-style picker, selection highlight color, notification +/// badge color, then a per-palette-entry editor and a Reset Palette +/// action. +@MainActor +public struct WorkspaceColorsSection: View { + private let jsonStore: JSONConfigStore + private let catalog: SettingCatalog + private let errorLog: SettingsErrorLog + + @State private var indicator: DefaultsValueModel + @State private var selectionHex: DefaultsValueModel + @State private var badgeHex: DefaultsValueModel + @State private var paletteModel: DefaultsValueModel<[String: String]> + + /// Built-in palette order and default hexes. Mirrors + /// `WorkspaceTabColorSettings.defaultPalette` in the legacy app target. + /// Kept in this file so the section can render the full effective + /// palette (built-ins + customs) with `Base:` subtitles and Remove + /// gating without reaching outside the package. + private static let builtInPalette: [(name: String, hex: String)] = [ + ("Red", "#C0392B"), + ("Crimson", "#922B21"), + ("Orange", "#A04000"), + ("Amber", "#7D6608"), + ("Olive", "#4A5C18"), + ("Green", "#196F3D"), + ("Teal", "#006B6B"), + ("Aqua", "#0E6B8C"), + ("Blue", "#1565C0"), + ("Navy", "#1A5276"), + ("Indigo", "#283593"), + ("Purple", "#6A1B9A"), + ("Magenta", "#AD1457"), + ("Rose", "#880E4F"), + ("Brown", "#7B3F00"), + ("Charcoal", "#3E4B5E"), + ] + + public init( + defaultsStore: UserDefaultsSettingsStore, + jsonStore: JSONConfigStore, + catalog: SettingCatalog, + errorLog: SettingsErrorLog + ) { + self.jsonStore = jsonStore + self.catalog = catalog + self.errorLog = errorLog + _indicator = State(initialValue: DefaultsValueModel(store: defaultsStore, key: catalog.workspaceColors.indicatorStyle)) + _selectionHex = State(initialValue: DefaultsValueModel(store: defaultsStore, key: catalog.workspaceColors.selectionColorHex)) + _badgeHex = State(initialValue: DefaultsValueModel(store: defaultsStore, key: catalog.workspaceColors.notificationBadgeColorHex)) + _paletteModel = State(initialValue: DefaultsValueModel(store: defaultsStore, key: catalog.workspaceColors.palette)) + } + + public var body: some View { + Group { + SettingsSectionHeader(String(localized: "settings.section.workspaceColors", defaultValue: "Workspace Colors")) + mainCard + } + } + + @ViewBuilder + private var mainCard: some View { + SettingsCard { + SettingsCardRow( + configurationReview: .json("workspaceColors.indicatorStyle"), + String(localized: "settings.workspaceColors.indicator", defaultValue: "Workspace Color Indicator"), + controlWidth: 196 + ) { + Picker("", selection: Binding(get: { indicator.current }, set: { indicator.set($0) })) { + ForEach(WorkspaceIndicatorStyle.allCases, id: \.self) { style in + Text(indicatorStyleLabel(style)).tag(style) + } + } + .labelsHidden() + .pickerStyle(.menu) + } + SettingsCardDivider() + + colorRow( + title: 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."), + json: "workspaceColors.selectionColor", + resetLabel: String(localized: "settings.workspaceColors.selectionColor.reset", defaultValue: "Reset"), + model: selectionHex + ) + SettingsCardDivider() + colorRow( + title: 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."), + json: "workspaceColors.notificationBadgeColor", + resetLabel: String(localized: "settings.workspaceColors.notificationBadgeColor.reset", defaultValue: "Reset"), + model: badgeHex + ) + SettingsCardDivider() + + SettingsCardNote( + String(localized: "settings.workspaceColors.dictionaryNote", defaultValue: "Edit cmux.json to add or remove named colors. \"Choose Custom Color...\" still adds local Custom N entries.") + ) + + let entries = effectivePaletteEntries(overrides: paletteModel.current) + if entries.isEmpty { + SettingsCardNote( + String(localized: "settings.workspaceColors.emptyPalette", defaultValue: "No palette entries. Add colors in cmux.json or use \"Choose Custom Color...\" from a workspace context menu.") + ) + } else { + ForEach(Array(entries.enumerated()), id: \.element.name) { index, entry in + if index > 0 { SettingsCardDivider() } + paletteEntryRow(entry: entry, paletteModel: paletteModel) + } + } + + 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")) { + paletteModel.reset() + } + .buttonStyle(.bordered) + .controlSize(.small) + } + } + } + + @ViewBuilder + private func colorRow(title: String, subtitle: String, json: String, resetLabel: String, model: DefaultsValueModel) -> some View { + let isCustom = !model.current.isEmpty + SettingsCardRow( + configurationReview: .json(json), + title, + subtitle: subtitle + ) { + HStack(spacing: 8) { + if isCustom { + Button(resetLabel) { model.reset() } + .buttonStyle(.bordered) + .controlSize(.small) + } + ColorPicker("", selection: Binding( + get: { Color(cmuxHex: model.current) ?? Self.cmuxAccentColor() }, + set: { newColor in model.set(newColor.cmuxHexString) } + ), supportsOpacity: false) + .labelsHidden() + .frame(width: 38) + Text(isCustom ? model.current : String(localized: "settings.sidebarAppearance.defaultLabel", defaultValue: "Default")) + .font(.system(size: 12, weight: .medium, design: .monospaced)) + .foregroundStyle(.secondary) + .frame(width: 76, alignment: .trailing) + } + } + } + + @ViewBuilder + private func paletteEntryRow( + entry: (name: String, hex: String), + paletteModel: DefaultsValueModel<[String: String]> + ) -> some View { + let baseHex = baseHex(for: entry.name) + let subtitle: String = { + if let baseHex { + return String(localized: "settings.workspaceColors.base", defaultValue: "Base: \(baseHex)") + } + return String(localized: "settings.workspaceColors.customEntry", defaultValue: "Named palette entry.") + }() + SettingsCardRow( + configurationReview: .json("workspaceColors.colors"), + entry.name, + subtitle: subtitle + ) { + HStack(spacing: 8) { + ColorPicker("", selection: Binding( + get: { Color(cmuxHex: entry.hex) ?? Color(nsColor: .systemBlue) }, + set: { newColor in + // Legacy semantics: persist the full effective + // palette (built-ins filled in at their default + // hex when missing) so editing one entry never + // drops the rest. + var snapshot = effectivePaletteMap(stored: paletteModel.current) + snapshot[entry.name] = newColor.cmuxHexString + paletteModel.set(snapshot) + } + ), 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 baseHex == nil { + Button(String(localized: "settings.workspaceColors.remove", defaultValue: "Remove")) { + var snapshot = effectivePaletteMap(stored: paletteModel.current) + snapshot.removeValue(forKey: entry.name) + paletteModel.set(snapshot) + } + .buttonStyle(.bordered) + .controlSize(.small) + } + } + } + } + + /// Returns the effective palette entries: built-in entries first + /// (in `builtInPalette` order, with overrides applied or default + /// hex), followed by custom entries sorted by name. Mirrors + /// `WorkspaceTabColorSettings.palette()`. + private func effectivePaletteEntries(overrides: [String: String]) -> [(name: String, hex: String)] { + let resolved = effectivePaletteMap(stored: overrides) + let builtInNames = Set(Self.builtInPalette.map(\.name)) + let builtIn: [(name: String, hex: String)] = Self.builtInPalette.compactMap { entry in + guard let hex = resolved[entry.name] else { return nil } + return (name: entry.name, hex: hex) + } + let customs = resolved + .filter { !builtInNames.contains($0.key) } + .sorted { $0.key.localizedStandardCompare($1.key) == .orderedAscending } + .map { (name: $0.key, hex: $0.value) } + return builtIn + customs + } + + /// Returns the full effective palette dictionary. When `stored` is + /// empty (no UserDefaults entry yet) this is the built-in default + /// palette; otherwise the stored map is returned verbatim. Matches + /// legacy `WorkspaceTabColorSettings.effectivePaletteMap`. + private func effectivePaletteMap(stored: [String: String]) -> [String: String] { + if stored.isEmpty { + return Dictionary(uniqueKeysWithValues: Self.builtInPalette.map { ($0.name, $0.hex) }) + } + return stored + } + + private func baseHex(for name: String) -> String? { + Self.builtInPalette.first(where: { $0.name == name })?.hex + } + + /// Localized label for an indicator style. + /// + /// Uses the legacy `sidebar.activeTabIndicator.*` localization keys + /// (mirrors `SidebarActiveTabIndicatorStyle.displayName` in the app + /// target) so existing translations apply. + private func indicatorStyleLabel(_ style: WorkspaceIndicatorStyle) -> String { + switch style { + case .leftRail: return String(localized: "sidebar.activeTabIndicator.leftRail", defaultValue: "Left Rail") + case .solidFill: return String(localized: "sidebar.activeTabIndicator.solidFill", defaultValue: "Solid Fill") + } + } + + + /// cmux-themed accent color used as the live ColorPicker fallback + /// when the selection or notification badge has no custom hex. + /// Mirrors the legacy `cmuxAccentColor()` helper (see + /// `Sources/Sidebar/SidebarAppearanceSupport.swift`) so the rendered + /// swatch matches the rest of the app instead of the system accent. + private static func cmuxAccentColor() -> Color { + let nsColor = NSColor(name: nil) { appearance in + let bestMatch = appearance.bestMatch(from: [.darkAqua, .aqua]) + if bestMatch == .darkAqua { + return NSColor(srgbRed: 0, green: 145.0 / 255.0, blue: 1.0, alpha: 1.0) + } + return NSColor(srgbRed: 0, green: 136.0 / 255.0, blue: 1.0, alpha: 1.0) + } + return Color(nsColor: nsColor) + } +} diff --git a/Packages/CmuxSettingsUI/Sources/CmuxSettingsUI/Values/SocketControlMode+Display.swift b/Packages/CmuxSettingsUI/Sources/CmuxSettingsUI/Values/SocketControlMode+Display.swift new file mode 100644 index 000000000000..e6efe6514ea6 --- /dev/null +++ b/Packages/CmuxSettingsUI/Sources/CmuxSettingsUI/Values/SocketControlMode+Display.swift @@ -0,0 +1,47 @@ +import CmuxSettings +import Foundation + +/// UI-facing labels for ``SocketControlMode``, ported byte-for-byte +/// from the legacy `Sources/SocketControlSettings.swift` so the +/// Automation section reads and writes the exact same display strings +/// users saw before the package refactor. +extension SocketControlMode { + /// Canonical UI ordering of the five modes. Matches legacy + /// `SocketControlMode.uiCases` so the picker rows render in the + /// same sequence. + static var uiCases: [SocketControlMode] { + [.off, .cmuxOnly, .automation, .password, .allowAll] + } + + /// Short label shown in the Automation picker. + var displayName: String { + switch self { + case .off: + return String(localized: "socketControl.off.name", defaultValue: "Off") + case .cmuxOnly: + return String(localized: "socketControl.cmuxOnly.name", defaultValue: "cmux processes only") + case .automation: + return String(localized: "socketControl.automation.name", defaultValue: "Automation mode") + case .password: + return String(localized: "socketControl.password.name", defaultValue: "Password mode") + case .allowAll: + return String(localized: "socketControl.allowAll.name", defaultValue: "Full open access") + } + } + + /// One-sentence row subtitle explaining the security tradeoff. + var description: String { + switch self { + case .off: + return String(localized: "socketControl.off.description", defaultValue: "Disable the local control socket.") + case .cmuxOnly: + return String(localized: "socketControl.cmuxOnly.description", defaultValue: "Only processes started inside cmux terminals can send commands.") + case .automation: + return String(localized: "socketControl.automation.description", defaultValue: "Allow external local automation clients from this macOS user (no ancestry check).") + case .password: + return String(localized: "socketControl.password.description", defaultValue: "Require socket authentication with a password stored in a local file.") + case .allowAll: + return String(localized: "socketControl.allowAll.description", defaultValue: "Allow any local process and user to connect with no auth. Unsafe.") + } + } +} diff --git a/Packages/CmuxSettingsUI/Tests/CmuxSettingsUITests/SettingsSearchIndexTests.swift b/Packages/CmuxSettingsUI/Tests/CmuxSettingsUITests/SettingsSearchIndexTests.swift new file mode 100644 index 000000000000..6a0d0f5bebc1 --- /dev/null +++ b/Packages/CmuxSettingsUI/Tests/CmuxSettingsUITests/SettingsSearchIndexTests.swift @@ -0,0 +1,34 @@ +import CmuxSettings +import Testing +@testable import CmuxSettingsUI + +/// Smoke tests for ``SettingsSearchIndex``. +/// +/// The index is the seam between the catalog (data) and the settings +/// window sidebar (UI). It is fully pure — no view-model, no actor — so +/// it can be tested without touching SwiftUI or AppKit. +@Suite("SettingsSearchIndex") +struct SettingsSearchIndexTests { + @Test func emptyQueryReturnsAllSectionEntries() { + let index = SettingsSearchIndex(catalog: SettingCatalog()) + let result = index.match("") + let sectionCount = result.filter { + if case .section = $0.kind { return true } else { return false } + }.count + #expect(sectionCount == SettingsSectionID.allCases.count) + } + + @Test func tokenizedQueryFiltersBothSectionsAndSettings() { + let index = SettingsSearchIndex(catalog: SettingCatalog()) + let result = index.match("automation") + // At minimum the Automation section itself should match. + #expect(result.contains(where: { $0.title == "Automation" })) + } + + @Test func diacriticInsensitiveMatch() { + let index = SettingsSearchIndex(catalog: SettingCatalog()) + let plain = index.match("automation") + let withDiacritics = index.match("autómation") + #expect(plain.count == withDiacritics.count) + } +} diff --git a/Packages/CmuxSettingsUI/Tests/CmuxSettingsUITests/SettingsSectionIDTests.swift b/Packages/CmuxSettingsUI/Tests/CmuxSettingsUITests/SettingsSectionIDTests.swift new file mode 100644 index 000000000000..898aef39fa9e --- /dev/null +++ b/Packages/CmuxSettingsUI/Tests/CmuxSettingsUITests/SettingsSectionIDTests.swift @@ -0,0 +1,17 @@ +import Testing +@testable import CmuxSettingsUI + +@Suite("SettingsSectionID") +struct SettingsSectionIDTests { + @Test func everyCaseHasNonEmptyTitleAndSymbol() { + for section in SettingsSectionID.allCases { + #expect(!section.title.isEmpty) + #expect(!section.symbolName.isEmpty) + } + } + + @Test func titlesAreUnique() { + let titles = SettingsSectionID.allCases.map(\.title) + #expect(titles.count == Set(titles).count) + } +} diff --git a/Resources/Localizable.xcstrings b/Resources/Localizable.xcstrings index b85fe09e2697..8ca2af249c56 100644 --- a/Resources/Localizable.xcstrings +++ b/Resources/Localizable.xcstrings @@ -2,6 +2,38 @@ "sourceLanguage": "en", "version": "1.0", "strings": { + "settings.error.alert.dismiss": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "OK" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "OK" + } + } + } + }, + "settings.error.alert.title": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Couldn't save setting" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "設定を保存できませんでした" + } + } + } + }, "diffViewer.empty.unstaged": { "extractionState": "manual", "localizations": { diff --git a/Sources/Auth/HostAccountFlow.swift b/Sources/Auth/HostAccountFlow.swift new file mode 100644 index 000000000000..02984cb785e3 --- /dev/null +++ b/Sources/Auth/HostAccountFlow.swift @@ -0,0 +1,103 @@ +import CMUXAuthCore +import CmuxSettingsUI +import Combine +import Foundation +import SwiftUI + +/// Adapts the legacy `AuthManager` + `AuthSettingsStore` to the +/// package's `AccountFlow` protocol so the new `CmuxSettingsUI` +/// `AccountSection` can drive sign-in / sign-out / refresh without +/// depending on `CMUXAuthCore`. +/// +/// Lifecycle: this is constructed once at app launch alongside the +/// `SettingsRuntime` and bridges `AuthManager`'s `@Published` +/// properties into `@Observable`-compatible state by subscribing in +/// `init`. The class itself is `@MainActor` because every read / +/// write hops to the main actor. +@MainActor +@Observable +final class HostAccountFlow: AccountFlow { + private(set) var currentIdentity: AccountIdentity? + private(set) var availableTeams: [AccountTeamSummary] = [] + var selectedTeamID: String? { + didSet { + guard selectedTeamID != oldValue else { return } + authManager.selectedTeamID = selectedTeamID + } + } + private(set) var isWorkingOnAuth: Bool = false + + private let authManager: AuthManager + @ObservationIgnored private var cancellables: Set = [] + + init(authManager: AuthManager) { + self.authManager = authManager + self.currentIdentity = Self.identity(from: authManager.currentUser) + self.availableTeams = authManager.availableTeams.map { team in + AccountTeamSummary(id: team.id, displayName: team.displayName, slug: team.slug) + } + self.selectedTeamID = authManager.selectedTeamID + self.isWorkingOnAuth = authManager.isLoading || authManager.isRestoringSession + + authManager.$currentUser + .receive(on: DispatchQueue.main) + .sink { [weak self] user in + self?.currentIdentity = Self.identity(from: user) + } + .store(in: &cancellables) + + authManager.$availableTeams + .receive(on: DispatchQueue.main) + .sink { [weak self] teams in + self?.availableTeams = teams.map { team in + AccountTeamSummary(id: team.id, displayName: team.displayName, slug: team.slug) + } + } + .store(in: &cancellables) + + authManager.$selectedTeamID + .receive(on: DispatchQueue.main) + .sink { [weak self] newValue in + guard let self else { return } + if self.selectedTeamID != newValue { + self.selectedTeamID = newValue + } + } + .store(in: &cancellables) + + authManager.$isLoading + .combineLatest(authManager.$isRestoringSession) + .map { $0 || $1 } + .receive(on: DispatchQueue.main) + .sink { [weak self] busy in + self?.isWorkingOnAuth = busy + } + .store(in: &cancellables) + } + + func startSignIn() { + authManager.beginSignIn() + } + + func signOut() async { + await authManager.signOut() + } + + func refreshCurrentUser() async { + // AuthManager.refreshSession is private; the public refresh + // path is `beginSignIn()` (browser flow) for full re-auth. + // For now refresh is a no-op; if the cached user is stale, + // the user can sign in again. If we surface a public refresh + // in AuthManager later, route it here. + } + + private static func identity(from user: CMUXAuthUser?) -> AccountIdentity? { + guard let user else { return nil } + return AccountIdentity( + id: user.id, + displayName: user.displayName ?? "", + email: user.primaryEmail ?? "", + avatarURL: nil + ) + } +} diff --git a/Sources/HostSettingsActions.swift b/Sources/HostSettingsActions.swift new file mode 100644 index 000000000000..78840509db77 --- /dev/null +++ b/Sources/HostSettingsActions.swift @@ -0,0 +1,162 @@ +import AppKit +import CmuxSettingsUI +import Foundation +import SwiftUI + +/// App-side implementation of the package's `SettingsHostActions` +/// protocol. Routes UI-triggered actions to the existing host +/// services (`BrowserHistoryStore`, `BrowserDataImportCoordinator`, +/// `TerminalNotificationStore`, etc.) so the package doesn't need to +/// depend on them directly. +@MainActor +final class HostSettingsActions: SettingsHostActions { + private let configFileURL: URL + + /// AppKit window identifier the dedicated terminal-config window carries. + /// Matches the value `ConfigSettingsView.configureWindow` assigns so the + /// host reuses a config window opened from any entrypoint (the legacy + /// in-app button's SwiftUI scene or this host-presented window). + private let configWindowIdentifier = "cmux.configEditor" + + /// Observes the `appIconMode` defaults key the settings package writes + /// so the host can re-apply the dock/app-switcher icon when the user + /// changes the App Icon picker. The package only persists the value; + /// applying `NSApplication.shared.applicationIconImage` is host work. + /// + /// Uses the closure-based `NSKeyValueObservation` token API, the + /// sanctioned seam for bridging a Foundation type that exposes change + /// only via KVO (`UserDefaults`). The token is invalidated in `deinit`. + private var appIconModeObservation: NSKeyValueObservation? + + /// Retains the AppKit window hosting ``ConfigSettingsView`` so repeated + /// "Open Config" presses reuse the same dedicated terminal-config + /// window instead of stacking duplicates. + private weak var configWindow: NSWindow? + + init(configFileURL: URL) { + self.configFileURL = configFileURL + startObservingAppIconMode() + } + + deinit { + appIconModeObservation?.invalidate() + } + + private func startObservingAppIconMode() { + // Apply once on construction so a value persisted before this + // instance existed (e.g. from the config file) is reflected. + AppIconSettings.applyIcon(AppIconSettings.resolvedMode()) + + appIconModeObservation = UserDefaults.standard.observe( + \.appIconMode, + options: [.new] + ) { _, _ in + // KVO delivers on the thread that mutated the key; @AppStorage + // writes happen on the main actor, so hop to it to apply. + Task { @MainActor in + AppIconSettings.applyIcon(AppIconSettings.resolvedMode()) + } + } + } + + func clearBrowserHistory() { + BrowserHistoryStore.shared.clearHistory() + } + + func openConfigInExternalEditor() { + NSWorkspace.shared.open(configFileURL) + } + + func sendFeedback() { + guard let url = URL(string: "https://github.com/manaflow-ai/cmux/issues/new") else { return } + NSWorkspace.shared.open(url) + } + + func sendTestNotification() { + TerminalNotificationStore.shared.sendSettingsTestNotification() + } + + func openSystemNotificationSettings() { + guard let url = URL(string: "x-apple.systempreferences:com.apple.preference.notifications") else { return } + NSWorkspace.shared.open(url) + } + + func restartApp() { + let bundlePath = Bundle.main.bundlePath + let task = Process() + task.launchPath = "/usr/bin/open" + task.arguments = ["-n", bundlePath] + try? task.run() + NSApp.terminate(nil) + } + + func openBrowserImportFlow() { + BrowserDataImportCoordinator.shared.presentImportDialog() + } + + func requestNotificationAuthorization() { + TerminalNotificationStore.shared.requestAuthorizationFromSettings() + } + + func openTerminalConfigWindow() { + NSApp.activate(ignoringOtherApps: true) + + // Legacy opened the dedicated config window via the SwiftUI + // `openWindow(id: ConfigSettingsView.windowID)` environment. The + // settings package can't reach that environment, so the host opens + // the same `ConfigSettingsView` directly. Reuse the existing window + // (identifier set by `ConfigSettingsView.configureWindow`) when one + // is already open so repeated presses focus instead of duplicate. + if let existing = existingConfigWindow() { + existing.makeKeyAndOrderFront(nil) + existing.orderFrontRegardless() + return + } + + let appearanceMode = UserDefaults.standard.string(forKey: AppearanceSettings.appearanceModeKey) + let root = ConfigSettingsView() + .cmuxAppearanceColorScheme(appearanceMode) + let hostingController = NSHostingController(rootView: root) + + let window = NSWindow(contentViewController: hostingController) + window.title = String(localized: "settings.config.windowTitle", defaultValue: "Config") + window.identifier = NSUserInterfaceItemIdentifier(configWindowIdentifier) + window.isReleasedWhenClosed = false + window.setContentSize(NSSize(width: 980, height: 680)) + window.center() + configWindow = window + window.makeKeyAndOrderFront(nil) + window.orderFrontRegardless() + } + + private func existingConfigWindow() -> NSWindow? { + if let configWindow, configWindow.isVisible || configWindow.isMiniaturized { + return configWindow + } + return NSApp.windows.first { + $0.identifier?.rawValue == configWindowIdentifier && ($0.isVisible || $0.isMiniaturized) + } + } + + func previewNotificationSound() { + NSSound(named: NSSound.Name("Glass"))?.play() + } + + func browserHistoryEntryCount() -> Int? { + guard BrowserHistoryStore.shared.isLoaded else { return nil } + return BrowserHistoryStore.shared.entries.count + } +} + +private extension UserDefaults { + /// KVO-observable accessor for the `appIconMode` defaults key. + /// + /// `UserDefaults` is KVO-compliant for any key accessed through a + /// matching `@objc dynamic` property whose name equals the key, which + /// lets ``HostSettingsActions`` observe App Icon changes the settings + /// package writes via `@AppStorage`. The property name must stay equal + /// to ``AppIconSettings/modeKey`` (`"appIconMode"`). + @objc dynamic var appIconMode: String? { + string(forKey: "appIconMode") + } +} diff --git a/Sources/cmuxApp.swift b/Sources/cmuxApp.swift index a8cb9ffd4f63..49d2397b979d 100644 --- a/Sources/cmuxApp.swift +++ b/Sources/cmuxApp.swift @@ -1,4 +1,6 @@ import AppKit +import CmuxSettings +import CmuxSettingsUI import SwiftUI import Observation import Darwin @@ -6,6 +8,12 @@ import Bonsplit import UniformTypeIdentifiers @main struct cmuxApp: App { + /// Dependency container for the new settings packages. Constructed + /// once at app launch and injected into the SwiftUI environment via + /// `.settingsRuntime(_:)`; descendant views resolve their settings + /// through it via the `@Setting` property wrapper. + private let settingsRuntime: SettingsRuntime + @StateObject private var tabManager: TabManager @StateObject private var notificationStore = TerminalNotificationStore.shared @StateObject var closedItemHistoryStore = ClosedItemHistoryStore.shared @@ -26,6 +34,24 @@ struct cmuxApp: App { } init() { + // Build the settings container once. All injected dependencies + // (the catalog, the two stores, the error log) live on this + // single struct; nothing in the package or app references a + // shared static. + let settingsCatalog = SettingCatalog() + let configFileURL = CmuxConfigLocation().userConfigFile + self.settingsRuntime = SettingsRuntime( + catalog: settingsCatalog, + userDefaultsStore: UserDefaultsSettingsStore( + defaults: .standard, + migrating: settingsCatalog.all + ), + jsonStore: JSONConfigStore(fileURL: configFileURL), + errorLog: SettingsErrorLog(), + accountFlow: HostAccountFlow(authManager: .shared), + hostActions: HostSettingsActions(configFileURL: configFileURL) + ) + // If invoked with CLI-style arguments (e.g. `cmux hooks setup`), exec the // bundled CLI at Contents/Resources/bin/cmux. The GUI binary and the CLI // share the name `cmux`, so if the GUI's Contents/MacOS leaks onto $PATH @@ -217,6 +243,7 @@ struct cmuxApp: App { var body: some Scene { WindowGroup { MainWindowBootstrapView() + .settingsRuntime(settingsRuntime) .cmuxAppearanceColorScheme(appearanceMode) .onAppear { SettingsWindowPresenter.configure( @@ -644,7 +671,11 @@ struct cmuxApp: App { } Window(String(localized: "settings.title", defaultValue: "Settings"), id: SettingsWindowPresenter.windowID) { - SettingsWindowRootView() + SettingsWindowRoot(runtime: settingsRuntime) + .settingsRuntime(settingsRuntime) + .background(WindowAccessor(dedupeByWindow: false) { window in + SettingsWindowPresenter.configure(window: window) + }) .cmuxAppearanceColorScheme(appearanceMode) } .defaultSize(width: 980, height: 680) @@ -655,6 +686,7 @@ struct cmuxApp: App { Window(String(localized: "settings.config.windowTitle", defaultValue: "Config"), id: ConfigSettingsView.windowID) { ConfigSettingsView() + .settingsRuntime(settingsRuntime) .cmuxAppearanceColorScheme(appearanceMode) } } @@ -5237,41 +5269,31 @@ struct SettingsView: View { @AppStorage(WorkspacePresentationModeSettings.modeKey) private var workspacePresentationMode = WorkspacePresentationModeSettings.defaultMode.rawValue @AppStorage(SocketControlSettings.appStorageKey) private var socketControlMode = SocketControlSettings.defaultMode.rawValue - @AppStorage(ClaudeCodeIntegrationSettings.hooksEnabledKey) - private var claudeCodeHooksEnabled = ClaudeCodeIntegrationSettings.defaultHooksEnabled - @AppStorage(ClaudeCodeIntegrationSettings.customClaudePathKey) - private var customClaudePath = "" - @AppStorage(RipgrepIntegrationSettings.customRipgrepPathKey) - private var customRipgrepPath = "" - @AppStorage(AgentSubagentNotificationSettings.suppressNotificationsKey) - private var suppressSubagentNotifications = AgentSubagentNotificationSettings.defaultSuppressNotifications - @AppStorage(CursorIntegrationSettings.hooksEnabledKey) - private var cursorHooksEnabled = CursorIntegrationSettings.defaultHooksEnabled - @AppStorage(GeminiIntegrationSettings.hooksEnabledKey) - private var geminiHooksEnabled = GeminiIntegrationSettings.defaultHooksEnabled - @AppStorage(TelemetrySettings.sendAnonymousTelemetryKey) - private var sendAnonymousTelemetry = TelemetrySettings.defaultSendAnonymousTelemetry - @AppStorage(PreferredEditorSettings.key) private var preferredEditorCommand = "" - @AppStorage(CmdClickSupportedFileRouteSettings.key) - private var openSupportedFilesInCmux = CmdClickSupportedFileRouteSettings.defaultValue - @AppStorage(CmdClickMarkdownRouteSettings.key) private var openMarkdownInCmuxViewer = CmdClickMarkdownRouteSettings.defaultValue + @Setting(\.integrations.claudeCodeHooksEnabled) private var claudeCodeHooksEnabled + @Setting(\.integrations.claudeCodeCustomClaudePath) private var customClaudePath + @Setting(\.integrations.ripgrepCustomBinaryPath) private var customRipgrepPath + @Setting(\.integrations.suppressSubagentNotifications) private var suppressSubagentNotifications + @Setting(\.integrations.cursorHooksEnabled) private var cursorHooksEnabled + @Setting(\.integrations.geminiHooksEnabled) private var geminiHooksEnabled + @Setting(\.app.sendAnonymousTelemetry) private var sendAnonymousTelemetry + @Setting(\.app.preferredEditor) private var preferredEditorCommand + @Setting(\.app.openSupportedFilesInCmux) private var openSupportedFilesInCmux + @Setting(\.app.openMarkdownInCmuxViewer) private var openMarkdownInCmuxViewer @AppStorage(AutomationSettings.portBaseKey) private var cmuxPortBase = AutomationSettings.defaultPortBase @AppStorage(AutomationSettings.portRangeKey) private var cmuxPortRange = AutomationSettings.defaultPortRange @AppStorage(BrowserSearchSettings.searchEngineKey) private var browserSearchEngine = BrowserSearchSettings.defaultSearchEngine.rawValue @AppStorage(BrowserSearchSettings.customSearchEngineNameKey) private var browserCustomSearchEngineName = BrowserSearchSettings.defaultCustomSearchEngineName @AppStorage(BrowserSearchSettings.customSearchEngineURLTemplateKey) private var browserCustomSearchEngineURLTemplate = BrowserSearchSettings.defaultCustomSearchEngineURLTemplate - @AppStorage(BrowserSearchSettings.searchSuggestionsEnabledKey) private var browserSearchSuggestionsEnabled = BrowserSearchSettings.defaultSearchSuggestionsEnabled @AppStorage(BrowserThemeSettings.modeKey) private var browserThemeMode = BrowserThemeSettings.defaultMode.rawValue - @AppStorage(BrowserAvailabilitySettings.disabledKey) private var browserDisabled = BrowserAvailabilitySettings.defaultDisabled - @AppStorage(BrowserHiddenWebViewDiscardPolicy.enabledKey) - private var browserHiddenWebViewDiscardEnabled = BrowserHiddenWebViewDiscardPolicy.defaultEnabled - @AppStorage(BrowserHiddenWebViewDiscardPolicy.hiddenDelayKey) - private var browserHiddenWebViewDiscardDelay = BrowserHiddenWebViewDiscardPolicy.defaultHiddenDelay @AppStorage(BrowserImportHintSettings.variantKey) private var browserImportHintVariantRaw = BrowserImportHintSettings.defaultVariant.rawValue - @AppStorage(BrowserImportHintSettings.showOnBlankTabsKey) private var showBrowserImportHintOnBlankTabs = BrowserImportHintSettings.defaultShowOnBlankTabs - @AppStorage(BrowserImportHintSettings.dismissedKey) private var isBrowserImportHintDismissed = BrowserImportHintSettings.defaultDismissed - @AppStorage(ReactGrabSettings.versionKey) private var reactGrabVersion = ReactGrabSettings.defaultVersion - @AppStorage(BrowserLinkOpenSettings.openTerminalLinksInCmuxBrowserKey) private var openTerminalLinksInCmuxBrowser = BrowserLinkOpenSettings.defaultOpenTerminalLinksInCmuxBrowser + @Setting(\.browser.showSearchSuggestions) private var browserSearchSuggestionsEnabled + @Setting(\.browser.disabled) private var browserDisabled + @Setting(\.browser.discardHiddenWebViews) private var browserHiddenWebViewDiscardEnabled + @Setting(\.browser.hiddenWebViewDiscardDelaySeconds) private var browserHiddenWebViewDiscardDelay + @Setting(\.browser.importHintDismissed) private var isBrowserImportHintDismissed + @Setting(\.browser.showImportHintOnBlankTabs) private var showBrowserImportHintOnBlankTabs + @Setting(\.browser.reactGrabVersion) private var reactGrabVersion + @Setting(\.browser.openTerminalLinksInCmuxBrowser) private var openTerminalLinksInCmuxBrowser @AppStorage(BrowserLinkOpenSettings.interceptTerminalOpenCommandInCmuxBrowserKey) private var interceptTerminalOpenCommandInCmuxBrowser = BrowserLinkOpenSettings.initialInterceptTerminalOpenCommandInCmuxBrowserValue() @AppStorage(BrowserLinkOpenSettings.browserHostWhitelistKey) private var browserHostWhitelist = BrowserLinkOpenSettings.defaultBrowserHostWhitelist @@ -5282,50 +5304,39 @@ struct SettingsView: View { @AppStorage(NotificationSoundSettings.customFilePathKey) private var notificationSoundCustomFilePath = NotificationSoundSettings.defaultCustomFilePath @AppStorage(NotificationSoundSettings.customCommandKey) private var notificationCustomCommand = NotificationSoundSettings.defaultCustomCommand - @AppStorage(NotificationBadgeSettings.dockBadgeEnabledKey) private var notificationDockBadgeEnabled = NotificationBadgeSettings.defaultDockBadgeEnabled - @AppStorage(NotificationPaneRingSettings.enabledKey) private var notificationPaneRingEnabled = NotificationPaneRingSettings.defaultEnabled - @AppStorage(NotificationPaneFlashSettings.enabledKey) private var notificationPaneFlashEnabled = NotificationPaneFlashSettings.defaultEnabled - @AppStorage(MenuBarExtraSettings.showInMenuBarKey) private var showMenuBarExtra = MenuBarExtraSettings.defaultShowInMenuBar - @AppStorage(MenuBarOnlySettings.menuBarOnlyKey) private var menuBarOnly = MenuBarOnlySettings.defaultMenuBarOnly + @Setting(\.notifications.dockBadge) private var notificationDockBadgeEnabled + @Setting(\.notifications.unreadPaneRing) private var notificationPaneRingEnabled + @Setting(\.notifications.paneFlash) private var notificationPaneFlashEnabled + @Setting(\.notifications.showInMenuBar) private var showMenuBarExtra + @Setting(\.app.menuBarOnly) private var menuBarOnly @AppStorage(QuitWarningSettings.confirmQuitKey) private var confirmQuitModeRaw = QuitWarningSettings.defaultConfirmQuitMode.rawValue - @AppStorage(QuitWarningSettings.warnBeforeQuitKey) private var warnBeforeQuitShortcut = QuitWarningSettings.defaultWarnBeforeQuit - @AppStorage(CloseTabWarningSettings.warnBeforeClosingTabKey) private var warnBeforeClosingTab = CloseTabWarningSettings.defaultWarnBeforeClosingTab - @AppStorage(CloseTabWarningSettings.warnBeforeClosingTabXButtonKey) - private var warnBeforeClosingTabXButton = CloseTabWarningSettings.defaultWarnBeforeClosingTabXButton - @AppStorage(CloseTabWarningSettings.hideTabCloseButtonKey) - private var hideTabCloseButton = CloseTabWarningSettings.defaultHideTabCloseButton - @AppStorage(CommandPaletteRenameSelectionSettings.selectAllOnFocusKey) - private var commandPaletteRenameSelectAllOnFocus = CommandPaletteRenameSelectionSettings.defaultSelectAllOnFocus - @AppStorage(CommandPaletteSwitcherSearchSettings.searchAllSurfacesKey) - private var commandPaletteSearchAllSurfaces = CommandPaletteSwitcherSearchSettings.defaultSearchAllSurfaces + @Setting(\.app.warnBeforeQuit) private var warnBeforeQuitShortcut + @Setting(\.app.warnBeforeClosingTab) private var warnBeforeClosingTab + @Setting(\.app.warnBeforeClosingTabXButton) private var warnBeforeClosingTabXButton + @Setting(\.app.hideTabCloseButton) private var hideTabCloseButton + @Setting(\.app.renameSelectsExistingName) private var commandPaletteRenameSelectAllOnFocus + @Setting(\.app.commandPaletteSearchesAllSurfaces) private var commandPaletteSearchAllSurfaces @AppStorage(WorkspacePlacementSettings.placementKey) private var newWorkspacePlacement = WorkspacePlacementSettings.defaultPlacement.rawValue @AppStorage(AgentConversationForkDefaultSettings.key) private var forkConversationDefaultDestination = AgentConversationForkDefaultSettings.defaultDestination.rawValue - @AppStorage(WorkspaceWorkingDirectoryInheritanceSettings.key) - private var workspaceInheritWorkingDirectory = WorkspaceWorkingDirectoryInheritanceSettings.defaultValue - @AppStorage(LastSurfaceCloseShortcutSettings.key) - private var closeWorkspaceOnLastSurfaceShortcut = LastSurfaceCloseShortcutSettings.defaultValue - @AppStorage(PaneFirstClickFocusSettings.enabledKey) - private var paneFirstClickFocusEnabled = PaneFirstClickFocusSettings.defaultEnabled - @AppStorage(TerminalScrollBarSettings.showScrollBarKey) - private var showTerminalScrollBar = TerminalScrollBarSettings.defaultShowScrollBar + @Setting(\.app.workspaceInheritWorkingDirectory) private var workspaceInheritWorkingDirectory + @Setting(\.app.keepWorkspaceOpenWhenClosingLastSurface) private var closeWorkspaceOnLastSurfaceShortcut + @Setting(\.app.focusPaneOnFirstClick) private var paneFirstClickFocusEnabled + @Setting(\.terminal.showScrollBar) private var showTerminalScrollBar @AppStorage(TerminalTextBoxInputSettings.maxLinesKey) private var textBoxMaxLines = TerminalTextBoxInputSettings.defaultMaxLines - @AppStorage(TerminalCopyOnSelectSettings.copyOnSelectKey) - private var terminalCopyOnSelect = TerminalCopyOnSelectSettings.defaultCopyOnSelect + @Setting(\.terminal.copyOnSelect) private var terminalCopyOnSelect @AppStorage(FileDropBehaviorSettings.defaultBehaviorKey) private var fileDropDefaultBehavior = FileDropBehaviorSettings.defaultBehavior.rawValue - @AppStorage(AgentSessionAutoResumeSettings.autoResumeAgentSessionsKey) - private var autoResumeAgentSessions = AgentSessionAutoResumeSettings.defaultAutoResumeAgentSessions - @AppStorage(AgentHibernationSettings.enabledKey) - private var agentHibernationEnabled = AgentHibernationSettings.defaultEnabled + @Setting(\.terminal.autoResumeAgentSessions) private var autoResumeAgentSessions + @Setting(\.terminal.agentHibernationEnabled) private var agentHibernationEnabled @AppStorage(AgentHibernationSettings.idleSecondsKey) private var agentHibernationIdleSeconds = AgentHibernationSettings.defaultIdleSeconds @AppStorage(AgentHibernationSettings.maxLiveTerminalsKey) private var agentHibernationMaxLiveTerminals = AgentHibernationSettings.defaultMaxLiveTerminals - @AppStorage(WorkspaceAutoReorderSettings.key) private var workspaceAutoReorder = WorkspaceAutoReorderSettings.defaultValue - @AppStorage(IMessageModeSettings.key) private var iMessageMode = IMessageModeSettings.defaultValue + @Setting(\.app.reorderOnNotification) private var workspaceAutoReorder + @Setting(\.app.iMessageMode) private var iMessageMode // iMessageModeGroupSortSettings.{sortInsideGroupsKey,floatGroupsKey} // exist in UserDefaults but are not surfaced in Settings yet — the // sort path doesn't read them yet. Re-add @AppStorage bindings here @@ -5363,8 +5374,13 @@ struct SettingsView: View { @AppStorage("sidebarTintHexDark") private var sidebarTintHexDark: String? @AppStorage("sidebarTintOpacity") private var sidebarTintOpacity = SidebarTintDefaults.opacity @AppStorage("sidebarMatchTerminalBackground") private var sidebarMatchTerminalBackground = false - @AppStorage(RightSidebarBetaFeatureSettings.dockEnabledKey) - private var rightSidebarDockEnabled = RightSidebarBetaFeatureSettings.defaultDockEnabled + // Dogfood: first @AppStorage call site converted to the new + // CmuxSettings/CmuxSettingsUI primitives. Resolves the + // SettingsRuntime injected on the Settings Window scene and + // routes reads/writes through UserDefaultsSettingsStore actor + + // DefaultsValueModel observation. UserDefaults key is the same + // ("rightSidebar.beta.dock.enabled") so existing values round-trip. + @Setting(\.betaFeatures.rightSidebarDock) private var rightSidebarDockEnabled @ObservedObject private var notificationStore = TerminalNotificationStore.shared @ObservedObject private var authManager = AuthManager.shared @@ -9057,70 +9073,15 @@ private struct GlobalHotkeySection: View { private struct SettingsWindowRootView: View { @State private var draftState = SettingsDraftState() - @State private var windowReference = WeakSettingsWindowReference() - @State private var shouldRenderSettingsContent = true var body: some View { - Group { - if shouldRenderSettingsContent { - SettingsRootView(draftState: draftState) - } else { - Color.clear - .frame( - minWidth: SettingsWindowPresenter.minimumSize.width, - minHeight: SettingsWindowPresenter.minimumSize.height - ) - } - } - .background(WindowAccessor { window in - windowReference.window = window - SettingsWindowPresenter.configure(window: window) - setContentVisibility(!window.isMiniaturized) - }) - .onReceive(NotificationCenter.default.publisher(for: NSWindow.didMiniaturizeNotification)) { notification in - guard isObservedWindow(notification.object) else { return } - setContentVisibility(false) - } - .onReceive(NotificationCenter.default.publisher(for: NSWindow.didDeminiaturizeNotification)) { notification in - guard isObservedWindow(notification.object) else { return } - setContentVisibility(true) - } - .onReceive(NotificationCenter.default.publisher(for: NSWindow.didBecomeKeyNotification)) { notification in - guard isObservedWindow(notification.object) else { return } - setContentVisibility(true) - } - .onReceive(NotificationCenter.default.publisher(for: NSWindow.didBecomeMainNotification)) { notification in - guard isObservedWindow(notification.object) else { return } - setContentVisibility(true) - } - .onReceive(NotificationCenter.default.publisher(for: NSWindow.willCloseNotification)) { notification in - guard isObservedWindow(notification.object) else { return } - setContentVisibility(false) - windowReference.window = nil - } - } - - private func isObservedWindow(_ object: Any?) -> Bool { - guard - let notificationWindow = object as? NSWindow, - let window = windowReference.window - else { - return false - } - return notificationWindow === window - } - - private func setContentVisibility(_ isVisible: Bool) { - guard shouldRenderSettingsContent != isVisible else { return } - shouldRenderSettingsContent = isVisible + SettingsRootView(draftState: draftState) + .background(WindowAccessor(dedupeByWindow: false) { window in + SettingsWindowPresenter.configure(window: window) + }) } } -@MainActor -private final class WeakSettingsWindowReference { - weak var window: NSWindow? -} - @MainActor @Observable final class SettingsDraftState { diff --git a/cmux.xcodeproj/project.pbxproj b/cmux.xcodeproj/project.pbxproj index 385138461fab..051040682198 100644 --- a/cmux.xcodeproj/project.pbxproj +++ b/cmux.xcodeproj/project.pbxproj @@ -146,7 +146,9 @@ C0DE31390000000000000101 /* CMUXOpenCommandTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = C0DE31390000000000000102 /* CMUXOpenCommandTests.swift */; }; 3069F1D10000000000000005 /* CMUXPasteboardFidelity in Frameworks */ = {isa = PBXBuildFile; productRef = 3069F1D10000000000000006 /* CMUXPasteboardFidelity */; }; A5C0DE0000000000000000A3 /* CMUXProjectModel in Frameworks */ = {isa = PBXBuildFile; productRef = A5C0DE0000000000000000A2 /* CMUXProjectModel */; }; + CD0CFE5300000000CD0CFE53 /* CmuxSettings in Frameworks */ = {isa = PBXBuildFile; productRef = CD0CFE5200000000CD0CFE52 /* CmuxSettings */; }; D3610C010000000000000001 /* CmuxSettingsJSONPathSupport.swift in Sources */ = {isa = PBXBuildFile; fileRef = D3610C010000000000000002 /* CmuxSettingsJSONPathSupport.swift */; }; + CD0CFE5600000000CD0CFE56 /* CmuxSettingsUI in Frameworks */ = {isa = PBXBuildFile; productRef = CD0CFE5500000000CD0CFE55 /* CmuxSettingsUI */; }; E7E000000000000000000009 /* CmuxSocketEventMapper.swift in Sources */ = {isa = PBXBuildFile; fileRef = E7E00000000000000000000A /* CmuxSocketEventMapper.swift */; }; A5354303A5354303A5354303 /* CMUXSocketPathDomain in Frameworks */ = {isa = PBXBuildFile; productRef = A5354305A5354305A5354305 /* CMUXSocketPathDomain */; }; B900004CA1B2C3D4E5F60719 /* CMUXSocketPathDomain in Frameworks */ = {isa = PBXBuildFile; productRef = A5354305A5354305A5354305 /* CMUXSocketPathDomain */; }; @@ -271,6 +273,8 @@ C1ADE10002A1B2C3D4E5F719 /* grok in Copy CLI */ = {isa = PBXBuildFile; fileRef = C1ADE10001A1B2C3D4E5F719 /* grok */; }; C0DE34020000000000000005 /* HelpMenuUITests.swift in Sources */ = {isa = PBXBuildFile; fileRef = C0DE34020000000000000006 /* HelpMenuUITests.swift */; }; F5320000A1B2C3D4E5F60718 /* HermesAgentIndex.swift in Sources */ = {isa = PBXBuildFile; fileRef = F5320001A1B2C3D4E5F60718 /* HermesAgentIndex.swift */; }; + CD0CFE6000000000CD0CFE60 /* HostAccountFlow.swift in Sources */ = {isa = PBXBuildFile; fileRef = CD0CFE6100000000CD0CFE61 /* HostAccountFlow.swift */; }; + CD0CFE6200000000CD0CFE62 /* HostSettingsActions.swift in Sources */ = {isa = PBXBuildFile; fileRef = CD0CFE6300000000CD0CFE63 /* HostSettingsActions.swift */; }; F1C1AA21B7E84D10A1C10001 /* InactivePaneFirstClickFocusTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = F1C1AA20B7E84D10A1C10001 /* InactivePaneFirstClickFocusTests.swift */; }; DA7A10CA710E000000000004 /* InfoPlist.xcstrings in Resources */ = {isa = PBXBuildFile; fileRef = DA7A10CA710E000000000002 /* InfoPlist.xcstrings */; }; 4D8B37E0E11A29997632765F /* InternalTabDragConfiguration.swift in Sources */ = {isa = PBXBuildFile; fileRef = 610008EA5E3744DDBE05C8E2 /* InternalTabDragConfiguration.swift */; }; @@ -382,13 +386,22 @@ A5001670 /* SessionRestoredTerminalCommandStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5001671 /* SessionRestoredTerminalCommandStore.swift */; }; A50016B1A1B2C3D4E5F60718 /* SessionSnapshotDebugBenchmark.swift in Sources */ = {isa = PBXBuildFile; fileRef = A50016B0A1B2C3D4E5F60718 /* SessionSnapshotDebugBenchmark.swift */; }; F5320002A1B2C3D4E5F60718 /* SessionTranscriptTypes.swift in Sources */ = {isa = PBXBuildFile; fileRef = F5320003A1B2C3D4E5F60718 /* SessionTranscriptTypes.swift */; }; + D10000000000000000A00020 /* SettingsAccountJSONResetBehaviorUITests.swift in Sources */ = {isa = PBXBuildFile; fileRef = D10000000000000000A00021 /* SettingsAccountJSONResetBehaviorUITests.swift */; }; + D10000000000000000A00012 /* SettingsAppBehaviorUITests.swift in Sources */ = {isa = PBXBuildFile; fileRef = D10000000000000000A00013 /* SettingsAppBehaviorUITests.swift */; }; + D10000000000000000A00018 /* SettingsAutomationBehaviorUITests.swift in Sources */ = {isa = PBXBuildFile; fileRef = D10000000000000000A00019 /* SettingsAutomationBehaviorUITests.swift */; }; + D10000000000000000A0001A /* SettingsBrowserBehaviorUITests.swift in Sources */ = {isa = PBXBuildFile; fileRef = D10000000000000000A0001B /* SettingsBrowserBehaviorUITests.swift */; }; B37A00000000000000000007 /* SettingsCardNote.swift in Sources */ = {isa = PBXBuildFile; fileRef = B37A00000000000000000008 /* SettingsCardNote.swift */; }; D9CCF002D9CCF002D9CCF002 /* SettingsLazyLoadHelpers.swift in Sources */ = {isa = PBXBuildFile; fileRef = D9CCF001D9CCF001D9CCF001 /* SettingsLazyLoadHelpers.swift */; }; A50019A0 /* SettingsNavigation.swift in Sources */ = {isa = PBXBuildFile; fileRef = A50019A1 /* SettingsNavigation.swift */; }; A50019B0 /* SettingsSearchAliases.swift in Sources */ = {isa = PBXBuildFile; fileRef = A50019B1 /* SettingsSearchAliases.swift */; }; A50019B2 /* SettingsSearchIndexTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = A50019B3 /* SettingsSearchIndexTests.swift */; }; + D10000000000000000A0001C /* SettingsShortcutsBehaviorUITests.swift in Sources */ = {isa = PBXBuildFile; fileRef = D10000000000000000A0001D /* SettingsShortcutsBehaviorUITests.swift */; }; + D10000000000000000A00016 /* SettingsSidebarBetaBehaviorUITests.swift in Sources */ = {isa = PBXBuildFile; fileRef = D10000000000000000A00017 /* SettingsSidebarBetaBehaviorUITests.swift */; }; + D10000000000000000A00014 /* SettingsTerminalBehaviorUITests.swift in Sources */ = {isa = PBXBuildFile; fileRef = D10000000000000000A00015 /* SettingsTerminalBehaviorUITests.swift */; }; + D10000000000000000A00010 /* SettingsUITestSupport.swift in Sources */ = {isa = PBXBuildFile; fileRef = D10000000000000000A00011 /* SettingsUITestSupport.swift */; }; D36090010000000000000001 /* SettingsWindowPresenter.swift in Sources */ = {isa = PBXBuildFile; fileRef = D36090010000000000000002 /* SettingsWindowPresenter.swift */; }; D36090010000000000000003 /* SettingsWindowPresenterTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = D36090010000000000000004 /* SettingsWindowPresenterTests.swift */; }; + D10000000000000000A0001E /* SettingsWorkspaceColorsBehaviorUITests.swift in Sources */ = {isa = PBXBuildFile; fileRef = D10000000000000000A0001F /* SettingsWorkspaceColorsBehaviorUITests.swift */; }; 1521D55DC63D5E5FC4955E31 /* ShortcutAndCommandPaletteTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6083A7DAD962E287FC2FFE94 /* ShortcutAndCommandPaletteTests.swift */; }; 76027A12C93B4538BF22C71E /* ShortcutBareStartRouting.swift in Sources */ = {isa = PBXBuildFile; fileRef = AB7F2E9143904957AAA70726 /* ShortcutBareStartRouting.swift */; }; 125BAEF2FE3B454547B964D1 /* ShortcutHintPill.swift in Sources */ = {isa = PBXBuildFile; fileRef = A080D1F6CCCBFBED8D95DD1D /* ShortcutHintPill.swift */; }; @@ -877,6 +890,8 @@ C1ADE10001A1B2C3D4E5F719 /* grok */ = {isa = PBXFileReference; lastKnownFileType = text.script.sh; path = Resources/bin/grok; sourceTree = SOURCE_ROOT; }; C0DE34020000000000000006 /* HelpMenuUITests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HelpMenuUITests.swift; sourceTree = ""; }; F5320001A1B2C3D4E5F60718 /* HermesAgentIndex.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HermesAgentIndex.swift; sourceTree = ""; }; + CD0CFE6100000000CD0CFE61 /* HostAccountFlow.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = HostAccountFlow.swift; sourceTree = ""; }; + CD0CFE6300000000CD0CFE63 /* HostSettingsActions.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = HostSettingsActions.swift; sourceTree = ""; }; F1C1AA20B7E84D10A1C10001 /* InactivePaneFirstClickFocusTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = InactivePaneFirstClickFocusTests.swift; sourceTree = ""; }; DA7A10CA710E000000000002 /* InfoPlist.xcstrings */ = {isa = PBXFileReference; lastKnownFileType = text.json.xcstrings; path = InfoPlist.xcstrings; sourceTree = ""; }; 610008EA5E3744DDBE05C8E2 /* InternalTabDragConfiguration.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Sidebar/InternalTabDragConfiguration.swift; sourceTree = ""; }; @@ -981,13 +996,22 @@ A5001671 /* SessionRestoredTerminalCommandStore.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SessionRestoredTerminalCommandStore.swift; sourceTree = ""; }; A50016B0A1B2C3D4E5F60718 /* SessionSnapshotDebugBenchmark.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = App/SessionSnapshotDebugBenchmark.swift; sourceTree = ""; }; F5320003A1B2C3D4E5F60718 /* SessionTranscriptTypes.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SessionTranscriptTypes.swift; sourceTree = ""; }; + D10000000000000000A00021 /* SettingsAccountJSONResetBehaviorUITests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SettingsAccountJSONResetBehaviorUITests.swift; sourceTree = ""; }; + D10000000000000000A00013 /* SettingsAppBehaviorUITests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SettingsAppBehaviorUITests.swift; sourceTree = ""; }; + D10000000000000000A00019 /* SettingsAutomationBehaviorUITests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SettingsAutomationBehaviorUITests.swift; sourceTree = ""; }; + D10000000000000000A0001B /* SettingsBrowserBehaviorUITests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SettingsBrowserBehaviorUITests.swift; sourceTree = ""; }; B37A00000000000000000008 /* SettingsCardNote.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SettingsCardNote.swift; sourceTree = ""; }; D9CCF001D9CCF001D9CCF001 /* SettingsLazyLoadHelpers.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SettingsLazyLoadHelpers.swift; sourceTree = ""; }; A50019A1 /* SettingsNavigation.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SettingsNavigation.swift; sourceTree = ""; }; A50019B1 /* SettingsSearchAliases.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SettingsSearchAliases.swift; sourceTree = ""; }; A50019B3 /* SettingsSearchIndexTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SettingsSearchIndexTests.swift; sourceTree = ""; }; + D10000000000000000A0001D /* SettingsShortcutsBehaviorUITests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SettingsShortcutsBehaviorUITests.swift; sourceTree = ""; }; + D10000000000000000A00017 /* SettingsSidebarBetaBehaviorUITests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SettingsSidebarBetaBehaviorUITests.swift; sourceTree = ""; }; + D10000000000000000A00015 /* SettingsTerminalBehaviorUITests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SettingsTerminalBehaviorUITests.swift; sourceTree = ""; }; + D10000000000000000A00011 /* SettingsUITestSupport.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SettingsUITestSupport.swift; sourceTree = ""; }; D36090010000000000000002 /* SettingsWindowPresenter.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = App/SettingsWindowPresenter.swift; sourceTree = ""; }; D36090010000000000000004 /* SettingsWindowPresenterTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SettingsWindowPresenterTests.swift; sourceTree = ""; }; + D10000000000000000A0001F /* SettingsWorkspaceColorsBehaviorUITests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SettingsWorkspaceColorsBehaviorUITests.swift; sourceTree = ""; }; 6083A7DAD962E287FC2FFE94 /* ShortcutAndCommandPaletteTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ShortcutAndCommandPaletteTests.swift; sourceTree = ""; }; AB7F2E9143904957AAA70726 /* ShortcutBareStartRouting.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = App/ShortcutBareStartRouting.swift; sourceTree = ""; }; A080D1F6CCCBFBED8D95DD1D /* ShortcutHintPill.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ShortcutHintPill.swift; sourceTree = ""; }; @@ -1159,6 +1183,8 @@ C0DE45000000000000000001 /* CmuxExtensionKit in Frameworks */, 3069F1D10000000000000005 /* CMUXPasteboardFidelity in Frameworks */, A5C0DE0000000000000000A3 /* CMUXProjectModel in Frameworks */, + CD0CFE5300000000CD0CFE53 /* CmuxSettings in Frameworks */, + CD0CFE5600000000CD0CFE56 /* CmuxSettingsUI in Frameworks */, A5354303A5354303A5354303 /* CMUXSocketPathDomain in Frameworks */, AA11BB22CC33DD44EE550001 /* CMUXWorkstream in Frameworks */, A5001006 /* GhosttyKit.xcframework in Frameworks */, @@ -1229,6 +1255,7 @@ 43430FA5929121E2EAAB3091 /* AuthEnvironment.swift */, 312DE7503B4658DD173121B8 /* AuthManager.swift */, 5B1EA8948C5F126FE63CFB4E /* AuthSettingsStore.swift */, + CD0CFE6100000000CD0CFE61 /* HostAccountFlow.swift */, ); name = Auth; path = Auth; @@ -1274,6 +1301,15 @@ D0E0F0B3A1B2C3D4E5F60718 /* BrowserOmnibarSuggestionsUITests.swift */, FB100001A1B2C3D4E5F60718 /* BrowserImportProfilesUITests.swift */, C0B4D9B1A1B2C3D4E5F60718 /* UpdatePillUITests.swift */, + D10000000000000000A00011 /* SettingsUITestSupport.swift */, + D10000000000000000A00013 /* SettingsAppBehaviorUITests.swift */, + D10000000000000000A00015 /* SettingsTerminalBehaviorUITests.swift */, + D10000000000000000A00017 /* SettingsSidebarBetaBehaviorUITests.swift */, + D10000000000000000A00019 /* SettingsAutomationBehaviorUITests.swift */, + D10000000000000000A0001B /* SettingsBrowserBehaviorUITests.swift */, + D10000000000000000A0001D /* SettingsShortcutsBehaviorUITests.swift */, + D10000000000000000A0001F /* SettingsWorkspaceColorsBehaviorUITests.swift */, + D10000000000000000A00021 /* SettingsAccountJSONResetBehaviorUITests.swift */, E1000001A1B2C3D4E5F60718 /* MenuKeyEquivalentRoutingUITests.swift */, AA1B2C3D4E5F60719 /* BonsplitTabDragUITests.swift */, AA1B2C3D4E5F60721 /* RightSidebarChromeHeightUITests.swift */, @@ -1585,6 +1621,7 @@ A5001219 /* WindowToolbarController.swift */, A5001241 /* WindowDecorationsController.swift */, A5001222 /* WindowAccessor.swift */, + CD0CFE6300000000CD0CFE63 /* HostSettingsActions.swift */, A5001611 /* SessionPersistence.swift */, A5001671 /* SessionRestoredTerminalCommandStore.swift */, B35751000000000000000001 /* TmuxResumeParser.swift */, @@ -1849,6 +1886,8 @@ A5B00002A1B2C3D4E5F60718 /* CMUXAgentLaunch */, A5354305A5354305A5354305 /* CMUXSocketPathDomain */, A500D013A1B2C3D4E5F60718 /* CMUXDebugLog */, + CD0CFE5200000000CD0CFE52 /* CmuxSettings */, + CD0CFE5500000000CD0CFE55 /* CmuxSettingsUI */, A8BD195031FC4B82B4354297 /* StackAuth */, ); productName = cmux; @@ -1983,6 +2022,8 @@ A5B00001A1B2C3D4E5F60718 /* XCLocalSwiftPackageReference "CMUXAgentLaunch" */, A500D012A1B2C3D4E5F60718 /* XCLocalSwiftPackageReference "CMUXDebugLog" */, A5354304A5354304A5354304 /* XCLocalSwiftPackageReference "CMUXSocketPathDomain" */, + CD0CFE5100000000CD0CFE51 /* XCLocalSwiftPackageReference "CmuxSettings" */, + CD0CFE5400000000CD0CFE54 /* XCLocalSwiftPackageReference "CmuxSettingsUI" */, 28B798BB9086C8E6B60C3355 /* XCLocalSwiftPackageReference "stack-auth-swift-sdk-prerelease" */, ); productRefGroup = A5001042 /* Products */; @@ -2253,6 +2294,8 @@ 3865A0073865A0073865A007 /* GlobalSearchDocuments.swift in Sources */, 3865A0083865A0083865A008 /* GlobalSearchPanelCaptureManager.swift in Sources */, F5320000A1B2C3D4E5F60718 /* HermesAgentIndex.swift in Sources */, + CD0CFE6000000000CD0CFE60 /* HostAccountFlow.swift in Sources */, + CD0CFE6200000000CD0CFE62 /* HostSettingsActions.swift in Sources */, 4D8B37E0E11A29997632765F /* InternalTabDragConfiguration.swift in Sources */, C0DEF0B10000000000000001 /* JSONCParser.swift in Sources */, A50012F5 /* KeyboardLayout.swift in Sources */, @@ -2498,6 +2541,15 @@ E1000000A1B2C3D4E5F60718 /* MenuKeyEquivalentRoutingUITests.swift in Sources */, B9000015A1B2C3D4E5F60719 /* MultiWindowNotificationsUITests.swift in Sources */, AA1B2C3D4E5F60720 /* RightSidebarChromeHeightUITests.swift in Sources */, + D10000000000000000A00020 /* SettingsAccountJSONResetBehaviorUITests.swift in Sources */, + D10000000000000000A00012 /* SettingsAppBehaviorUITests.swift in Sources */, + D10000000000000000A00018 /* SettingsAutomationBehaviorUITests.swift in Sources */, + D10000000000000000A0001A /* SettingsBrowserBehaviorUITests.swift in Sources */, + D10000000000000000A0001C /* SettingsShortcutsBehaviorUITests.swift in Sources */, + D10000000000000000A00016 /* SettingsSidebarBetaBehaviorUITests.swift in Sources */, + D10000000000000000A00014 /* SettingsTerminalBehaviorUITests.swift in Sources */, + D10000000000000000A00010 /* SettingsUITestSupport.swift in Sources */, + D10000000000000000A0001E /* SettingsWorkspaceColorsBehaviorUITests.swift in Sources */, B8F266246A1A3D9A45BD840F /* SidebarHelpMenuUITests.swift in Sources */, B9000130A1B2C3D4E5F60719 /* SidebarPullRequestInteractivityUITests.swift in Sources */, B8F266236A1A3D9A45BD840F /* SidebarResizeUITests.swift in Sources */, @@ -3075,6 +3127,14 @@ isa = XCLocalSwiftPackageReference; relativePath = Packages/CMUXSocketPathDomain; }; + CD0CFE5100000000CD0CFE51 /* XCLocalSwiftPackageReference "CmuxSettings" */ = { + isa = XCLocalSwiftPackageReference; + relativePath = Packages/CmuxSettings; + }; + CD0CFE5400000000CD0CFE54 /* XCLocalSwiftPackageReference "CmuxSettingsUI" */ = { + isa = XCLocalSwiftPackageReference; + relativePath = Packages/CmuxSettingsUI; + }; A5001260 /* XCLocalSwiftPackageReference "bonsplit" */ = { isa = XCLocalSwiftPackageReference; relativePath = vendor/bonsplit; @@ -3162,6 +3222,16 @@ package = A5354304A5354304A5354304 /* XCLocalSwiftPackageReference "CMUXSocketPathDomain" */; productName = CMUXSocketPathDomain; }; + CD0CFE5200000000CD0CFE52 /* CmuxSettings */ = { + isa = XCSwiftPackageProductDependency; + package = CD0CFE5100000000CD0CFE51 /* XCLocalSwiftPackageReference "CmuxSettings" */; + productName = CmuxSettings; + }; + CD0CFE5500000000CD0CFE55 /* CmuxSettingsUI */ = { + isa = XCSwiftPackageProductDependency; + package = CD0CFE5400000000CD0CFE54 /* XCLocalSwiftPackageReference "CmuxSettingsUI" */; + productName = CmuxSettingsUI; + }; A5001231 /* Sparkle */ = { isa = XCSwiftPackageProductDependency; package = A5001232 /* XCRemoteSwiftPackageReference "Sparkle" */; diff --git a/cmux.xcworkspace/contents.xcworkspacedata b/cmux.xcworkspace/contents.xcworkspacedata index 0b62982d6cee..3ac4d8ffd8a0 100644 --- a/cmux.xcworkspace/contents.xcworkspacedata +++ b/cmux.xcworkspace/contents.xcworkspacedata @@ -25,6 +25,12 @@ + + + + diff --git a/cmuxUITests/SettingsAccountJSONResetBehaviorUITests.swift b/cmuxUITests/SettingsAccountJSONResetBehaviorUITests.swift new file mode 100644 index 000000000000..5045a2b39026 --- /dev/null +++ b/cmuxUITests/SettingsAccountJSONResetBehaviorUITests.swift @@ -0,0 +1,212 @@ +import XCTest + +/// Behavioral UI tests for the Settings sections that make up the +/// "Account + cmux.json + Reset" group: the Account identity row, the +/// cmux.json config-file/docs card, and the Reset All Settings button. +/// +/// These three sidebar sections (`Account`, `cmux.json`, `Reset`) are +/// almost entirely *action* rows: their controls fire a host action +/// (open a file in an external editor, launch a browser auth flow, open +/// a docs URL) rather than persist a defaults-backed value. The only +/// control whose effect is observable purely inside the cmux app surface +/// is **Reset All Settings**, which clears every catalog key — provable +/// by flipping a defaults-backed Settings toggle, resetting, and watching +/// the toggle snap back to its default. +/// +/// Tier classification (see the structured output and the TIER comments +/// throughout this file): +/// - TIER 1: Reset All Settings (effect verified by a reverted toggle). +/// - TIER 2: cmux.json "Open" button (opens the config file in an +/// external editor — leaves the cmux process, no in-app element). +/// - TIER 3: Account Sign In / Sign Out (browser auth round trip), +/// cmux.json "Open Docs" link (external browser). +/// +/// The Account identity row and the cmux.json card *layout* are still +/// asserted for presence, because those elements are real accessibility +/// elements in the main app surface (the identity row's signed-out title +/// + button; the `SettingsJSONOpenButton` / `SettingsJSONDocsLink` ids). +/// Presence is a weaker claim than an effect, so those are not counted as +/// TIER-1 behavioral coverage — they only guard against the section +/// failing to render at all. +final class SettingsAccountJSONResetBehaviorUITests: SettingsUITestCase { + + /// `userDefaultsKey` for the App-section "Inherit Workspace Working + /// Directory" toggle (catalog id `app.workspaceInheritWorkingDirectory`, + /// default `true`). Used as the Reset probe: it is benign (only affects + /// the working directory new workspaces inherit, never the test surface) + /// and surfaces as `SettingsWorkspaceInheritWorkingDirectoryToggle`. + private let inheritDirKey = "workspaceInheritWorkingDirectory" + private let inheritDirToggleID = "SettingsWorkspaceInheritWorkingDirectoryToggle" + + override func setUp() { + super.setUp() + // Start every test from the catalog default for the probe key so the + // initial toggle state is deterministic (default `true` => ON). + resetDefaults([inheritDirKey]) + } + + override func tearDown() { + resetDefaults([inheritDirKey]) + super.tearDown() + } + + // MARK: - TIER 1: Reset All Settings + + /// Reset All Settings must clear a previously-changed defaults-backed + /// setting back to its catalog default. We flip the "Inherit Workspace + /// Working Directory" toggle OFF (default is ON), click Reset, and + /// assert the same toggle reads ON again. This verifies the *effect* of + /// the reset (the persisted value was cleared and the control reflects + /// the default), not merely that the Reset button is clickable. + func testResetAllSettingsRevertsChangedToggleToDefault() { + let app = makeLaunchedApp() + let window = openSettings(app) + defer { closeSettings(app, window) } + + // Navigate to the App section where the probe toggle lives. + navigate(window, to: "App") + let probeToggle = toggle(window, id: inheritDirToggleID) + + // Default is ON. Confirm the starting state, then flip OFF. + XCTAssertTrue( + poll(timeout: 4.0) { isToggleOn(probeToggle) }, + "Inherit Working Directory should start ON (catalog default true)" + ) + probeToggle.click() + XCTAssertTrue( + poll(timeout: 4.0) { !isToggleOn(probeToggle) }, + "Toggle should read OFF after the user clicks it" + ) + + // Trigger the reset from the Reset section. + navigate(window, to: "Reset") + let resetButton = requireElement( + candidates: [ + window.buttons["Reset All Settings"], + window.descendants(matching: .button)["Reset All Settings"], + ], + timeout: 4.0, + description: "Reset All Settings button" + ) + resetButton.click() + + // The reset clears the key; the toggle must snap back to its default + // (ON). Re-resolve the toggle after navigating back so we read the + // live control rather than a stale snapshot. + navigate(window, to: "App") + let toggleAfter = toggle(window, id: inheritDirToggleID) + XCTAssertTrue( + poll(timeout: 6.0) { isToggleOn(toggleAfter) }, + "Reset All Settings should restore Inherit Working Directory to its default (ON)" + ) + } + + // MARK: - Presence guards (not TIER-1 effects) + + /// The Account identity row renders its AccountFlow-driven identity + /// title and the trailing action button. The button label is auth-state + /// dependent ("Sign In…" when signed out, "Sign Out" when signed in), and + /// the harness launch args do not pin a session, so we assert the row + /// surfaces *one* of those action buttons rather than hard-coding a single + /// label (which would flake on a machine with a cached session). This is a + /// presence guard for the row, not a behavioral effect test: clicking the + /// button launches an external browser auth flow / backend round trip + /// (TIER 3 below), which XCUITest cannot observe inside cmux. + func testAccountSectionRendersIdentityRowActionButton() { + let app = makeLaunchedApp() + let window = openSettings(app) + defer { closeSettings(app, window) } + + navigate(window, to: "Account") + + let actionButton = requireElement( + candidates: [ + window.buttons["Sign In…"], + window.buttons["Sign Out"], + window.descendants(matching: .button)["Sign In…"], + window.descendants(matching: .button)["Sign Out"], + ], + timeout: 5.0, + description: "Account identity-row action button (Sign In… / Sign Out)" + ) + XCTAssertTrue( + actionButton.exists, + "Account section should render the identity-row sign-in/sign-out button" + ) + } + + /// The cmux.json card renders its two action rows: the User config file + /// row with an "Open" button (`SettingsJSONOpenButton`) and the + /// Documentation row with an "Open Docs" link (`SettingsJSONDocsLink`). + /// Presence guard only — both controls leave the cmux process when + /// clicked (TIER 2 / TIER 3 below), so their effects are not asserted. + func testSettingsJSONCardRendersOpenAndDocsControls() { + let app = makeLaunchedApp() + let window = openSettings(app) + defer { closeSettings(app, window) } + + navigate(window, to: "cmux.json") + + let openButton = requireElement( + candidates: [ + window.buttons["SettingsJSONOpenButton"], + window.descendants(matching: .button)["SettingsJSONOpenButton"], + ], + timeout: 5.0, + description: "cmux.json Open button" + ) + XCTAssertTrue(openButton.isEnabled, "Open config-file button should be enabled") + + let docsLink = requireElement( + candidates: [ + window.links["SettingsJSONDocsLink"], + window.buttons["SettingsJSONDocsLink"], + window.descendants(matching: .any)["SettingsJSONDocsLink"], + ], + timeout: 4.0, + description: "cmux.json Open Docs link" + ) + XCTAssertTrue(docsLink.exists, "Documentation row should expose the Open Docs link") + } + + // MARK: - Helpers + + /// Reads a SwiftUI `Toggle`'s on/off state across the control kinds it + /// can surface as in XCUITest. Switches report `"1"` / `"0"` in + /// `.value`; checkboxes report `isSelected`. + private func isToggleOn(_ element: XCUIElement) -> Bool { + if let value = element.value as? String { + return value == "1" + } + if let value = element.value as? Bool { + return value + } + return element.isSelected + } + + // MARK: - Tier documentation (controls without an in-app observable effect) + + // TIER 2 (needs runtime seam): cmux.json "Open" button (SettingsJSONOpenButton) + // — calls SettingsHostActions.openConfigInExternalEditor(), implemented in + // the host as `NSWorkspace.shared.open(configFileURL)`. This launches the + // user's default editor for ~/.config/cmux/cmux.json in a *separate* + // application. cmux opens no in-app window and changes no in-app element, + // so XCUITest (scoped to the cmux process) cannot observe the effect. + // Verifying it would require a runtime seam (e.g. a host action spy / + // injectable opener that records the opened URL); per task constraints we + // do not add app seams. Asserted for presence only above. + + // TIER 3 (not e2e): Account "Sign In…" / "Sign Out" button (AccountIdentityCard) + // — `startSignIn()` routes to AuthManager.beginSignIn(), which opens the + // cmux sign-in page in the system browser for an OAuth-style round trip; + // `signOut()` performs a backend network call. Neither produces an in-app + // window or element XCUITest can drive without real credentials and an + // external browser. The signed-out identity *display* state is asserted + // for presence above; the click effect is out of scope for e2e. + + // TIER 3 (not e2e): cmux.json "Open Docs" link (SettingsJSONDocsLink) + // — a SwiftUI `Link` to https://cmux.com/docs/configuration#cmux-json that + // opens in the system browser. Cross-app navigation with no cmux-side + // observable effect. Presence asserted above; the navigation itself is + // out of scope for e2e. +} diff --git a/cmuxUITests/SettingsAppBehaviorUITests.swift b/cmuxUITests/SettingsAppBehaviorUITests.swift new file mode 100644 index 000000000000..f66619f22fc5 --- /dev/null +++ b/cmuxUITests/SettingsAppBehaviorUITests.swift @@ -0,0 +1,307 @@ +import XCTest + +/// Behavioral XCUITests for the Settings **App** section. +/// +/// The App section is a single `SettingsCard` with ~30 rows. Most rows +/// drive runtime behavior that lives inside the main-app / Ghostty +/// terminal / Metal surface (dock badge, pane ring/flash, reorder, +/// iMessage, file drops, etc.) which a freshly launched UI-test app +/// cannot exercise without a runtime seam — those are documented as +/// TIER 2 / TIER 3 below. +/// +/// What *is* observable through XCUITest, deterministically and without +/// adding any app seam, is the Settings window's own reaction to a +/// changed setting: several App rows recompute their subtitle text from +/// the stored value, and the "Menu Bar Only" row disables the "Show in +/// Menu Bar" row. Those rows expose stable accessibility identifiers +/// (`SettingsMinimalModeToggle`, +/// `SettingsWorkspaceInheritWorkingDirectoryToggle`, +/// `SettingsMenuBarOnlyToggle`, `CommandPaletteSearchAllSurfacesToggle`). +/// Each test below flips one of those, then asserts the *effect* — the +/// new subtitle string appears / the old one disappears, or the gated +/// control's enabled state flips — not merely that the toggle changed +/// value. +/// +/// Subtitle strings are matched against the English `defaultValue`s in +/// `AppSection.swift`; the harness forces `-AppleLanguages (en)` so the +/// labels are stable across machines. +/// +/// TIER 2 (needs runtime seam): effect lives in the main-app window / +/// Ghostty / Metal surface or in timing; not observable from a fresh +/// UI-test launch without an app seam this test must not add. +/// - Theme (app.appearance / appearanceMode): repaints the terminal +/// and chrome via the Metal-backed surface; appearance is not a +/// queryable accessibility attribute. +/// - App Icon (app.appIcon / appIconMode): swaps the Dock/app icon +/// image; Dock tile imagery is not an XCUITest accessibility element. +/// - New Workspace Placement (app.newWorkspacePlacement): only +/// observable after creating ≥2 workspaces and inspecting sidebar +/// order; requires workspace scaffolding the fresh UI-test launch +/// does not have (CMUX_UI_TEST_MODE skips session restore). +/// - Inherit CWD effect on a real new workspace (the *subtitle* swap is +/// TIER 1 below; the actual working-directory inheritance needs a +/// spawned terminal to inspect, which is a terminal-surface seam). +/// - Keep Workspace Open When Closing Last Surface +/// (closeWorkspaceOnLastSurfaceShortcut): only observable by closing +/// the last surface of a real workspace and checking whether the +/// workspace survives; needs workspace+surface scaffolding. +/// - Focus Pane on First Click (paneFirstClickFocus.enabled): effect is +/// window-activation + pane-focus timing on an inactive window; +/// requires two windows and focus-state inspection of a Ghostty pane. +/// - File Drops (fileDrop.defaultBehavior): effect is on a drag-and-drop +/// gesture over a terminal/editor surface; XCUITest cannot synthesize +/// the file-promise drag this consumes. +/// - Open Supported Files / Open Markdown (openSupportedFilesInCmux, +/// openMarkdownInCmuxViewer): effect triggers on Cmd-click of a file +/// in a terminal surface, opening a preview/markdown window; needs a +/// live terminal with clickable file text. +/// - iMessage Mode (app.iMessageMode): reorders a workspace to top and +/// shows the submitted message when an agent prompt is sent; needs an +/// agent surface and a send action. +/// - Reorder on Notification (workspaceAutoReorderOnNotification): needs +/// ≥2 workspaces and an injected notification to observe reordering. +/// - Dock Badge (notificationDockBadgeEnabled): sets the Dock tile +/// badge label; Dock tile state is not an XCUITest element. +/// - Show in Menu Bar (showMenuBarExtra): adds/removes an NSStatusItem +/// in the system menu bar, which is a separate process surface not in +/// this app's element tree. (Its *disabled* gating by Menu Bar Only is +/// TIER 1 below.) +/// - Unread Pane Ring / Pane Flash (notificationPaneRingEnabled, +/// notificationPaneFlashEnabled): draw a ring/flash overlay on a pane +/// inside the Ghostty/Metal surface on notification; not queryable and +/// needs an injected notification. +/// - Warn Before Quit (confirmQuit/warnBeforeQuitShortcut): effect is a +/// confirmation sheet on Cmd+Q; exercising it would terminate the app +/// under test mid-run. +/// - Warn Before Closing Tab (warnBeforeClosingTabShortcut): effect is a +/// confirmation sheet when closing a real tab; needs a tab to close +/// (the *subtitle* swap would be TIER 1, but this row has no stable +/// accessibility id to flip the toggle deterministically). +/// - Warn Before Tab Close Button (warnBeforeClosingTabXButton): same — +/// confirmation on the tab "X" button; needs a tab and a stable id. +/// - Hide Tab Close Button (hideTabCloseButton): hides the per-tab close +/// "X" in the main window tab strip; needs a workspace tab present and +/// has no stable accessibility id on its Settings toggle to flip. +/// - Rename Selects Existing Name (commandPalette.renameSelectAllOnFocus): +/// effect is whether the Command Palette rename field starts fully +/// selected vs caret-at-end; needs the palette open on a renamable +/// row and selection-range inspection. +/// - Preferred Editor / Notification Sound / Notification Command: +/// out-of-process effects (launch an editor, play a sound, run a shell +/// command); not in-app observable. +/// +/// TIER 3 (not e2e): cross-app/external/no in-app UI effect. +/// - Send anonymous telemetry (sendAnonymousTelemetry): gates a network +/// analytics pipeline read only at next launch; no UI effect to assert +/// and nothing should hit the network from a test. Verify via the +/// telemetry client's unit tests instead. +final class SettingsAppBehaviorUITests: SettingsUITestCase { + // UserDefaults keys (the catalog `userDefaultsKey`s) touched here, so + // each test starts from the documented default regardless of prior + // local state. + private static let touchedKeys = [ + "workspacePresentationMode", // Minimal Mode (default .standard) + "workspaceInheritWorkingDirectory", // Inherit CWD (default true) + "menuBarOnly", // Menu Bar Only (default false) + "showMenuBarExtra", // Show in Menu Bar (gated row) + "commandPalette.switcherSearchAllSurfaces", // Palette all surfaces (default false) + ] + + override func setUp() { + super.setUp() + resetDefaults(Self.touchedKeys) + } + + override func tearDown() { + resetDefaults(Self.touchedKeys) + super.tearDown() + } + + // MARK: - English subtitle strings (must match AppSection defaultValues) + + private enum Subtitle { + static let minimalOn = "Hide the workspace title bar and move workspace controls into the sidebar." + static let minimalOff = "Use the standard workspace title bar and controls." + + static let inheritOn = "New workspaces start in the focused workspace's working directory." + static let inheritOff = "New workspaces leave their working directory unset so Ghostty's working-directory setting can apply." + + static let paletteOn = "Cmd+P also matches panel surfaces across workspaces." + static let paletteOff = "Cmd+P matches workspace rows only." + } + + // MARK: - Helpers + + /// Opens Settings, lands on the App section, and returns the window. + private func openAppSection(_ app: XCUIApplication) -> XCUIElement { + let window = openSettings(app) + navigate(window, to: "App") + // The App section header carries a stable id; wait for it so we + // know the detail pane rendered before we touch any row. + let header = window.descendants(matching: .any)["SettingsAppSection"] + XCTAssertTrue(poll(timeout: 4.0) { header.exists }, "App section did not render") + return window + } + + /// A static-text whose visible string equals `text`. + private func subtitleText(_ window: XCUIElement, _ text: String) -> XCUIElement { + window.staticTexts[text] + } + + // MARK: - TIER 1: Minimal Mode subtitle swap + + /// Toggling Minimal Mode flips the row subtitle between the + /// standard-title-bar and the hidden-title-bar wording. This proves + /// the stored `workspacePresentationMode` propagated through the + /// view-model and re-rendered the row, which is the observable effect + /// of the setting inside Settings. + func testMinimalModeToggleSwapsSubtitle() { + let app = makeLaunchedApp() + let window = openAppSection(app) + + // Default .standard → "off" subtitle present, "on" absent. + XCTAssertTrue( + poll(timeout: 4.0) { subtitleText(window, Subtitle.minimalOff).exists }, + "Expected standard-mode subtitle at default" + ) + + let minimal = toggle(window, id: "SettingsMinimalModeToggle") + minimal.click() + + XCTAssertTrue( + poll(timeout: 4.0) { subtitleText(window, Subtitle.minimalOn).exists }, + "Enabling Minimal Mode should show the hidden-title-bar subtitle" + ) + XCTAssertTrue( + poll(timeout: 4.0) { !subtitleText(window, Subtitle.minimalOff).exists }, + "Standard-mode subtitle should disappear once Minimal Mode is on" + ) + + // Toggle back and assert the subtitle reverts — confirms the bind + // is two-way and the effect tracks the stored value, not a latch. + minimal.click() + XCTAssertTrue( + poll(timeout: 4.0) { subtitleText(window, Subtitle.minimalOff).exists }, + "Disabling Minimal Mode should restore the standard-mode subtitle" + ) + + closeSettings(app, window) + } + + // MARK: - TIER 1: Inherit Working Directory subtitle swap + + /// Toggling Inherit Working Directory flips the row subtitle between + /// the inherit-on and inherit-off wording. Default is `true`, so the + /// "on" subtitle is present first. + func testInheritWorkingDirectoryToggleSwapsSubtitle() { + let app = makeLaunchedApp() + let window = openAppSection(app) + + XCTAssertTrue( + poll(timeout: 4.0) { subtitleText(window, Subtitle.inheritOn).exists }, + "Expected inherit-on subtitle at default (true)" + ) + + let inherit = toggle(window, id: "SettingsWorkspaceInheritWorkingDirectoryToggle") + inherit.click() + + XCTAssertTrue( + poll(timeout: 4.0) { subtitleText(window, Subtitle.inheritOff).exists }, + "Disabling inherit should show the unset-working-directory subtitle" + ) + XCTAssertTrue( + poll(timeout: 4.0) { !subtitleText(window, Subtitle.inheritOn).exists }, + "Inherit-on subtitle should disappear once inherit is off" + ) + + closeSettings(app, window) + } + + // MARK: - TIER 1: Command Palette Searches All Surfaces subtitle swap + + /// Toggling "Command Palette Searches All Surfaces" flips the row + /// subtitle between the all-surfaces and workspace-rows-only wording. + /// Default is `false`, so the "off" subtitle is present first. + func testCommandPaletteAllSurfacesToggleSwapsSubtitle() { + let app = makeLaunchedApp() + let window = openAppSection(app) + + XCTAssertTrue( + poll(timeout: 4.0) { subtitleText(window, Subtitle.paletteOff).exists }, + "Expected workspace-rows-only subtitle at default (false)" + ) + + let palette = toggle(window, id: "CommandPaletteSearchAllSurfacesToggle") + palette.click() + + XCTAssertTrue( + poll(timeout: 4.0) { subtitleText(window, Subtitle.paletteOn).exists }, + "Enabling all-surfaces should show the panel-surfaces subtitle" + ) + XCTAssertTrue( + poll(timeout: 4.0) { !subtitleText(window, Subtitle.paletteOff).exists }, + "Workspace-rows-only subtitle should disappear once all-surfaces is on" + ) + + closeSettings(app, window) + } + + // MARK: - TIER 1: Menu Bar Only disables the Show in Menu Bar row + + /// "Menu Bar Only" gates the "Show in Menu Bar" row with + /// `.disabled(menuBarOnly.current)`. Enabling Menu Bar Only must make + /// the Show-in-Menu-Bar toggle report `isEnabled == false`; disabling + /// it must re-enable that toggle. This is the in-Settings observable + /// effect of the Menu Bar Only setting (the actual Dock-icon hiding is + /// TIER 2). We locate the gated toggle by walking to the row that + /// follows the Menu Bar Only row. + func testMenuBarOnlyDisablesShowInMenuBarRow() { + let app = makeLaunchedApp() + let window = openAppSection(app) + + let menuBarOnly = toggle(window, id: "SettingsMenuBarOnlyToggle") + + // The "Show in Menu Bar" row has no explicit id; identify it by its + // title static text, then find the nearest sibling toggle/checkbox. + // Resolve the gated control as the switch/checkbox whose enabled + // state we can read. With only the Menu Bar Only toggle carrying an + // id, the remaining switches in the card are addressed positionally; + // we assert the *aggregate* effect: when Menu Bar Only is on, at + // least one previously-enabled switch in the card becomes disabled, + // and re-enabling Menu Bar Only restores it. + let showInMenuBarTitle = window.staticTexts["Show in Menu Bar"] + XCTAssertTrue( + poll(timeout: 4.0) { showInMenuBarTitle.exists }, + "Show in Menu Bar row should be present" + ) + + // Count enabled toggle controls before turning Menu Bar Only on. + // SwiftUI `Toggle(.switch)` surfaces as either a switch or a + // checkbox in XCUITest depending on host config, so count both + // kinds (matching the harness `toggle()` resolution). The gated + // Show-in-Menu-Bar row contributes one enabled control at default. + func enabledToggleCount() -> Int { + let switches = window.switches.allElementsBoundByIndex + let checkboxes = window.checkBoxes.allElementsBoundByIndex + return (switches + checkboxes).filter { $0.exists && $0.isEnabled }.count + } + + let baselineEnabled = enabledToggleCount() + + menuBarOnly.click() + // Effect: the gated Show-in-Menu-Bar control becomes disabled, so the + // count of enabled toggle controls drops by at least one. + XCTAssertTrue( + poll(timeout: 4.0) { enabledToggleCount() < baselineEnabled }, + "Enabling Menu Bar Only should disable the gated Show in Menu Bar control" + ) + + menuBarOnly.click() + XCTAssertTrue( + poll(timeout: 4.0) { enabledToggleCount() >= baselineEnabled }, + "Disabling Menu Bar Only should re-enable the gated control" + ) + + closeSettings(app, window) + } +} diff --git a/cmuxUITests/SettingsAutomationBehaviorUITests.swift b/cmuxUITests/SettingsAutomationBehaviorUITests.swift new file mode 100644 index 000000000000..0798fa21cdb5 --- /dev/null +++ b/cmuxUITests/SettingsAutomationBehaviorUITests.swift @@ -0,0 +1,431 @@ +import XCTest + +/// Behavioral UI tests for the Settings **Automation** section. +/// +/// The Automation section (`AutomationSection` in `CmuxSettingsUI`) exposes: +/// Socket Control Mode (menu picker), a conditional Socket Password subrow, +/// Claude Code / Cursor / Gemini integration toggles, Claude binary path, +/// Ripgrep binary path, Suppress Subagent Notifications, and Port Base / +/// Port Range Size fields. +/// +/// The genuine *runtime* effects of most of these settings (who may connect +/// to the Unix automation socket, whether `claude`/`cursor`/`gemini` get hook +/// env injection, which `rg` binary Find resolves, the `CMUX_PORT` range a new +/// terminal inherits, whether subagent completions notify) all live in +/// backend/CLI/terminal-env code paths that XCUITest cannot observe from the +/// app surface without adding a runtime seam (see TIER 2/3 notes at the bottom +/// of this file). They are intentionally NOT given fake "the toggle flipped" +/// tests here. +/// +/// What XCUITest *can* assert behaviorally is the Settings UI's own reactive +/// surface, which is the contract this section actually owns: +/// * choosing **Password mode** reveals a Socket Password subrow that does +/// not exist in the default `cmux processes only` mode, and choosing a +/// non-password mode hides it again; +/// * choosing **Full open access** raises a destructive confirmation dialog, +/// and cancelling it does not show the open-access warning; +/// * each integration toggle drives a live subtitle that flips between its +/// "on" and "off" sentence — an effect bound to the same model the runtime +/// reads, so the subtitle change proves the stored value actually changed. +/// +/// Each test resets the backing `UserDefaults` keys so it starts from the +/// documented default value. +final class SettingsAutomationBehaviorUITests: SettingsUITestCase { + + // Raw `userDefaultsKey`s backing the Automation catalog entries + // (see `AutomationCatalogSection` / `IntegrationsCatalogSection`). + private static let automationDefaultsKeys = [ + "socketControlMode", + "claudeCodeHooksEnabled", + "claudeCodeCustomClaudePath", + "ripgrepCustomBinaryPath", + "suppressSubagentNotifications", + "cursorHooksEnabled", + "geminiHooksEnabled", + "cmuxPortBase", + "cmuxPortRange", + ] + + // Display strings ported byte-for-byte from + // `SocketControlMode+Display.swift` and `AutomationSection.swift`. + private enum L { + static let modePassword = "Password mode" + static let modeOpenAccess = "Full open access" + static let modeCmuxOnly = "cmux processes only" + + static let passwordRowTitle = "Socket Password" + static let passwordSubtitleUnset = "No password set. External clients will be blocked until one is configured." + static let passwordSubtitleSet = "Stored in Application Support." + static let passwordSaved = "Saved." + + static let openAccessDialogTitle = "Enable full open access?" + static let openAccessDialogConfirm = "Enable Full Open Access" + static let openAccessDialogCancel = "Cancel" + static let openAccessWarningPrefix = "Warning: Full open access makes the control socket" + + // Claude Code Integration subtitles. + static let claudeOn = "Sidebar shows Claude session status and notifications." + static let claudeOff = "Claude Code runs without cmux integration." + + // Cursor Integration subtitles. + static let cursorOn = "Sidebar shows Cursor agent status and notifications." + static let cursorOff = "Cursor runs without cmux integration." + + // Gemini CLI Integration subtitles. + static let geminiOn = "Sidebar shows Gemini session status and notifications." + static let geminiOff = "Gemini runs without cmux integration." + + // Suppress Subagent Notifications subtitles. + static let suppressOn = "Child agent completions stay in Feed without notifications." + static let suppressOff = "Child agent completions notify like top-level agents." + } + + override func setUp() { + super.setUp() + resetDefaults(Self.automationDefaultsKeys) + } + + override func tearDown() { + resetDefaults(Self.automationDefaultsKeys) + super.tearDown() + } + + // MARK: - Helpers + + /// Opens Settings, scrolls to the Automation section, and returns the + /// Settings window. + private func openAutomation(_ app: XCUIApplication) -> XCUIElement { + let window = openSettings(app) + navigate(window, to: "Automation") + return window + } + + /// Resolves the Socket Control Mode picker, which a SwiftUI menu-style + /// `Picker` surfaces as a pop-up button (or, on some macOS versions, a + /// generic element with the same identifier). + private func modePicker(_ window: XCUIElement) -> XCUIElement { + requireElement( + candidates: [ + window.popUpButtons["AutomationSocketModePicker"], + window.menuButtons["AutomationSocketModePicker"], + window.descendants(matching: .any)["AutomationSocketModePicker"], + ], + timeout: 4.0, + description: "socket control mode picker" + ) + } + + /// Opens the mode picker menu and selects the row whose label matches + /// `title`. Returns true on success. + @discardableResult + private func selectMode(_ window: XCUIElement, _ app: XCUIApplication, _ title: String) -> Bool { + let picker = modePicker(window) + picker.click() + // The menu items can hang off the app, the window, or the picker + // depending on macOS. Try each surface. + let candidates = [ + app.menuItems[title], + window.menuItems[title], + picker.menuItems[title], + ] + let item = poll(timeout: 4.0) { candidates.contains { $0.exists } } + guard item else { return false } + for c in candidates where c.exists { + c.click() + return true + } + return false + } + + // MARK: - TIER 1: Socket Control Mode reveals/hides the password subrow + + /// Default mode is `cmux processes only`, so the Socket Password row must + /// be absent. Selecting `Password mode` must reveal it (title + the + /// "no password set" subtitle), and selecting `cmux processes only` + /// again must hide it. This is the section's core conditional-UI contract. + func testPasswordModeRevealsAndHidesSocketPasswordRow() { + let app = makeLaunchedApp() + let window = openAutomation(app) + defer { closeSettings(app, window) } + + let passwordTitle = window.staticTexts[L.passwordRowTitle] + + // Default (cmuxOnly): no password row. + XCTAssertFalse( + passwordTitle.waitForExistence(timeout: 1.0), + "Socket Password row should be hidden in the default cmux-only mode" + ) + + // Switch to Password mode → row appears with the unset subtitle. + XCTAssertTrue(selectMode(window, app, L.modePassword), "could not select Password mode") + XCTAssertTrue( + poll(timeout: 4.0) { passwordTitle.exists }, + "Socket Password row should appear after choosing Password mode" + ) + XCTAssertTrue( + poll(timeout: 2.0) { window.staticTexts[L.passwordSubtitleUnset].exists }, + "Password row should show the unset-password subtitle when no password is configured" + ) + + // Switch back to cmux-only → row disappears again. + XCTAssertTrue(selectMode(window, app, L.modeCmuxOnly), "could not select cmux-only mode") + XCTAssertTrue( + poll(timeout: 4.0) { !passwordTitle.exists }, + "Socket Password row should disappear after leaving Password mode" + ) + } + + // MARK: - TIER 1: Setting a socket password updates the row state + + /// In Password mode, typing a password and pressing Set must surface the + /// "Saved." status and flip the row subtitle from the unset sentence to + /// the "Stored in Application Support." sentence. Both observable changes + /// are driven by the same `socketPasswordModel` the runtime reads, so the + /// subtitle change proves the value was actually stored. + func testSettingSocketPasswordShowsSavedStateAndStoredSubtitle() { + let app = makeLaunchedApp() + let window = openAutomation(app) + defer { closeSettings(app, window) } + + XCTAssertTrue(selectMode(window, app, L.modePassword), "could not select Password mode") + XCTAssertTrue( + poll(timeout: 4.0) { window.staticTexts[L.passwordRowTitle].exists }, + "Password row should appear after choosing Password mode" + ) + + // The SecureField has no accessibility id; resolve the only secure + // text field inside the Settings window. + let field = requireElement( + candidates: [window.secureTextFields.firstMatch], + timeout: 4.0, + description: "socket password secure field" + ) + field.click() + field.typeText("hunter2") + + // The Set button is enabled once the draft is non-empty. + let setButton = requireElement( + candidates: [window.buttons["Set"], window.buttons["Change"]], + timeout: 4.0, + description: "Set socket password button" + ) + setButton.click() + + XCTAssertTrue( + poll(timeout: 4.0) { window.staticTexts[L.passwordSaved].exists }, + "A 'Saved.' status should appear after setting the password" + ) + XCTAssertTrue( + poll(timeout: 2.0) { window.staticTexts[L.passwordSubtitleSet].exists }, + "Row subtitle should switch to the 'Stored in Application Support.' sentence once a password is set" + ) + } + + // MARK: - TIER 1: Full open access raises a confirmation dialog + + /// Choosing `Full open access` must raise the destructive confirmation + /// dialog before applying the mode. Cancelling must NOT show the + /// open-access warning (the mode change is gated on confirmation). This + /// verifies the safety gate, the most important behavior of this control. + func testFullOpenAccessRaisesConfirmationDialogAndCancelLeavesWarningHidden() { + let app = makeLaunchedApp() + let window = openAutomation(app) + defer { closeSettings(app, window) } + + // The open-access warning must not be present before confirmation. + XCTAssertFalse( + window.staticTexts.containing( + NSPredicate(format: "label BEGINSWITH %@", L.openAccessWarningPrefix) + ).firstMatch.waitForExistence(timeout: 1.0), + "Open-access warning should be hidden in the default mode" + ) + + XCTAssertTrue(selectMode(window, app, L.modeOpenAccess), "could not select Full open access") + + // Confirmation dialog appears. + let confirmButton = requireElement( + candidates: [ + app.buttons[L.openAccessDialogConfirm], + window.buttons[L.openAccessDialogConfirm], + app.sheets.buttons[L.openAccessDialogConfirm], + ], + timeout: 4.0, + description: "open-access confirmation dialog confirm button" + ) + XCTAssertTrue(confirmButton.exists, "Full open access should raise a confirmation dialog") + + // Cancel and assert the warning never appears (mode stayed gated). + let cancelButton = requireElement( + candidates: [ + app.buttons[L.openAccessDialogCancel], + window.buttons[L.openAccessDialogCancel], + app.sheets.buttons[L.openAccessDialogCancel], + ], + timeout: 4.0, + description: "open-access confirmation dialog cancel button" + ) + cancelButton.click() + + let warning = window.staticTexts.containing( + NSPredicate(format: "label BEGINSWITH %@", L.openAccessWarningPrefix) + ).firstMatch + XCTAssertFalse( + warning.waitForExistence(timeout: 1.5), + "Cancelling the dialog must not apply Full open access, so the warning must stay hidden" + ) + } + + // MARK: - TIER 1: Confirming full open access shows the warning + + /// Confirming the dialog applies `allowAll` and the open-access warning + /// text must then render, proving the mode change took effect in the UI. + func testConfirmingFullOpenAccessShowsWarning() { + let app = makeLaunchedApp() + let window = openAutomation(app) + defer { closeSettings(app, window) } + + XCTAssertTrue(selectMode(window, app, L.modeOpenAccess), "could not select Full open access") + + let confirmButton = requireElement( + candidates: [ + app.buttons[L.openAccessDialogConfirm], + window.buttons[L.openAccessDialogConfirm], + app.sheets.buttons[L.openAccessDialogConfirm], + ], + timeout: 4.0, + description: "open-access confirmation dialog confirm button" + ) + confirmButton.click() + + let warning = window.staticTexts.containing( + NSPredicate(format: "label BEGINSWITH %@", L.openAccessWarningPrefix) + ).firstMatch + XCTAssertTrue( + poll(timeout: 4.0) { warning.exists }, + "Confirming Full open access should render the open-access warning" + ) + } + + // MARK: - TIER 1: Integration toggle subtitles reflect the stored value + + /// Each integration toggle drives a live subtitle bound to the same model + /// the runtime reads. Flipping the toggle must swap the subtitle between + /// its on/off sentence; the subtitle change is the observable proof the + /// stored value flipped (the runtime hook-injection effect itself is + /// TIER 2 — see notes below). + func testClaudeCodeToggleFlipsSubtitle() { + assertToggleFlipsSubtitle( + id: "SettingsClaudeCodeHooksToggle", + offSubtitle: L.claudeOff, + onSubtitle: L.claudeOn + ) + } + + func testCursorToggleFlipsSubtitle() { + assertToggleFlipsSubtitle( + id: "SettingsCursorHooksToggle", + offSubtitle: L.cursorOff, + onSubtitle: L.cursorOn + ) + } + + func testGeminiToggleFlipsSubtitle() { + assertToggleFlipsSubtitle( + id: "SettingsGeminiHooksToggle", + offSubtitle: L.geminiOff, + onSubtitle: L.geminiOn + ) + } + + func testSuppressSubagentToggleFlipsSubtitle() { + assertToggleFlipsSubtitle( + id: "SettingsSuppressSubagentNotificationsToggle", + offSubtitle: L.suppressOff, + onSubtitle: L.suppressOn + ) + } + + /// Shared driver: defaults are all `false`, so the "off" subtitle must be + /// present initially; clicking the toggle must replace it with the "on" + /// subtitle. + private func assertToggleFlipsSubtitle(id: String, offSubtitle: String, onSubtitle: String) { + let app = makeLaunchedApp() + let window = openAutomation(app) + defer { closeSettings(app, window) } + + XCTAssertTrue( + poll(timeout: 4.0) { window.staticTexts[offSubtitle].exists }, + "\(id): off-state subtitle should be shown at the default (disabled) value" + ) + XCTAssertFalse( + window.staticTexts[onSubtitle].exists, + "\(id): on-state subtitle should not be shown before toggling" + ) + + let control = toggle(window, id: id) + control.click() + + XCTAssertTrue( + poll(timeout: 4.0) { window.staticTexts[onSubtitle].exists }, + "\(id): on-state subtitle should appear after enabling the toggle" + ) + XCTAssertTrue( + poll(timeout: 2.0) { !window.staticTexts[offSubtitle].exists }, + "\(id): off-state subtitle should be gone after enabling the toggle" + ) + } + + // MARK: - TIER 2 (needs runtime seam): not e2e-testable from the app surface + // + // The settings below store a value the UI faithfully round-trips, but + // their *behavior* is enacted entirely in backend/CLI/terminal-env code + // that XCUITest cannot observe without adding a runtime seam. Per the + // task's no-seam rule, they are documented here rather than given fake + // "the control changed" assertions. + // + // TIER 2 (needs runtime seam): Socket Control Mode (cmuxOnly/automation/ + // password/allowAll/off) — the mode's real effect is which clients the + // Unix automation socket accepts (ancestry check, password check, + // world access). That gate is enforced inside the socket-server + // connection handler (consumed in AppDelegate/cmuxApp socket setup), not + // in any AX-visible app element. The conditional-UI consequences + // (password subrow, open-access dialog/warning) ARE covered above as + // TIER 1; the access-control enforcement needs a socket-level harness. + // + // TIER 2 (needs runtime seam): Socket Password — stored to Application + // Support and read by the socket auth path. The UI "Saved." / subtitle + // transition is covered TIER 1; whether a client presenting that + // password is admitted is only observable through the socket server. + // + // TIER 2 (needs runtime seam): Claude Code / Cursor / Gemini integration + // toggles — when enabled, cmux injects session-tracking + notification + // hook env into newly launched `claude`/`cursor`/`gemini` processes + // inside cmux terminals. The observable consequence (sidebar session + // status rows / completion notifications) only appears when a real agent + // process runs with the injected hooks; there is no AX element that + // changes purely from the toggle. The toggle's *stored* value is proven + // TIER 1 via the live subtitle; the env-injection behavior needs a + // terminal-launch + hook harness. + // + // TIER 2 (needs runtime seam): Suppress Subagent Notifications — affects + // whether child-agent completions raise a notification vs. only landing + // in Feed, decided by process-ancestry logic in the notification path. + // Observable only with a real nested-agent completion event. Stored + // value proven TIER 1 via the subtitle. + // + // TIER 2 (needs runtime seam): Claude Binary Path / Ripgrep Binary Path — + // these select which `claude` / `rg` executable cmux resolves + // (FileExplorerSearchController for ripgrep, agent launch for claude). + // The effect is which on-disk binary runs; there is no app-surface + // element reflecting the resolved path. The text fields have no + // accessibility identifier and no observable consequence to assert + // without a process-launch seam. + // + // TIER 2 (needs runtime seam): Port Base / Port Range Size — set the + // CMUX_PORT / CMUX_PORT_END environment range a new terminal inherits. + // The only true verification is reading the env of a freshly spawned + // terminal (e.g. `echo $CMUX_PORT`), which requires driving the Ghostty + // terminal surface and scraping its text buffer — not reachable through + // XCUITest accessibility without a terminal-content seam. The fields + // also carry no accessibility identifier. +} diff --git a/cmuxUITests/SettingsBrowserBehaviorUITests.swift b/cmuxUITests/SettingsBrowserBehaviorUITests.swift new file mode 100644 index 000000000000..1ebb171c8686 --- /dev/null +++ b/cmuxUITests/SettingsBrowserBehaviorUITests.swift @@ -0,0 +1,196 @@ +import XCTest + +/// Behavioral UI tests for the Settings → **Browser** section +/// (`Packages/CmuxSettingsUI/.../Sections/BrowserSection.swift`). +/// +/// The goal of each test here is behavioral: change a setting or press a +/// control, then drive the surface it affects and assert the *effect* +/// actually happened, not merely that the control flipped. +/// +/// Most of the Browser section's settings change state that only the +/// embedded WebKit/Ghostty browser surface reads (search engine, theme, +/// memory saver, HTTP allowlist, react-grab injection). Those are not +/// observable through XCUITest without a runtime seam and are documented +/// in the tier comments below rather than fake-tested. +/// +/// The one effect that crosses into an XCUITest-observable AppKit surface +/// is the **Import Browser Data → Choose…** button: it routes through +/// `HostSettingsActions.openBrowserImportFlow()` → +/// `BrowserDataImportCoordinator.shared.presentImportDialog()`, which +/// opens the import wizard window. That wizard is the same surface the +/// existing `BrowserImportProfilesUITests` drives, and it honors the +/// `CMUX_UI_TEST_BROWSER_IMPORT_FIXTURE` env seam, so we can launch with a +/// fake installed browser and assert the wizard appears after pressing the +/// Settings button. +final class SettingsBrowserBehaviorUITests: SettingsUITestCase { + + // Catalog `userDefaultsKey`s touched by this section. Cleared before + // each test so a stale value from a prior run can't mask the effect. + private static let browserDefaultsKeys = [ + "browserDisabled", + "browserSearchEngine", + "browserThemeMode", + "browserHiddenWebViewDiscardEnabled", + "browserHiddenWebViewDiscardDelaySeconds", + "browserInsecureHTTPAllowlist", + "browserImportHintShowOnBlankTabs", + "reactGrabVersion", + "browserDisabledOverride", + ] + + override func setUp() { + super.setUp() + resetDefaults(Self.browserDefaultsKeys) + } + + override func tearDown() { + resetDefaults(Self.browserDefaultsKeys) + super.tearDown() + } + + // MARK: - TIER 1 (behavioral) + + /// Import Browser Data → **Choose…** opens the import wizard. + /// + /// Launches with a fixture installed browser so the coordinator has + /// something to import from (otherwise it would show the "No importable + /// browsers found" alert). Opens Settings, scrolls to the Browser + /// section, presses the Choose… button (`SettingsBrowserImportChooseButton`, + /// which exists in `BrowserSection.swift`), then asserts the import + /// wizard surface appears. The wizard's first-step `Next` button and the + /// "Import Browser Data" window are the same elements + /// `BrowserImportProfilesUITests` asserts on, so they are known to exist. + func testImportChooseButtonOpensImportWizard() { + let app = makeLaunchedAppWithImportFixture() + let window = openSettings(app) + navigate(window, to: "Browser") + + let chooseButton = requireElement( + candidates: [ + window.buttons["SettingsBrowserImportChooseButton"], + app.buttons["SettingsBrowserImportChooseButton"], + ], + timeout: 6.0, + description: "Import Browser Data Choose… button" + ) + chooseButton.click() + + // Effect: the import wizard surface opens. The wizard is presented + // as its own AppKit window (titled "Import Browser Data") whose + // first step exposes a `Next` button. + let wizardAppeared = poll(timeout: 6.0) { + app.buttons["Next"].exists || app.windows["Import Browser Data"].exists + } + XCTAssertTrue( + wizardAppeared, + "Expected the browser import wizard to open after pressing Choose…" + ) + } + + // MARK: - Launch helpers + + /// Launches the app in UI-test mode with a fixture installed browser so + /// the import coordinator opens the wizard instead of the + /// "No importable browsers found" alert. Mirrors the env seam used by + /// `BrowserImportProfilesUITests`. + private func makeLaunchedAppWithImportFixture() -> XCUIApplication { + let app = XCUIApplication() + app.launchArguments += settingsLaunchArguments + app.launchEnvironment["CMUX_UI_TEST_MODE"] = "1" + app.launchEnvironment["CMUX_UI_TEST_BROWSER_IMPORT_FIXTURE"] = + #"{"browserName":"Helium","profiles":["You","austin"]}"# + app.launchEnvironment["CMUX_UI_TEST_BROWSER_IMPORT_DESTINATIONS"] = #"["Default"]"# + app.launchEnvironment["CMUX_UI_TEST_BROWSER_IMPORT_MODE"] = "capture-only" + launchAndActivate(app) + XCTAssertTrue( + waitForWindowCount(atLeast: 1, app: app, timeout: 8.0), + "main window did not appear" + ) + return app + } + + // MARK: - TIER 2 (needs runtime seam): effects live in the browser/WebKit surface or in a key mismatch + + // TIER 2 (needs runtime seam): Enable cmux Browser (BrowserEnabledToggle) — + // The SPM `BrowserSection` toggle writes only the catalog key + // `browserDisabled`. The runtime gate that decides browser + // availability — `BrowserAvailabilitySettings.isDisabled()`, which + // drives the `palette.enableBrowser` / `palette.disableBrowser` + // command-palette context key and WebView creation — reads a + // *different* key, `browserDisabledOverride`, which the SPM section + // never writes (only the legacy `cmuxApp.browserEnabledBinding` + // mirrors both keys). So toggling Enable cmux Browser in the new + // Settings UI produces no XCUITest-observable change (the command + // palette still shows "Disable cmux Browser"). Verifying this + // behaviorally requires the host to bind both keys (a real product + // fix) before a palette-flip assertion would pass; asserting it now + // would be a test against a known seam gap, not against intended + // behavior. + // + // TIER 2 (needs runtime seam): Default Search Engine (browser.defaultSearchEngine) — + // Consumed by `BrowserPanel.currentSearchEngine` / `currentConfiguration` + // to build the omnibar search URL inside the embedded WebKit browser. + // The effect (a query resolving to that engine's URL) only manifests + // inside the browser web content, which XCUITest cannot inspect + // without a navigation-capture seam. + // + // TIER 2 (needs runtime seam): Browser Theme (browser.theme) — + // `BrowserThemeSettings.mode` is applied via + // `BrowserPopupWindowController.setBrowserThemeMode` to the WebKit + // content's preferred color scheme. The forced light/dark rendering + // happens inside web content, not in any AppKit accessibility element. + // + // TIER 2 (needs runtime seam): Browser Memory Saver enable + // (SettingsBrowserHiddenWebViewDiscardToggle) and Memory Saver Delay + // (SettingsBrowserHiddenWebViewDiscardDelayStepper / browser.hiddenWebViewDiscardDelaySeconds) — + // These feed `BrowserHiddenWebViewDiscardPolicy`, which discards a + // hidden WebView's page memory after the delay and restores it when + // shown. The observable effect is a WebView being torn down/restored + // after a timer; it is both inside the browser surface and + // time-based, so it needs a deterministic discard-event seam to test. + // + // TIER 2 (needs runtime seam): HTTP Hosts Allowlist + // (SettingsBrowserHTTPAllowlistField + SettingsBrowserHTTPAllowlistSaveButton / + // browser.insecureHttpHostsAllowedInEmbeddedBrowser) — + // `BrowserInsecureHTTPSettings` gates whether an http:// host opens in + // cmux without a warning prompt. The effect (warning prompt shown or + // suppressed on navigation) requires navigating the embedded browser + // to an http host, which is a WebKit-surface interaction not reachable + // from Settings alone in XCUITest. The Save button's draft-sync logic + // is unit-testable against the store, not e2e. + // + // TIER 2 (needs runtime seam): Show import hint on blank browser tabs + // (SettingsBrowserImportHintToggle / browser.showImportHintOnBlankTabs) — + // Controls whether the import hint (BrowserImportHintImportButton) is + // rendered on a blank embedded-browser tab. Asserting the effect + // requires opening a blank browser tab and inspecting WebView-hosted + // chrome; `BrowserImportProfilesUITests` exercises that hint surface + // only via dedicated CMUX_UI_TEST_BROWSER_IMPORT_HINT_* launch seams, + // not by flipping this Settings toggle at runtime. + // + // TIER 2 (needs runtime seam): React Grab Version + // (SettingsReactGrabVersionField / browser.reactGrabVersion) — + // The pinned version is injected by the toolbar React Grab button + // (Cmd+Shift+G) into the active embedded WebView. The effect (which + // npm version of react-grab loads) is entirely inside browser web + // content; verifying it needs an injection/version-capture seam. + + // MARK: - TIER 3 (not e2e) + + // TIER 3 (not e2e): Browsing History → Clear History… + // (settings.browser.history.clearButton) — + // The Clear button is disabled when the history count is 0, and in + // UI-test mode `BrowserHistoryStore.shared` has no seeded entries, so + // the button is disabled and the destructive confirmation dialog + // cannot be reached. The observable effect (omnibar suggestions + // shrinking) lives in the embedded browser omnibar. There is no + // practical end-to-end path to seed history and then observe cleared + // suggestions purely through Settings in XCUITest; this is better + // covered by a `BrowserHistoryStore` unit test (clear → count == 0). + // + // TIER 3 (not e2e): Import Browser Data → Refresh + // (inside SettingsBrowserImportActions) — + // The Refresh button is hard-disabled (`.disabled(true)`) in + // `BrowserSection.swift`; it is a placeholder with no action wired, + // so there is no runtime effect to observe. +} diff --git a/cmuxUITests/SettingsShortcutsBehaviorUITests.swift b/cmuxUITests/SettingsShortcutsBehaviorUITests.swift new file mode 100644 index 000000000000..ac3353c962e9 --- /dev/null +++ b/cmuxUITests/SettingsShortcutsBehaviorUITests.swift @@ -0,0 +1,341 @@ +import XCTest + +/// Behavioral UI tests for the Settings **Global Hotkey** and +/// **Keyboard Shortcuts** sections. +/// +/// The two sections share one model: both read and write +/// `shortcuts.bindings` in cmux.json (the JSON config store), and the +/// Global Hotkey section additionally owns the `systemWideHotkey.enabled` +/// UserDefaults flag. The controls in scope are: +/// +/// Global Hotkey section: +/// - `SettingsGlobalHotkeyToggle` — enable the system-wide hotkey. +/// - `SettingsGlobalHotkeyRecorder` — record the global chord. +/// - `ShortcutRecorderClearRestoreButton` — clear / restore the global chord. +/// - `SettingsGlobalHotkeyNote` — static permissions note. +/// +/// Keyboard Shortcuts section: +/// - `SettingsKeyboardShortcutsChordDocsLink` — external chord docs URL. +/// - `SettingsKeyboardShortcutsOpenSettingsFileButton` — open cmux.json. +/// - `SettingsKeyboardShortcutsResetDefaultsButton` — reset all bindings. +/// - per-action `ShortcutRecorderView` + `ShortcutRecorderClearRestoreButton`. +/// - `ShortcutRecorderValidationMessage` — conflict / bare-key banner. +/// - `ShortcutRecordingHint` — static hint text. +/// +/// The tests below drive only the effects that are observable through +/// XCUITest from the Settings window surface. The actual runtime effect +/// of binding a hotkey (the OS-level global hotkey registration in +/// `SystemWideHotkeyController`) is not observable here and is documented +/// in the tier-2/tier-3 blocks at the bottom of the file. +final class SettingsShortcutsBehaviorUITests: SettingsUITestCase { + + // The system-wide hotkey enable flag is a real UserDefaults key + // (`AppCatalogSection.systemWideHotkeyEnabled.userDefaultsKey`). + // Reset it so the toggle starts from its `false` default. + private let hotkeyEnabledKey = "systemWideHotkey.enabled" + + // Resting display labels rendered by both sections' recorder / + // clear-restore controls. These mirror the localized default values + // in GlobalHotkeySection / KeyboardShortcutsSection. + private let unboundLabel = "None" // shortcut.unbound.displayValue + private let unbindAXLabel = "Unbind" // shortcut.recorder.clear + private let restoreAXLabel = "Restore" // shortcut.recorder.restore + + // The Global Hotkey default chord is `ShortcutStroke(key: ".", + // command: true, option: true, control: true)` → formatted "⌃⌥⌘." + // by GlobalHotkeySection.format(_:). The clear button starts as + // "Unbind" because the default binding is non-unbound. + private let globalHotkeyDefaultDisplay = "⌃⌥⌘." + + override func setUp() { + super.setUp() + resetDefaults([hotkeyEnabledKey]) + } + + override func tearDown() { + resetDefaults([hotkeyEnabledKey]) + super.tearDown() + } + + // MARK: - Tier 1: Enable toggle drives the card subtitle copy + + /// TIER 1 — `SettingsGlobalHotkeyToggle`. + /// + /// The OS-level hotkey registration is not observable via XCUITest, + /// but the enable row's *subtitle* is derived from `enabled.current` + /// inside the same Settings card: + /// - off: "Turn this on to show or hide all cmux windows from any app." + /// - on: "Press the shortcut from any app to show or hide all cmux windows." + /// Flipping the toggle must swap the rendered subtitle. We assert the + /// effect (the visible subtitle copy), not merely that the switch flipped. + func testEnableToggleSwapsSubtitleCopy() { + let app = makeLaunchedApp() + let window = openSettings(app) + defer { closeSettings(app, window) } + navigate(window, to: "Global Hotkey") + + let offSubtitle = "Turn this on to show or hide all cmux windows from any app." + let onSubtitle = "Press the shortcut from any app to show or hide all cmux windows." + + // Default is disabled → off subtitle present, on subtitle absent. + XCTAssertTrue( + poll(timeout: 5.0) { window.staticTexts[offSubtitle].exists }, + "Disabled hotkey row should show the off subtitle" + ) + + let toggle = toggle(window, id: "SettingsGlobalHotkeyToggle") + toggle.click() + + // After enabling, the subtitle copy must change to the on variant. + XCTAssertTrue( + poll(timeout: 5.0) { window.staticTexts[onSubtitle].exists }, + "Enabling the hotkey should swap the row subtitle to the on variant" + ) + XCTAssertFalse( + window.staticTexts[offSubtitle].exists, + "Off subtitle should disappear once the hotkey is enabled" + ) + + // Disabling restores the off subtitle. + toggle.click() + XCTAssertTrue( + poll(timeout: 5.0) { window.staticTexts[offSubtitle].exists }, + "Disabling the hotkey should restore the off subtitle" + ) + } + + // MARK: - Tier 1: Global hotkey clear / restore button + + /// TIER 1 — `ShortcutRecorderClearRestoreButton` (Global Hotkey). + /// + /// The clear/restore button is fully button-driven (no keystroke + /// capture needed), so its effect is reliably observable: + /// - At the default binding the button's accessibility label is + /// "Unbind" and the recorder shows the default chord "⌃⌥⌘.". + /// - Clicking it unbinds: recorder shows "None" and the button's + /// accessibility label flips to "Restore". + /// - Clicking again restores the previous chord: recorder shows + /// "⌃⌥⌘." again and the button label flips back to "Unbind". + /// + /// This round-trips the `shortcuts.bindings` JSON store back to its + /// starting state, so the test leaves cmux.json clean. + func testGlobalHotkeyClearThenRestoreRoundTrips() { + let app = makeLaunchedApp() + let window = openSettings(app) + defer { closeSettings(app, window) } + navigate(window, to: "Global Hotkey") + + // The Global Hotkey section contains exactly one clear/restore + // button; scope the lookup to that section's recorder area by + // taking the button that lives in this section's card. + let clearRestore = requireElement( + candidates: [ + window.buttons["ShortcutRecorderClearRestoreButton"], + window.descendants(matching: .button)["ShortcutRecorderClearRestoreButton"], + ], + timeout: 5.0, + description: "global hotkey clear/restore button" + ) + + // Default binding is non-unbound → button offers "Unbind". + XCTAssertTrue( + poll(timeout: 5.0) { clearRestore.label == unbindAXLabel }, + "At the default binding the button should be labeled Unbind, was \(clearRestore.label)" + ) + + // Recorder should display the default chord at rest. + let recorder = window.descendants(matching: .any)["SettingsGlobalHotkeyRecorder"] + if recorder.exists { + XCTAssertTrue( + poll(timeout: 3.0) { recorderShows(recorder, globalHotkeyDefaultDisplay) }, + "Recorder should show the default chord \(globalHotkeyDefaultDisplay)" + ) + } + + // Clear: button flips to "Restore", recorder shows "None". + clearRestore.click() + XCTAssertTrue( + poll(timeout: 5.0) { clearRestore.label == restoreAXLabel }, + "After clearing, the button should flip to Restore, was \(clearRestore.label)" + ) + if recorder.exists { + XCTAssertTrue( + poll(timeout: 3.0) { recorderShows(recorder, unboundLabel) }, + "After clearing, the recorder should show the unbound label \(unboundLabel)" + ) + } + + // Restore: button flips back to "Unbind", recorder shows the chord. + clearRestore.click() + XCTAssertTrue( + poll(timeout: 5.0) { clearRestore.label == unbindAXLabel }, + "After restoring, the button should flip back to Unbind, was \(clearRestore.label)" + ) + if recorder.exists { + XCTAssertTrue( + poll(timeout: 3.0) { recorderShows(recorder, globalHotkeyDefaultDisplay) }, + "After restoring, the recorder should show \(globalHotkeyDefaultDisplay) again" + ) + } + } + + // MARK: - Tier 1: Reset Defaults re-binds a cleared action + + /// TIER 1 — `SettingsKeyboardShortcutsResetDefaultsButton`. + /// + /// Reset Defaults writes an empty `shortcuts.bindings` map, so every + /// per-action recorder returns to its default stroke and the row's + /// clear/restore button reverts to "Unbind". We make the effect + /// observable by first clearing the first per-action shortcut (its + /// row button flips to "Restore"), then pressing Reset Defaults and + /// asserting that row's button flips back to "Unbind" — proving the + /// reset actually re-applied the default binding rather than just + /// flipping a control. + /// + /// The button-row state is the observable proxy for the binding + /// reset; the per-row clear/restore label is driven directly off the + /// effective binding (`canRestore = isUnbound && restore != nil`). + func testResetDefaultsRebindsClearedAction() { + let app = makeLaunchedApp() + let window = openSettings(app) + defer { closeSettings(app, window) } + navigate(window, to: "Keyboard Shortcuts") + + // There are many clear/restore buttons in this section (one per + // action row). Use the first one as the subject. It starts as + // "Unbind" for any action whose default stroke is non-unbound. + let rowButtons = window.buttons.matching(identifier: "ShortcutRecorderClearRestoreButton") + XCTAssertTrue( + poll(timeout: 6.0) { rowButtons.count > 0 }, + "Keyboard Shortcuts section should render per-action clear/restore buttons" + ) + + // Find a row button currently labeled "Unbind" (a bound action). + let subject = firstButton(in: rowButtons, withLabel: unbindAXLabel, timeout: 6.0) + XCTAssertNotNil(subject, "Expected at least one bound per-action shortcut to clear") + guard let subject else { return } + + // Clear it → flips to "Restore". + subject.click() + XCTAssertTrue( + poll(timeout: 5.0) { subject.label == restoreAXLabel }, + "After clearing the action, its button should read Restore, was \(subject.label)" + ) + + // Reset Defaults → cleared action returns to its default binding, + // so the row button reverts to "Unbind". + let resetButton = requireElement( + candidates: [ + window.buttons["SettingsKeyboardShortcutsResetDefaultsButton"], + window.descendants(matching: .button)["SettingsKeyboardShortcutsResetDefaultsButton"], + ], + timeout: 5.0, + description: "Reset Defaults button" + ) + resetButton.click() + + XCTAssertTrue( + poll(timeout: 6.0) { subject.label == unbindAXLabel }, + "Reset Defaults should re-bind the cleared action so its button reads Unbind again, was \(subject.label)" + ) + } + + // MARK: - Helpers + + /// Returns true when the recorder element's label or value contains + /// `text`. The recorder is an AppKit `NSButton` hosted via + /// `NSViewRepresentable`; depending on the accessibility bridge its + /// title surfaces as either the element label or its value, so we + /// check both. + private func recorderShows(_ recorder: XCUIElement, _ text: String) -> Bool { + if recorder.label.contains(text) { return true } + if let value = recorder.value as? String, value.contains(text) { return true } + // Fall back to a descendant button carrying the title. + return recorder.buttons.element(boundBy: 0).exists + && (recorder.buttons.element(boundBy: 0).label.contains(text)) + } + + /// Returns the first button in `query` whose accessibility label + /// equals `label`, polling until one exists or the timeout elapses. + private func firstButton( + in query: XCUIElementQuery, + withLabel label: String, + timeout: TimeInterval + ) -> XCUIElement? { + var result: XCUIElement? + _ = poll(timeout: timeout) { + let count = query.count + for index in 0.. XCUIElement { + let window = openSettings(app) + navigate(window, to: "Terminal") + return window + } + + /// Returns true once a descendant static text whose value contains + /// `fragment` exists in `root`. + private func staticTextContaining(_ root: XCUIElement, _ fragment: String) -> XCUIElement { + let predicate = NSPredicate(format: "label CONTAINS[c] %@", fragment) + return root.staticTexts.containing(predicate).firstMatch + } + + private func waitForStaticText(_ root: XCUIElement, _ fragment: String, timeout: TimeInterval = 4.0) -> Bool { + let element = staticTextContaining(root, fragment) + return poll(timeout: timeout) { element.exists } + } + + // Distinctive substrings of the on/off description sentences. These + // mirror the localized defaultValue strings in the live Terminal + // section and are stable enough to disambiguate the two states. + private enum Subtitle { + static let scrollBarOn = "Shows the right-edge terminal scroll bar" + static let scrollBarOff = "Hides the right-edge terminal scroll bar" + static let copyOn = "Selected terminal text is copied to the system clipboard" + static let copyOff = "Terminal selections do not replace the system clipboard" + static let resumeOn = "automatically run their resume command" + static let resumeOff = "stay idle until you resume them manually" + static let hibernateOn = "Idle background agent terminals can be suspended" + static let hibernateOff = "Agent terminals stay live until you close them" + } + + // MARK: - TIER 1: bound description flips with the setting + + /// Show Terminal Scroll Bar defaults ON, so the description starts in + /// its "on" sentence; toggling the control flips the bound subtitle + /// to the "off" sentence and back. This proves the toggle change + /// propagates through the live settings model that drives the row's + /// description, not just the control's reported value. + func testScrollBarToggleFlipsBoundDescription() { + let app = makeLaunchedApp() + let window = openTerminalSettings(app) + + XCTAssertTrue( + waitForStaticText(window, Subtitle.scrollBarOn), + "Scroll bar row should start with the on-state description (default true)" + ) + + let control = toggle(window, id: "SettingsTerminalScrollBarToggle") + control.click() + XCTAssertTrue( + waitForStaticText(window, Subtitle.scrollBarOff), + "After turning the scroll bar off the description should switch to the off sentence" + ) + + control.click() + XCTAssertTrue( + waitForStaticText(window, Subtitle.scrollBarOn), + "Turning the scroll bar back on should restore the on sentence" + ) + + closeSettings(app, window) + } + + /// Copy on Selection defaults OFF; toggling on switches the bound + /// description to the clipboard-copy sentence and back. + func testCopyOnSelectToggleFlipsBoundDescription() { + let app = makeLaunchedApp() + let window = openTerminalSettings(app) + + XCTAssertTrue( + waitForStaticText(window, Subtitle.copyOff), + "Copy-on-select row should start with the off-state description (default false)" + ) + + let control = toggle(window, id: "SettingsTerminalCopyOnSelectToggle") + control.click() + XCTAssertTrue( + waitForStaticText(window, Subtitle.copyOn), + "Enabling copy-on-select should switch the description to the clipboard-copy sentence" + ) + + control.click() + XCTAssertTrue( + waitForStaticText(window, Subtitle.copyOff), + "Disabling copy-on-select should restore the off sentence" + ) + + closeSettings(app, window) + } + + /// Resume Agent Sessions defaults ON; toggling flips the bound + /// description between the auto-resume and stay-idle sentences. (The + /// actual reopen behavior is TIER 3 — see file header.) + func testAutoResumeToggleFlipsBoundDescription() { + let app = makeLaunchedApp() + let window = openTerminalSettings(app) + + XCTAssertTrue( + waitForStaticText(window, Subtitle.resumeOn), + "Auto-resume row should start with the on-state description (default true)" + ) + + let control = toggle(window, id: "SettingsTerminalAgentAutoResumeToggle") + control.click() + XCTAssertTrue( + waitForStaticText(window, Subtitle.resumeOff), + "Disabling auto-resume should switch the description to the stay-idle sentence" + ) + + control.click() + XCTAssertTrue( + waitForStaticText(window, Subtitle.resumeOn), + "Re-enabling auto-resume should restore the auto-run sentence" + ) + + closeSettings(app, window) + } + + /// Agent Hibernation defaults OFF; toggling on switches the bound + /// description to the suspend sentence and back. (Actual hibernation + /// is TIER 2 — see file header.) + func testAgentHibernationToggleFlipsBoundDescription() { + let app = makeLaunchedApp() + let window = openTerminalSettings(app) + + XCTAssertTrue( + waitForStaticText(window, Subtitle.hibernateOff), + "Hibernation row should start with the off-state description (default false)" + ) + + let control = toggle(window, id: "SettingsTerminalAgentHibernationToggle") + control.click() + XCTAssertTrue( + waitForStaticText(window, Subtitle.hibernateOn), + "Enabling hibernation should switch the description to the suspend sentence" + ) + + control.click() + XCTAssertTrue( + waitForStaticText(window, Subtitle.hibernateOff), + "Disabling hibernation should restore the stay-live sentence" + ) + + closeSettings(app, window) + } + + // MARK: - TIER 1: numeric value label tracks the stepper + + /// TextBox Max Lines defaults to 10. Incrementing the stepper must + /// update the bound numeric value label shown in the row (proving the + /// value persisted through the settings model), and decrementing must + /// bring it back down. The numeric label is rendered with a + /// monospaced digit `Text` next to the stepper, so the new value + /// surfaces as a queryable static text. + func testTextBoxMaxLinesStepperUpdatesValueLabel() { + let app = makeLaunchedApp() + let window = openTerminalSettings(app) + + // Default value 10 should be visible somewhere in the section. + XCTAssertTrue( + waitForStaticText(window, "10"), + "TextBox Max Lines should display its default value of 10" + ) + + let stepper = window.steppers["SettingsTerminalTextBoxMaxLinesStepper"] + XCTAssertTrue(poll(timeout: 4.0) { stepper.exists }, "TextBox Max Lines stepper should exist") + + stepper.incrementArrows.firstMatch.click() + XCTAssertTrue( + waitForStaticText(window, "11"), + "Incrementing TextBox Max Lines should display 11" + ) + + stepper.decrementArrows.firstMatch.click() + XCTAssertTrue( + waitForStaticText(window, "10"), + "Decrementing should return the displayed value to 10" + ) + + closeSettings(app, window) + } + + /// Max Live Agent Terminals defaults to 12; the stepper value label + /// must track increment/decrement. (When this number actually gates + /// hibernation is TIER 2 — see file header.) + func testMaxLiveTerminalsStepperUpdatesValueLabel() { + let app = makeLaunchedApp() + let window = openTerminalSettings(app) + + XCTAssertTrue( + waitForStaticText(window, "12"), + "Max Live Agent Terminals should display its default value of 12" + ) + + let stepper = window.steppers["SettingsTerminalAgentHibernationMaxLiveStepper"] + XCTAssertTrue(poll(timeout: 4.0) { stepper.exists }, "Max Live Terminals stepper should exist") + + stepper.incrementArrows.firstMatch.click() + XCTAssertTrue( + waitForStaticText(window, "13"), + "Incrementing Max Live Agent Terminals should display 13" + ) + + stepper.decrementArrows.firstMatch.click() + XCTAssertTrue( + waitForStaticText(window, "12"), + "Decrementing should return the displayed value to 12" + ) + + closeSettings(app, window) + } + + /// Hibernate After Idle Seconds defaults to 3600 and steps by 60. The + /// value label must track the stepper. (When idle-seconds actually + /// drives a suspend is TIER 2 — see file header.) + func testIdleSecondsStepperUpdatesValueLabel() { + let app = makeLaunchedApp() + let window = openTerminalSettings(app) + + XCTAssertTrue( + waitForStaticText(window, "3600"), + "Hibernate After Idle Seconds should display its default value of 3600" + ) + + let stepper = window.steppers["SettingsTerminalAgentHibernationIdleSecondsStepper"] + XCTAssertTrue(poll(timeout: 4.0) { stepper.exists }, "Idle Seconds stepper should exist") + + stepper.incrementArrows.firstMatch.click() + XCTAssertTrue( + waitForStaticText(window, "3660"), + "Incrementing Idle Seconds by one step (60) should display 3660" + ) + + stepper.decrementArrows.firstMatch.click() + XCTAssertTrue( + waitForStaticText(window, "3600"), + "Decrementing should return the displayed value to 3600" + ) + + closeSettings(app, window) + } +} diff --git a/cmuxUITests/SettingsUITestSupport.swift b/cmuxUITests/SettingsUITestSupport.swift new file mode 100644 index 000000000000..70b330172072 --- /dev/null +++ b/cmuxUITests/SettingsUITestSupport.swift @@ -0,0 +1,145 @@ +import XCTest + +/// Shared base class for the Settings behavioral UI tests. +/// +/// Every `Settings
BehaviorUITests` subclass uses these helpers +/// to launch the app, open the Settings window, navigate sidebar +/// sections, and poll for conditions. The goal of the subclasses is +/// behavioral: change a setting, then drive the surface it affects and +/// assert the *effect* actually happened — not merely that the control +/// flipped. +class SettingsUITestCase: XCTestCase { + override func setUp() { + super.setUp() + continueAfterFailure = false + } + + /// Launch arguments forcing English + transient state so element + /// labels are stable across machines. + var settingsLaunchArguments: [String] { + [ + "-AppleLanguages", "(en)", + "-AppleLocale", "en_US", + "-ApplePersistenceIgnoreState", "YES", + "-NSQuitAlwaysKeepsWindows", "NO", + "-menuBarOnly", "false", + ] + } + + /// Sidebar section titles in top-to-bottom order. Must match + /// `SettingsSectionID.title` default values. + static let sectionTitles = [ + "Account", "App", "Terminal", "Sidebar", "Beta Features", "Automation", + "Browser", "Global Hotkey", "Keyboard Shortcuts", "Workspace Colors", + "cmux.json", "Reset", + ] + + // MARK: - Launch / window + + func makeLaunchedApp() -> XCUIApplication { + let app = XCUIApplication() + app.launchArguments += settingsLaunchArguments + app.launchEnvironment["CMUX_UI_TEST_MODE"] = "1" + launchAndActivate(app) + XCTAssertTrue(waitForWindowCount(atLeast: 1, app: app, timeout: 8.0), "main window did not appear") + return app + } + + /// Opens the Settings window via ⌘, and returns it. + @discardableResult + func openSettings(_ app: XCUIApplication) -> XCUIElement { + app.typeKey(",", modifierFlags: .command) + let window = app.windows["Settings"] + XCTAssertTrue(poll(timeout: 6.0) { window.exists }, "Settings window did not open") + return window + } + + func closeSettings(_ app: XCUIApplication, _ window: XCUIElement) { + window.typeKey("w", modifierFlags: .command) + _ = poll(timeout: 3.0) { !window.exists } + } + + /// Clicks the sidebar row for `title`, scrolling the detail to that + /// section. Tolerates the row appearing as a cell or a static text. + func navigate(_ window: XCUIElement, to title: String) { + let cell = window.cells.containing(.staticText, identifier: title).firstMatch + let text = window.staticTexts[title] + let target = requireElement(candidates: [cell, text], timeout: 4.0, description: "sidebar row \(title)") + target.click() + _ = poll(timeout: 1.0) { true } + } + + // MARK: - Polling / element resolution + + func poll(timeout: TimeInterval, interval: TimeInterval = 0.05, _ condition: () -> Bool) -> Bool { + let start = ProcessInfo.processInfo.systemUptime + while true { + if condition() { return true } + if (ProcessInfo.processInfo.systemUptime - start) >= timeout { return false } + RunLoop.current.run(until: Date().addingTimeInterval(interval)) + } + } + + func waitForWindowCount(atLeast count: Int, app: XCUIApplication, timeout: TimeInterval) -> Bool { + poll(timeout: timeout) { app.windows.count >= count } + } + + @discardableResult + func requireElement(candidates: [XCUIElement], timeout: TimeInterval, description: String) -> XCUIElement { + var match: XCUIElement? + let found = poll(timeout: timeout) { + for c in candidates where c.exists { match = c; return true } + return false + } + XCTAssertTrue(found, "Expected \(description) to exist") + return match ?? candidates[0] + } + + /// Resolves a toggle by accessibility id across the control kinds a + /// SwiftUI `Toggle(.switch)` can surface as in XCUITest. + func toggle(_ root: XCUIElement, id: String, timeout: TimeInterval = 4.0) -> XCUIElement { + requireElement( + candidates: [root.switches[id], root.checkBoxes[id], root.descendants(matching: .any)[id]], + timeout: timeout, + description: "toggle \(id)" + ) + } + + /// Deletes UserDefaults keys from the debug suite so a test starts + /// from the known default. Pass the raw `userDefaultsKey`s. + func resetDefaults(_ keys: [String], suite: String = "com.cmuxterm.app.debug") { + for key in keys { + let process = Process() + process.executableURL = URL(fileURLWithPath: "/usr/bin/defaults") + process.arguments = ["delete", suite, key] + try? process.run() + process.waitUntilExit() + } + } + + // MARK: - Launch implementation + + func launchAndActivate(_ app: XCUIApplication, activateTimeout: TimeInterval = 2.0) { + let options = XCTExpectedFailure.Options() + options.isStrict = false + XCTExpectFailure("Headless CI may launch the app without foreground activation", options: options) { + app.launch() + } + XCTAssertTrue( + poll(timeout: 10.0) { app.state == .runningForeground || app.state == .runningBackground }, + "App failed to launch. state=\(app.state.rawValue)" + ) + if app.state != .runningForeground { + _ = poll(timeout: activateTimeout) { + guard app.state != .runningForeground else { return true } + app.activate() + return app.state == .runningForeground + } + app.activate() + } + XCTAssertTrue( + poll(timeout: 6.0) { app.state == .runningForeground }, + "App did not become foreground. state=\(app.state.rawValue)" + ) + } +} diff --git a/cmuxUITests/SettingsWorkspaceColorsBehaviorUITests.swift b/cmuxUITests/SettingsWorkspaceColorsBehaviorUITests.swift new file mode 100644 index 000000000000..8915b641f422 --- /dev/null +++ b/cmuxUITests/SettingsWorkspaceColorsBehaviorUITests.swift @@ -0,0 +1,164 @@ +import XCTest + +/// Behavioral UI tests for the Settings **Workspace Colors** section. +/// +/// Section controls and their catalog keys (see +/// `Packages/CmuxSettingsUI/.../Sections/WorkspaceColorsSection.swift` +/// and `Packages/CmuxSettings/.../Keys/WorkspaceColorsCatalogSection.swift`): +/// +/// - **Workspace Color Indicator** (`workspaceColors.indicatorStyle`, +/// UserDefaults `sidebarActiveTabIndicatorStyle`): menu Picker, Left +/// Rail / Solid Fill. +/// - **Selection Highlight** (`workspaceColors.selectionColor`, +/// UserDefaults `sidebarSelectionColorHex`): `ColorPicker` + a "Reset" +/// button shown only when a custom hex is stored. +/// - **Notification Badge** (`workspaceColors.notificationBadgeColor`, +/// UserDefaults `sidebarNotificationBadgeColorHex`): same shape. +/// - **Palette entry colors** (`workspaceColors.colors`, UserDefaults +/// `workspaceTabColor.colors`): one row per effective palette entry, +/// each rendering the entry's hex as monospaced `Text` plus a +/// `ColorPicker`; custom entries also get a "Remove" button. +/// - **Reset Palette** (action): restores the built-in palette and +/// removes extra named colors. +/// +/// ## Tiering +/// +/// The *visible* runtime effect of every color/style setting lives in +/// the sidebar workspace-tab rendering and is pixel-only: +/// +/// - `WorkspaceTabTitleView.activeBorderLineWidth` / `activeBorderColor` +/// switch on `activeTabIndicatorStyle` (left rail vs solid fill) — a +/// `CGFloat` border width and a `Color`, no accessibility surface. +/// - `selectedWorkspaceBackgroundNSColor` is derived from +/// `sidebarSelectionColorHex` and used only as a fill. +/// - `activeUnreadBadgeFillColor` is derived from +/// `sidebarNotificationBadgeColorHex` and used only as a fill. +/// - palette entry colors feed `WorkspaceTabColorSettings.palette()`, +/// consumed as `NSColor`/`Color` fills for workspace tab swatches and +/// the per-workspace color context menu. +/// +/// The sidebar workspace row exposes `accessibilityIdentifier` +/// `sidebarWorkspace.` but no accessibility *value* that reflects +/// the resolved indicator style or any color, so none of these effects +/// are assertable through XCUITest without adding a runtime seam (e.g. +/// publishing the resolved color/style as an accessibility value, or a +/// pixel sampler). Per the task constraints, no app seam is added, so +/// those effects are documented as TIER 2 below. +/// +/// What *is* seam-free observable: the section renders one row per +/// effective palette entry, and each row prints that entry's hex as a +/// monospaced static text. The effective palette is computed directly +/// from the `workspaceColors.colors` catalog default (the 16 built-in +/// named colors when no override is stored). So the built-in palette +/// hexes appearing as static text in the running Settings surface is a +/// genuine, effect-level reflection of the palette setting's resolved +/// value, not just "a control flipped". `testBuiltInPaletteEntriesRenderTheirEffectiveHexValues` +/// asserts that effect. The Selection / Badge `ColorPicker`s and the +/// per-row Reset buttons cannot be exercised here: editing a color +/// requires driving the native `NSColorPanel` (no stable identifier, +/// flaky under XCUITest), and the per-row Reset buttons only appear once +/// a custom hex is stored, which there is no seam-free way to set up. +/// +// TIER 2 (needs runtime seam): Workspace Color Indicator (left rail vs +// solid fill) — only changes `activeBorderLineWidth`/`activeBorderColor` +// on the active workspace tab; pixel-only, no accessibility value. +// TIER 2 (needs runtime seam): Selection Highlight color — only changes +// the selected workspace tab background fill; pixel-only, and the +// ColorPicker drives NSColorPanel which XCUITest cannot reliably set. +// TIER 2 (needs runtime seam): Notification Badge color — only changes +// the unread-badge fill on workspace tabs; pixel-only, same +// NSColorPanel limitation. +// TIER 2 (needs runtime seam): palette entry color edit — editing one +// entry persists the whole effective palette, but the edit path is the +// per-row ColorPicker (NSColorPanel), unreachable seam-free; the *fill* +// it changes on workspace swatches is pixel-only. +// TIER 2 (needs runtime seam): Reset Palette effect — the only seam-free +// way to observe the reset is for a custom/overridden palette row to +// disappear and built-in hexes to revert, but reaching the non-default +// state first requires the ColorPicker/NSColorPanel or editing +// cmux.json, neither reachable from XCUITest without a seam. Verifying +// the reset removes custom rows is therefore TIER 2. +final class SettingsWorkspaceColorsBehaviorUITests: SettingsUITestCase { + + /// UserDefaults keys (raw `userDefaultsKey`s) backing this section. + private static let workspaceColorKeys = [ + "sidebarActiveTabIndicatorStyle", + "sidebarSelectionColorHex", + "sidebarNotificationBadgeColorHex", + "workspaceTabColor.colors", + ] + + /// Built-in palette default hexes (mirrors + /// `WorkspaceColorsSection.builtInPalette`). When no override is + /// stored, these are the effective palette and each is rendered as a + /// monospaced static text in its entry row. + private static let builtInHexes: [(name: String, hex: String)] = [ + ("Red", "#C0392B"), + ("Green", "#196F3D"), + ("Blue", "#1565C0"), + ("Purple", "#6A1B9A"), + ] + + override func setUp() { + super.setUp() + resetDefaults(Self.workspaceColorKeys) + } + + override func tearDown() { + resetDefaults(Self.workspaceColorKeys) + super.tearDown() + } + + /// TIER 1: with no stored palette override, the section renders the + /// built-in palette and each entry row prints its effective hex. This + /// asserts the *effect* of the `workspaceColors.colors` setting's + /// resolved value (the 16 built-in named colors) on the live Settings + /// surface, not merely that a control exists. + func testBuiltInPaletteEntriesRenderTheirEffectiveHexValues() { + let app = makeLaunchedApp() + let window = openSettings(app) + defer { closeSettings(app, window) } + + navigate(window, to: "Workspace Colors") + + // Section anchor: the indicator row title must render so we know + // the Workspace Colors detail is on screen. + let indicatorTitle = window.staticTexts["Workspace Color Indicator"] + XCTAssertTrue( + poll(timeout: 6.0) { indicatorTitle.exists }, + "Workspace Colors section did not render its indicator row" + ) + + // The selection / badge color rows render their titles too. + XCTAssertTrue( + window.staticTexts["Selection Highlight"].exists, + "Selection Highlight row missing" + ) + XCTAssertTrue( + window.staticTexts["Notification Badge"].exists, + "Notification Badge row missing" + ) + + // Effect: each built-in entry row prints its resolved default + // hex. These come straight from the effective palette computed + // from the `workspaceColors.colors` catalog default. + for entry in Self.builtInHexes { + let hexText = window.staticTexts[entry.hex] + XCTAssertTrue( + poll(timeout: 4.0) { hexText.exists }, + "Expected palette entry \(entry.name) to render hex \(entry.hex)" + ) + XCTAssertTrue( + window.staticTexts[entry.name].exists, + "Expected palette entry row titled \(entry.name)" + ) + } + + // The Reset Palette action row is present (its observable reset + // effect is documented TIER 2 above). + XCTAssertTrue( + window.staticTexts["Reset Palette"].exists, + "Reset Palette row missing" + ) + } +}