add auto-save with configurable directory and blank note cleanup
This commit is contained in:
parent
5dce808863
commit
d17c1fe919
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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<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() {
|
||||
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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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() }
|
||||
}
|
||||
}
|
||||
Loading…
Reference in New Issue