merge integration into ux-polish-1f610c

This commit is contained in:
jess 2026-04-06 01:01:01 -07:00
commit 53e1832644
8 changed files with 300 additions and 32 deletions

View File

@ -1,6 +1,7 @@
import Cocoa import Cocoa
import Combine import Combine
import SwiftUI import SwiftUI
import UniformTypeIdentifiers
class AppDelegate: NSObject, NSApplicationDelegate { class AppDelegate: NSObject, NSApplicationDelegate {
var window: NSWindow! var window: NSWindow!
@ -10,6 +11,7 @@ class AppDelegate: NSObject, NSApplicationDelegate {
private var focusTitleObserver: NSObjectProtocol? private var focusTitleObserver: NSObjectProtocol?
func applicationDidFinishLaunching(_ notification: Notification) { func applicationDidFinishLaunching(_ notification: Notification) {
_ = ConfigManager.shared
appState = AppState() appState = AppState()
let contentView = ContentView(state: appState) let contentView = ContentView(state: appState)
@ -152,7 +154,7 @@ class AppDelegate: NSObject, NSApplicationDelegate {
@objc private func openNote() { @objc private func openNote() {
let panel = NSOpenPanel() let panel = NSOpenPanel()
panel.allowedContentTypes = [.plainText] panel.allowedContentTypes = [UTType(filenameExtension: "md")!, .plainText]
panel.canChooseFiles = true panel.canChooseFiles = true
panel.canChooseDirectories = false panel.canChooseDirectories = false
panel.allowsMultipleSelection = false panel.allowsMultipleSelection = false
@ -168,8 +170,8 @@ class AppDelegate: NSObject, NSApplicationDelegate {
@objc private func saveNoteAs() { @objc private func saveNoteAs() {
let panel = NSSavePanel() let panel = NSSavePanel()
panel.allowedContentTypes = [.plainText] panel.allowedContentTypes = [UTType(filenameExtension: "md")!]
panel.nameFieldStringValue = "note.txt" panel.nameFieldStringValue = "note.md"
panel.beginSheetModal(for: window) { [weak self] response in panel.beginSheetModal(for: window) { [weak self] response in
guard response == .OK, let url = panel.url else { return } guard response == .OK, let url = panel.url else { return }
self?.appState.saveNoteToFile(url) self?.appState.saveNoteToFile(url)

View File

@ -7,6 +7,7 @@ class AppState: ObservableObject {
if documentText != oldValue { if documentText != oldValue {
modified = true modified = true
bridge.setText(currentNoteID, text: documentText) bridge.setText(currentNoteID, text: documentText)
scheduleAutoSave()
} }
} }
} }
@ -16,6 +17,10 @@ class AppState: ObservableObject {
@Published var modified: Bool = false @Published var modified: Bool = false
private let bridge = RustBridge.shared 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() { init() {
let id = bridge.newDocument() let id = bridge.newDocument()
@ -23,8 +28,90 @@ class AppState: ObservableObject {
refreshNoteList() 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() { func newNote() {
saveCurrentIfNeeded() saveCurrentIfNeeded()
cleanupBlankNote(currentNoteID)
let id = bridge.newDocument() let id = bridge.newDocument()
currentNoteID = id currentNoteID = id
documentText = "" documentText = ""
@ -35,6 +122,7 @@ class AppState: ObservableObject {
func loadNote(_ id: UUID) { func loadNote(_ id: UUID) {
saveCurrentIfNeeded() saveCurrentIfNeeded()
cleanupBlankNote(currentNoteID)
if bridge.cacheLoad(id) { if bridge.cacheLoad(id) {
currentNoteID = id currentNoteID = id
documentText = bridge.getText(id) documentText = bridge.getText(id)
@ -74,12 +162,40 @@ class AppState: ObservableObject {
refreshNoteList() 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() { func evaluate() {
evalResults = bridge.evaluate(currentNoteID) evalResults = bridge.evaluate(currentNoteID)
} }
func refreshNoteList() { 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() { private func saveCurrentIfNeeded() {
@ -87,4 +203,12 @@ class AppState: ObservableObject {
saveNote() saveNote()
} }
} }
private func cleanupBlankNote(_ id: UUID) {
let text = bridge.getText(id)
let trimmed = text.trimmingCharacters(in: .whitespacesAndNewlines)
if trimmed.isEmpty {
bridge.deleteNote(id)
}
}
} }

55
src/ConfigManager.swift Normal file
View File

@ -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() }
}
}

View File

@ -3,10 +3,10 @@ import SwiftUI
struct ContentView: View { struct ContentView: View {
@ObservedObject var state: AppState @ObservedObject var state: AppState
@State private var sidebarVisible: Bool = false @State private var sidebarVisible: Bool = false
@AppStorage("themeMode") private var themeMode: String = "auto" @State private var themeVersion: Int = 0
var body: some View { var body: some View {
let _ = themeMode let _ = themeVersion
HSplitView { HSplitView {
if sidebarVisible { if sidebarVisible {
SidebarView(state: state) SidebarView(state: state)
@ -20,6 +20,9 @@ struct ContentView: View {
.onReceive(NotificationCenter.default.publisher(for: .toggleSidebar)) { _ in .onReceive(NotificationCenter.default.publisher(for: .toggleSidebar)) { _ in
withAnimation { sidebarVisible.toggle() } withAnimation { sidebarVisible.toggle() }
} }
.onReceive(NotificationCenter.default.publisher(for: .settingsChanged)) { _ in
themeVersion += 1
}
} }
} }

View File

@ -1865,7 +1865,7 @@ class LineNumberTextView: NSTextView {
let text = string as NSString let text = string as NSString
guard text.length > 0 else { return } guard text.length > 0 else { return }
let lineMode = UserDefaults.standard.string(forKey: "lineIndicatorMode") ?? "on" let lineMode = ConfigManager.shared.lineIndicatorMode
var containerVisible = visibleRect var containerVisible = visibleRect
containerVisible.origin.x -= origin.x containerVisible.origin.x -= origin.x

View File

@ -30,8 +30,9 @@ enum LineIndicatorMode: String, CaseIterable {
} }
struct SettingsView: View { struct SettingsView: View {
@AppStorage("themeMode") private var themeMode: String = "auto" @State private var themeMode: String = ConfigManager.shared.themeMode
@AppStorage("lineIndicatorMode") private var lineIndicatorMode: String = "on" @State private var lineIndicatorMode: String = ConfigManager.shared.lineIndicatorMode
@State private var autoSaveDir: String = ConfigManager.shared.autoSaveDirectory
var body: some View { var body: some View {
let palette = Theme.current let palette = Theme.current
@ -53,26 +54,47 @@ struct SettingsView: View {
} }
.pickerStyle(.segmented) .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) .formStyle(.grouped)
.frame(width: 320, height: 180) .frame(width: 400, height: 260)
.background(Color(ns: palette.base)) .background(Color(ns: palette.base))
.onChange(of: themeMode) { .onChange(of: themeMode) {
ConfigManager.shared.themeMode = themeMode
DispatchQueue.main.async { DispatchQueue.main.async {
applyThemeAppearance() applyThemeAppearance()
NotificationCenter.default.post(name: .settingsChanged, object: nil) NotificationCenter.default.post(name: .settingsChanged, object: nil)
} }
} }
.onChange(of: lineIndicatorMode) { .onChange(of: lineIndicatorMode) {
ConfigManager.shared.lineIndicatorMode = lineIndicatorMode
DispatchQueue.main.async { DispatchQueue.main.async {
NotificationCenter.default.post(name: .settingsChanged, object: nil) NotificationCenter.default.post(name: .settingsChanged, object: nil)
} }
} }
.onChange(of: autoSaveDir) {
ConfigManager.shared.autoSaveDirectory = autoSaveDir
}
} }
} }
func applyThemeAppearance() { func applyThemeAppearance() {
let mode = UserDefaults.standard.string(forKey: "themeMode") ?? "auto" let mode = ConfigManager.shared.themeMode
switch mode { switch mode {
case "dark": case "dark":
NSApp.appearance = NSAppearance(named: .darkAqua) NSApp.appearance = NSAppearance(named: .darkAqua)
@ -98,10 +120,10 @@ class SettingsWindowController {
let settingsView = SettingsView() let settingsView = SettingsView()
let hostingView = NSHostingView(rootView: 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( 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], styleMask: [.titled, .closable],
backing: .buffered, backing: .buffered,
defer: false defer: false

View File

@ -2,6 +2,8 @@ import SwiftUI
struct SidebarView: View { struct SidebarView: View {
@ObservedObject var state: AppState @ObservedObject var state: AppState
@State private var selection: Set<UUID> = []
@State private var lastClickedID: UUID?
private let dateFormatter: DateFormatter = { private let dateFormatter: DateFormatter = {
let f = DateFormatter() let f = DateFormatter()
@ -38,46 +40,106 @@ struct SidebarView: View {
Spacer() Spacer()
} }
} else { } else {
List(state.noteList) { note in ScrollView {
NoteRow( LazyVStack(spacing: 0) {
note: note, ForEach(Array(state.noteList.enumerated()), id: \.element.id) { index, note in
isSelected: note.id == state.currentNoteID, NoteRow(
dateFormatter: dateFormatter note: note,
) isActive: note.id == state.currentNoteID,
.contentShape(Rectangle()) isSelected: selection.contains(note.id),
.onTapGesture { state.loadNote(note.id) } dateFormatter: dateFormatter
.contextMenu { )
Button("Delete") { state.deleteNote(note.id) } .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.base)) .background(Color(ns: Theme.current.base))
.onAppear { state.refreshNoteList() } .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 { struct NoteRow: View {
let note: NoteInfo let note: NoteInfo
let isActive: Bool
let isSelected: Bool let isSelected: Bool
let dateFormatter: DateFormatter let dateFormatter: DateFormatter
var body: some View { var body: some View {
VStack(alignment: .leading, spacing: 2) { VStack(alignment: .leading, spacing: 2) {
Text(note.title) 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)) .foregroundColor(Color(ns: Theme.current.text))
.lineLimit(1) .lineLimit(1)
Text(dateFormatter.string(from: note.lastModified)) Text(dateFormatter.string(from: note.lastModified))
.font(.system(size: 11)) .font(.system(size: 11))
.foregroundColor(Color(ns: Theme.current.subtext0)) .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
)
} }
} }

View File

@ -90,7 +90,7 @@ struct Theme {
) )
static var current: CatppuccinPalette { static var current: CatppuccinPalette {
let mode = UserDefaults.standard.string(forKey: "themeMode") ?? "auto" let mode = ConfigManager.shared.themeMode
switch mode { switch mode {
case "dark": return mocha case "dark": return mocha
case "light": return latte case "light": return latte