From 9bdaa84b3b4f3aec401576aa7ae838092a3fbc88 Mon Sep 17 00:00:00 2001 From: Jason Cumberland Date: Thu, 5 Feb 2026 21:29:51 -0700 Subject: [PATCH] Add configurable external editor with keyboard shortcut MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add an "Open in External Editor" menu item with a default Cmd+E shortcut. The editor application and shortcut are fully configurable in Settings. No editor is pre-configured — on first use the user is prompted to select one. Includes a custom shortcut recorder with validation against reserved system shortcuts and unit tests for the settings store. Co-Authored-By: Claude Opus 4.6 --- CHANGELOG.md | 3 + MarkdownViewer/App/AppDelegate.swift | 99 +++++ MarkdownViewer/App/MarkdownViewerApp.swift | 10 + .../Stores/ExternalEditorSettings.swift | 156 ++++++++ MarkdownViewer/Views/SettingsView.swift | 85 +++++ .../Views/ShortcutRecorderView.swift | 109 ++++++ README.md | 1 + .../ExternalEditorSettingsTests.swift | 361 ++++++++++++++++++ 8 files changed, 824 insertions(+) create mode 100644 MarkdownViewer/Stores/ExternalEditorSettings.swift create mode 100644 MarkdownViewer/Views/SettingsView.swift create mode 100644 MarkdownViewer/Views/ShortcutRecorderView.swift create mode 100644 Tests/MarkdownViewerTests/ExternalEditorSettingsTests.swift diff --git a/CHANGELOG.md b/CHANGELOG.md index bed2306..be16a6c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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). diff --git a/MarkdownViewer/App/AppDelegate.swift b/MarkdownViewer/App/AppDelegate.swift index 14a987b..7fc9f7d 100644 --- a/MarkdownViewer/App/AppDelegate.swift +++ b/MarkdownViewer/App/AppDelegate.swift @@ -1,4 +1,5 @@ import AppKit +import Combine import SwiftUI import UniformTypeIdentifiers @@ -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 @@ -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 } @@ -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 { @@ -74,6 +95,7 @@ class AppDelegate: NSObject, NSApplicationDelegate { if let keyDownMonitor { NSEvent.removeMonitor(keyDownMonitor) } + settingsCancellable?.cancel() } func openFileFromPanel() { @@ -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) } diff --git a/MarkdownViewer/App/MarkdownViewerApp.swift b/MarkdownViewer/App/MarkdownViewerApp.swift index 5fc8f9f..f8b1996 100644 --- a/MarkdownViewer/App/MarkdownViewerApp.swift +++ b/MarkdownViewer/App/MarkdownViewerApp.swift @@ -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 { @@ -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") { @@ -97,5 +104,8 @@ struct MarkdownViewerApp: App { .keyboardShortcut("0", modifiers: .command) } } + Settings { + SettingsView() + } } } diff --git a/MarkdownViewer/Stores/ExternalEditorSettings.swift b/MarkdownViewer/Stores/ExternalEditorSettings.swift new file mode 100644 index 0000000..c1867f6 --- /dev/null +++ b/MarkdownViewer/Stores/ExternalEditorSettings.swift @@ -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)) + } + } +} diff --git a/MarkdownViewer/Views/SettingsView.swift b/MarkdownViewer/Views/SettingsView.swift new file mode 100644 index 0000000..e8e94c9 --- /dev/null +++ b/MarkdownViewer/Views/SettingsView.swift @@ -0,0 +1,85 @@ +import AppKit +import SwiftUI + +struct SettingsView: View { + @ObservedObject private var settings = ExternalEditorSettings.shared + + var body: some View { + Form { + Section { + editorSection + } header: { + Text("External Editor") + } + + Section { + shortcutSection + } header: { + Text("Keyboard Shortcut") + } + } + .formStyle(.grouped) + .frame(width: 420, height: 300) + } + + @ViewBuilder + private var editorSection: some View { + HStack(spacing: 12) { + if let icon = settings.editorIcon { + Image(nsImage: icon) + .resizable() + .frame(width: 32, height: 32) + } else { + Image(systemName: "app.dashed") + .font(.system(size: 28)) + .foregroundColor(.secondary) + .frame(width: 32, height: 32) + } + + VStack(alignment: .leading, spacing: 2) { + if settings.editorAppURL != nil { + Text(settings.editorDisplayName) + .fontWeight(.medium) + Text(settings.editorAppURL?.path ?? "") + .font(.caption) + .foregroundColor(.secondary) + .lineLimit(1) + .truncationMode(.middle) + } else { + Text("No editor selected") + .foregroundColor(.secondary) + Text("You will be prompted to choose on first use") + .font(.caption) + .foregroundColor(.secondary) + } + } + + Spacer() + } + + HStack { + Button("Choose\u{2026}") { + chooseEditor() + } + if settings.editorAppURL != nil { + Button("Clear") { + settings.clearEditor() + } + } + } + } + + @ViewBuilder + private var shortcutSection: some View { + HStack { + Text("Shortcut:") + ShortcutRecorderView(settings: settings) + } + } + + private func chooseEditor() { + if let url = ExternalEditorSettings.presentEditorChooserPanel(message: "Select an editor application") { + settings.setEditor(url: url) + } + } +} diff --git a/MarkdownViewer/Views/ShortcutRecorderView.swift b/MarkdownViewer/Views/ShortcutRecorderView.swift new file mode 100644 index 0000000..18f7fd2 --- /dev/null +++ b/MarkdownViewer/Views/ShortcutRecorderView.swift @@ -0,0 +1,109 @@ +import AppKit +import SwiftUI + +struct ShortcutRecorderView: View { + @ObservedObject var settings: ExternalEditorSettings + @State private var isRecording = false + @State private var eventMonitor: Any? + + private static let reservedKeys: Set = [ + "q", "w", "c", "v", "x", "z", "a", "o", "t", "n", "f", "s", "p", "h", "m", ",", + "r", "g", "+", "-", "0", "[", "]", + "1", "2", "3", "4", "5", "6", "7", "8", "9" + ] + + private static let reservedCmdShiftKeys: Set = ["[", "]", "g"] + + var body: some View { + HStack(spacing: 8) { + ZStack { + RoundedRectangle(cornerRadius: 6) + .fill(isRecording + ? Color.accentColor.opacity(0.1) + : Color(nsColor: .controlBackgroundColor)) + RoundedRectangle(cornerRadius: 6) + .strokeBorder(isRecording + ? Color.accentColor + : Color(nsColor: .separatorColor), + lineWidth: 1) + + if isRecording { + Text("Type shortcut\u{2026}") + .foregroundColor(.accentColor) + } else if !settings.shortcutKey.isEmpty { + Text(settings.shortcutDisplayString) + .font(.system(.body, design: .rounded)) + } else { + Text("Click to record") + .foregroundColor(.secondary) + } + } + .frame(width: 140, height: 24) + .onTapGesture { + if isRecording { + stopRecording() + } else { + startRecording() + } + } + + if !settings.shortcutKey.isEmpty { + Button { + settings.clearShortcut() + } label: { + Image(systemName: "xmark.circle.fill") + .foregroundColor(.secondary) + } + .buttonStyle(.plain) + .help("Remove keyboard shortcut") + } + } + .onDisappear { + stopRecording() + } + } + + private func startRecording() { + isRecording = true + eventMonitor = NSEvent.addLocalMonitorForEvents(matching: [.keyDown]) { event in + if event.keyCode == 53 { // Escape + stopRecording() + return nil + } + let flags = event.modifierFlags.intersection(.deviceIndependentFlagsMask) + if let chars = event.charactersIgnoringModifiers, + chars.count == 1, + let char = chars.first, + char.asciiValue.map({ $0 >= 32 && $0 < 127 }) == true { + // Require at least one modifier key (Cmd, Ctrl, or Option) + let requiredMods: NSEvent.ModifierFlags = [.command, .control, .option] + guard !flags.intersection(requiredMods).isEmpty else { + NSSound.beep() + return nil + } + // Reject reserved Cmd-only shortcuts + if flags == [.command] && Self.reservedKeys.contains(chars.lowercased()) { + NSSound.beep() + return nil + } + // Reject reserved Cmd+Shift shortcuts used by the app + if flags == [.command, .shift] && Self.reservedCmdShiftKeys.contains(chars.lowercased()) { + NSSound.beep() + return nil + } + settings.setShortcut(key: chars, modifiers: flags) + stopRecording() + return nil + } + return event + } + } + + private func stopRecording() { + isRecording = false + if let monitor = eventMonitor { + NSEvent.removeMonitor(monitor) + eventMonitor = nil + } + } +} diff --git a/README.md b/README.md index 0a18ec3..90021b0 100644 --- a/README.md +++ b/README.md @@ -16,6 +16,7 @@ A native macOS Markdown viewer built with SwiftUI and WebKit. - Find-in-page with next/previous navigation. - Zoom controls (in/out/actual size). - Open Recent menu for quick access to files. +- Configurable external editor with customizable keyboard shortcut (Cmd+E default). ## Build & Run diff --git a/Tests/MarkdownViewerTests/ExternalEditorSettingsTests.swift b/Tests/MarkdownViewerTests/ExternalEditorSettingsTests.swift new file mode 100644 index 0000000..8785c6d --- /dev/null +++ b/Tests/MarkdownViewerTests/ExternalEditorSettingsTests.swift @@ -0,0 +1,361 @@ +import XCTest +@testable import MarkdownViewer + +final class ExternalEditorSettingsTests: XCTestCase { + + // MARK: - UserDefaults backup/restore for test isolation + + private let editorAppURLKey = "externalEditorAppURL" + private let editorDisplayNameKey = "externalEditorDisplayName" + private let shortcutKeyKey = "externalEditorShortcutKey" + private let shortcutModifiersKey = "externalEditorShortcutModifiers" + + private var savedEditorAppURL: Any? + private var savedEditorDisplayName: Any? + private var savedShortcutKey: Any? + private var savedShortcutModifiers: Any? + + override func setUp() { + super.setUp() + let defaults = UserDefaults.standard + savedEditorAppURL = defaults.object(forKey: editorAppURLKey) + savedEditorDisplayName = defaults.object(forKey: editorDisplayNameKey) + savedShortcutKey = defaults.object(forKey: shortcutKeyKey) + savedShortcutModifiers = defaults.object(forKey: shortcutModifiersKey) + + // Clear state so each test starts fresh + defaults.removeObject(forKey: editorAppURLKey) + defaults.removeObject(forKey: editorDisplayNameKey) + defaults.removeObject(forKey: shortcutKeyKey) + defaults.removeObject(forKey: shortcutModifiersKey) + + let settings = ExternalEditorSettings.shared + settings.clearEditor() + settings.clearShortcut() + // Restore default shortcut key to "e" with Cmd, matching the class defaults + settings.setShortcut(key: "e", modifiers: .command) + } + + override func tearDown() { + let defaults = UserDefaults.standard + restoreDefault(defaults, key: editorAppURLKey, value: savedEditorAppURL) + restoreDefault(defaults, key: editorDisplayNameKey, value: savedEditorDisplayName) + restoreDefault(defaults, key: shortcutKeyKey, value: savedShortcutKey) + restoreDefault(defaults, key: shortcutModifiersKey, value: savedShortcutModifiers) + super.tearDown() + } + + private func restoreDefault(_ defaults: UserDefaults, key: String, value: Any?) { + if let value { + defaults.set(value, forKey: key) + } else { + defaults.removeObject(forKey: key) + } + } + + // MARK: - displayName(for:) tests + + func testDisplayNameForStandardApp() { + let url = URL(fileURLWithPath: "/Applications/Sublime Text.app") + XCTAssertEqual(ExternalEditorSettings.displayName(for: url), "Sublime Text") + } + + func testDisplayNameForSingleWordApp() { + let url = URL(fileURLWithPath: "/Applications/TextEdit.app") + XCTAssertEqual(ExternalEditorSettings.displayName(for: url), "TextEdit") + } + + func testDisplayNameForNestedApp() { + let url = URL(fileURLWithPath: "/Applications/Utilities/Terminal.app") + XCTAssertEqual(ExternalEditorSettings.displayName(for: url), "Terminal") + } + + func testDisplayNameForPathWithoutExtension() { + let url = URL(fileURLWithPath: "/usr/local/bin/vim") + XCTAssertEqual(ExternalEditorSettings.displayName(for: url), "vim") + } + + func testDisplayNameForRootURL() { + // URL(fileURLWithPath: "/") has an empty lastPathComponent after removing extension + let url = URL(fileURLWithPath: "/") + let name = ExternalEditorSettings.displayName(for: url) + // The lastPathComponent of "/" is "/", deleting path extension still gives "/" + // so name should not be empty + XCTAssertFalse(name.isEmpty) + } + + // MARK: - shortcutDisplayString tests + + func testShortcutDisplayStringCommandOnly() { + let settings = ExternalEditorSettings.shared + settings.setShortcut(key: "e", modifiers: .command) + XCTAssertEqual(settings.shortcutDisplayString, "\u{2318}E") + } + + func testShortcutDisplayStringCommandShift() { + let settings = ExternalEditorSettings.shared + settings.setShortcut(key: "o", modifiers: [.command, .shift]) + XCTAssertEqual(settings.shortcutDisplayString, "\u{21E7}\u{2318}O") + } + + func testShortcutDisplayStringAllModifiers() { + let settings = ExternalEditorSettings.shared + settings.setShortcut(key: "x", modifiers: [.control, .option, .shift, .command]) + XCTAssertEqual(settings.shortcutDisplayString, "\u{2303}\u{2325}\u{21E7}\u{2318}X") + } + + func testShortcutDisplayStringNoModifiers() { + let settings = ExternalEditorSettings.shared + settings.shortcutKey = "f" + settings.shortcutModifiers = 0 + XCTAssertEqual(settings.shortcutDisplayString, "F") + } + + func testShortcutDisplayStringOptionCommand() { + let settings = ExternalEditorSettings.shared + settings.setShortcut(key: "t", modifiers: [.option, .command]) + XCTAssertEqual(settings.shortcutDisplayString, "\u{2325}\u{2318}T") + } + + func testShortcutDisplayStringControlOnly() { + let settings = ExternalEditorSettings.shared + settings.setShortcut(key: "c", modifiers: .control) + XCTAssertEqual(settings.shortcutDisplayString, "\u{2303}C") + } + + // MARK: - menuItemTitle tests + + func testMenuItemTitleWithNoEditor() { + let settings = ExternalEditorSettings.shared + settings.clearEditor() + XCTAssertEqual(settings.menuItemTitle, "Open in External Editor") + } + + func testMenuItemTitleWithEditorConfigured() { + let settings = ExternalEditorSettings.shared + settings.setEditor(url: URL(fileURLWithPath: "/Applications/Sublime Text.app")) + XCTAssertEqual(settings.menuItemTitle, "Open in Sublime Text") + } + + func testMenuItemTitleUpdatesAfterEditorChange() { + let settings = ExternalEditorSettings.shared + settings.setEditor(url: URL(fileURLWithPath: "/Applications/TextEdit.app")) + XCTAssertEqual(settings.menuItemTitle, "Open in TextEdit") + + settings.setEditor(url: URL(fileURLWithPath: "/Applications/Visual Studio Code.app")) + XCTAssertEqual(settings.menuItemTitle, "Open in Visual Studio Code") + } + + func testMenuItemTitleAfterClearEditor() { + let settings = ExternalEditorSettings.shared + settings.setEditor(url: URL(fileURLWithPath: "/Applications/Sublime Text.app")) + settings.clearEditor() + XCTAssertEqual(settings.menuItemTitle, "Open in External Editor") + } + + // MARK: - keyboardShortcut tests + + func testKeyboardShortcutIsNotNilWithValidKey() { + let settings = ExternalEditorSettings.shared + settings.setShortcut(key: "e", modifiers: .command) + XCTAssertNotNil(settings.keyboardShortcut) + } + + func testKeyboardShortcutIsNilWhenKeyEmpty() { + let settings = ExternalEditorSettings.shared + settings.clearShortcut() + XCTAssertNil(settings.keyboardShortcut) + } + + // MARK: - setShortcut / clearShortcut tests + + func testSetShortcutUpdatesKeyAndModifiers() { + let settings = ExternalEditorSettings.shared + settings.setShortcut(key: "T", modifiers: [.command, .shift]) + XCTAssertEqual(settings.shortcutKey, "t") // lowercased + XCTAssertEqual(settings.shortcutModifiers, NSEvent.ModifierFlags([.command, .shift]).rawValue) + } + + func testSetShortcutLowercasesKey() { + let settings = ExternalEditorSettings.shared + settings.setShortcut(key: "Z", modifiers: .command) + XCTAssertEqual(settings.shortcutKey, "z") + } + + func testClearShortcutResetsKeyAndModifiers() { + let settings = ExternalEditorSettings.shared + settings.setShortcut(key: "e", modifiers: .command) + settings.clearShortcut() + XCTAssertEqual(settings.shortcutKey, "") + XCTAssertEqual(settings.shortcutModifiers, 0) + } + + // MARK: - setEditor / clearEditor tests + + func testSetEditorUpdatesURLAndDisplayName() { + let settings = ExternalEditorSettings.shared + let url = URL(fileURLWithPath: "/Applications/Sublime Text.app") + settings.setEditor(url: url) + XCTAssertEqual(settings.editorAppURL, url) + XCTAssertEqual(settings.editorDisplayName, "Sublime Text") + } + + func testClearEditorResetsURLAndDisplayName() { + let settings = ExternalEditorSettings.shared + settings.setEditor(url: URL(fileURLWithPath: "/Applications/TextEdit.app")) + settings.clearEditor() + XCTAssertNil(settings.editorAppURL) + XCTAssertEqual(settings.editorDisplayName, "External Editor") + } + + func testSetEditorMultipleTimes() { + let settings = ExternalEditorSettings.shared + settings.setEditor(url: URL(fileURLWithPath: "/Applications/TextEdit.app")) + XCTAssertEqual(settings.editorDisplayName, "TextEdit") + + settings.setEditor(url: URL(fileURLWithPath: "/Applications/Nova.app")) + XCTAssertEqual(settings.editorDisplayName, "Nova") + XCTAssertEqual(settings.editorAppURL, URL(fileURLWithPath: "/Applications/Nova.app")) + } + + // MARK: - shortcutIdentity tests + + func testShortcutIdentityFormat() { + let settings = ExternalEditorSettings.shared + settings.setShortcut(key: "e", modifiers: .command) + let expected = "e-\(NSEvent.ModifierFlags.command.rawValue)" + XCTAssertEqual(settings.shortcutIdentity, expected) + } + + func testShortcutIdentityChangesWhenKeyChanges() { + let settings = ExternalEditorSettings.shared + settings.setShortcut(key: "e", modifiers: .command) + let identity1 = settings.shortcutIdentity + + settings.setShortcut(key: "o", modifiers: .command) + let identity2 = settings.shortcutIdentity + + XCTAssertNotEqual(identity1, identity2) + } + + func testShortcutIdentityChangesWhenModifiersChange() { + let settings = ExternalEditorSettings.shared + settings.setShortcut(key: "e", modifiers: .command) + let identity1 = settings.shortcutIdentity + + settings.setShortcut(key: "e", modifiers: [.command, .shift]) + let identity2 = settings.shortcutIdentity + + XCTAssertNotEqual(identity1, identity2) + } + + func testShortcutIdentityStableWhenUnchanged() { + let settings = ExternalEditorSettings.shared + settings.setShortcut(key: "e", modifiers: .command) + let identity1 = settings.shortcutIdentity + let identity2 = settings.shortcutIdentity + XCTAssertEqual(identity1, identity2) + } + + func testShortcutIdentityAfterClear() { + let settings = ExternalEditorSettings.shared + settings.setShortcut(key: "e", modifiers: .command) + let identityBefore = settings.shortcutIdentity + + settings.clearShortcut() + let identityAfter = settings.shortcutIdentity + + XCTAssertNotEqual(identityBefore, identityAfter) + XCTAssertEqual(identityAfter, "-0") + } + + // MARK: - Shortcut matching tests (exercises AppDelegate.eventMatchesEditorShortcut) + + /// Creates a synthetic NSEvent for testing shortcut matching. + private func keyEvent(chars: String, modifiers: NSEvent.ModifierFlags) -> NSEvent? { + return NSEvent.keyEvent( + with: .keyDown, + location: .zero, + modifierFlags: modifiers, + timestamp: 0, + windowNumber: 0, + context: nil, + characters: chars, + charactersIgnoringModifiers: chars, + isARepeat: false, + keyCode: 0 + ) + } + + func testDefaultShortcutMatchesCmdE() { + let delegate = AppDelegate() + let settings = ExternalEditorSettings.shared + settings.setShortcut(key: "e", modifiers: .command) + + let event = keyEvent(chars: "e", modifiers: .command)! + let flags = event.modifierFlags.intersection(.deviceIndependentFlagsMask) + XCTAssertTrue(delegate.eventMatchesEditorShortcut(flags: flags, event: event)) + } + + func testShortcutDoesNotMatchWrongKey() { + let delegate = AppDelegate() + let settings = ExternalEditorSettings.shared + settings.setShortcut(key: "e", modifiers: .command) + + let event = keyEvent(chars: "r", modifiers: .command)! + let flags = event.modifierFlags.intersection(.deviceIndependentFlagsMask) + XCTAssertFalse(delegate.eventMatchesEditorShortcut(flags: flags, event: event)) + } + + func testShortcutDoesNotMatchWrongModifiers() { + let delegate = AppDelegate() + let settings = ExternalEditorSettings.shared + settings.setShortcut(key: "e", modifiers: .command) + + let event = keyEvent(chars: "e", modifiers: [.command, .shift])! + let flags = event.modifierFlags.intersection(.deviceIndependentFlagsMask) + XCTAssertFalse(delegate.eventMatchesEditorShortcut(flags: flags, event: event)) + } + + func testCustomShortcutMatchesAfterChange() { + let delegate = AppDelegate() + let settings = ExternalEditorSettings.shared + settings.setShortcut(key: "b", modifiers: [.command, .shift]) + + let event = keyEvent(chars: "b", modifiers: [.command, .shift])! + let flags = event.modifierFlags.intersection(.deviceIndependentFlagsMask) + XCTAssertTrue(delegate.eventMatchesEditorShortcut(flags: flags, event: event)) + } + + func testOldShortcutStopsMatchingAfterChange() { + let delegate = AppDelegate() + let settings = ExternalEditorSettings.shared + settings.setShortcut(key: "e", modifiers: .command) + settings.setShortcut(key: "b", modifiers: [.command, .shift]) + + // Old shortcut (Cmd+E) should no longer match + let event = keyEvent(chars: "e", modifiers: .command)! + let flags = event.modifierFlags.intersection(.deviceIndependentFlagsMask) + XCTAssertFalse(delegate.eventMatchesEditorShortcut(flags: flags, event: event)) + } + + func testClearedShortcutMatchesNothing() { + let delegate = AppDelegate() + let settings = ExternalEditorSettings.shared + settings.clearShortcut() + + let event = keyEvent(chars: "e", modifiers: .command)! + let flags = event.modifierFlags.intersection(.deviceIndependentFlagsMask) + XCTAssertFalse(delegate.eventMatchesEditorShortcut(flags: flags, event: event)) + } + + func testShortcutMatchIsCaseInsensitive() { + let delegate = AppDelegate() + let settings = ExternalEditorSettings.shared + settings.setShortcut(key: "e", modifiers: .command) + + let event = keyEvent(chars: "E", modifiers: .command)! + let flags = event.modifierFlags.intersection(.deviceIndependentFlagsMask) + XCTAssertTrue(delegate.eventMatchesEditorShortcut(flags: flags, event: event)) + } +}