diff --git a/Resources/Localizable.xcstrings b/Resources/Localizable.xcstrings index 7649789be6d4..bce8f916bfea 100644 --- a/Resources/Localizable.xcstrings +++ b/Resources/Localizable.xcstrings @@ -87714,6 +87714,124 @@ } } } + }, + "settings.app.openMarkdownInCmuxViewer": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Open Markdown in cmux Viewer" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "cmux ビューアで Markdown を開く" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "cmux 뷰어에서 Markdown 열기" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "在 cmux 查看器中打开 Markdown" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "在 cmux 檢視器中開啟 Markdown" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Markdown im cmux-Viewer öffnen" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Abrir Markdown en el visor cmux" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Ouvrir Markdown dans la visionneuse cmux" + } + }, + "it": { + "stringUnit": { + "state": "translated", + "value": "Apri Markdown nel visualizzatore cmux" + } + } + } + }, + "settings.app.openMarkdownInCmuxViewer.subtitle": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Cmd-clicking .md/.markdown/.mkd/.mdx files opens the cmux markdown viewer panel instead of the preferred editor." + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": ".md/.markdown/.mkd/.mdx ファイルを Cmd+クリックすると、優先エディタの代わりに cmux マークダウンビューアパネルが開きます。" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": ".md/.markdown/.mkd/.mdx 파일을 Cmd+클릭하면 기본 에디터 대신 cmux 마크다운 뷰어 패널이 열립니다." + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "Cmd+点击 .md/.markdown/.mkd/.mdx 文件将打开 cmux 标记查看器面板,而不是使用首选编辑器。" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "Cmd+點擊 .md/.markdown/.mkd/.mdx 檔案會開啟 cmux Markdown 檢視器面板,而不是使用偏好編輯器。" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Cmd-Klick auf .md/.markdown/.mkd/.mdx-Dateien öffnet das cmux-Markdown-Viewer-Panel statt des bevorzugten Editors." + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Hacer Cmd+clic en archivos .md/.markdown/.mkd/.mdx abre el panel del visor de markdown de cmux en lugar del editor preferido." + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Cmd+clic sur les fichiers .md/.markdown/.mkd/.mdx ouvre le panneau de la visionneuse markdown cmux au lieu de l'éditeur préféré." + } + }, + "it": { + "stringUnit": { + "state": "translated", + "value": "Cmd+clic sui file .md/.markdown/.mkd/.mdx apre il pannello del visualizzatore markdown cmux invece dell'editor preferito." + } + } + } } } } diff --git a/Sources/GhosttyTerminalView.swift b/Sources/GhosttyTerminalView.swift index c3828acf262c..32a50628e2db 100644 --- a/Sources/GhosttyTerminalView.swift +++ b/Sources/GhosttyTerminalView.swift @@ -3272,6 +3272,40 @@ class GhosttyApp { #endif return false } + // Route markdown file URLs into the cmux viewer when the toggle is + // on AND the link is local + has no anchor/query. Anything else + // (toggle off, hosted file URL, #fragment, ?query, non-markdown, + // remote workspace, unreadable file, split creation failure) falls + // through to the existing NSWorkspace path below so the default-off + // behavior and URL semantics are preserved. + let fileURLHost = target.url.host + if CmdClickMarkdownRouteSettings.isEnabled(), + target.url.isFileURL, + target.url.fragment == nil, + target.url.query == nil, + fileURLHost == nil || fileURLHost?.isEmpty == true || fileURLHost == "localhost", + CmdClickMarkdownRouteSettings.isMarkdownPath(target.url.path) { + let fileURL = target.url + let routed: Bool = performOnMain { + // Remote-surface guard runs before shouldRoute so we never + // stat a local path on the main thread for a remote workspace. + guard let termSurface = surfaceView.terminalSurface, + let workspace = termSurface.owningWorkspace(), + !workspace.isRemoteTerminalSurface(termSurface.id), + CmdClickMarkdownRouteSettings.shouldRoute(path: fileURL.path) else { + return false + } + return workspace.openOrFocusMarkdownSplit( + from: termSurface.id, + filePath: fileURL.path + ) != nil + } + if routed { + return true + } + // Fall through to the existing NSWorkspace path below. + } + if !BrowserLinkOpenSettings.openTerminalLinksInCmuxBrowser() { #if DEBUG dlog("link.openURL cmuxBrowser=disabled, opening externally url=\(target.url)") @@ -7846,6 +7880,18 @@ class GhosttyNSView: NSView, NSUserInterfaceValidations { ) #endif + // Remote-surface guard runs before shouldRoute so we never stat a local + // path on the main thread for a remote workspace. When the viewer path + // is applicable but split creation fails, fall back to the preferred + // editor so the click never silently no-ops. + if let termSurface = terminalSurface, + let workspace = termSurface.owningWorkspace(), + !workspace.isRemoteTerminalSurface(termSurface.id), + CmdClickMarkdownRouteSettings.shouldRoute(path: resolution.path), + workspace.openOrFocusMarkdownSplit(from: termSurface.id, filePath: resolution.path) != nil { + return resolution + } + PreferredEditorSettings.open(URL(fileURLWithPath: resolution.path)) return resolution } diff --git a/Sources/KeyboardShortcutSettingsFileStore.swift b/Sources/KeyboardShortcutSettingsFileStore.swift index c102e85b7fff..1a8cb6db2e2f 100644 --- a/Sources/KeyboardShortcutSettingsFileStore.swift +++ b/Sources/KeyboardShortcutSettingsFileStore.swift @@ -33,6 +33,7 @@ final class CmuxSettingsFileStore { "app.keepWorkspaceOpenWhenClosingLastSurface", "app.focusPaneOnFirstClick", "app.preferredEditor", + "app.openMarkdownInCmuxViewer", "app.reorderOnNotification", "app.sendAnonymousTelemetry", "app.warnBeforeQuit", @@ -430,6 +431,9 @@ final class CmuxSettingsFileStore { if let value = jsonString(section["preferredEditor"]) { snapshot.managedUserDefaults[PreferredEditorSettings.key] = .string(value) } + if let value = jsonBool(section["openMarkdownInCmuxViewer"]) { + snapshot.managedUserDefaults[CmdClickMarkdownRouteSettings.key] = .bool(value) + } if let value = jsonBool(section["reorderOnNotification"]) { snapshot.managedUserDefaults[WorkspaceAutoReorderSettings.key] = .bool(value) } @@ -1357,6 +1361,7 @@ final class CmuxSettingsFileStore { "keepWorkspaceOpenWhenClosingLastSurface": !LastSurfaceCloseShortcutSettings.defaultValue, "focusPaneOnFirstClick": PaneFirstClickFocusSettings.defaultEnabled, "preferredEditor": "", + "openMarkdownInCmuxViewer": CmdClickMarkdownRouteSettings.defaultValue, "reorderOnNotification": WorkspaceAutoReorderSettings.defaultValue, "sendAnonymousTelemetry": TelemetrySettings.defaultSendAnonymousTelemetry, "warnBeforeQuit": QuitWarningSettings.defaultWarnBeforeQuit, diff --git a/Sources/Workspace.swift b/Sources/Workspace.swift index 91ea1f269fcf..d5250f6782d3 100644 --- a/Sources/Workspace.swift +++ b/Sources/Workspace.swift @@ -9178,6 +9178,34 @@ final class Workspace: Identifiable, ObservableObject { return browserPanel } + /// Open the markdown viewer for `filePath`, reusing an existing + /// `MarkdownPanel` in this workspace that already shows the same file. + /// Paths are compared after symlink resolution so `./README.md` and a + /// symlink pointing at the same file focus the same viewer. + /// Returns `nil` when no existing viewer matches and split creation + /// fails, so callers can fall back to the preferred editor / system opener. + @discardableResult + func openOrFocusMarkdownSplit( + from panelId: UUID, + filePath: String + ) -> MarkdownPanel? { + let canonical = (filePath as NSString).resolvingSymlinksInPath + for (existingId, panel) in panels { + guard let md = panel as? MarkdownPanel else { continue } + if (md.filePath as NSString).resolvingSymlinksInPath == canonical { + focusPanel(existingId) + return md + } + } + return newMarkdownSplit( + from: panelId, + orientation: .horizontal, + insertFirst: false, + filePath: filePath, + focus: true + ) + } + func newMarkdownSplit( from panelId: UUID, orientation: SplitOrientation, diff --git a/Sources/cmuxApp.swift b/Sources/cmuxApp.swift index 7c65e9d934cb..5c700ba51951 100644 --- a/Sources/cmuxApp.swift +++ b/Sources/cmuxApp.swift @@ -4128,6 +4128,37 @@ enum TelemetrySettings { static let enabledForCurrentLaunch = isEnabled() } +enum CmdClickMarkdownRouteSettings { + static let key = "openMarkdownInCmuxViewer" + static let defaultValue = false + + static func isEnabled(defaults: UserDefaults = .standard) -> Bool { + defaults.object(forKey: key) == nil ? defaultValue : defaults.bool(forKey: key) + } + + /// Cheap extension check. Safe to call off the main thread before any + /// filesystem probe so remote/non-markdown paths can be filtered early. + static func isMarkdownPath(_ path: String) -> Bool { + let ext = (path as NSString).pathExtension.lowercased() + return ext == "md" || ext == "markdown" || ext == "mkd" || ext == "mdx" + } + + static func shouldRoute(path: String) -> Bool { + guard isEnabled(), isMarkdownPath(path) else { return false } + // Match the `markdown.open` socket path: only route real, readable + // files. Rejects FIFOs, device nodes, sockets, symlinks to non-regular + // targets, and permission-denied paths so the viewer never opens into + // an unavailable state. + let resolved = (path as NSString).resolvingSymlinksInPath + guard FileManager.default.isReadableFile(atPath: resolved), + let attrs = try? FileManager.default.attributesOfItem(atPath: resolved), + (attrs[.type] as? FileAttributeType) == .typeRegular else { + return false + } + return true + } +} + enum PreferredEditorSettings { static let key = "preferredEditorCommand" @@ -4362,6 +4393,7 @@ struct SettingsView: View { @AppStorage(TelemetrySettings.sendAnonymousTelemetryKey) private var sendAnonymousTelemetry = TelemetrySettings.defaultSendAnonymousTelemetry @AppStorage(PreferredEditorSettings.key) private var preferredEditorCommand = "" + @AppStorage(CmdClickMarkdownRouteSettings.key) private var openMarkdownInCmuxViewer = CmdClickMarkdownRouteSettings.defaultValue @AppStorage("cmuxPortBase") private var cmuxPortBase = 9100 @AppStorage("cmuxPortRange") private var cmuxPortRange = 10 @AppStorage(BrowserSearchSettings.searchEngineKey) private var browserSearchEngine = BrowserSearchSettings.defaultSearchEngine.rawValue @@ -5068,6 +5100,21 @@ struct SettingsView: View { SettingsCardDivider() + SettingsCardRow( + configurationReview: .json("app.openMarkdownInCmuxViewer"), + String(localized: "settings.app.openMarkdownInCmuxViewer", defaultValue: "Open Markdown in cmux Viewer"), + subtitle: String(localized: "settings.app.openMarkdownInCmuxViewer.subtitle", defaultValue: "Cmd-clicking .md/.markdown/.mkd/.mdx files opens the cmux markdown viewer panel instead of the preferred editor.") + ) { + Toggle("", isOn: $openMarkdownInCmuxViewer) + .labelsHidden() + .controlSize(.small) + .accessibilityLabel( + String(localized: "settings.app.openMarkdownInCmuxViewer", defaultValue: "Open Markdown in cmux Viewer") + ) + } + + SettingsCardDivider() + SettingsCardRow( configurationReview: .json("app.reorderOnNotification"), String(localized: "settings.app.reorderOnNotification", defaultValue: "Reorder on Notification"), @@ -6479,6 +6526,7 @@ struct SettingsView: View { geminiHooksEnabled = GeminiIntegrationSettings.defaultHooksEnabled sendAnonymousTelemetry = TelemetrySettings.defaultSendAnonymousTelemetry preferredEditorCommand = "" + openMarkdownInCmuxViewer = CmdClickMarkdownRouteSettings.defaultValue browserSearchEngine = BrowserSearchSettings.defaultSearchEngine.rawValue browserSearchSuggestionsEnabled = BrowserSearchSettings.defaultSearchSuggestionsEnabled browserThemeMode = BrowserThemeSettings.defaultMode.rawValue diff --git a/web/data/cmux-settings.schema.json b/web/data/cmux-settings.schema.json index 5d883528804e..2605a98911c5 100644 --- a/web/data/cmux-settings.schema.json +++ b/web/data/cmux-settings.schema.json @@ -26,7 +26,27 @@ "properties": { "language": { "type": "string", - "enum": ["system", "en", "ar", "bs", "zh-Hans", "zh-Hant", "da", "de", "es", "fr", "it", "ja", "ko", "nb", "pl", "pt-BR", "ru", "th", "tr"], + "enum": [ + "system", + "en", + "ar", + "bs", + "zh-Hans", + "zh-Hant", + "da", + "de", + "es", + "fr", + "it", + "ja", + "ko", + "nb", + "pl", + "pt-BR", + "ru", + "th", + "tr" + ], "default": "system", "description": "Preferred app language." }, @@ -68,6 +88,11 @@ "default": "", "description": "Custom editor command used by cmux where applicable. Leave empty to use the default." }, + "openMarkdownInCmuxViewer": { + "type": "boolean", + "default": false, + "description": "When enabled, Cmd-clicking .md/.markdown/.mkd/.mdx files in the terminal opens them in the cmux markdown viewer panel (with live reload) instead of the preferred editor." + }, "reorderOnNotification": { "type": "boolean", "default": true, @@ -136,7 +161,25 @@ }, "sound": { "type": "string", - "enum": ["default", "Basso", "Blow", "Bottle", "Frog", "Funk", "Glass", "Hero", "Morse", "Ping", "Pop", "Purr", "Sosumi", "Submarine", "Tink", "custom_file", "none"], + "enum": [ + "default", + "Basso", + "Blow", + "Bottle", + "Frog", + "Funk", + "Glass", + "Hero", + "Morse", + "Ping", + "Pop", + "Purr", + "Sosumi", + "Submarine", + "Tink", + "custom_file", + "none" + ], "default": "default", "description": "Notification sound preset." }, @@ -229,7 +272,17 @@ "properties": { "indicatorStyle": { "type": "string", - "enum": ["leftRail", "solidFill", "rail", "border", "wash", "lift", "typography", "washRail", "blueWashColorRail"], + "enum": [ + "leftRail", + "solidFill", + "rail", + "border", + "wash", + "lift", + "typography", + "washRail", + "blueWashColorRail" + ], "default": "leftRail", "description": "Active workspace indicator style. Legacy aliases are accepted and normalized." }, @@ -313,7 +366,17 @@ "properties": { "socketControlMode": { "type": "string", - "enum": ["off", "cmuxOnly", "automation", "password", "allowAll", "openAccess", "fullOpenAccess", "notifications", "full"], + "enum": [ + "off", + "cmuxOnly", + "automation", + "password", + "allowAll", + "openAccess", + "fullOpenAccess", + "notifications", + "full" + ], "default": "cmuxOnly", "description": "Socket control mode. Legacy aliases are accepted and normalized." }, @@ -423,7 +486,13 @@ "items": { "type": "string" }, - "default": ["localhost", "127.0.0.1", "::1", "0.0.0.0", "*.localtest.me"], + "default": [ + "localhost", + "127.0.0.1", + "::1", + "0.0.0.0", + "*.localtest.me" + ], "description": "HTTP hosts allowed in the embedded browser without a warning prompt." }, "showImportHintOnBlankTabs": {