Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -79,6 +79,12 @@ public struct AppCatalogSection: SettingCatalogSection {
userDefaultsKey: "openMarkdownInCmuxViewer"
)

public let fileExtensionOpeners = DefaultsKey<[String: FileExtensionOpenBehavior]>(
id: "app.fileExtensionOpeners",
defaultValue: FileExtensionOpenBehavior.defaultOpeners,
userDefaultsKey: "fileExtensionOpeners"
)

public let iMessageMode = DefaultsKey<Bool>(
id: "app.iMessageMode",
defaultValue: false,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -61,6 +61,20 @@ public actor UserDefaultsSettingsStore {
underlyingDefaults.set(value.encodeForUserDefaults(), forKey: key.userDefaultsKey)
}

/// Returns the effective extension opener map, including built-in defaults.
public func fileExtensionOpeners() -> [String: FileExtensionOpenBehavior] {
FileExtensionOpenBehaviorSettings.openers(defaults: underlyingDefaults)
}

/// Writes extension openers through their specialized normalization rules.
public func setFileExtensionOpeners(_ value: [String: FileExtensionOpenBehavior]) {
FileExtensionOpenBehaviorSettings.setOpeners(
value,
defaults: underlyingDefaults,
notificationCenter: .default
)
}

/// 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<Value>(_ key: DefaultsKey<Value>) {
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
import Foundation

/// Per-extension destination used when Cmd-click opens a file path.
public enum FileExtensionOpenBehavior: String, CaseIterable, Identifiable, Sendable, SettingCodable {

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Add DocC comments to the public package API

The repository's AGENTS.md requires every public symbol added under Packages/ to have Swift-DocC /// documentation, including types, enum cases, properties, and methods. This new public enum and its public members are currently undocumented, so the package API violates the documented review contract and will leave generated package docs incomplete; please add DocC comments for the enum, cases, id, defaultOpeners, and normalizedExtension.

Useful? React with 👍 / 👎.

/// Defer to the legacy Markdown and supported-file routing settings.
case automatic
/// Open the file in cmux's generic file preview surface.
case cmuxPreview
/// Open the file in cmux's rendered Markdown viewer.
case markdownViewer
/// Open the file URL in a cmux browser surface.
case cmuxBrowser
/// Open the file with the configured preferred editor command.
case preferredEditor
/// Hand the file to the operating system's default opener.
case systemDefault

/// Stable identifier matching the raw config value.
public var id: String { rawValue }

/// Built-in extension opener defaults applied before user overrides.
public static let defaultOpeners: [String: FileExtensionOpenBehavior] = [
"htm": .cmuxBrowser,
"html": .cmuxBrowser,
]

/// Normalizes a user-entered extension key for storage and lookup.
///
/// Leading dots are stripped, case is folded, and only simple extension
/// characters are accepted. Returns `nil` for empty or unsupported values.
public static func normalizedExtension(_ rawValue: String) -> String? {
var trimmed = rawValue
.trimmingCharacters(in: .whitespacesAndNewlines)
.lowercased()
while trimmed.hasPrefix(".") {
trimmed.removeFirst()
}
guard !trimmed.isEmpty,
trimmed.allSatisfy({ character in
character.isLetter || character.isNumber || character == "-" || character == "_" || character == "+"
}) else {
return nil
}
return trimmed
}

/// Decodes stored opener behavior, treating unknown stored values as
/// ``automatic`` so one corrupt UserDefaults entry does not hide the rest
/// of the extension map in Settings.
public static func decodeFromUserDefaults(_ raw: Any?) -> Self? {
guard let rawValue = String.decodeFromUserDefaults(raw) else { return nil }
return Self(rawValue: rawValue) ?? .automatic
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,76 @@
import Foundation

/// UserDefaults-backed storage helpers for per-extension file opener behavior.
public enum FileExtensionOpenBehaviorSettings {
/// UserDefaults key storing normalized extension-to-behavior raw values.
public static let key = "fileExtensionOpeners"
/// Posted after this helper writes the opener map.
public static let didChangeNotification = Notification.Name("cmux.fileExtensionOpenersDidChange")
/// Product defaults applied before user overrides.
public static let defaultValue = FileExtensionOpenBehavior.defaultOpeners

/// Reads normalized opener mappings from `defaults`.
///
/// Built-in defaults are preserved, and valid stored entries override them.
/// Invalid stored entries are skipped.
public static func openers(defaults: UserDefaults) -> [String: FileExtensionOpenBehavior] {
guard defaults.object(forKey: key) != nil else { return defaultValue }
guard let stored = defaults.dictionary(forKey: key) else { return defaultValue }

var storedOpeners: [String: FileExtensionOpenBehavior] = [:]
for (rawExtension, rawBehavior) in stored {
guard let normalizedExtension = FileExtensionOpenBehavior.normalizedExtension(rawExtension),
let rawBehavior = rawBehavior as? String,
let behavior = FileExtensionOpenBehavior(rawValue: rawBehavior) else {
continue
}
storedOpeners[normalizedExtension] = behavior
}
return effectiveOpeners(from: storedOpeners)
}

/// Returns the opener behavior for `path`'s extension, if one is configured.
public static func behavior(forPath path: String, defaults: UserDefaults) -> FileExtensionOpenBehavior? {
let ext = (path as NSString).pathExtension
guard let normalizedExtension = FileExtensionOpenBehavior.normalizedExtension(ext) else {
return nil
}
return openers(defaults: defaults)[normalizedExtension]

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Settings and routing disagree

Medium Severity

Cmd-click routing reads fileExtensionOpeners through FileExtensionOpenBehaviorSettings.openers, which always merges built-in entries such as html/htm → cmuxBrowser. The File Opening settings UI persists and reloads the raw UserDefaults map via DefaultsValueModel, so partial saves, an empty {}, or removing rows can show “no overrides” while HTML still opens in the cmux browser. The legacy card reload path re-merges defaults and can make removed built-in rows reappear.

Additional Locations (1)
Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit f864d02. Configure here.

}

/// Writes normalized opener mappings and posts ``didChangeNotification``.
///
/// Entries matching built-in defaults are pruned. Store ``FileExtensionOpenBehavior/automatic``
/// for a built-in extension to explicitly opt out of that built-in route.
public static func setOpeners(
_ openers: [String: FileExtensionOpenBehavior],
defaults: UserDefaults,
notificationCenter: NotificationCenter
) {
var normalized: [String: String] = [:]
for (rawExtension, behavior) in openers {
guard let normalizedExtension = FileExtensionOpenBehavior.normalizedExtension(rawExtension) else { continue }
guard FileExtensionOpenBehavior.defaultOpeners[normalizedExtension] != behavior else { continue }
normalized[normalizedExtension] = behavior.rawValue
}
defaults.set(normalized, forKey: key)
notifyDidChange(notificationCenter: notificationCenter)
}

/// Normalizes `openers` and overlays them onto the built-in defaults.
public static func effectiveOpeners(
from openers: [String: FileExtensionOpenBehavior]
) -> [String: FileExtensionOpenBehavior] {
var result = defaultValue
for (rawExtension, behavior) in openers {
guard let normalizedExtension = FileExtensionOpenBehavior.normalizedExtension(rawExtension) else { continue }
result[normalizedExtension] = behavior
}
return result
}

/// Posts ``didChangeNotification`` on `notificationCenter`.
public static func notifyDidChange(notificationCenter: NotificationCenter) {
notificationCenter.post(name: didChangeNotification, object: nil)
}
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Original file line number Diff line number Diff line change
Expand Up @@ -37,4 +37,46 @@ struct SettingCodableTests {
let encoded = value.encodeForJSON()
#expect([String: Int].decodeFromJSON(encoded) == value)
}

@Test func fileExtensionOpenBehaviorDictionaryRoundTrips() {
let value: [String: FileExtensionOpenBehavior] = ["html": .cmuxBrowser, "md": .markdownViewer]
let encoded = value.encodeForUserDefaults()
#expect([String: FileExtensionOpenBehavior].decodeFromUserDefaults(encoded) == value)
#expect([String: FileExtensionOpenBehavior].decodeFromJSON(encoded) == value)
}

@Test func fileExtensionOpenersPreserveDefaultsWithOverrides() throws {
let suiteName = "cmux.fileExtensionOpeners.\(UUID().uuidString)"
let defaults = try #require(UserDefaults(suiteName: suiteName))
defer { defaults.removePersistentDomain(forName: suiteName) }

defaults.set(["md": "markdownViewer"], forKey: FileExtensionOpenBehaviorSettings.key)

let openers = FileExtensionOpenBehaviorSettings.openers(defaults: defaults)
#expect(openers["html"] == .cmuxBrowser)
#expect(openers["htm"] == .cmuxBrowser)
#expect(openers["md"] == .markdownViewer)
}

@Test func fileExtensionOpenersPruneDefaultsAndPreserveAutomaticOverrides() throws {
let suiteName = "cmux.fileExtensionOpeners.prune.\(UUID().uuidString)"
let defaults = try #require(UserDefaults(suiteName: suiteName))
defer { defaults.removePersistentDomain(forName: suiteName) }

FileExtensionOpenBehaviorSettings.setOpeners(
["html": .automatic, "htm": .cmuxBrowser, "md": .markdownViewer],
defaults: defaults,
notificationCenter: .default
)

let stored = defaults.dictionary(forKey: FileExtensionOpenBehaviorSettings.key) as? [String: String]
#expect(stored?["html"] == "automatic")
#expect(stored?["htm"] == nil)
#expect(stored?["md"] == "markdownViewer")

let openers = FileExtensionOpenBehaviorSettings.openers(defaults: defaults)
#expect(openers["html"] == .automatic)
#expect(openers["htm"] == .cmuxBrowser)
#expect(openers["md"] == .markdownViewer)
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,68 @@
import CmuxSettings
import Foundation
import Observation

private final class NotificationObserverBag {
private var observers: [NSObjectProtocol] = []

func append(_ observer: NSObjectProtocol) {
observers.append(observer)
}

deinit {
for observer in observers {
NotificationCenter.default.removeObserver(observer)
}
}
}

@MainActor
@Observable
final class FileExtensionOpenersValueModel {
private(set) var current: [String: FileExtensionOpenBehavior]

private let store: UserDefaultsSettingsStore
@ObservationIgnored private let observerBag = NotificationObserverBag()

init(store: UserDefaultsSettingsStore) {
self.store = store
self.current = FileExtensionOpenBehaviorSettings.defaultValue
observe(FileExtensionOpenBehaviorSettings.didChangeNotification)
observe(UserDefaults.didChangeNotification)
Task { @MainActor [weak self] in
await self?.refresh()
}
}

func set(_ value: [String: FileExtensionOpenBehavior]) {
Task { @MainActor [weak self] in
await self?.setAndRefresh(value)
}
}

func setAndRefresh(_ value: [String: FileExtensionOpenBehavior]) async {
current = FileExtensionOpenBehaviorSettings.effectiveOpeners(from: value)
await store.setFileExtensionOpeners(value)
await refresh()
}

private func observe(_ name: Notification.Name) {
observerBag.append(
NotificationCenter.default.addObserver(
forName: name,
object: nil,
queue: .main
) { [weak self] _ in
Task { @MainActor [weak self] in
await self?.refresh()
}
}
)
}

func refresh() async {
let next = await store.fileExtensionOpeners()
guard next != current else { return }
current = next
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -37,10 +37,6 @@ extension Array where Element == CuratedSettingEntry {
.init(section: .app, id: "minimal-mode", title: "Minimal Mode", synonyms: "app.minimalMode presentation compact chrome layout simple titlebar controls"),
.init(section: .app, id: "keep-workspace-open", title: "Keep Workspace Open When Closing Last Surface", synonyms: "app.keepWorkspaceOpenWhenClosingLastSurface close last pane surface keep tab workspace"),
.init(section: .app, id: "focus-pane-first-click", title: "Focus Pane on First Click", synonyms: "app.focusPaneOnFirstClick click to focus focus follows mouse first click mouse activation"),
.init(section: .app, id: "file-drops", title: "File Drops", synonyms: "drag drop files finder path text terminal editor split preview shift"),
.init(section: .app, id: "preferred-editor", title: "Open Files With", synonyms: "app.preferredEditor 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: "app.openSupportedFilesInCmux 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: "app.openMarkdownInCmuxViewer md markdown mdx viewer preview readme"),
.init(section: .app, id: "terminal-config", title: "Terminal Config", synonyms: "ghostty config merged generated preview terminal configuration window open config"),
.init(section: .app, id: "imessage-mode", title: "iMessage Mode", synonyms: "app.iMessageMode imessage message messages chat prompt prompts submitted texting reorder move workspace top agent send"),
.init(section: .app, id: "reorder-notification", title: "Reorder on Notification", synonyms: "app.reorderOnNotification notification reorder move workspace top unread sort"),
Expand All @@ -60,6 +56,13 @@ extension Array where Element == CuratedSettingEntry {
.init(section: .app, id: "notification-command", title: "Notification Command", synonyms: "notifications.command shell command hook script env environment variable done agent"),
.init(section: .app, id: "desktop-notifications", title: "Desktop Notifications", synonyms: "desktop notifications permission authorize enable alerts banners send test notification center"),

// File Opening
.init(section: .fileOpening, id: "file-drops", title: String(localized: "settings.app.fileDrop.defaultBehavior", defaultValue: "File Drops"), synonyms: "drag drop files finder path text terminal editor split preview shift"),
.init(section: .fileOpening, id: "preferred-editor", title: String(localized: "settings.app.preferredEditor", defaultValue: "Open Files With"), synonyms: "app.preferredEditor editor open file code vscode visual studio zed sublime subl cursor"),
.init(section: .fileOpening, id: "file-extension-openers", title: String(localized: "settings.app.fileExtensionOpeners", defaultValue: "File Extension Openers"), synonyms: "app.fileExtensionOpeners cmd click html htm browser preview markdown system default editor extension opener"),
.init(section: .fileOpening, id: "supported-file-previews", title: String(localized: "settings.app.openSupportedFilesInCmux", defaultValue: "Open Supported Files in cmux"), synonyms: "app.openSupportedFilesInCmux cmd click file preview pdf image video audio quicklook quick look editor external"),
.init(section: .fileOpening, id: "markdown-viewer", title: String(localized: "settings.app.openMarkdownInCmuxViewer", defaultValue: "Open Markdown in cmux Viewer"), synonyms: "app.openMarkdownInCmuxViewer md markdown mdx viewer preview readme"),

// 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: "copy-on-select", title: "Copy on Selection", synonyms: "terminal.copyOnSelect copy on selection select clipboard mouse double click triple click iterm"),
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ import Foundation
public enum SettingsSectionID: String, CaseIterable, Identifiable, Sendable, Hashable {
case account
case app
case fileOpening
case terminal
case textBox
case sidebarAppearance
Expand All @@ -32,6 +33,7 @@ public enum SettingsSectionID: String, CaseIterable, Identifiable, Sendable, Has
switch self {
case .account: return "Account"
case .app: return "App"
case .fileOpening: return String(localized: "settings.section.fileOpening", defaultValue: "File Opening")
case .terminal: return "Terminal"
case .textBox: return String(localized: "settings.section.textBox", defaultValue: "TextBox (Beta)")
case .sidebarAppearance: return "Sidebar"
Expand All @@ -52,6 +54,7 @@ public enum SettingsSectionID: String, CaseIterable, Identifiable, Sendable, Has
switch self {
case .account: return "person.crop.circle"
case .app: return "gearshape"
case .fileOpening: return "doc.badge.gearshape"
case .terminal: return "terminal"
case .textBox: return "textformat"
case .sidebarAppearance: return "sidebar.left"
Expand All @@ -74,6 +77,7 @@ public enum SettingsSectionID: String, CaseIterable, Identifiable, Sendable, Has
switch self {
case .account: return "sign in team sync user profile"
case .app: return "appearance language workspace notifications menu bar telemetry"
case .fileOpening: return "files paths cmd click html markdown preview editor drag drop extensions"
case .terminal: return "scrollbar copy on select agent resume hibernation"
case .textBox: return "textbox text box rich input prompt default new terminal workspace split tab focus show beta"
case .sidebarAppearance: return "sidebar details branches material terminal background"
Expand Down
Loading
Loading