Skip to content
Open
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
3 changes: 3 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,4 +1,7 @@
# Changelog

## 2026-02-05
- Configurable external editor: choose your preferred editor and keyboard shortcut via Settings (Cmd+,). Prompts to choose an editor on first use. Default shortcut: Cmd+E.

## 2026-01-29
- Added Mermaid rendering via Beautiful Mermaid (thanks [@zalun](https://github.com/zalun) for PR #11).
99 changes: 99 additions & 0 deletions MarkdownViewer/App/AppDelegate.swift
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import AppKit
import Combine
import SwiftUI
import UniformTypeIdentifiers

Expand All @@ -7,6 +8,8 @@ class AppDelegate: NSObject, NSApplicationDelegate {
private let openFilesStore = OpenFilesStore.shared
private var windowCloseObserver: Any?
private var keyDownMonitor: Any?
private var editorMenuItem: NSMenuItem?
private var settingsCancellable: AnyCancellable?
private var didRestoreOpenFiles = false
private var isTerminating = false

Expand All @@ -31,6 +34,15 @@ class AppDelegate: NSObject, NSApplicationDelegate {
keyDownMonitor = NSEvent.addLocalMonitorForEvents(matching: .keyDown) { [weak self] event in
guard let self else { return event }
let flags = event.modifierFlags.intersection(.deviceIndependentFlagsMask)

// External editor shortcut (configurable, only when a document window is active)
if self.eventMatchesEditorShortcut(flags: flags, event: event) {
guard self.activeDocumentWindow() != nil else { return event }
self.openInExternalEditor()
return nil
}

// Ctrl+Tab / Ctrl+Shift+Tab for tab switching
guard flags.contains(.control) else { return event }
guard !flags.contains(.command), !flags.contains(.option) else { return event }
guard event.keyCode == 48 else { return event }
Expand All @@ -45,6 +57,15 @@ class AppDelegate: NSObject, NSApplicationDelegate {
DispatchQueue.main.async { [weak self] in
self?.restoreOpenFilesIfNeeded()
}
// After SwiftUI builds the menu, find the editor menu item and set its keyEquivalent for display
DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) { [weak self] in
self?.syncEditorMenuItemShortcut()
}
settingsCancellable = ExternalEditorSettings.shared.objectWillChange.sink { [weak self] _ in
DispatchQueue.main.async {
self?.syncEditorMenuItemShortcut()
}
}
}

func applicationShouldTerminateAfterLastWindowClosed(_ sender: NSApplication) -> Bool {
Expand Down Expand Up @@ -74,6 +95,7 @@ class AppDelegate: NSObject, NSApplicationDelegate {
if let keyDownMonitor {
NSEvent.removeMonitor(keyDownMonitor)
}
settingsCancellable?.cancel()
}

func openFileFromPanel() {
Expand Down Expand Up @@ -132,6 +154,83 @@ class AppDelegate: NSObject, NSApplicationDelegate {
activeDocumentState()?.findPrevious()
}

func openInExternalEditor() {
guard let url = activeDocumentState()?.currentURL else {
NSSound.beep()
return
}
let settings = ExternalEditorSettings.shared
if let editorURL = settings.editorAppURL {
if FileManager.default.fileExists(atPath: editorURL.path) {
NSWorkspace.shared.open(
[url],
withApplicationAt: editorURL,
configuration: NSWorkspace.OpenConfiguration()
)
} else {
let alert = NSAlert()
alert.messageText = "Editor Not Found"
alert.informativeText = "\(settings.editorDisplayName) could not be found at \(editorURL.path)."
alert.addButton(withTitle: "Choose Editor\u{2026}")
alert.addButton(withTitle: "Cancel")
alert.alertStyle = .warning
if alert.runModal() == .alertFirstButtonReturn {
promptForEditor(thenOpen: url)
}
}
} else {
promptForEditor(thenOpen: url)
}
}

func eventMatchesEditorShortcut(flags: NSEvent.ModifierFlags, event: NSEvent) -> Bool {
let settings = ExternalEditorSettings.shared
guard !settings.shortcutKey.isEmpty else { return false }
let editorMods = NSEvent.ModifierFlags(rawValue: settings.shortcutModifiers)
.intersection(.deviceIndependentFlagsMask)
guard flags == editorMods else { return false }
guard let chars = event.charactersIgnoringModifiers?.lowercased() else { return false }
return chars == settings.shortcutKey
}

private func syncEditorMenuItemShortcut() {
let settings = ExternalEditorSettings.shared
if editorMenuItem == nil || editorMenuItem?.menu == nil {
editorMenuItem = findMenuItem(withTitlePrefix: "Open in ")
}
guard let item = editorMenuItem else { return }
if settings.shortcutKey.isEmpty {
item.keyEquivalent = ""
item.keyEquivalentModifierMask = []
} else {
item.keyEquivalent = settings.shortcutKey
item.keyEquivalentModifierMask = NSEvent.ModifierFlags(rawValue: settings.shortcutModifiers)
}
}

private func findMenuItem(withTitlePrefix prefix: String) -> NSMenuItem? {
guard let mainMenu = NSApp.mainMenu else { return nil }
for menuBarItem in mainMenu.items {
guard let submenu = menuBarItem.submenu else { continue }
for item in submenu.items {
if item.title.hasPrefix(prefix) { return item }
}
}
return nil
}

private func promptForEditor(thenOpen fileURL: URL) {
guard let editorURL = ExternalEditorSettings.presentEditorChooserPanel(
message: "Choose an editor application for Markdown files"
) else { return }
ExternalEditorSettings.shared.setEditor(url: editorURL)
NSWorkspace.shared.open(
[fileURL],
withApplicationAt: editorURL,
configuration: NSWorkspace.OpenConfiguration()
)
}

func selectNextTab() {
activeDocumentWindow()?.selectNextTab(nil)
}
Expand Down
10 changes: 10 additions & 0 deletions MarkdownViewer/App/MarkdownViewerApp.swift
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import SwiftUI
struct MarkdownViewerApp: App {
@NSApplicationDelegateAdaptor(AppDelegate.self) var appDelegate
@StateObject private var recentFilesStore = RecentFilesStore.shared
@ObservedObject private var editorSettings = ExternalEditorSettings.shared

var body: some Scene {
WindowGroup {
Expand Down Expand Up @@ -79,6 +80,12 @@ struct MarkdownViewerApp: App {
appDelegate.findPrevious()
}
.keyboardShortcut("g", modifiers: [.command, .shift])

Divider()

Button(editorSettings.menuItemTitle) {
appDelegate.openInExternalEditor()
}
}
CommandGroup(after: .toolbar) {
Button("Zoom In") {
Expand All @@ -97,5 +104,8 @@ struct MarkdownViewerApp: App {
.keyboardShortcut("0", modifiers: .command)
}
}
Settings {
SettingsView()
}
}
}
156 changes: 156 additions & 0 deletions MarkdownViewer/Stores/ExternalEditorSettings.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,156 @@
import Foundation
import SwiftUI

final class ExternalEditorSettings: ObservableObject {
static let shared = ExternalEditorSettings()

@Published var editorAppURL: URL? {
didSet { saveIfNeeded() }
}
@Published var editorDisplayName: String = "External Editor" {
didSet { saveIfNeeded() }
}
@Published var shortcutKey: String = "e" {
didSet { saveIfNeeded() }
}
@Published var shortcutModifiers: UInt = NSEvent.ModifierFlags.command.rawValue {
didSet { saveIfNeeded() }
}

var menuItemTitle: String {
if editorAppURL != nil {
return "Open in \(editorDisplayName)"
}
return "Open in External Editor"
}

var keyboardShortcut: KeyboardShortcut? {
guard let char = shortcutKey.lowercased().first else { return nil }
var modifiers: EventModifiers = []
if modifierFlags.contains(.command) { modifiers.insert(.command) }
if modifierFlags.contains(.shift) { modifiers.insert(.shift) }
if modifierFlags.contains(.option) { modifiers.insert(.option) }
if modifierFlags.contains(.control) { modifiers.insert(.control) }
return KeyboardShortcut(KeyEquivalent(char), modifiers: modifiers)
}

var shortcutIdentity: String {
"\(shortcutKey)-\(shortcutModifiers)"
}

var shortcutDisplayString: String {
var parts: [String] = []
if modifierFlags.contains(.control) { parts.append("\u{2303}") }
if modifierFlags.contains(.option) { parts.append("\u{2325}") }
if modifierFlags.contains(.shift) { parts.append("\u{21E7}") }
if modifierFlags.contains(.command) { parts.append("\u{2318}") }
parts.append(shortcutKey.uppercased())
return parts.joined()
}

var editorIcon: NSImage? {
guard let url = editorAppURL else { return nil }
return NSWorkspace.shared.icon(forFile: url.path)
}

/// Prevents circular @Published updates during load() from UserDefaults
private var isLoading = false
/// Prevents redundant saves during batched multi-property mutations
private var isBatching = false
private let defaults = UserDefaults.standard
private let editorAppURLKey = "externalEditorAppURL"
private let editorDisplayNameKey = "externalEditorDisplayName"
private let shortcutKeyKey = "externalEditorShortcutKey"
private let shortcutModifiersKey = "externalEditorShortcutModifiers"

/// Decomposes the stored shortcutModifiers UInt into NSEvent.ModifierFlags
private var modifierFlags: NSEvent.ModifierFlags {
NSEvent.ModifierFlags(rawValue: shortcutModifiers)
}

private init() {
load()
}

func setEditor(url: URL) {
isBatching = true
editorAppURL = url
editorDisplayName = Self.displayName(for: url)
isBatching = false
save()
}

func clearEditor() {
isBatching = true
editorAppURL = nil
editorDisplayName = "External Editor"
isBatching = false
save()
}

func setShortcut(key: String, modifiers: NSEvent.ModifierFlags) {
isBatching = true
shortcutKey = key.lowercased()
shortcutModifiers = modifiers.rawValue
isBatching = false
save()
}

func clearShortcut() {
isBatching = true
shortcutKey = ""
shortcutModifiers = 0
isBatching = false
save()
}

static func presentEditorChooserPanel(message: String = "Choose an editor application") -> URL? {
let panel = NSOpenPanel()
panel.allowedContentTypes = [.applicationBundle]
panel.allowsMultipleSelection = false
panel.canChooseDirectories = false
panel.canChooseFiles = true
panel.directoryURL = URL(fileURLWithPath: "/Applications")
panel.message = message
panel.prompt = "Choose"
return panel.runModal() == .OK ? panel.url : nil
}

static func displayName(for appURL: URL) -> String {
let name = appURL.deletingPathExtension().lastPathComponent
return name.isEmpty ? "External Editor" : name
}

private func saveIfNeeded() {
if !isLoading && !isBatching { save() }
}

private func save() {
guard !isLoading else { return }
if let url = editorAppURL {
defaults.set(url.path, forKey: editorAppURLKey)
} else {
defaults.removeObject(forKey: editorAppURLKey)
}
defaults.set(editorDisplayName, forKey: editorDisplayNameKey)
defaults.set(shortcutKey, forKey: shortcutKeyKey)
defaults.set(shortcutModifiers, forKey: shortcutModifiersKey)
}

private func load() {
isLoading = true
defer { isLoading = false }
if let path = defaults.string(forKey: editorAppURLKey) {
editorAppURL = URL(fileURLWithPath: path)
}
if let name = defaults.string(forKey: editorDisplayNameKey) {
editorDisplayName = name
}
if let key = defaults.string(forKey: shortcutKeyKey) {
shortcutKey = key
}
if defaults.object(forKey: shortcutModifiersKey) != nil {
shortcutModifiers = UInt(defaults.integer(forKey: shortcutModifiersKey))
}
}
}
Loading