215 lines
6.3 KiB
Swift
215 lines
6.3 KiB
Swift
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<UUID>) {
|
|
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)
|
|
}
|
|
}
|
|
}
|