diff --git a/CLAUDE.md b/CLAUDE.md index c6b9028..cf40af9 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -40,7 +40,7 @@ This repository contains a macOS markdown viewer application built with SwiftUI. - [x] Live preview while editing - [ ] Search within document - [ ] Zoom in/out functionality -- [ ] Recent files menu +- [x] Recent files menu - [ ] Drag and drop file support - [ ] Support for math equations (LaTeX) - [ ] Image paste support diff --git a/MarkdownViewer.xcodeproj/project.pbxproj b/MarkdownViewer.xcodeproj/project.pbxproj index dd94021..8ba3b59 100644 --- a/MarkdownViewer.xcodeproj/project.pbxproj +++ b/MarkdownViewer.xcodeproj/project.pbxproj @@ -10,6 +10,7 @@ 1A2345678901234567890001 /* MarkdownViewerApp.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1A2345678901234567890002 /* MarkdownViewerApp.swift */; }; 1A2345678901234567890003 /* ContentView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1A2345678901234567890004 /* ContentView.swift */; }; 1A2345678901234567890005 /* MarkdownRenderer.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1A2345678901234567890006 /* MarkdownRenderer.swift */; }; + 1A2345678901234567890020 /* RecentFilesManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1A2345678901234567890021 /* RecentFilesManager.swift */; }; 1A2345678901234567890007 /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 1A2345678901234567890008 /* Assets.xcassets */; }; 1A2345678901234567890009 /* Preview Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 1A234567890123456789000A /* Preview Assets.xcassets */; }; /* End PBXBuildFile section */ @@ -19,6 +20,7 @@ 1A2345678901234567890002 /* MarkdownViewerApp.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MarkdownViewerApp.swift; sourceTree = ""; }; 1A2345678901234567890004 /* ContentView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ContentView.swift; sourceTree = ""; }; 1A2345678901234567890006 /* MarkdownRenderer.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MarkdownRenderer.swift; sourceTree = ""; }; + 1A2345678901234567890021 /* RecentFilesManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RecentFilesManager.swift; sourceTree = ""; }; 1A2345678901234567890008 /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = ""; }; 1A234567890123456789000A /* Preview Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = "Preview Assets.xcassets"; sourceTree = ""; }; /* End PBXFileReference section */ @@ -48,6 +50,7 @@ 1A2345678901234567890002 /* MarkdownViewerApp.swift */, 1A2345678901234567890004 /* ContentView.swift */, 1A2345678901234567890006 /* MarkdownRenderer.swift */, + 1A2345678901234567890021 /* RecentFilesManager.swift */, 1A2345678901234567890008 /* Assets.xcassets */, 1A2345678901234567890010 /* Preview Content */, ); @@ -142,6 +145,7 @@ files = ( 1A2345678901234567890003 /* ContentView.swift in Sources */, 1A2345678901234567890005 /* MarkdownRenderer.swift in Sources */, + 1A2345678901234567890020 /* RecentFilesManager.swift in Sources */, 1A2345678901234567890001 /* MarkdownViewerApp.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; diff --git a/MarkdownViewer/MarkdownViewerApp.swift b/MarkdownViewer/MarkdownViewerApp.swift index 82540f6..af5b3a5 100644 --- a/MarkdownViewer/MarkdownViewerApp.swift +++ b/MarkdownViewer/MarkdownViewerApp.swift @@ -4,6 +4,8 @@ import AppKit @main struct MarkdownViewerApp: App { + @StateObject private var recentFilesManager = RecentFilesManager.shared + var body: some Scene { Settings { EmptyView() @@ -14,6 +16,34 @@ struct MarkdownViewerApp: App { openMarkdownFile() } .keyboardShortcut("o", modifiers: .command) + + Divider() + + Menu("Recent Files") { + if recentFilesManager.recentFiles.isEmpty { + Text("No Recent Files") + .disabled(true) + } else { + ForEach(recentFilesManager.recentFiles, id: \.self) { url in + Button(action: { + openRecentFile(url) + }) { + VStack(alignment: .leading) { + Text(recentFilesManager.getDisplayName(for: url)) + Text(recentFilesManager.getRelativePath(for: url)) + .font(.caption) + .foregroundColor(.secondary) + } + } + } + + Divider() + + Button("Clear Recent Files") { + recentFilesManager.clearRecentFiles() + } + } + } } } } @@ -32,9 +62,24 @@ struct MarkdownViewerApp: App { } } + private func openRecentFile(_ url: URL) { + // Check if file still exists + guard FileManager.default.fileExists(atPath: url.path) else { + recentFilesManager.removeRecentFile(url) + showErrorWindow(message: "File no longer exists", details: url.path) + return + } + + openNewWindow(for: url) + } + private func openNewWindow(for url: URL) { do { let content = try String(contentsOf: url, encoding: .utf8) + + // Add to recent files + recentFilesManager.addRecentFile(url) + let newWindow = NSWindow( contentRect: NSRect(x: 0, y: 0, width: 800, height: 600), styleMask: [.titled, .closable, .resizable, .miniaturizable], @@ -54,31 +99,34 @@ struct MarkdownViewerApp: App { } catch { print("Error reading file: \(error)") - // Show error in a new window - let newWindow = NSWindow( - contentRect: NSRect(x: 0, y: 0, width: 400, height: 200), - styleMask: [.titled, .closable], - backing: .buffered, - defer: false - ) - - newWindow.title = "Error" - newWindow.contentView = NSHostingView( - rootView: VStack { - Image(systemName: "exclamationmark.triangle") - .font(.system(size: 40)) - .foregroundColor(.red) - Text("Error loading file") - .font(.headline) - Text(error.localizedDescription) - .font(.caption) - .foregroundColor(.secondary) - .multilineTextAlignment(.center) - } - .padding() - ) - newWindow.center() - newWindow.makeKeyAndOrderFront(nil) + showErrorWindow(message: "Error loading file", details: error.localizedDescription) } } + + private func showErrorWindow(message: String, details: String) { + let newWindow = NSWindow( + contentRect: NSRect(x: 0, y: 0, width: 400, height: 200), + styleMask: [.titled, .closable], + backing: .buffered, + defer: false + ) + + newWindow.title = "Error" + newWindow.contentView = NSHostingView( + rootView: VStack { + Image(systemName: "exclamationmark.triangle") + .font(.system(size: 40)) + .foregroundColor(.red) + Text(message) + .font(.headline) + Text(details) + .font(.caption) + .foregroundColor(.secondary) + .multilineTextAlignment(.center) + } + .padding() + ) + newWindow.center() + newWindow.makeKeyAndOrderFront(nil) + } } \ No newline at end of file diff --git a/MarkdownViewer/RecentFilesManager.swift b/MarkdownViewer/RecentFilesManager.swift new file mode 100644 index 0000000..39aee39 --- /dev/null +++ b/MarkdownViewer/RecentFilesManager.swift @@ -0,0 +1,71 @@ +import Foundation + +class RecentFilesManager: ObservableObject { + static let shared = RecentFilesManager() + + @Published var recentFiles: [URL] = [] + + private let maxRecentFiles = 10 + private let userDefaultsKey = "RecentMarkdownFiles" + + private init() { + loadRecentFiles() + } + + func addRecentFile(_ url: URL) { + // Remove if already exists to move it to top + recentFiles.removeAll { $0 == url } + + // Add to beginning + recentFiles.insert(url, at: 0) + + // Limit to max count + if recentFiles.count > maxRecentFiles { + recentFiles = Array(recentFiles.prefix(maxRecentFiles)) + } + + saveRecentFiles() + } + + func removeRecentFile(_ url: URL) { + recentFiles.removeAll { $0 == url } + saveRecentFiles() + } + + func clearRecentFiles() { + recentFiles.removeAll() + saveRecentFiles() + } + + private func loadRecentFiles() { + if let data = UserDefaults.standard.data(forKey: userDefaultsKey), + let urls = try? JSONDecoder().decode([URL].self, from: data) { + // Filter out files that no longer exist + recentFiles = urls.filter { FileManager.default.fileExists(atPath: $0.path) } + + // Save filtered list back if any files were removed + if recentFiles.count != urls.count { + saveRecentFiles() + } + } + } + + private func saveRecentFiles() { + if let data = try? JSONEncoder().encode(recentFiles) { + UserDefaults.standard.set(data, forKey: userDefaultsKey) + } + } + + func getDisplayName(for url: URL) -> String { + return url.lastPathComponent + } + + func getRelativePath(for url: URL) -> String { + let homeURL = FileManager.default.homeDirectoryForCurrentUser + if url.path.hasPrefix(homeURL.path) { + let relativePath = String(url.path.dropFirst(homeURL.path.count)) + return "~" + relativePath + } + return url.path + } +} \ No newline at end of file