From d17c1fe9198df08a059b9c0ac5698500a544556d Mon Sep 17 00:00:00 2001 From: jess Date: Mon, 6 Apr 2026 00:57:21 -0700 Subject: [PATCH] add auto-save with configurable directory and blank note cleanup --- src/AppDelegate.swift | 1 + src/AppState.swift | 126 +++++++++++++++++++++++++++++++++++++++- src/ConfigManager.swift | 55 ++++++++++++++++++ 3 files changed, 181 insertions(+), 1 deletion(-) create mode 100644 src/ConfigManager.swift diff --git a/src/AppDelegate.swift b/src/AppDelegate.swift index a686631..8bd4bc0 100644 --- a/src/AppDelegate.swift +++ b/src/AppDelegate.swift @@ -11,6 +11,7 @@ class AppDelegate: NSObject, NSApplicationDelegate { private var focusTitleObserver: NSObjectProtocol? func applicationDidFinishLaunching(_ notification: Notification) { + _ = ConfigManager.shared appState = AppState() let contentView = ContentView(state: appState) diff --git a/src/AppState.swift b/src/AppState.swift index 55252db..f92911f 100644 --- a/src/AppState.swift +++ b/src/AppState.swift @@ -7,6 +7,7 @@ class AppState: ObservableObject { if documentText != oldValue { modified = true bridge.setText(currentNoteID, text: documentText) + scheduleAutoSave() } } } @@ -16,6 +17,10 @@ class AppState: ObservableObject { @Published var modified: Bool = false private let bridge = RustBridge.shared + private var autoSaveTimer: DispatchSourceTimer? + private var autoSaveDirty = false + private var autoSaveCoolingDown = false + private let autoSaveQueue = DispatchQueue(label: "com.swiftly.autosave") init() { let id = bridge.newDocument() @@ -23,8 +28,90 @@ class AppState: ObservableObject { refreshNoteList() } + // MARK: - Auto-save + + private func scheduleAutoSave() { + if autoSaveCoolingDown { + autoSaveDirty = true + return + } + performAutoSave() + } + + private func performAutoSave() { + guard shouldAutoSave() else { return } + + autoSaveCoolingDown = true + autoSaveDirty = false + + let text = documentText + let noteID = currentNoteID + let title = extractTitle(from: text) + + autoSaveQueue.async { [weak self] in + self?.writeAutoSaveFile(noteID: noteID, title: title, text: text) + DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) { [weak self] in + guard let self = self else { return } + self.autoSaveCoolingDown = false + if self.autoSaveDirty { + self.autoSaveDirty = false + self.performAutoSave() + } + } + } + + bridge.setText(currentNoteID, text: documentText) + let _ = bridge.cacheSave(currentNoteID) + modified = false + refreshNoteList() + } + + private func shouldAutoSave() -> Bool { + let trimmed = documentText.trimmingCharacters(in: .whitespacesAndNewlines) + if trimmed.isEmpty { + let title = extractTitle(from: documentText) + return title != "Untitled" && !title.isEmpty + } + return true + } + + private func extractTitle(from text: String) -> String { + let firstLine = text.components(separatedBy: "\n").first? + .trimmingCharacters(in: .whitespaces) ?? "" + let clean = firstLine.replacingOccurrences( + of: "^#+\\s*", with: "", options: .regularExpression + ) + return clean.isEmpty ? "Untitled" : String(clean.prefix(60)) + } + + private func sanitizeFilename(_ name: String) -> String { + let illegal = CharacterSet(charactersIn: "/\\:*?\"<>|") + let parts = name.unicodeScalars.filter { !illegal.contains($0) } + let cleaned = String(String.UnicodeScalarView(parts)) + .trimmingCharacters(in: .whitespaces) + return cleaned.isEmpty ? UUID().uuidString : cleaned + } + + private func writeAutoSaveFile(noteID: UUID, title: String, text: String) { + let dir = ConfigManager.shared.autoSaveDirectory + let dirURL = URL(fileURLWithPath: dir) + try? FileManager.default.createDirectory(at: dirURL, withIntermediateDirectories: true) + + let filename: String + if title == "Untitled" { + filename = noteID.uuidString.lowercased() + } else { + filename = sanitizeFilename(title) + } + let fileURL = dirURL.appendingPathComponent(filename + ".md") + try? text.write(to: fileURL, atomically: true, encoding: .utf8) + } + + // MARK: - Note operations + func newNote() { saveCurrentIfNeeded() + cleanupBlankNote(currentNoteID) let id = bridge.newDocument() currentNoteID = id documentText = "" @@ -35,6 +122,7 @@ class AppState: ObservableObject { func loadNote(_ id: UUID) { saveCurrentIfNeeded() + cleanupBlankNote(currentNoteID) if bridge.cacheLoad(id) { currentNoteID = id documentText = bridge.getText(id) @@ -74,12 +162,40 @@ class AppState: ObservableObject { refreshNoteList() } + func deleteNotes(_ ids: Set) { + for id in ids { + bridge.deleteNote(id) + } + if ids.contains(currentNoteID) { + let remaining = noteList.first { !ids.contains($0.id) } + if let next = remaining { + currentNoteID = next.id + if bridge.cacheLoad(next.id) { + documentText = bridge.getText(next.id) + } + } else { + let id = bridge.newDocument() + currentNoteID = id + documentText = "" + } + evalResults = [:] + modified = false + } + refreshNoteList() + } + func evaluate() { evalResults = bridge.evaluate(currentNoteID) } func refreshNoteList() { - noteList = bridge.listNotes() + var notes = bridge.listNotes() + notes.removeAll { note in + let trimmed = note.title.trimmingCharacters(in: .whitespacesAndNewlines) + let isBlank = trimmed.isEmpty || trimmed == "Untitled" + return isBlank && note.id != currentNoteID + } + noteList = notes } private func saveCurrentIfNeeded() { @@ -87,4 +203,12 @@ class AppState: ObservableObject { saveNote() } } + + private func cleanupBlankNote(_ id: UUID) { + let text = bridge.getText(id) + let trimmed = text.trimmingCharacters(in: .whitespacesAndNewlines) + if trimmed.isEmpty { + bridge.deleteNote(id) + } + } } diff --git a/src/ConfigManager.swift b/src/ConfigManager.swift new file mode 100644 index 0000000..4306824 --- /dev/null +++ b/src/ConfigManager.swift @@ -0,0 +1,55 @@ +import Foundation + +class ConfigManager { + static let shared = ConfigManager() + + private let configDir: URL + private let configFile: URL + private let defaultNotesDir: URL + private var config: [String: String] + + private init() { + let home = FileManager.default.homeDirectoryForCurrentUser + configDir = home.appendingPathComponent(".swiftly") + configFile = configDir.appendingPathComponent("config.json") + defaultNotesDir = configDir.appendingPathComponent("notes") + config = [:] + ensureDirectories() + load() + } + + private func ensureDirectories() { + let fm = FileManager.default + try? fm.createDirectory(at: configDir, withIntermediateDirectories: true) + try? fm.createDirectory(at: defaultNotesDir, withIntermediateDirectories: true) + } + + private func load() { + guard let data = try? Data(contentsOf: configFile), + let dict = try? JSONSerialization.jsonObject(with: data) as? [String: String] + else { return } + config = dict + } + + private func save() { + guard let data = try? JSONSerialization.data( + withJSONObject: config, options: [.prettyPrinted, .sortedKeys] + ) else { return } + try? data.write(to: configFile, options: .atomic) + } + + var autoSaveDirectory: String { + get { config["autoSaveDirectory"] ?? defaultNotesDir.path } + set { config["autoSaveDirectory"] = newValue; save() } + } + + var themeMode: String { + get { config["themeMode"] ?? "auto" } + set { config["themeMode"] = newValue; save() } + } + + var lineIndicatorMode: String { + get { config["lineIndicatorMode"] ?? "on" } + set { config["lineIndicatorMode"] = newValue; save() } + } +}