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:
@@ -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
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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,7 +99,11 @@ 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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func showErrorWindow(message: String, details: String) {
|
||||||
let newWindow = NSWindow(
|
let newWindow = NSWindow(
|
||||||
contentRect: NSRect(x: 0, y: 0, width: 400, height: 200),
|
contentRect: NSRect(x: 0, y: 0, width: 400, height: 200),
|
||||||
styleMask: [.titled, .closable],
|
styleMask: [.titled, .closable],
|
||||||
@@ -68,9 +117,9 @@ struct MarkdownViewerApp: App {
|
|||||||
Image(systemName: "exclamationmark.triangle")
|
Image(systemName: "exclamationmark.triangle")
|
||||||
.font(.system(size: 40))
|
.font(.system(size: 40))
|
||||||
.foregroundColor(.red)
|
.foregroundColor(.red)
|
||||||
Text("Error loading file")
|
Text(message)
|
||||||
.font(.headline)
|
.font(.headline)
|
||||||
Text(error.localizedDescription)
|
Text(details)
|
||||||
.font(.caption)
|
.font(.caption)
|
||||||
.foregroundColor(.secondary)
|
.foregroundColor(.secondary)
|
||||||
.multilineTextAlignment(.center)
|
.multilineTextAlignment(.center)
|
||||||
@@ -80,5 +129,4 @@ struct MarkdownViewerApp: App {
|
|||||||
newWindow.center()
|
newWindow.center()
|
||||||
newWindow.makeKeyAndOrderFront(nil)
|
newWindow.makeKeyAndOrderFront(nil)
|
||||||
}
|
}
|
||||||
}
|
|
||||||
}
|
}
|
||||||
71
MarkdownViewer/RecentFilesManager.swift
Normal file
71
MarkdownViewer/RecentFilesManager.swift
Normal 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
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user