Add recent files menu functionality

Implemented comprehensive recent files tracking system:
- Created RecentFilesManager singleton with UserDefaults persistence
- Added Recent Files submenu to File menu with up to 10 recent files
- Automatic file validation that removes deleted files from list
- Smart file tracking that adds files when opened and moves to top
- Clear Recent Files option for user control
- User-friendly display showing filename and relative path
- Error handling for missing files with user notification

🤖 Generated with [Claude Code](https://claude.ai/code)

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
0x8664b2
2025-06-20 10:48:11 -07:00
parent 732f633086
commit a3e8b09a8f
4 changed files with 149 additions and 26 deletions

View File

@@ -40,7 +40,7 @@ This repository contains a macOS markdown viewer application built with SwiftUI.
- [x] Live preview while editing - [x] Live preview while editing
- [ ] Search within document - [ ] Search within document
- [ ] Zoom in/out functionality - [ ] Zoom in/out functionality
- [ ] Recent files menu - [x] Recent files menu
- [ ] Drag and drop file support - [ ] Drag and drop file support
- [ ] Support for math equations (LaTeX) - [ ] Support for math equations (LaTeX)
- [ ] Image paste support - [ ] Image paste support

View File

@@ -10,6 +10,7 @@
1A2345678901234567890001 /* MarkdownViewerApp.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1A2345678901234567890002 /* MarkdownViewerApp.swift */; }; 1A2345678901234567890001 /* MarkdownViewerApp.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1A2345678901234567890002 /* MarkdownViewerApp.swift */; };
1A2345678901234567890003 /* ContentView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1A2345678901234567890004 /* ContentView.swift */; }; 1A2345678901234567890003 /* ContentView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1A2345678901234567890004 /* ContentView.swift */; };
1A2345678901234567890005 /* MarkdownRenderer.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1A2345678901234567890006 /* MarkdownRenderer.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 */; }; 1A2345678901234567890007 /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 1A2345678901234567890008 /* Assets.xcassets */; };
1A2345678901234567890009 /* Preview Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 1A234567890123456789000A /* Preview Assets.xcassets */; }; 1A2345678901234567890009 /* Preview Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 1A234567890123456789000A /* Preview Assets.xcassets */; };
/* End PBXBuildFile section */ /* End PBXBuildFile section */
@@ -19,6 +20,7 @@
1A2345678901234567890002 /* MarkdownViewerApp.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MarkdownViewerApp.swift; sourceTree = "<group>"; }; 1A2345678901234567890002 /* MarkdownViewerApp.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MarkdownViewerApp.swift; sourceTree = "<group>"; };
1A2345678901234567890004 /* ContentView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ContentView.swift; sourceTree = "<group>"; }; 1A2345678901234567890004 /* ContentView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ContentView.swift; sourceTree = "<group>"; };
1A2345678901234567890006 /* MarkdownRenderer.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MarkdownRenderer.swift; sourceTree = "<group>"; }; 1A2345678901234567890006 /* MarkdownRenderer.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MarkdownRenderer.swift; sourceTree = "<group>"; };
1A2345678901234567890021 /* RecentFilesManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RecentFilesManager.swift; sourceTree = "<group>"; };
1A2345678901234567890008 /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = "<group>"; }; 1A2345678901234567890008 /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = "<group>"; };
1A234567890123456789000A /* Preview Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = "Preview Assets.xcassets"; sourceTree = "<group>"; }; 1A234567890123456789000A /* Preview Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = "Preview Assets.xcassets"; sourceTree = "<group>"; };
/* End PBXFileReference section */ /* End PBXFileReference section */
@@ -48,6 +50,7 @@
1A2345678901234567890002 /* MarkdownViewerApp.swift */, 1A2345678901234567890002 /* MarkdownViewerApp.swift */,
1A2345678901234567890004 /* ContentView.swift */, 1A2345678901234567890004 /* ContentView.swift */,
1A2345678901234567890006 /* MarkdownRenderer.swift */, 1A2345678901234567890006 /* MarkdownRenderer.swift */,
1A2345678901234567890021 /* RecentFilesManager.swift */,
1A2345678901234567890008 /* Assets.xcassets */, 1A2345678901234567890008 /* Assets.xcassets */,
1A2345678901234567890010 /* Preview Content */, 1A2345678901234567890010 /* Preview Content */,
); );
@@ -142,6 +145,7 @@
files = ( files = (
1A2345678901234567890003 /* ContentView.swift in Sources */, 1A2345678901234567890003 /* ContentView.swift in Sources */,
1A2345678901234567890005 /* MarkdownRenderer.swift in Sources */, 1A2345678901234567890005 /* MarkdownRenderer.swift in Sources */,
1A2345678901234567890020 /* RecentFilesManager.swift in Sources */,
1A2345678901234567890001 /* MarkdownViewerApp.swift in Sources */, 1A2345678901234567890001 /* MarkdownViewerApp.swift in Sources */,
); );
runOnlyForDeploymentPostprocessing = 0; runOnlyForDeploymentPostprocessing = 0;

View File

@@ -4,6 +4,8 @@ import AppKit
@main @main
struct MarkdownViewerApp: App { struct MarkdownViewerApp: App {
@StateObject private var recentFilesManager = RecentFilesManager.shared
var body: some Scene { var body: some Scene {
Settings { Settings {
EmptyView() EmptyView()
@@ -14,6 +16,34 @@ struct MarkdownViewerApp: App {
openMarkdownFile() openMarkdownFile()
} }
.keyboardShortcut("o", modifiers: .command) .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) { private func openNewWindow(for url: URL) {
do { do {
let content = try String(contentsOf: url, encoding: .utf8) let content = try String(contentsOf: url, encoding: .utf8)
// Add to recent files
recentFilesManager.addRecentFile(url)
let newWindow = NSWindow( let newWindow = NSWindow(
contentRect: NSRect(x: 0, y: 0, width: 800, height: 600), contentRect: NSRect(x: 0, y: 0, width: 800, height: 600),
styleMask: [.titled, .closable, .resizable, .miniaturizable], styleMask: [.titled, .closable, .resizable, .miniaturizable],
@@ -54,31 +99,34 @@ struct MarkdownViewerApp: App {
} catch { } catch {
print("Error reading file: \(error)") print("Error reading file: \(error)")
// Show error in a new window showErrorWindow(message: "Error loading file", details: error.localizedDescription)
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)
} }
} }
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)
}
} }

View File

@@ -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
}
}