import Foundation import Combine class AppState: ObservableObject { @Published var documentText: String = "" { didSet { if documentText != oldValue { modified = true bridge.setText(currentNoteID, text: documentText) scheduleAutoSave() } } } @Published var evalResults: [Int: String] = [:] @Published var noteList: [NoteInfo] = [] @Published var currentNoteID: UUID @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() self.currentNoteID = id 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 = "" evalResults = [:] modified = false refreshNoteList() } func loadNote(_ id: UUID) { saveCurrentIfNeeded() cleanupBlankNote(currentNoteID) if bridge.cacheLoad(id) { currentNoteID = id documentText = bridge.getText(id) modified = false evaluate() } } func saveNote() { bridge.setText(currentNoteID, text: documentText) let _ = bridge.cacheSave(currentNoteID) modified = false refreshNoteList() } func saveNoteToFile(_ url: URL) { let _ = bridge.saveNote(currentNoteID, path: url.path) modified = false } func loadNoteFromFile(_ url: URL) { if let (id, text) = bridge.loadNote(path: url.path) { currentNoteID = id documentText = text modified = false let _ = bridge.cacheSave(id) evaluate() refreshNoteList() } } func deleteNote(_ id: UUID) { bridge.deleteNote(id) if id == currentNoteID { newNote() } 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() { 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() { if modified { saveNote() } } private func cleanupBlankNote(_ id: UUID) { let text = bridge.getText(id) let trimmed = text.trimmingCharacters(in: .whitespacesAndNewlines) if trimmed.isEmpty { bridge.deleteNote(id) } } }