From 5dce8088638352d2a51fa8cee149f37e6e88939a Mon Sep 17 00:00:00 2001 From: jess Date: Mon, 6 Apr 2026 00:55:19 -0700 Subject: [PATCH 1/4] default save format to .md --- src/AppDelegate.swift | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/src/AppDelegate.swift b/src/AppDelegate.swift index d55f89a..a686631 100644 --- a/src/AppDelegate.swift +++ b/src/AppDelegate.swift @@ -1,6 +1,7 @@ import Cocoa import Combine import SwiftUI +import UniformTypeIdentifiers class AppDelegate: NSObject, NSApplicationDelegate { var window: NSWindow! @@ -152,7 +153,7 @@ class AppDelegate: NSObject, NSApplicationDelegate { @objc private func openNote() { let panel = NSOpenPanel() - panel.allowedContentTypes = [.plainText] + panel.allowedContentTypes = [UTType(filenameExtension: "md")!, .plainText] panel.canChooseFiles = true panel.canChooseDirectories = false panel.allowsMultipleSelection = false @@ -168,8 +169,8 @@ class AppDelegate: NSObject, NSApplicationDelegate { @objc private func saveNoteAs() { let panel = NSSavePanel() - panel.allowedContentTypes = [.plainText] - panel.nameFieldStringValue = "note.txt" + panel.allowedContentTypes = [UTType(filenameExtension: "md")!] + panel.nameFieldStringValue = "note.md" panel.beginSheetModal(for: window) { [weak self] response in guard response == .OK, let url = panel.url else { return } self?.appState.saveNoteToFile(url) From d17c1fe9198df08a059b9c0ac5698500a544556d Mon Sep 17 00:00:00 2001 From: jess Date: Mon, 6 Apr 2026 00:57:21 -0700 Subject: [PATCH 2/4] 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() } + } +} From ecd01dfb37a66c5ece604d6ddd64ea28ae291379 Mon Sep 17 00:00:00 2001 From: jess Date: Mon, 6 Apr 2026 00:58:11 -0700 Subject: [PATCH 3/4] add sidebar multi-selection with shift-click, cmd-click, and batch delete --- src/SidebarView.swift | 98 +++++++++++++++++++++++++++++++++++-------- 1 file changed, 80 insertions(+), 18 deletions(-) diff --git a/src/SidebarView.swift b/src/SidebarView.swift index 7674bc1..2d3696e 100644 --- a/src/SidebarView.swift +++ b/src/SidebarView.swift @@ -2,6 +2,8 @@ import SwiftUI struct SidebarView: View { @ObservedObject var state: AppState + @State private var selection: Set = [] + @State private var lastClickedID: UUID? private let dateFormatter: DateFormatter = { let f = DateFormatter() @@ -38,46 +40,106 @@ struct SidebarView: View { Spacer() } } else { - List(state.noteList) { note in - NoteRow( - note: note, - isSelected: note.id == state.currentNoteID, - dateFormatter: dateFormatter - ) - .contentShape(Rectangle()) - .onTapGesture { state.loadNote(note.id) } - .contextMenu { - Button("Delete") { state.deleteNote(note.id) } + ScrollView { + LazyVStack(spacing: 0) { + ForEach(Array(state.noteList.enumerated()), id: \.element.id) { index, note in + NoteRow( + note: note, + isActive: note.id == state.currentNoteID, + isSelected: selection.contains(note.id), + dateFormatter: dateFormatter + ) + .contentShape(Rectangle()) + .onTapGesture(count: 2) { + selection = [note.id] + lastClickedID = note.id + state.loadNote(note.id) + } + .onTapGesture(count: 1) { + handleClick(note: note, index: index, modifiers: currentModifiers()) + } + .contextMenu { + if selection.count > 1 && selection.contains(note.id) { + Button("Delete \(selection.count) Notes") { + state.deleteNotes(selection) + selection.removeAll() + lastClickedID = nil + } + } else { + Button("Delete") { state.deleteNote(note.id) } + } + } + } } - .listRowBackground( - note.id == state.currentNoteID - ? Color(ns: Theme.current.surface1) - : Color.clear - ) } - .listStyle(.plain) + .onDeleteCommand { + guard !selection.isEmpty else { return } + state.deleteNotes(selection) + selection.removeAll() + lastClickedID = nil + } } } .background(Color(ns: Theme.current.mantle)) .onAppear { state.refreshNoteList() } } + + private func handleClick(note: NoteInfo, index: Int, modifiers: EventModifiers) { + if modifiers.contains(.command) { + if selection.contains(note.id) { + selection.remove(note.id) + } else { + selection.insert(note.id) + } + lastClickedID = note.id + } else if modifiers.contains(.shift), let lastID = lastClickedID { + if let lastIndex = state.noteList.firstIndex(where: { $0.id == lastID }) { + let range = min(lastIndex, index)...max(lastIndex, index) + for i in range { + selection.insert(state.noteList[i].id) + } + } + } else { + selection = [note.id] + lastClickedID = note.id + state.loadNote(note.id) + } + } + + private func currentModifiers() -> EventModifiers { + let flags = NSEvent.modifierFlags + var mods: EventModifiers = [] + if flags.contains(.command) { mods.insert(.command) } + if flags.contains(.shift) { mods.insert(.shift) } + return mods + } } struct NoteRow: View { let note: NoteInfo + let isActive: Bool let isSelected: Bool let dateFormatter: DateFormatter var body: some View { VStack(alignment: .leading, spacing: 2) { Text(note.title) - .font(.system(size: 13, weight: isSelected ? .semibold : .regular)) + .font(.system(size: 13, weight: isActive ? .semibold : .regular)) .foregroundColor(Color(ns: Theme.current.text)) .lineLimit(1) Text(dateFormatter.string(from: note.lastModified)) .font(.system(size: 11)) .foregroundColor(Color(ns: Theme.current.subtext0)) } - .padding(.vertical, 2) + .frame(maxWidth: .infinity, alignment: .leading) + .padding(.vertical, 4) + .padding(.horizontal, 12) + .background( + isActive + ? Color(ns: Theme.current.surface1) + : isSelected + ? Color(ns: Theme.current.surface0) + : Color.clear + ) } } From c8f883742bc81a1c084c68f56144e9f710d29888 Mon Sep 17 00:00:00 2001 From: jess Date: Mon, 6 Apr 2026 00:59:25 -0700 Subject: [PATCH 4/4] migrate settings persistence to ~/.swiftly/config.json --- src/ContentView.swift | 7 +++++-- src/EditorView.swift | 2 +- src/SettingsView.swift | 34 ++++++++++++++++++++++++++++------ src/Theme.swift | 2 +- 4 files changed, 35 insertions(+), 10 deletions(-) diff --git a/src/ContentView.swift b/src/ContentView.swift index 5a56606..76e5533 100644 --- a/src/ContentView.swift +++ b/src/ContentView.swift @@ -3,10 +3,10 @@ import SwiftUI struct ContentView: View { @ObservedObject var state: AppState @State private var sidebarVisible: Bool = false - @AppStorage("themeMode") private var themeMode: String = "auto" + @State private var themeVersion: Int = 0 var body: some View { - let _ = themeMode + let _ = themeVersion HSplitView { if sidebarVisible { SidebarView(state: state) @@ -20,6 +20,9 @@ struct ContentView: View { .onReceive(NotificationCenter.default.publisher(for: .toggleSidebar)) { _ in withAnimation { sidebarVisible.toggle() } } + .onReceive(NotificationCenter.default.publisher(for: .settingsChanged)) { _ in + themeVersion += 1 + } } } diff --git a/src/EditorView.swift b/src/EditorView.swift index 582db4d..84e9024 100644 --- a/src/EditorView.swift +++ b/src/EditorView.swift @@ -1864,7 +1864,7 @@ class LineNumberTextView: NSTextView { let text = string as NSString guard text.length > 0 else { return } - let lineMode = UserDefaults.standard.string(forKey: "lineIndicatorMode") ?? "on" + let lineMode = ConfigManager.shared.lineIndicatorMode var containerVisible = visibleRect containerVisible.origin.x -= origin.x diff --git a/src/SettingsView.swift b/src/SettingsView.swift index ac9fdf0..a90f1ba 100644 --- a/src/SettingsView.swift +++ b/src/SettingsView.swift @@ -30,8 +30,9 @@ enum LineIndicatorMode: String, CaseIterable { } struct SettingsView: View { - @AppStorage("themeMode") private var themeMode: String = "auto" - @AppStorage("lineIndicatorMode") private var lineIndicatorMode: String = "on" + @State private var themeMode: String = ConfigManager.shared.themeMode + @State private var lineIndicatorMode: String = ConfigManager.shared.lineIndicatorMode + @State private var autoSaveDir: String = ConfigManager.shared.autoSaveDirectory var body: some View { let palette = Theme.current @@ -53,26 +54,47 @@ struct SettingsView: View { } .pickerStyle(.segmented) } + + Section("Auto-Save") { + HStack { + TextField("Directory", text: $autoSaveDir) + .textFieldStyle(.roundedBorder) + Button("Choose...") { + let panel = NSOpenPanel() + panel.canChooseFiles = false + panel.canChooseDirectories = true + panel.allowsMultipleSelection = false + if panel.runModal() == .OK, let url = panel.url { + autoSaveDir = url.path + } + } + } + } } .formStyle(.grouped) - .frame(width: 320, height: 180) + .frame(width: 400, height: 260) .background(Color(ns: palette.base)) .onChange(of: themeMode) { + ConfigManager.shared.themeMode = themeMode DispatchQueue.main.async { applyThemeAppearance() NotificationCenter.default.post(name: .settingsChanged, object: nil) } } .onChange(of: lineIndicatorMode) { + ConfigManager.shared.lineIndicatorMode = lineIndicatorMode DispatchQueue.main.async { NotificationCenter.default.post(name: .settingsChanged, object: nil) } } + .onChange(of: autoSaveDir) { + ConfigManager.shared.autoSaveDirectory = autoSaveDir + } } } func applyThemeAppearance() { - let mode = UserDefaults.standard.string(forKey: "themeMode") ?? "auto" + let mode = ConfigManager.shared.themeMode switch mode { case "dark": NSApp.appearance = NSAppearance(named: .darkAqua) @@ -98,10 +120,10 @@ class SettingsWindowController { let settingsView = SettingsView() let hostingView = NSHostingView(rootView: settingsView) - hostingView.frame = NSRect(x: 0, y: 0, width: 320, height: 200) + hostingView.frame = NSRect(x: 0, y: 0, width: 400, height: 280) let w = NSWindow( - contentRect: NSRect(x: 0, y: 0, width: 320, height: 200), + contentRect: NSRect(x: 0, y: 0, width: 400, height: 280), styleMask: [.titled, .closable], backing: .buffered, defer: false diff --git a/src/Theme.swift b/src/Theme.swift index 171afe5..de82cbb 100644 --- a/src/Theme.swift +++ b/src/Theme.swift @@ -90,7 +90,7 @@ struct Theme { ) static var current: CatppuccinPalette { - let mode = UserDefaults.standard.string(forKey: "themeMode") ?? "auto" + let mode = ConfigManager.shared.themeMode switch mode { case "dark": return mocha case "light": return latte