Repository navigation
Add configurable file extension openers #5040
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
31f2c19
7d9b706
df2e59f
01ba7ad
559c9f2
f864d02
f0e9b09
9676f3d
1f1ab68
5c43325
4297516
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| 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 { | ||
| /// 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] | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Settings and routing disagreeMedium Severity Cmd-click routing reads Additional Locations (1)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) | ||
| } | ||
| } | ||
|
coderabbitai[bot] marked this conversation as resolved.
|
||
| 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 | ||
| } | ||
| } |


There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
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, andnormalizedExtension.Useful? React with 👍 / 👎.