From 693db08f9c6d0f5a3af024fdc743096f566d21e0 Mon Sep 17 00:00:00 2001 From: Gabko14 Date: Sun, 1 Mar 2026 17:05:22 +0100 Subject: [PATCH 1/7] feat: add durable notes persistence --- Ghostly.xcodeproj/project.pbxproj | 31 + Ghostly/AppState.swift | 160 ++++- Ghostly/Ghostly.entitlements | 2 +- Ghostly/GhostlyApp.swift | 20 +- Ghostly/Models/Tab.swift | 34 +- Ghostly/Persistence/NotesStore.swift | 643 ++++++++++++++++++ .../Persistence/PersistenceCoordinator.swift | 139 ++++ Ghostly/Persistence/PersistenceModels.swift | 156 +++++ Ghostly/TabManager.swift | 218 +++--- Ghostly/Views/ContentView.swift | 2 +- Ghostly/Views/RecoveryView.swift | 99 +++ GhostlyTests/AppStateTests.swift | 9 +- GhostlyTests/NotesStoreTests.swift | 244 +++++++ GhostlyTests/TabTests.swift | 402 +++++------ 14 files changed, 1773 insertions(+), 386 deletions(-) create mode 100644 Ghostly/Persistence/NotesStore.swift create mode 100644 Ghostly/Persistence/PersistenceCoordinator.swift create mode 100644 Ghostly/Persistence/PersistenceModels.swift create mode 100644 Ghostly/Views/RecoveryView.swift create mode 100644 GhostlyTests/NotesStoreTests.swift diff --git a/Ghostly.xcodeproj/project.pbxproj b/Ghostly.xcodeproj/project.pbxproj index 0b840a7..5da5f74 100644 --- a/Ghostly.xcodeproj/project.pbxproj +++ b/Ghostly.xcodeproj/project.pbxproj @@ -12,6 +12,11 @@ EDSB000125F5000000000001 /* EditorTextViewConfiguration.swift in Sources */ = {isa = PBXBuildFile; fileRef = EDSB000025F5000000000000 /* EditorTextViewConfiguration.swift */; }; EDSB000325F5000000000003 /* EditorTextViewConfigurationTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = EDSB000225F5000000000002 /* EditorTextViewConfigurationTests.swift */; }; GTED000125F6000000000001 /* GhostlyTextEditor.swift in Sources */ = {isa = PBXBuildFile; fileRef = GTED000025F6000000000000 /* GhostlyTextEditor.swift */; }; + PERS000125F7000000000001 /* PersistenceModels.swift in Sources */ = {isa = PBXBuildFile; fileRef = PERS000025F7000000000000 /* PersistenceModels.swift */; }; + PERS000325F7000000000003 /* NotesStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = PERS000225F7000000000002 /* NotesStore.swift */; }; + PERS000525F7000000000005 /* PersistenceCoordinator.swift in Sources */ = {isa = PBXBuildFile; fileRef = PERS000425F7000000000004 /* PersistenceCoordinator.swift */; }; + RECV000125F7000000000001 /* RecoveryView.swift in Sources */ = {isa = PBXBuildFile; fileRef = RECV000025F7000000000000 /* RecoveryView.swift */; }; + SQLT000125F7000000000001 /* libsqlite3.tbd in Frameworks */ = {isa = PBXBuildFile; fileRef = SQLT000025F7000000000000 /* libsqlite3.tbd */; }; TABS000125F3000000000001 /* Tab.swift in Sources */ = {isa = PBXBuildFile; fileRef = TABS000025F3000000000000 /* Tab.swift */; }; TABM000125F3000000000001 /* TabManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = TABM000025F3000000000000 /* TabManager.swift */; }; TABV000125F3000000000001 /* TabBarView.swift in Sources */ = {isa = PBXBuildFile; fileRef = TABV000025F3000000000000 /* TabBarView.swift */; }; @@ -37,6 +42,7 @@ KBSH000625E0000000000006 /* KeyboardShortcutTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = KBSH000425E0000000000004 /* KeyboardShortcutTests.swift */; }; MDTR000125F4000000000001 /* MarkdownTransformer.swift in Sources */ = {isa = PBXBuildFile; fileRef = MDTR000025F4000000000000 /* MarkdownTransformer.swift */; }; MDTR000325F4000000000003 /* MarkdownTransformerTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = MDTR000225F4000000000002 /* MarkdownTransformerTests.swift */; }; + NOTS000125F8000000000001 /* NotesStoreTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = NOTS000025F8000000000000 /* NotesStoreTests.swift */; }; /* End PBXBuildFile section */ /* Begin PBXContainerItemProxy section */ @@ -55,6 +61,11 @@ EDSB000025F5000000000000 /* EditorTextViewConfiguration.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EditorTextViewConfiguration.swift; sourceTree = ""; }; EDSB000225F5000000000002 /* EditorTextViewConfigurationTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EditorTextViewConfigurationTests.swift; sourceTree = ""; }; GTED000025F6000000000000 /* GhostlyTextEditor.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GhostlyTextEditor.swift; sourceTree = ""; }; + PERS000025F7000000000000 /* PersistenceModels.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PersistenceModels.swift; sourceTree = ""; }; + PERS000225F7000000000002 /* NotesStore.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NotesStore.swift; sourceTree = ""; }; + PERS000425F7000000000004 /* PersistenceCoordinator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PersistenceCoordinator.swift; sourceTree = ""; }; + RECV000025F7000000000000 /* RecoveryView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RecoveryView.swift; sourceTree = ""; }; + SQLT000025F7000000000000 /* libsqlite3.tbd */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.text-based-dylib-definition; name = libsqlite3.tbd; path = usr/lib/libsqlite3.tbd; sourceTree = SDKROOT; }; TABS000025F3000000000000 /* Tab.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Tab.swift; sourceTree = ""; }; TABM000025F3000000000000 /* TabManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TabManager.swift; sourceTree = ""; }; TABV000025F3000000000000 /* TabBarView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TabBarView.swift; sourceTree = ""; }; @@ -82,6 +93,7 @@ TXST000225F2000000000002 /* TextStatisticsTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TextStatisticsTests.swift; sourceTree = ""; }; MDTR000025F4000000000000 /* MarkdownTransformer.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MarkdownTransformer.swift; sourceTree = ""; }; MDTR000225F4000000000002 /* MarkdownTransformerTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MarkdownTransformerTests.swift; sourceTree = ""; }; + NOTS000025F8000000000000 /* NotesStoreTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NotesStoreTests.swift; sourceTree = ""; }; /* End PBXFileReference section */ /* Begin PBXFrameworksBuildPhase section */ @@ -91,6 +103,7 @@ files = ( KBSH000125E0000000000001 /* KeyboardShortcuts in Frameworks */, MBEA000125E0000000000001 /* MenuBarExtraAccess in Frameworks */, + SQLT000125F7000000000001 /* libsqlite3.tbd in Frameworks */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -112,12 +125,23 @@ FOOT000025F1000000000000 /* FooterView.swift */, GTED000025F6000000000000 /* GhostlyTextEditor.swift */, C82C210625C60B3D00CCCDF7 /* HeaderView.swift */, + RECV000025F7000000000000 /* RecoveryView.swift */, SETT000325D0C00000000003 /* SettingsView.swift */, TABV000025F3000000000000 /* TabBarView.swift */, ); path = Views; sourceTree = ""; }; + PERS000625F7000000000006 /* Persistence */ = { + isa = PBXGroup; + children = ( + PERS000225F7000000000002 /* NotesStore.swift */, + PERS000425F7000000000004 /* PersistenceCoordinator.swift */, + PERS000025F7000000000000 /* PersistenceModels.swift */, + ); + path = Persistence; + sourceTree = ""; + }; C836C54C25A0171500BEB83F = { isa = PBXGroup; children = ( @@ -143,6 +167,7 @@ APPS000025E0000000000000 /* AppState.swift */, CATP000025F0000000000000 /* CatppuccinColors.swift */, KBSH000325E0000000000003 /* KeyboardShortcuts+Extensions.swift */, + PERS000625F7000000000006 /* Persistence */, SETT000125D0C00000000001 /* SettingsManager.swift */, TABM000025F3000000000000 /* TabManager.swift */, MODL000025F3000000000000 /* Models */, @@ -199,6 +224,7 @@ EDSB000225F5000000000002 /* EditorTextViewConfigurationTests.swift */, KBSH000425E0000000000004 /* KeyboardShortcutTests.swift */, MDTR000225F4000000000002 /* MarkdownTransformerTests.swift */, + NOTS000025F8000000000000 /* NotesStoreTests.swift */, SETT000525D0C00000000005 /* SettingsManagerTests.swift */, TABT000025F3000000000000 /* TabTests.swift */, TXST000225F2000000000002 /* TextStatisticsTests.swift */, @@ -322,6 +348,10 @@ C82C210725C60B3D00CCCDF7 /* HeaderView.swift in Sources */, GTED000125F6000000000001 /* GhostlyTextEditor.swift in Sources */, KBSH000225E0000000000002 /* KeyboardShortcuts+Extensions.swift in Sources */, + PERS000325F7000000000003 /* NotesStore.swift in Sources */, + PERS000525F7000000000005 /* PersistenceCoordinator.swift in Sources */, + PERS000125F7000000000001 /* PersistenceModels.swift in Sources */, + RECV000125F7000000000001 /* RecoveryView.swift in Sources */, SETT000225D0C00000000002 /* SettingsManager.swift in Sources */, SETT000425D0C00000000004 /* SettingsView.swift in Sources */, C82C210A25C60B5E00CCCDF7 /* DropdownMenuView.swift in Sources */, @@ -344,6 +374,7 @@ APPS000325E0000000000003 /* AppStateTests.swift in Sources */, EDSB000325F5000000000003 /* EditorTextViewConfigurationTests.swift in Sources */, KBSH000625E0000000000006 /* KeyboardShortcutTests.swift in Sources */, + NOTS000125F8000000000001 /* NotesStoreTests.swift in Sources */, SETT000625D0C00000000006 /* SettingsManagerTests.swift in Sources */, TABT000125F3000000000001 /* TabTests.swift in Sources */, TXST000325F2000000000003 /* TextStatisticsTests.swift in Sources */, diff --git a/Ghostly/AppState.swift b/Ghostly/AppState.swift index 6d93e89..6b0a95b 100644 --- a/Ghostly/AppState.swift +++ b/Ghostly/AppState.swift @@ -6,6 +6,7 @@ // import SwiftUI +import AppKit import KeyboardShortcuts @MainActor @@ -14,16 +15,70 @@ final class AppState { var isMenuPresented: Bool = false { didSet { updateTabShortcuts(enabled: isMenuPresented) + if oldValue, !isMenuPresented { + Task { await persistenceCoordinator.popoverDidClose() } + } } } - let settingsManager = SettingsManager() + let settingsManager: SettingsManager + let notesStore: NotesStore + let tabManager: TabManager + let persistenceCoordinator: PersistenceCoordinator + + var launchState: LaunchState = .loading + var recoveryNotice: String = "" + var isSettingsOpen: Bool { get { settingsManager.isSettingsOpen } set { settingsManager.isSettingsOpen = newValue } } - let tabManager = TabManager() - init() { + init( + notesStore: NotesStore = NotesStore(), + settingsManager: SettingsManager = SettingsManager(), + registerKeyboardShortcuts: Bool = true, + autoStart: Bool = true + ) { + self.notesStore = notesStore + self.settingsManager = settingsManager + self.tabManager = TabManager() + self.persistenceCoordinator = PersistenceCoordinator( + notesStore: notesStore, + tabManager: tabManager + ) + + tabManager.onPersistenceTrigger = { [weak self] trigger in + guard let self else { return } + switch trigger { + case .scheduleAutosave: + self.persistenceCoordinator.scheduleAutosave() + case let .flush(reason): + Task { await self.persistenceCoordinator.flushNow(reason: reason) } + } + } + + if registerKeyboardShortcuts { + registerShortcuts() + updateTabShortcuts(enabled: false) + } + + if autoStart { + Task { await start() } + } + } + + private func performTabAction(_ action: @escaping @MainActor (AppState) -> Void) { + Task { @MainActor in + guard !self.isSettingsOpen else { return } + action(self) + } + } + + private static let tabShortcuts: [KeyboardShortcuts.Name] = [ + .newTab, .closeTab, .nextTab, .previousTab + ] + + private func registerShortcuts() { KeyboardShortcuts.onKeyUp(for: .toggleGhostly) { [weak self] in Task { @MainActor in self?.isMenuPresented.toggle() @@ -52,22 +107,8 @@ final class AppState { KeyboardShortcuts.onKeyDown(for: .previousTab) { [weak self] in self?.performTabAction { $0.tabManager.selectPreviousTab() } } - - // Disable tab shortcuts initially (popover starts closed) - updateTabShortcuts(enabled: false) } - private func performTabAction(_ action: @escaping @MainActor (AppState) -> Void) { - Task { @MainActor in - guard !self.isSettingsOpen else { return } - action(self) - } - } - - private static let tabShortcuts: [KeyboardShortcuts.Name] = [ - .newTab, .closeTab, .nextTab, .previousTab - ] - private func updateTabShortcuts(enabled: Bool) { for shortcut in Self.tabShortcuts { if enabled { @@ -77,4 +118,89 @@ final class AppState { } } } + + func start() async { + launchState = .loading + recoveryNotice = "" + persistenceCoordinator.setReady(false) + + do { + try await notesStore.open() + _ = try await notesStore.migrateLegacyUserDefaultsIfNeeded() + let snapshot = try await notesStore.loadSnapshot() + tabManager.load(snapshot: snapshot) + launchState = .ready + persistenceCoordinator.setReady(true) + } catch { + launchState = await buildRecoveryState(for: error) + } + } + + func restoreFromLatestBackup() async { + recoveryNotice = "" + launchState = .recovery(.restoreInProgress) + do { + let snapshot = try await notesStore.restoreFromLatestBackup() + tabManager.load(snapshot: snapshot) + launchState = .ready + persistenceCoordinator.setReady(true) + } catch { + launchState = await buildRecoveryState(for: error) + } + } + + func startFresh() async { + recoveryNotice = "" + do { + let snapshot = try await notesStore.createFreshStore() + tabManager.load(snapshot: snapshot) + launchState = .ready + persistenceCoordinator.setReady(true) + } catch { + launchState = await buildRecoveryState(for: error) + } + } + + func exportRecoveryBundle() async { + let panel = NSOpenPanel() + panel.canChooseDirectories = true + panel.canChooseFiles = false + panel.allowsMultipleSelection = false + panel.prompt = "Export" + panel.message = "Choose a folder for the Ghostly recovery bundle." + + guard panel.runModal() == .OK, let destinationURL = panel.url else { return } + + do { + try await notesStore.exportRecoveryBundle(to: destinationURL) + recoveryNotice = "Recovery bundle exported to \(destinationURL.path)" + } catch { + recoveryNotice = error.localizedDescription + } + } + + private func buildRecoveryState(for error: Error) async -> LaunchState { + persistenceCoordinator.setReady(false) + + if case .migrationFailed = error as? NotesStoreError { + let backupURL = await notesStore.lastMigrationBackupIfAvailable() + return .recovery( + .migrationFailed( + summary: error.localizedDescription, + legacyBackupURL: backupURL + ) + ) + } + + let quarantineURL = (try? await notesStore.quarantineCurrentStore(reason: error.localizedDescription)) + ?? URL(fileURLWithPath: NSTemporaryDirectory()).appendingPathComponent("Ghostly-quarantine-unavailable", isDirectory: true) + let backupURL = await notesStore.latestBackupIfAvailable() + return .recovery( + .storeUnreadable( + summary: error.localizedDescription, + quarantineURL: quarantineURL, + backupURL: backupURL + ) + ) + } } diff --git a/Ghostly/Ghostly.entitlements b/Ghostly/Ghostly.entitlements index f2ef3ae..6d968ed 100644 --- a/Ghostly/Ghostly.entitlements +++ b/Ghostly/Ghostly.entitlements @@ -4,7 +4,7 @@ com.apple.security.app-sandbox - com.apple.security.files.user-selected.read-only + com.apple.security.files.user-selected.read-write diff --git a/Ghostly/GhostlyApp.swift b/Ghostly/GhostlyApp.swift index 12a8f9e..f8d9a95 100644 --- a/Ghostly/GhostlyApp.swift +++ b/Ghostly/GhostlyApp.swift @@ -11,17 +11,25 @@ import MenuBarExtraAccess @main struct GhostlyApp: App { + @NSApplicationDelegateAdaptor(AppLifecycleDelegate.self) private var appLifecycleDelegate @State private var appState = AppState() @State private var statusItemContextMenuController = StatusItemContextMenuController() var body: some Scene { MenuBarExtra("Ghostly", image: "MenubarIcon") { - ContentView(appState: appState) - .frame(width: 436, height: 400) - .background(.clear) - .onReceive(NotificationCenter.default.publisher(for: NSApplication.willTerminateNotification)) { _ in - appState.tabManager.flushPendingSave() + Group { + switch appState.launchState { + case .loading: + ProgressView("Loading notes...") + .frame(maxWidth: .infinity, maxHeight: .infinity) + case .ready: + ContentView(appState: appState) + case let .recovery(recoveryState): + RecoveryView(appState: appState, recoveryState: recoveryState) } + } + .frame(width: 436, height: 400) + .background(.clear) } .menuBarExtraStyle(.window) .menuBarExtraAccess(isPresented: $appState.isMenuPresented) { statusItem in @@ -108,7 +116,7 @@ final class StatusItemContextMenuController: NSObject { @objc private func openSettings() { appState?.isMenuPresented = true - DispatchQueue.main.async { [weak self] in + Task { @MainActor [weak self] in self?.appState?.isSettingsOpen = true } } diff --git a/Ghostly/Models/Tab.swift b/Ghostly/Models/Tab.swift index c4662b7..37062aa 100644 --- a/Ghostly/Models/Tab.swift +++ b/Ghostly/Models/Tab.swift @@ -8,14 +8,46 @@ import Foundation struct GhostlyTab: Identifiable, Codable, Equatable { + private enum CodingKeys: String, CodingKey { + case id + case content + case createdAt + case updatedAt + } + let id: UUID var content: String var createdAt: Date + var updatedAt: Date - init(id: UUID = UUID(), content: String = "", createdAt: Date = Date()) { + init( + id: UUID = UUID(), + content: String = "", + createdAt: Date = Date(), + updatedAt: Date? = nil + ) { self.id = id self.content = content self.createdAt = createdAt + self.updatedAt = updatedAt ?? createdAt + } + + init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + let id = try container.decode(UUID.self, forKey: .id) + let content = try container.decode(String.self, forKey: .content) + let createdAt = try container.decode(Date.self, forKey: .createdAt) + let updatedAt = try container.decodeIfPresent(Date.self, forKey: .updatedAt) + + self.init(id: id, content: content, createdAt: createdAt, updatedAt: updatedAt) + } + + func encode(to encoder: Encoder) throws { + var container = encoder.container(keyedBy: CodingKeys.self) + try container.encode(id, forKey: .id) + try container.encode(content, forKey: .content) + try container.encode(createdAt, forKey: .createdAt) + try container.encode(updatedAt, forKey: .updatedAt) } /// Title derived from first line of content, truncated by visual width. diff --git a/Ghostly/Persistence/NotesStore.swift b/Ghostly/Persistence/NotesStore.swift new file mode 100644 index 0000000..83110b1 --- /dev/null +++ b/Ghostly/Persistence/NotesStore.swift @@ -0,0 +1,643 @@ +// +// NotesStore.swift +// Ghostly +// + +import Foundation +import OSLog +import SQLite3 + +actor NotesStore { + private let logger = Logger( + subsystem: Bundle.main.bundleIdentifier ?? "com.ghostly.Ghostly", + category: "NotesStore" + ) + + private let fileManager: FileManager + private let userDefaults: UserDefaults + private let rootDirectoryURL: URL + private let now: @Sendable () -> Date + + private let legacyTabsKey = "ghostlyTabs" + private let legacyActiveTabKey = "ghostlyTabs_activeId" + private let legacyTextKey = "text" + + private let schemaVersion = "1" + private let expectedTables: Set = ["tabs", "app_metadata"] + + private var db: OpaquePointer? + private var openedFreshStore = false + private var lastQuarantineURL: URL? + private var lastMigrationBackupURL: URL? + + init( + baseDirectory: URL? = nil, + userDefaults: UserDefaults = .standard, + fileManager: FileManager = .default, + now: @escaping @Sendable () -> Date = Date.init + ) { + self.fileManager = fileManager + self.userDefaults = userDefaults + self.now = now + if let baseDirectory { + self.rootDirectoryURL = baseDirectory + } else { + self.rootDirectoryURL = fileManager.urls(for: .applicationSupportDirectory, in: .userDomainMask).first + ?? URL(fileURLWithPath: NSTemporaryDirectory()) + } + } + + var storeDirectoryURL: URL { + rootDirectoryURL.appendingPathComponent("Ghostly", isDirectory: true) + } + + var databaseURL: URL { + storeDirectoryURL.appendingPathComponent("notes.sqlite") + } + + var backupsDirectoryURL: URL { + storeDirectoryURL.appendingPathComponent("Backups", isDirectory: true) + } + + var quarantineDirectoryURL: URL { + storeDirectoryURL.appendingPathComponent("Quarantine", isDirectory: true) + } + + var latestBackupURL: URL { + backupsDirectoryURL.appendingPathComponent("latest-good-snapshot.json") + } + + func open() throws { + guard db == nil else { return } + + try fileManager.createDirectory(at: storeDirectoryURL, withIntermediateDirectories: true, attributes: nil) + try fileManager.createDirectory(at: backupsDirectoryURL, withIntermediateDirectories: true, attributes: nil) + try fileManager.createDirectory(at: quarantineDirectoryURL, withIntermediateDirectories: true, attributes: nil) + + let databaseExisted = fileManager.fileExists(atPath: databaseURL.path) + + var connection: OpaquePointer? + let flags = SQLITE_OPEN_CREATE | SQLITE_OPEN_READWRITE | SQLITE_OPEN_FULLMUTEX + if sqlite3_open_v2(databaseURL.path, &connection, flags, nil) != SQLITE_OK { + let message = Self.sqliteMessage(from: connection) + sqlite3_close(connection) + throw NotesStoreError.sqlite(message: message) + } + + db = connection + openedFreshStore = !databaseExisted + + do { + try executeStatements( + """ + PRAGMA foreign_keys = ON; + PRAGMA journal_mode = WAL; + PRAGMA synchronous = NORMAL; + """ + ) + try initializeSchema() + } catch { + closeConnection() + throw error + } + } + + func close() { + closeConnection() + } + + func loadSnapshot() throws -> NotesSnapshot { + try open() + try quickCheck() + + var snapshot = NotesSnapshot( + tabs: try fetchTabs().sorted { + if $0.sortIndex == $1.sortIndex { + if $0.createdAt == $1.createdAt { + return $0.id.uuidString < $1.id.uuidString + } + return $0.createdAt < $1.createdAt + } + return $0.sortIndex < $1.sortIndex + }, + activeTabID: try fetchActiveTabID() + ) + + var requiresNormalization = false + snapshot.tabs = normalizeSortIndexes(snapshot.tabs, changed: &requiresNormalization) + + if snapshot.tabs.isEmpty { + snapshot = NotesSnapshot.fresh(now: now()) + requiresNormalization = true + } + + if snapshot.activeTabID == nil || !snapshot.tabs.contains(where: { $0.id == snapshot.activeTabID }) { + snapshot.activeTabID = snapshot.tabs.first?.id + requiresNormalization = true + } + + if requiresNormalization { + try writeFullSnapshot(snapshot) + } + + return snapshot + } + + func save(_ changeSet: NotesChangeSet, writeBackup: Bool) throws { + guard changeSet.hasChanges else { return } + try open() + + try beginImmediateTransaction() + do { + for deletedID in changeSet.deletedTabIDs { + try execute( + "DELETE FROM tabs WHERE id = ?;", + bind: { statement in + self.bind(text: deletedID.uuidString, at: 1, in: statement) + } + ) + } + + for tab in changeSet.upsertedTabs { + try execute( + """ + INSERT INTO tabs (id, content, created_at, updated_at, sort_index) + VALUES (?, ?, ?, ?, ?) + ON CONFLICT(id) DO UPDATE SET + content = excluded.content, + created_at = excluded.created_at, + updated_at = excluded.updated_at, + sort_index = excluded.sort_index; + """, + bind: { statement in + self.bind(text: tab.id.uuidString, at: 1, in: statement) + self.bind(text: tab.content, at: 2, in: statement) + sqlite3_bind_double(statement, 3, tab.createdAt.timeIntervalSince1970) + sqlite3_bind_double(statement, 4, tab.updatedAt.timeIntervalSince1970) + sqlite3_bind_int64(statement, 5, sqlite3_int64(tab.sortIndex)) + } + ) + } + + if let activeTabID = changeSet.activeTabID { + try setMetadata(activeTabID.uuidString, for: "active_tab_id") + } else { + try deleteMetadata(for: "active_tab_id") + } + + try commitTransaction() + } catch { + try? rollbackTransaction() + throw error + } + + if writeBackup { + try writeLatestBackupSnapshot(loadSnapshot()) + } + } + + func flush() throws { + guard let db else { return } + let result = sqlite3_wal_checkpoint_v2(db, nil, SQLITE_CHECKPOINT_PASSIVE, nil, nil) + guard result == SQLITE_OK else { + throw NotesStoreError.sqlite(message: Self.sqliteMessage(from: db)) + } + } + + func migrateLegacyUserDefaultsIfNeeded() throws -> MigrationResult { + try open() + guard openedFreshStore else { return .notNeeded } + + if let data = userDefaults.data(forKey: legacyTabsKey) { + do { + let legacyTabs = try JSONDecoder().decode([GhostlyTab].self, from: data) + let activeTabID = userDefaults.string(forKey: legacyActiveTabKey).flatMap(UUID.init(uuidString:)) + let snapshot = NotesSnapshot( + tabs: legacyTabs.enumerated().map { index, tab in + PersistedTab( + id: tab.id, + content: tab.content, + createdAt: tab.createdAt, + updatedAt: tab.updatedAt, + sortIndex: index + ) + }, + activeTabID: activeTabID + ) + let backupURL = try writeMigrationBackup(snapshot) + try writeFullSnapshot(snapshot) + clearLegacyDefaults() + openedFreshStore = false + return .migrated(backupURL: backupURL) + } catch { + try resetFreshStoreAfterFailure() + throw NotesStoreError.migrationFailed(error.localizedDescription) + } + } + + if let legacyText = userDefaults.string(forKey: legacyTextKey), !legacyText.isEmpty { + let timestamp = now() + let snapshot = NotesSnapshot( + tabs: [ + PersistedTab( + content: legacyText, + createdAt: timestamp, + updatedAt: timestamp, + sortIndex: 0 + ) + ], + activeTabID: nil + ) + do { + let backupURL = try writeMigrationBackup(snapshot) + try writeFullSnapshot(snapshot) + clearLegacyDefaults() + openedFreshStore = false + return .migrated(backupURL: backupURL) + } catch { + try resetFreshStoreAfterFailure() + throw NotesStoreError.migrationFailed(error.localizedDescription) + } + } + + return .noLegacyData + } + + func createFreshStore() throws -> NotesSnapshot { + closeConnection() + try removeStoreFiles() + try open() + let snapshot = NotesSnapshot.fresh(now: now()) + try writeFullSnapshot(snapshot) + try writeLatestBackupSnapshot(snapshot) + openedFreshStore = false + return snapshot + } + + func restoreFromLatestBackup() throws -> NotesSnapshot { + guard fileManager.fileExists(atPath: latestBackupURL.path) else { + throw NotesStoreError.backupUnavailable + } + + let data = try Data(contentsOf: latestBackupURL) + let snapshot = try JSONDecoder().decode(NotesSnapshot.self, from: data) + + closeConnection() + try removeStoreFiles() + try open() + try writeFullSnapshot(snapshot) + try writeLatestBackupSnapshot(snapshot) + openedFreshStore = false + return try loadSnapshot() + } + + func quarantineCurrentStore(reason: String) throws -> URL { + closeConnection() + + let timestamp = ISO8601DateFormatter().string(from: now()).replacingOccurrences(of: ":", with: "-") + let directory = quarantineDirectoryURL.appendingPathComponent(timestamp, isDirectory: true) + try fileManager.createDirectory(at: directory, withIntermediateDirectories: true, attributes: nil) + + let urls = [databaseURL, sidecarURL(suffix: "-wal"), sidecarURL(suffix: "-shm")] + + for url in urls where fileManager.fileExists(atPath: url.path) { + let targetURL = directory.appendingPathComponent(url.lastPathComponent) + if fileManager.fileExists(atPath: targetURL.path) { + try fileManager.removeItem(at: targetURL) + } + try fileManager.moveItem(at: url, to: targetURL) + } + + let metadataURL = directory.appendingPathComponent("metadata.txt") + try """ + reason: \(reason) + created_at: \(ISO8601DateFormatter().string(from: now())) + """.write(to: metadataURL, atomically: true, encoding: .utf8) + + lastQuarantineURL = directory + return directory + } + + func exportRecoveryBundle(to destination: URL) throws { + let timestamp = ISO8601DateFormatter().string(from: now()).replacingOccurrences(of: ":", with: "-") + let bundleURL = destination.appendingPathComponent("Ghostly Recovery \(timestamp)", isDirectory: true) + try fileManager.createDirectory(at: bundleURL, withIntermediateDirectories: true, attributes: nil) + + if let lastQuarantineURL { + let target = bundleURL.appendingPathComponent("Quarantine", isDirectory: true) + try copyDirectoryContents(at: lastQuarantineURL, to: target) + } + + if fileManager.fileExists(atPath: latestBackupURL.path) { + try fileManager.copyItem( + at: latestBackupURL, + to: bundleURL.appendingPathComponent(latestBackupURL.lastPathComponent) + ) + } + + let metadataURL = bundleURL.appendingPathComponent("recovery-metadata.txt") + try """ + exported_at: \(ISO8601DateFormatter().string(from: now())) + quarantine_present: \(lastQuarantineURL != nil) + latest_backup_present: \(fileManager.fileExists(atPath: latestBackupURL.path)) + """.write(to: metadataURL, atomically: true, encoding: .utf8) + } + + func latestBackupIfAvailable() -> URL? { + fileManager.fileExists(atPath: latestBackupURL.path) ? latestBackupURL : nil + } + + func lastMigrationBackupIfAvailable() -> URL? { + lastMigrationBackupURL + } + + private func initializeSchema() throws { + let existingTables = try fetchUserTables() + if !existingTables.isEmpty, !expectedTables.isSubset(of: existingTables) { + throw NotesStoreError.invalidSchema(foundTables: existingTables.sorted()) + } + + try executeStatements( + """ + CREATE TABLE IF NOT EXISTS tabs ( + id TEXT PRIMARY KEY, + content TEXT NOT NULL, + created_at REAL NOT NULL, + updated_at REAL NOT NULL, + sort_index INTEGER NOT NULL + ); + + CREATE TABLE IF NOT EXISTS app_metadata ( + key TEXT PRIMARY KEY, + value TEXT NOT NULL + ); + + CREATE UNIQUE INDEX IF NOT EXISTS tabs_sort_index_idx ON tabs(sort_index); + """ + ) + + try setMetadata(schemaVersion, for: "schema_version") + } + + private func fetchUserTables() throws -> Set { + try queryStrings("SELECT name FROM sqlite_master WHERE type = 'table' AND name NOT LIKE 'sqlite_%';") + } + + private func quickCheck() throws { + let result = try querySingleString("PRAGMA quick_check;") + guard result == "ok" else { + throw NotesStoreError.unreadableStore(result ?? "quick_check failed") + } + } + + private func fetchTabs() throws -> [PersistedTab] { + guard let db else { throw NotesStoreError.sqlite(message: "Database is not open") } + + let sql = "SELECT id, content, created_at, updated_at, sort_index FROM tabs ORDER BY sort_index ASC, created_at ASC, id ASC;" + var statement: OpaquePointer? + guard sqlite3_prepare_v2(db, sql, -1, &statement, nil) == SQLITE_OK else { + throw NotesStoreError.sqlite(message: Self.sqliteMessage(from: db)) + } + defer { sqlite3_finalize(statement) } + + var tabs: [PersistedTab] = [] + while sqlite3_step(statement) == SQLITE_ROW { + guard let idCString = sqlite3_column_text(statement, 0), + let contentCString = sqlite3_column_text(statement, 1), + let id = UUID(uuidString: String(cString: idCString)) else { + throw NotesStoreError.unreadableStore("A persisted tab row is malformed.") + } + + tabs.append( + PersistedTab( + id: id, + content: String(cString: contentCString), + createdAt: Date(timeIntervalSince1970: sqlite3_column_double(statement, 2)), + updatedAt: Date(timeIntervalSince1970: sqlite3_column_double(statement, 3)), + sortIndex: Int(sqlite3_column_int64(statement, 4)) + ) + ) + } + return tabs + } + + private func fetchActiveTabID() throws -> UUID? { + guard let value = try querySingleString("SELECT value FROM app_metadata WHERE key = 'active_tab_id';"), + let activeID = UUID(uuidString: value) else { + return nil + } + return activeID + } + + private func writeFullSnapshot(_ snapshot: NotesSnapshot) throws { + try beginImmediateTransaction() + do { + try execute("DELETE FROM tabs;") + for tab in snapshot.tabs { + try execute( + "INSERT INTO tabs (id, content, created_at, updated_at, sort_index) VALUES (?, ?, ?, ?, ?);", + bind: { statement in + self.bind(text: tab.id.uuidString, at: 1, in: statement) + self.bind(text: tab.content, at: 2, in: statement) + sqlite3_bind_double(statement, 3, tab.createdAt.timeIntervalSince1970) + sqlite3_bind_double(statement, 4, tab.updatedAt.timeIntervalSince1970) + sqlite3_bind_int64(statement, 5, sqlite3_int64(tab.sortIndex)) + } + ) + } + + if let activeTabID = snapshot.activeTabID { + try setMetadata(activeTabID.uuidString, for: "active_tab_id") + } else { + try deleteMetadata(for: "active_tab_id") + } + + try commitTransaction() + } catch { + try? rollbackTransaction() + throw error + } + } + + private func normalizeSortIndexes(_ tabs: [PersistedTab], changed: inout Bool) -> [PersistedTab] { + tabs.enumerated().map { index, tab in + guard tab.sortIndex != index else { return tab } + changed = true + var normalized = tab + normalized.sortIndex = index + return normalized + } + } + + private func writeLatestBackupSnapshot(_ snapshot: NotesSnapshot) throws { + let data = try JSONEncoder().encode(snapshot) + let tempURL = latestBackupURL.appendingPathExtension("tmp") + try data.write(to: tempURL, options: .atomic) + if fileManager.fileExists(atPath: latestBackupURL.path) { + try fileManager.removeItem(at: latestBackupURL) + } + try fileManager.moveItem(at: tempURL, to: latestBackupURL) + } + + private func writeMigrationBackup(_ snapshot: NotesSnapshot) throws -> URL { + let timestamp = ISO8601DateFormatter().string(from: now()).replacingOccurrences(of: ":", with: "-") + let url = backupsDirectoryURL.appendingPathComponent("migration-\(timestamp).json") + let data = try JSONEncoder().encode(snapshot) + try data.write(to: url, options: .atomic) + lastMigrationBackupURL = url + return url + } + + private func clearLegacyDefaults() { + userDefaults.removeObject(forKey: legacyTabsKey) + userDefaults.removeObject(forKey: legacyActiveTabKey) + userDefaults.removeObject(forKey: legacyTextKey) + } + + private func resetFreshStoreAfterFailure() throws { + closeConnection() + try removeStoreFiles() + openedFreshStore = false + } + + private func removeStoreFiles() throws { + let urls = [databaseURL, sidecarURL(suffix: "-wal"), sidecarURL(suffix: "-shm")] + for url in urls where fileManager.fileExists(atPath: url.path) { + try fileManager.removeItem(at: url) + } + } + + private func copyDirectoryContents(at source: URL, to destination: URL) throws { + try fileManager.createDirectory(at: destination, withIntermediateDirectories: true, attributes: nil) + for url in try fileManager.contentsOfDirectory(at: source, includingPropertiesForKeys: nil) { + let targetURL = destination.appendingPathComponent(url.lastPathComponent) + if fileManager.fileExists(atPath: targetURL.path) { + try fileManager.removeItem(at: targetURL) + } + try fileManager.copyItem(at: url, to: targetURL) + } + } + + private func beginImmediateTransaction() throws { + try execute("BEGIN IMMEDIATE TRANSACTION;") + } + + private func commitTransaction() throws { + try execute("COMMIT;") + } + + private func rollbackTransaction() throws { + try execute("ROLLBACK;") + } + + private func setMetadata(_ value: String, for key: String) throws { + try execute( + """ + INSERT INTO app_metadata (key, value) VALUES (?, ?) + ON CONFLICT(key) DO UPDATE SET value = excluded.value; + """, + bind: { statement in + self.bind(text: key, at: 1, in: statement) + self.bind(text: value, at: 2, in: statement) + } + ) + } + + private func deleteMetadata(for key: String) throws { + try execute( + "DELETE FROM app_metadata WHERE key = ?;", + bind: { statement in + self.bind(text: key, at: 1, in: statement) + } + ) + } + + private func queryStrings(_ sql: String) throws -> Set { + guard let db else { throw NotesStoreError.sqlite(message: "Database is not open") } + + var statement: OpaquePointer? + guard sqlite3_prepare_v2(db, sql, -1, &statement, nil) == SQLITE_OK else { + throw NotesStoreError.sqlite(message: Self.sqliteMessage(from: db)) + } + defer { sqlite3_finalize(statement) } + + var strings = Set() + while sqlite3_step(statement) == SQLITE_ROW { + if let cString = sqlite3_column_text(statement, 0) { + strings.insert(String(cString: cString)) + } + } + return strings + } + + private func querySingleString(_ sql: String) throws -> String? { + guard let db else { throw NotesStoreError.sqlite(message: "Database is not open") } + + var statement: OpaquePointer? + guard sqlite3_prepare_v2(db, sql, -1, &statement, nil) == SQLITE_OK else { + throw NotesStoreError.sqlite(message: Self.sqliteMessage(from: db)) + } + defer { sqlite3_finalize(statement) } + + guard sqlite3_step(statement) == SQLITE_ROW, + let cString = sqlite3_column_text(statement, 0) else { + return nil + } + return String(cString: cString) + } + + private func execute(_ sql: String, bind: ((OpaquePointer?) -> Void)? = nil) throws { + guard let db else { throw NotesStoreError.sqlite(message: "Database is not open") } + + var statement: OpaquePointer? + guard sqlite3_prepare_v2(db, sql, -1, &statement, nil) == SQLITE_OK else { + throw NotesStoreError.sqlite(message: Self.sqliteMessage(from: db)) + } + defer { sqlite3_finalize(statement) } + + bind?(statement) + + let result = sqlite3_step(statement) + guard result == SQLITE_DONE || result == SQLITE_ROW else { + throw NotesStoreError.sqlite(message: Self.sqliteMessage(from: db)) + } + } + + private func executeStatements(_ sql: String) throws { + guard let db else { throw NotesStoreError.sqlite(message: "Database is not open") } + + var errorMessage: UnsafeMutablePointer? + let result = sqlite3_exec(db, sql, nil, nil, &errorMessage) + guard result == SQLITE_OK else { + let message = errorMessage.map { String(cString: $0) } ?? Self.sqliteMessage(from: db) + sqlite3_free(errorMessage) + throw NotesStoreError.sqlite(message: message) + } + } + + private func sidecarURL(suffix: String) -> URL { + URL(fileURLWithPath: databaseURL.path + suffix) + } + + private func bind(text: String, at index: Int32, in statement: OpaquePointer?) { + _ = text.withCString { cString in + sqlite3_bind_text(statement, index, cString, -1, transientDestructor) + } + } + + private func closeConnection() { + if let db { + sqlite3_close(db) + self.db = nil + } + } + + private static func sqliteMessage(from db: OpaquePointer?) -> String { + guard let cString = sqlite3_errmsg(db) else { + return "Unknown SQLite error" + } + return String(cString: cString) + } +} + +private let transientDestructor = unsafeBitCast(-1, to: sqlite3_destructor_type.self) diff --git a/Ghostly/Persistence/PersistenceCoordinator.swift b/Ghostly/Persistence/PersistenceCoordinator.swift new file mode 100644 index 0000000..0b47a5c --- /dev/null +++ b/Ghostly/Persistence/PersistenceCoordinator.swift @@ -0,0 +1,139 @@ +// +// PersistenceCoordinator.swift +// Ghostly +// + +import Foundation +import OSLog + +@MainActor +final class PersistenceCoordinator { + static var shared: PersistenceCoordinator? + + private let logger = Logger( + subsystem: Bundle.main.bundleIdentifier ?? "com.ghostly.Ghostly", + category: "PersistenceCoordinator" + ) + + private let notesStore: NotesStore + private unowned let tabManager: TabManager + private let autosaveDelay: Duration + private let backupInterval: Duration + + private var autosaveTask: Task? + private var isReady = false + private var isFlushing = false + private var pendingFlushReason: FlushReason? + private var lastBackupAt: Date? + + init( + notesStore: NotesStore, + tabManager: TabManager, + autosaveDelay: Duration = .milliseconds(250), + backupInterval: Duration = .seconds(30) + ) { + self.notesStore = notesStore + self.tabManager = tabManager + self.autosaveDelay = autosaveDelay + self.backupInterval = backupInterval + Self.shared = self + } + + func setReady(_ isReady: Bool) { + self.isReady = isReady + } + + func scheduleAutosave() { + guard isReady else { return } + + autosaveTask?.cancel() + autosaveTask = Task { [weak self] in + guard let self else { return } + try? await Task.sleep(for: self.autosaveDelay) + guard !Task.isCancelled else { return } + await self.flushNow(reason: .typingIdle) + } + } + + func flushNow(reason: FlushReason) async { + guard isReady else { return } + autosaveTask?.cancel() + + guard tabManager.hasDirtyChanges else { return } + if isFlushing { + pendingFlushReason = reason + return + } + + isFlushing = true + defer { isFlushing = false } + + var currentReason: FlushReason? = reason + while let reason = currentReason { + pendingFlushReason = nil + let changeSet = tabManager.currentChangeSet() + guard changeSet.hasChanges else { + currentReason = pendingFlushReason + continue + } + + do { + let shouldWriteBackup = shouldWriteBackup(for: reason) + try await notesStore.save(changeSet, writeBackup: shouldWriteBackup) + try await notesStore.flush() + tabManager.markPersisted(changeSet) + if shouldWriteBackup { + lastBackupAt = Date() + } + } catch { + logger.error("Failed to flush notes for \(reason.rawValue, privacy: .public): \(error.localizedDescription, privacy: .public)") + return + } + + currentReason = pendingFlushReason ?? (tabManager.hasDirtyChanges ? .typingIdle : nil) + } + } + + func popoverDidClose() async { + await flushNow(reason: .popoverClosed) + } + + func appDidResignActive() async { + await flushNow(reason: .appResignedActive) + } + + func handleTerminationRequest() async -> Bool { + guard isReady else { return true } + + return await withTaskGroup(of: Bool.self) { group in + group.addTask { [weak self] in + guard let self else { return true } + await self.flushNow(reason: .appTermination) + return true + } + group.addTask { + try? await Task.sleep(for: .seconds(2)) + return true + } + let result = await group.next() ?? true + group.cancelAll() + return result + } + } + + private func shouldWriteBackup(for reason: FlushReason) -> Bool { + switch reason { + case .typingIdle: + guard let lastBackupAt else { return true } + return Date().timeIntervalSince(lastBackupAt) >= backupInterval.timeInterval + case .tabSwitch, .tabCreated, .tabClosed, .popoverClosed, .appResignedActive, .appTermination, .manualRecoveryAction: + return true + } + } +} + +private extension Duration { + var timeInterval: TimeInterval { + TimeInterval(components.seconds) + (TimeInterval(components.attoseconds) / 1_000_000_000_000_000_000) + } +} diff --git a/Ghostly/Persistence/PersistenceModels.swift b/Ghostly/Persistence/PersistenceModels.swift new file mode 100644 index 0000000..ea3169e --- /dev/null +++ b/Ghostly/Persistence/PersistenceModels.swift @@ -0,0 +1,156 @@ +// +// PersistenceModels.swift +// Ghostly +// + +import AppKit +import Foundation + +struct PersistedTab: Identifiable, Codable, Equatable { + let id: UUID + var content: String + var createdAt: Date + var updatedAt: Date + var sortIndex: Int + + init( + id: UUID = UUID(), + content: String = "", + createdAt: Date = Date(), + updatedAt: Date = Date(), + sortIndex: Int + ) { + self.id = id + self.content = content + self.createdAt = createdAt + self.updatedAt = updatedAt + self.sortIndex = sortIndex + } + + init(tab: GhostlyTab, sortIndex: Int) { + self.id = tab.id + self.content = tab.content + self.createdAt = tab.createdAt + self.updatedAt = tab.updatedAt + self.sortIndex = sortIndex + } +} + +extension GhostlyTab { + init(persistedTab: PersistedTab) { + self.init( + id: persistedTab.id, + content: persistedTab.content, + createdAt: persistedTab.createdAt, + updatedAt: persistedTab.updatedAt + ) + } +} + +struct NotesSnapshot: Codable, Equatable { + var tabs: [PersistedTab] + var activeTabID: UUID? + + static func fresh(now: Date = Date()) -> NotesSnapshot { + let tab = PersistedTab( + content: "", + createdAt: now, + updatedAt: now, + sortIndex: 0 + ) + return NotesSnapshot(tabs: [tab], activeTabID: tab.id) + } +} + +struct NotesChangeSet { + let upsertedTabs: [PersistedTab] + let deletedTabIDs: [UUID] + let activeTabID: UUID? + let tabRevisions: [UUID: Int] + let metadataRevision: Int? + + var hasChanges: Bool { + !upsertedTabs.isEmpty || !deletedTabIDs.isEmpty || metadataRevision != nil + } +} + +enum LaunchState: Equatable { + case loading + case ready + case recovery(RecoveryState) +} + +enum RecoveryState: Equatable { + case storeUnreadable(summary: String, quarantineURL: URL, backupURL: URL?) + case migrationFailed(summary: String, legacyBackupURL: URL?) + case restoreInProgress + case freshStartAvailable(summary: String, quarantineURL: URL) +} + +enum FlushReason: String { + case typingIdle + case tabSwitch + case tabCreated + case tabClosed + case popoverClosed + case appResignedActive + case appTermination + case manualRecoveryAction +} + +enum PersistenceTrigger { + case scheduleAutosave + case flush(FlushReason) +} + +enum MigrationResult: Equatable { + case notNeeded + case noLegacyData + case migrated(backupURL: URL) +} + +enum NotesStoreError: LocalizedError { + case sqlite(message: String) + case invalidSchema(foundTables: [String]) + case unreadableStore(String) + case migrationFailed(String) + case backupUnavailable + case exportFailed(String) + + var errorDescription: String? { + switch self { + case let .sqlite(message): + return "SQLite error: \(message)" + case let .invalidSchema(foundTables): + return "Invalid notes schema. Found tables: \(foundTables.joined(separator: ", "))" + case let .unreadableStore(message): + return "The notes store is unreadable: \(message)" + case let .migrationFailed(message): + return "Legacy note migration failed: \(message)" + case .backupUnavailable: + return "No backup snapshot is available." + case let .exportFailed(message): + return "Recovery bundle export failed: \(message)" + } + } +} + +@MainActor +final class AppLifecycleDelegate: NSObject, NSApplicationDelegate { + func applicationDidResignActive(_ notification: Notification) { + guard let coordinator = PersistenceCoordinator.shared else { return } + Task { await coordinator.appDidResignActive() } + } + + func applicationShouldTerminate(_ sender: NSApplication) -> NSApplication.TerminateReply { + guard let coordinator = PersistenceCoordinator.shared else { + return .terminateNow + } + + Task { + let shouldTerminate = await coordinator.handleTerminationRequest() + sender.reply(toApplicationShouldTerminate: shouldTerminate) + } + return .terminateLater + } +} diff --git a/Ghostly/TabManager.swift b/Ghostly/TabManager.swift index ba11a81..294cd7b 100644 --- a/Ghostly/TabManager.swift +++ b/Ghostly/TabManager.swift @@ -6,29 +6,28 @@ // import Foundation -import OSLog import SwiftUI @Observable @MainActor final class TabManager { - private let logger = Logger( - subsystem: Bundle.main.bundleIdentifier ?? "com.ghostly.Ghostly", - category: "TabManager" - ) - - private(set) var tabs: [GhostlyTab] = [] + private(set) var tabs: [GhostlyTab] var activeTabId: UUID? - private let userDefaultsKey = "ghostlyTabs" - private let legacyTextKey = "text" - private var saveTask: Task? + var onPersistenceTrigger: ((PersistenceTrigger) -> Void)? + + private var deletedTabIDs = Set() + private var dirtyTabRevisions: [UUID: Int] = [:] + private var metadataRevision: Int? + private var revisionCounter = 0 + private var isHydrating = false init() { - loadTabs() + let initialTab = GhostlyTab() + self.tabs = [initialTab] + self.activeTabId = initialTab.id } - /// Binding for the active tab's content, suitable for TextEditor var activeTabBinding: Binding { Binding( get: { [weak self] in @@ -45,172 +44,177 @@ final class TabManager { let index = self.tabs.firstIndex(where: { $0.id == activeId }) else { return } - self.tabs[index].content = newValue - self.debouncedSave() + + let transformedValue = newValue + guard self.tabs[index].content != transformedValue else { return } + + self.tabs[index].content = transformedValue + self.tabs[index].updatedAt = Date() + self.markTabDirty(activeId) + self.onPersistenceTrigger?(.scheduleAutosave) } ) } - /// The currently active tab var activeTab: GhostlyTab? { guard let activeId = activeTabId else { return nil } return tabs.first { $0.id == activeId } } - // MARK: - Tab Operations - - /// Creates a new empty tab, appends it, and makes it active (does not save) - private func createTab() -> GhostlyTab { - let tab = GhostlyTab() - tabs.append(tab) - activeTabId = tab.id - return tab + var hasDirtyChanges: Bool { + !dirtyTabRevisions.isEmpty || !deletedTabIDs.isEmpty || metadataRevision != nil } - /// Creates a new empty tab and makes it active @discardableResult func newTab() -> GhostlyTab { - let tab = createTab() - cancelDebouncedSave() - saveTabs() + let tab = GhostlyTab() + tabs.append(tab) + activeTabId = tab.id + markTabDirty(tab.id) + markMetadataDirty() + requestImmediateFlush(.tabCreated) return tab } - /// Closes the specified tab func closeTab(_ tabId: UUID) { guard let index = tabs.firstIndex(where: { $0.id == tabId }) else { return } let wasActive = activeTabId == tabId tabs.remove(at: index) + deletedTabIDs.insert(tabId) + dirtyTabRevisions.removeValue(forKey: tabId) - // If we closed the active tab, select an adjacent one if wasActive { if tabs.isEmpty { - // If no tabs left, create a new empty one (without saving yet) - _ = createTab() + let replacement = GhostlyTab() + tabs = [replacement] + markTabDirty(replacement.id) + activeTabId = replacement.id } else { - // Select the tab at the same index, or the last one if we were at the end let newIndex = min(index, tabs.count - 1) activeTabId = tabs[newIndex].id } } - cancelDebouncedSave() - saveTabs() + markAllTabsDirty() + markMetadataDirty() + requestImmediateFlush(.tabClosed) } - /// Closes the currently active tab func closeActiveTab() { guard let activeId = activeTabId else { return } closeTab(activeId) } - /// Selects the specified tab func selectTab(_ tabId: UUID) { - guard tabs.contains(where: { $0.id == tabId }) else { return } + guard tabs.contains(where: { $0.id == tabId }), activeTabId != tabId else { return } activeTabId = tabId - cancelDebouncedSave() - saveTabs() + markMetadataDirty() + requestImmediateFlush(.tabSwitch) } - /// Selects the tab at the given index (0-based) func selectTabAtIndex(_ index: Int) { guard index >= 0 && index < tabs.count else { return } - activeTabId = tabs[index].id - cancelDebouncedSave() - saveTabs() + selectTab(tabs[index].id) } - /// Selects the next tab, wrapping to first if at the end func selectNextTab() { guard let activeId = activeTabId, let currentIndex = tabs.firstIndex(where: { $0.id == activeId }), tabs.count > 1 else { return } let nextIndex = (currentIndex + 1) % tabs.count - activeTabId = tabs[nextIndex].id - cancelDebouncedSave() - saveTabs() + selectTab(tabs[nextIndex].id) } - /// Selects the previous tab, wrapping to last if at the beginning func selectPreviousTab() { guard let activeId = activeTabId, let currentIndex = tabs.firstIndex(where: { $0.id == activeId }), tabs.count > 1 else { return } let previousIndex = (currentIndex - 1 + tabs.count) % tabs.count - activeTabId = tabs[previousIndex].id - cancelDebouncedSave() - saveTabs() - } - - // MARK: - Persistence - - /// Schedules a save after a 500ms delay, cancelling any pending debounced save - private func debouncedSave() { - saveTask?.cancel() - saveTask = Task { [weak self] in - try? await Task.sleep(for: .milliseconds(500)) - guard let self, !Task.isCancelled else { return } - self.saveTabs() + selectTab(tabs[previousIndex].id) + } + + func load(snapshot: NotesSnapshot) { + isHydrating = true + tabs = snapshot.tabs.sorted { $0.sortIndex < $1.sortIndex }.map(GhostlyTab.init(persistedTab:)) + if tabs.isEmpty { + let initialTab = GhostlyTab() + tabs = [initialTab] + activeTabId = initialTab.id + } else if let activeTabID = snapshot.activeTabID, + tabs.contains(where: { $0.id == activeTabID }) { + activeTabId = activeTabID + } else { + activeTabId = tabs.first?.id } + clearDirtyState() + isHydrating = false } - /// Cancels any pending debounced save to avoid stale overwrites - private func cancelDebouncedSave() { - saveTask?.cancel() - saveTask = nil + func currentSnapshot() -> NotesSnapshot { + NotesSnapshot( + tabs: tabs.enumerated().map { index, tab in PersistedTab(tab: tab, sortIndex: index) }, + activeTabID: activeTabId + ) } - /// Forces any pending debounced content to be saved immediately - func flushPendingSave() { - cancelDebouncedSave() - saveTabs() + func currentChangeSet() -> NotesChangeSet { + let upsertedTabs = tabs.enumerated().compactMap { index, tab -> PersistedTab? in + guard dirtyTabRevisions[tab.id] != nil else { return nil } + return PersistedTab(tab: tab, sortIndex: index) + } + + return NotesChangeSet( + upsertedTabs: upsertedTabs, + deletedTabIDs: Array(deletedTabIDs), + activeTabID: activeTabId, + tabRevisions: dirtyTabRevisions, + metadataRevision: metadataRevision + ) } - private func loadTabs() { - // Try to load existing tabs - if let data = UserDefaults.standard.data(forKey: userDefaultsKey), - let savedTabs = try? JSONDecoder().decode([GhostlyTab].self, from: data), - !savedTabs.isEmpty { - tabs = savedTabs - // Restore active tab, defaulting to first if not found - if let savedActiveId = UserDefaults.standard.string(forKey: "\(userDefaultsKey)_activeId"), - let activeUUID = UUID(uuidString: savedActiveId), - tabs.contains(where: { $0.id == activeUUID }) { - activeTabId = activeUUID - } else { - activeTabId = tabs.first?.id + func markPersisted(_ changeSet: NotesChangeSet) { + for tab in changeSet.upsertedTabs { + guard let revision = changeSet.tabRevisions[tab.id], + dirtyTabRevisions[tab.id] == revision else { + continue } - return + dirtyTabRevisions.removeValue(forKey: tab.id) } - // Migrate from legacy single-document storage - if let legacyText = UserDefaults.standard.string(forKey: legacyTextKey), !legacyText.isEmpty { - let migratedTab = GhostlyTab(content: legacyText) - tabs = [migratedTab] - activeTabId = migratedTab.id - saveTabs() - // Clear legacy storage after migration - UserDefaults.standard.removeObject(forKey: legacyTextKey) - return + for deletedID in changeSet.deletedTabIDs { + deletedTabIDs.remove(deletedID) } - // No existing data - create first empty tab - let initialTab = GhostlyTab() - tabs = [initialTab] - activeTabId = initialTab.id - saveTabs() + if let metadataRevision, changeSet.metadataRevision == metadataRevision { + self.metadataRevision = nil + } } - private func saveTabs() { - do { - let data = try JSONEncoder().encode(tabs) - UserDefaults.standard.set(data, forKey: userDefaultsKey) - } catch { - logger.error("Failed to encode tabs for persistence: \(error.localizedDescription, privacy: .public)") - } - if let activeId = activeTabId { - UserDefaults.standard.set(activeId.uuidString, forKey: "\(userDefaultsKey)_activeId") + private func requestImmediateFlush(_ reason: FlushReason) { + guard !isHydrating else { return } + onPersistenceTrigger?(.flush(reason)) + } + + private func markTabDirty(_ tabID: UUID) { + revisionCounter += 1 + dirtyTabRevisions[tabID] = revisionCounter + } + + private func markMetadataDirty() { + revisionCounter += 1 + metadataRevision = revisionCounter + } + + private func markAllTabsDirty() { + for tab in tabs { + markTabDirty(tab.id) } } + + private func clearDirtyState() { + deletedTabIDs.removeAll() + dirtyTabRevisions.removeAll() + metadataRevision = nil + } } diff --git a/Ghostly/Views/ContentView.swift b/Ghostly/Views/ContentView.swift index c6c3852..5e23262 100644 --- a/Ghostly/Views/ContentView.swift +++ b/Ghostly/Views/ContentView.swift @@ -131,5 +131,5 @@ struct ContentView: View { } #Preview { - ContentView(appState: AppState()) + ContentView(appState: AppState(registerKeyboardShortcuts: false, autoStart: false)) } diff --git a/Ghostly/Views/RecoveryView.swift b/Ghostly/Views/RecoveryView.swift new file mode 100644 index 0000000..dbd8999 --- /dev/null +++ b/Ghostly/Views/RecoveryView.swift @@ -0,0 +1,99 @@ +// +// RecoveryView.swift +// Ghostly +// + +import SwiftUI + +struct RecoveryView: View { + var appState: AppState + let recoveryState: RecoveryState + + var body: some View { + VStack(alignment: .leading, spacing: 18) { + Text("Notes Recovery") + .font(.system(size: 18, weight: .semibold)) + .foregroundStyle(Color.catText) + + Text(summaryText) + .font(.system(size: 13, weight: .regular)) + .foregroundStyle(Color.catSubtext) + .fixedSize(horizontal: false, vertical: true) + + if let backupURL { + Text("Latest backup: \(backupURL.path)") + .font(.system(size: 11, weight: .regular, design: .monospaced)) + .foregroundStyle(Color.catOverlay) + .textSelection(.enabled) + } + + if let quarantineURL { + Text("Quarantined data: \(quarantineURL.path)") + .font(.system(size: 11, weight: .regular, design: .monospaced)) + .foregroundStyle(Color.catOverlay) + .textSelection(.enabled) + } + + if !appState.recoveryNotice.isEmpty { + Text(appState.recoveryNotice) + .font(.system(size: 12, weight: .medium)) + .foregroundStyle(Color.catPeach) + } + + HStack(spacing: 10) { + Button("Restore Backup") { + Task { await appState.restoreFromLatestBackup() } + } + .disabled(backupURL == nil) + + Button("Export Recovery Bundle") { + Task { await appState.exportRecoveryBundle() } + } + + Button("Start Fresh") { + Task { await appState.startFresh() } + } + } + .buttonStyle(.borderedProminent) + .tint(.catLavender) + } + .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading) + .padding(20) + .background(Color.catBase.opacity(0.7)) + } + + private var summaryText: String { + switch recoveryState { + case let .storeUnreadable(summary, _, _): + return summary + case let .migrationFailed(summary, _): + return summary + case .restoreInProgress: + return "Ghostly is restoring your latest good backup." + case let .freshStartAvailable(summary, _): + return summary + } + } + + private var backupURL: URL? { + switch recoveryState { + case let .storeUnreadable(_, _, backupURL): + return backupURL + case let .migrationFailed(_, legacyBackupURL): + return legacyBackupURL + case .restoreInProgress, .freshStartAvailable: + return nil + } + } + + private var quarantineURL: URL? { + switch recoveryState { + case let .storeUnreadable(_, quarantineURL, _): + return quarantineURL + case let .freshStartAvailable(_, quarantineURL): + return quarantineURL + case .migrationFailed, .restoreInProgress: + return nil + } + } +} diff --git a/GhostlyTests/AppStateTests.swift b/GhostlyTests/AppStateTests.swift index 4823f09..976cf1a 100644 --- a/GhostlyTests/AppStateTests.swift +++ b/GhostlyTests/AppStateTests.swift @@ -11,16 +11,19 @@ import Testing @Suite("AppState Tests") @MainActor struct AppStateTests { + private func makeAppState() -> AppState { + AppState(registerKeyboardShortcuts: false, autoStart: false) + } @Test("Initial state has menu not presented") func initialStateHasMenuNotPresented() { - let appState = AppState() + let appState = makeAppState() #expect(appState.isMenuPresented == false) } @Test("Menu presented state can be toggled") func menuPresentedStateCanBeToggled() { - let appState = AppState() + let appState = makeAppState() #expect(appState.isMenuPresented == false) @@ -33,7 +36,7 @@ struct AppStateTests { @Test("Menu presented state can be set directly") func menuPresentedStateCanBeSetDirectly() { - let appState = AppState() + let appState = makeAppState() appState.isMenuPresented = true #expect(appState.isMenuPresented == true) diff --git a/GhostlyTests/NotesStoreTests.swift b/GhostlyTests/NotesStoreTests.swift new file mode 100644 index 0000000..2b677dc --- /dev/null +++ b/GhostlyTests/NotesStoreTests.swift @@ -0,0 +1,244 @@ +// +// NotesStoreTests.swift +// GhostlyTests +// + +import Foundation +import SQLite3 +import Testing +@testable import Ghostly + +@Suite("NotesStore Tests") +struct NotesStoreTests { + private func makeEnvironment(now: Date = Date(timeIntervalSince1970: 1_700_000_000)) throws -> (store: NotesStore, rootURL: URL, suiteName: String) { + let rootURL = FileManager.default.temporaryDirectory + .appendingPathComponent(UUID().uuidString, isDirectory: true) + try FileManager.default.createDirectory(at: rootURL, withIntermediateDirectories: true) + + let suiteName = "GhostlyTests.\(UUID().uuidString)" + let defaults = try #require(UserDefaults(suiteName: suiteName)) + defaults.removePersistentDomain(forName: suiteName) + + let store = NotesStore(baseDirectory: rootURL, userDefaults: defaults, now: { now }) + return (store, rootURL, suiteName) + } + + private func makeChangeSet( + tabs: [PersistedTab], + activeTabID: UUID?, + deletedTabIDs: [UUID] = [], + metadataRevision: Int? = 1 + ) -> NotesChangeSet { + NotesChangeSet( + upsertedTabs: tabs, + deletedTabIDs: deletedTabIDs, + activeTabID: activeTabID, + tabRevisions: Dictionary(uniqueKeysWithValues: tabs.enumerated().map { offset, tab in + (tab.id, offset + 1) + }), + metadataRevision: metadataRevision + ) + } + + private func executeSQL(_ sql: String, at databaseURL: URL) throws { + var db: OpaquePointer? + let flags = SQLITE_OPEN_READWRITE | SQLITE_OPEN_FULLMUTEX + guard sqlite3_open_v2(databaseURL.path, &db, flags, nil) == SQLITE_OK else { + let message = db.flatMap(sqlite3_errmsg).map(String.init(cString:)) ?? "Unknown SQLite error" + sqlite3_close(db) + throw NSError(domain: "NotesStoreTests", code: 1, userInfo: [NSLocalizedDescriptionKey: message]) + } + defer { sqlite3_close(db) } + + var errorMessage: UnsafeMutablePointer? + guard sqlite3_exec(db, sql, nil, nil, &errorMessage) == SQLITE_OK else { + let message = errorMessage.map { String(cString: $0) } ?? "Unknown SQLite error" + sqlite3_free(errorMessage) + throw NSError(domain: "NotesStoreTests", code: 2, userInfo: [NSLocalizedDescriptionKey: message]) + } + } + + @Test("Fresh store loads as one empty active tab") + func freshStoreLoadsAsEmptyWorkspace() async throws { + let environment = try makeEnvironment() + defer { + UserDefaults(suiteName: environment.suiteName)?.removePersistentDomain(forName: environment.suiteName) + try? FileManager.default.removeItem(at: environment.rootURL) + } + + try await environment.store.open() + let snapshot = try await environment.store.loadSnapshot() + + #expect(snapshot.tabs.count == 1) + #expect(snapshot.tabs.first?.content == "") + #expect(snapshot.activeTabID == snapshot.tabs.first?.id) + } + + @Test("Store round-trips persisted tabs and active tab") + func storeRoundTripsSnapshot() async throws { + let environment = try makeEnvironment() + defer { + UserDefaults(suiteName: environment.suiteName)?.removePersistentDomain(forName: environment.suiteName) + try? FileManager.default.removeItem(at: environment.rootURL) + } + + let first = PersistedTab( + id: UUID(), + content: "First note", + createdAt: Date(timeIntervalSince1970: 10), + updatedAt: Date(timeIntervalSince1970: 11), + sortIndex: 0 + ) + let second = PersistedTab( + id: UUID(), + content: "Second note", + createdAt: Date(timeIntervalSince1970: 20), + updatedAt: Date(timeIntervalSince1970: 21), + sortIndex: 1 + ) + let expected = NotesSnapshot(tabs: [first, second], activeTabID: second.id) + + try await environment.store.open() + try await environment.store.save(makeChangeSet(tabs: expected.tabs, activeTabID: expected.activeTabID), writeBackup: true) + let loaded = try await environment.store.loadSnapshot() + + #expect(loaded == expected) + #expect(await environment.store.latestBackupIfAvailable() != nil) + } + + @Test("Legacy text migrates into SQLite and clears old defaults") + func legacyTextMigratesIntoStore() async throws { + let environment = try makeEnvironment(now: Date(timeIntervalSince1970: 42)) + defer { + UserDefaults(suiteName: environment.suiteName)?.removePersistentDomain(forName: environment.suiteName) + try? FileManager.default.removeItem(at: environment.rootURL) + } + + let legacyDefaults = try #require(UserDefaults(suiteName: environment.suiteName)) + legacyDefaults.set("Legacy note", forKey: "text") + + try await environment.store.open() + let result = try await environment.store.migrateLegacyUserDefaultsIfNeeded() + let snapshot = try await environment.store.loadSnapshot() + + if case let .migrated(backupURL) = result { + #expect(FileManager.default.fileExists(atPath: backupURL.path)) + } else { + #expect(Bool(false)) + } + + #expect(snapshot.tabs.count == 1) + #expect(snapshot.tabs.first?.content == "Legacy note") + #expect(snapshot.activeTabID == snapshot.tabs.first?.id) + let verificationDefaults = try #require(UserDefaults(suiteName: environment.suiteName)) + #expect(verificationDefaults.string(forKey: "text") == nil) + #expect(await environment.store.lastMigrationBackupIfAvailable() != nil) + } + + @Test("Load normalizes invalid active tab and dense sort indexes") + func loadNormalizesMetadataAndSortIndexes() async throws { + let environment = try makeEnvironment() + defer { + UserDefaults(suiteName: environment.suiteName)?.removePersistentDomain(forName: environment.suiteName) + try? FileManager.default.removeItem(at: environment.rootURL) + } + + let first = PersistedTab( + id: UUID(), + content: "First", + createdAt: Date(timeIntervalSince1970: 1), + updatedAt: Date(timeIntervalSince1970: 2), + sortIndex: 0 + ) + let second = PersistedTab( + id: UUID(), + content: "Second", + createdAt: Date(timeIntervalSince1970: 3), + updatedAt: Date(timeIntervalSince1970: 4), + sortIndex: 1 + ) + + try await environment.store.open() + try await environment.store.save(makeChangeSet(tabs: [first, second], activeTabID: second.id), writeBackup: false) + await environment.store.close() + + let databaseURL = await environment.store.databaseURL + try executeSQL( + """ + UPDATE tabs SET sort_index = 10 WHERE id = '\(first.id.uuidString)'; + UPDATE tabs SET sort_index = 20 WHERE id = '\(second.id.uuidString)'; + UPDATE app_metadata SET value = 'not-a-uuid' WHERE key = 'active_tab_id'; + """, + at: databaseURL + ) + + try await environment.store.open() + let normalized = try await environment.store.loadSnapshot() + + #expect(normalized.tabs.map(\.sortIndex) == [0, 1]) + #expect(normalized.activeTabID == first.id) + } + + @Test("Restore from backup rebuilds the store from the last good snapshot") + func restoreFromBackupRebuildsStore() async throws { + let environment = try makeEnvironment() + defer { + UserDefaults(suiteName: environment.suiteName)?.removePersistentDomain(forName: environment.suiteName) + try? FileManager.default.removeItem(at: environment.rootURL) + } + + let original = PersistedTab( + id: UUID(), + content: "Original", + createdAt: Date(timeIntervalSince1970: 5), + updatedAt: Date(timeIntervalSince1970: 6), + sortIndex: 0 + ) + let updated = PersistedTab( + id: original.id, + content: "Updated", + createdAt: original.createdAt, + updatedAt: Date(timeIntervalSince1970: 7), + sortIndex: 0 + ) + + try await environment.store.open() + try await environment.store.save(makeChangeSet(tabs: [original], activeTabID: original.id), writeBackup: true) + try await environment.store.save(makeChangeSet(tabs: [updated], activeTabID: updated.id), writeBackup: false) + + let liveSnapshot = try await environment.store.loadSnapshot() + #expect(liveSnapshot.tabs.first?.content == "Updated") + + let restored = try await environment.store.restoreFromLatestBackup() + + #expect(restored.tabs.first?.content == "Original") + #expect(restored.activeTabID == original.id) + } + + @Test("Quarantine moves the unreadable store aside without deleting it") + func quarantineMovesStoreFiles() async throws { + let environment = try makeEnvironment() + defer { + UserDefaults(suiteName: environment.suiteName)?.removePersistentDomain(forName: environment.suiteName) + try? FileManager.default.removeItem(at: environment.rootURL) + } + + let tab = PersistedTab( + id: UUID(), + content: "Quarantine me", + createdAt: Date(timeIntervalSince1970: 8), + updatedAt: Date(timeIntervalSince1970: 9), + sortIndex: 0 + ) + + try await environment.store.open() + try await environment.store.save(makeChangeSet(tabs: [tab], activeTabID: tab.id), writeBackup: true) + + let databaseURL = await environment.store.databaseURL + let quarantineURL = try await environment.store.quarantineCurrentStore(reason: "Test") + + #expect(!FileManager.default.fileExists(atPath: databaseURL.path)) + #expect(FileManager.default.fileExists(atPath: quarantineURL.appendingPathComponent("notes.sqlite").path)) + #expect(FileManager.default.fileExists(atPath: quarantineURL.appendingPathComponent("metadata.txt").path)) + } +} diff --git a/GhostlyTests/TabTests.swift b/GhostlyTests/TabTests.swift index 699bc5b..518e5d2 100644 --- a/GhostlyTests/TabTests.swift +++ b/GhostlyTests/TabTests.swift @@ -14,6 +14,12 @@ import Testing @Suite("GhostlyTab Model Tests") struct GhostlyTabModelTests { + private struct LegacyTabPayload: Codable { + let id: UUID + let content: String + let createdAt: Date + } + @Test("Tab has unique identifier") func tabHasUniqueId() { let tab1 = GhostlyTab() @@ -33,6 +39,21 @@ struct GhostlyTabModelTests { #expect(tab.content == "Hello world") } + @Test("Legacy decoding defaults updatedAt to createdAt") + func legacyDecodingDefaultsUpdatedAt() throws { + let payload = LegacyTabPayload( + id: UUID(), + content: "Legacy content", + createdAt: Date(timeIntervalSince1970: 123) + ) + + let data = try JSONEncoder().encode(payload) + let decoded = try JSONDecoder().decode(GhostlyTab.self, from: data) + + #expect(decoded.createdAt == payload.createdAt) + #expect(decoded.updatedAt == payload.createdAt) + } + @Test("Title returns Untitled for empty content") func titleReturnsUntitledForEmpty() { let tab = GhostlyTab(content: "") @@ -60,7 +81,7 @@ struct GhostlyTabModelTests { @Test("Title exactly 20 chars is not truncated") func titleExactly20CharsNotTruncated() { - let tab = GhostlyTab(content: "12345678901234567890") // exactly 20 chars + let tab = GhostlyTab(content: "12345678901234567890") #expect(tab.title == "12345678901234567890") #expect(tab.title.count == 20) } @@ -79,14 +100,15 @@ struct GhostlyTabModelTests { #expect(decoded.id == original.id) #expect(decoded.content == original.content) + #expect(decoded.updatedAt == original.updatedAt) } @Test("Tab is Equatable") func tabIsEquatable() { let id = UUID() let date = Date() - let tab1 = GhostlyTab(id: id, content: "Test", createdAt: date) - let tab2 = GhostlyTab(id: id, content: "Test", createdAt: date) + let tab1 = GhostlyTab(id: id, content: "Test", createdAt: date, updatedAt: date) + let tab2 = GhostlyTab(id: id, content: "Test", createdAt: date, updatedAt: date) #expect(tab1 == tab2) } @@ -99,316 +121,208 @@ struct GhostlyTabModelTests { struct TabManagerTests { private func freshManager() -> TabManager { - // Clear any existing data - UserDefaults.standard.removeObject(forKey: "ghostlyTabs") - UserDefaults.standard.removeObject(forKey: "ghostlyTabs_activeId") - UserDefaults.standard.removeObject(forKey: "text") - return TabManager() + TabManager() } @Test("Manager initializes with one empty tab") func managerInitializesWithOneTab() { let manager = freshManager() + #expect(manager.tabs.count == 1) - #expect(manager.activeTabId != nil) + #expect(manager.activeTabId == manager.tabs.first?.id) #expect(manager.tabs.first?.content == "") + #expect(!manager.hasDirtyChanges) } @Test("New tab creates and activates a new tab") func newTabCreatesAndActivates() { let manager = freshManager() - let initialCount = manager.tabs.count + var flushReasons: [FlushReason] = [] + manager.onPersistenceTrigger = { trigger in + if case let .flush(reason) = trigger { + flushReasons.append(reason) + } + } let newTab = manager.newTab() - #expect(manager.tabs.count == initialCount + 1) + #expect(manager.tabs.count == 2) #expect(manager.activeTabId == newTab.id) + #expect(manager.hasDirtyChanges) + #expect(flushReasons == [.tabCreated]) } - @Test("Close tab removes the specified tab") - func closeTabRemovesTab() { + @Test("Closing a tab reindexes remaining tabs for persistence") + func closingTabReindexesRemainingTabs() { let manager = freshManager() let firstTab = manager.tabs[0] - let _ = manager.newTab() + let middleTab = manager.newTab() + let lastTab = manager.newTab() + manager.markPersisted(manager.currentChangeSet()) - #expect(manager.tabs.count == 2) + var flushReasons: [FlushReason] = [] + manager.onPersistenceTrigger = { trigger in + if case let .flush(reason) = trigger { + flushReasons.append(reason) + } + } - manager.closeTab(firstTab.id) + manager.closeTab(middleTab.id) + let changeSet = manager.currentChangeSet() - #expect(manager.tabs.count == 1) - #expect(!manager.tabs.contains { $0.id == firstTab.id }) + #expect(manager.tabs.map(\.id) == [firstTab.id, lastTab.id]) + #expect(Set(changeSet.deletedTabIDs) == [middleTab.id]) + #expect(changeSet.upsertedTabs.map(\.id) == [firstTab.id, lastTab.id]) + #expect(changeSet.upsertedTabs.map(\.sortIndex) == [0, 1]) + #expect(flushReasons == [.tabClosed]) } - @Test("Closing last tab creates new empty tab") + @Test("Closing last tab creates a fresh empty replacement") func closingLastTabCreatesNew() { let manager = freshManager() - #expect(manager.tabs.count == 1) - let onlyTab = manager.tabs[0] + manager.closeTab(onlyTab.id) #expect(manager.tabs.count == 1) #expect(manager.tabs[0].id != onlyTab.id) #expect(manager.tabs[0].content == "") + #expect(manager.activeTabId == manager.tabs[0].id) } - @Test("Closing active tab selects adjacent tab") - func closingActiveTabSelectsAdjacent() { - let manager = freshManager() - let tab1 = manager.tabs[0] - let tab2 = manager.newTab() - let tab3 = manager.newTab() - - // Active is tab3, close it - manager.closeTab(tab3.id) - #expect(manager.activeTabId == tab2.id) - - // Active is tab2, close it - manager.closeTab(tab2.id) - #expect(manager.activeTabId == tab1.id) - } - - @Test("Select tab changes active tab") + @Test("Selecting tab changes active tab and requests flush") func selectTabChangesActive() { let manager = freshManager() - let tab1 = manager.tabs[0] - let _ = manager.newTab() - - manager.selectTab(tab1.id) - - #expect(manager.activeTabId == tab1.id) - } - - @Test("Select nonexistent tab does nothing") - func selectNonexistentTabDoesNothing() { - let manager = freshManager() - let currentActive = manager.activeTabId - - manager.selectTab(UUID()) + let firstTab = manager.tabs[0] + _ = manager.newTab() + manager.markPersisted(manager.currentChangeSet()) - #expect(manager.activeTabId == currentActive) - } + var flushReasons: [FlushReason] = [] + manager.onPersistenceTrigger = { trigger in + if case let .flush(reason) = trigger { + flushReasons.append(reason) + } + } - @Test("Active tab binding reads content") - func activeTabBindingReadsContent() { - let manager = freshManager() - manager.activeTabBinding.wrappedValue = "Hello" + manager.selectTab(firstTab.id) - #expect(manager.activeTabBinding.wrappedValue == "Hello") + #expect(manager.activeTabId == firstTab.id) + #expect(flushReasons == [.tabSwitch]) + #expect(manager.currentChangeSet().metadataRevision != nil) } - @Test("Active tab binding writes content") + @Test("Active tab binding writes content and schedules autosave") func activeTabBindingWritesContent() { let manager = freshManager() + var autosaveRequests = 0 + manager.onPersistenceTrigger = { trigger in + if case .scheduleAutosave = trigger { + autosaveRequests += 1 + } + } + manager.activeTabBinding.wrappedValue = "Test content" + let changeSet = manager.currentChangeSet() #expect(manager.tabs.first?.content == "Test content") + #expect(changeSet.upsertedTabs.count == 1) + #expect(changeSet.upsertedTabs.first?.content == "Test content") + #expect(autosaveRequests == 1) } - @Test("Close active tab convenience method works") - func closeActiveTabConvenienceMethod() { + @Test("No-op edit does not schedule autosave") + func noOpEditDoesNotScheduleAutosave() { let manager = freshManager() - let tab1 = manager.tabs[0] - let _ = manager.newTab() + var autosaveRequests = 0 + manager.onPersistenceTrigger = { trigger in + if case .scheduleAutosave = trigger { + autosaveRequests += 1 + } + } - #expect(manager.tabs.count == 2) - #expect(manager.activeTabId != tab1.id) + manager.activeTabBinding.wrappedValue = "" - manager.closeActiveTab() - - #expect(manager.tabs.count == 1) - #expect(manager.activeTabId == tab1.id) + #expect(!manager.hasDirtyChanges) + #expect(autosaveRequests == 0) } - @Test("Active tab property returns correct tab") - func activeTabPropertyReturnsCorrectTab() { + @Test("Loading snapshot hydrates manager and clears dirty state") + func loadingSnapshotHydratesAndClearsDirtyState() { let manager = freshManager() - let activeTab = manager.activeTab - - #expect(activeTab != nil) - #expect(activeTab?.id == manager.activeTabId) - } - - @Test("Migrates legacy text storage") - func migratesLegacyTextStorage() { - UserDefaults.standard.removeObject(forKey: "ghostlyTabs") - UserDefaults.standard.removeObject(forKey: "ghostlyTabs_activeId") - UserDefaults.standard.set("Legacy content", forKey: "text") - - let manager = TabManager() - - #expect(manager.tabs.count == 1) - #expect(manager.tabs.first?.content == "Legacy content") - #expect(UserDefaults.standard.string(forKey: "text") == nil) - } - - // MARK: - Tab Navigation Tests - - @Test("Select tab at valid index changes active tab") - func selectTabAtValidIndex() { + manager.activeTabBinding.wrappedValue = "Dirty content" + + let first = PersistedTab( + id: UUID(), + content: "First", + createdAt: Date(timeIntervalSince1970: 1), + updatedAt: Date(timeIntervalSince1970: 2), + sortIndex: 0 + ) + let second = PersistedTab( + id: UUID(), + content: "Second", + createdAt: Date(timeIntervalSince1970: 3), + updatedAt: Date(timeIntervalSince1970: 4), + sortIndex: 1 + ) + + manager.load(snapshot: NotesSnapshot(tabs: [first, second], activeTabID: second.id)) + + #expect(manager.tabs.map(\.id) == [first.id, second.id]) + #expect(manager.activeTabId == second.id) + #expect(manager.activeTabBinding.wrappedValue == "Second") + #expect(!manager.hasDirtyChanges) + } + + @Test("Mark persisted ignores stale revisions") + func markPersistedIgnoresStaleRevisions() { let manager = freshManager() - let tab1 = manager.tabs[0] - let tab2 = manager.newTab() - let _ = manager.newTab() - manager.selectTabAtIndex(0) - #expect(manager.activeTabId == tab1.id) + manager.activeTabBinding.wrappedValue = "First edit" + let staleChangeSet = manager.currentChangeSet() + manager.activeTabBinding.wrappedValue = "Second edit" - manager.selectTabAtIndex(1) - #expect(manager.activeTabId == tab2.id) - } - - @Test("Select tab at invalid index does nothing") - func selectTabAtInvalidIndex() { - let manager = freshManager() - let currentActive = manager.activeTabId + manager.markPersisted(staleChangeSet) - manager.selectTabAtIndex(-1) - #expect(manager.activeTabId == currentActive) - - manager.selectTabAtIndex(100) - #expect(manager.activeTabId == currentActive) + #expect(manager.hasDirtyChanges) + #expect(manager.currentChangeSet().upsertedTabs.first?.content == "Second edit") } - @Test("Next tab wraps around to first") - func nextTabWrapsAround() { + @Test("Current snapshot reflects tab order and active tab") + func currentSnapshotReflectsOrderAndActiveTab() { let manager = freshManager() - let tab1 = manager.tabs[0] - let tab2 = manager.newTab() - let tab3 = manager.newTab() + manager.activeTabBinding.wrappedValue = "First" + let secondTab = manager.newTab() + manager.activeTabBinding.wrappedValue = "Second" - // Start at tab3 (last created is active) - #expect(manager.activeTabId == tab3.id) + let snapshot = manager.currentSnapshot() - manager.selectNextTab() - #expect(manager.activeTabId == tab1.id) - - manager.selectNextTab() - #expect(manager.activeTabId == tab2.id) - - manager.selectNextTab() - #expect(manager.activeTabId == tab3.id) + #expect(snapshot.tabs.map(\.sortIndex) == [0, 1]) + #expect(snapshot.tabs.map(\.content) == ["First", "Second"]) + #expect(snapshot.activeTabID == secondTab.id) } - @Test("Previous tab wraps around to last") - func previousTabWrapsAround() { + @Test("Tab navigation wraps across tabs") + func tabNavigationWraps() { let manager = freshManager() - let tab1 = manager.tabs[0] - let tab2 = manager.newTab() - let tab3 = manager.newTab() - - // Start at tab3 - #expect(manager.activeTabId == tab3.id) - - manager.selectPreviousTab() - #expect(manager.activeTabId == tab2.id) - - manager.selectPreviousTab() - #expect(manager.activeTabId == tab1.id) - - manager.selectPreviousTab() - #expect(manager.activeTabId == tab3.id) - } + let firstTab = manager.tabs[0] + let secondTab = manager.newTab() + let thirdTab = manager.newTab() - @Test("Next tab does nothing with single tab") - func nextTabSingleTab() { - let manager = freshManager() - let onlyTab = manager.tabs[0] + #expect(manager.activeTabId == thirdTab.id) manager.selectNextTab() - #expect(manager.activeTabId == onlyTab.id) - } - - @Test("Previous tab does nothing with single tab") - func previousTabSingleTab() { - let manager = freshManager() - let onlyTab = manager.tabs[0] + #expect(manager.activeTabId == firstTab.id) manager.selectPreviousTab() - #expect(manager.activeTabId == onlyTab.id) - } - - // MARK: - Persistence Tests - - @Test("Tabs and content persist across TabManager instances") - func tabsAndContentPersistAcrossInstances() { - let manager1 = freshManager() - manager1.activeTabBinding.wrappedValue = "First tab content" - let tab2 = manager1.newTab() - manager1.activeTabBinding.wrappedValue = "Second tab content" - manager1.flushPendingSave() - - // Create a new manager that loads from the same UserDefaults - let manager2 = TabManager() - - #expect(manager2.tabs.count == 2) - #expect(manager2.tabs.contains { $0.content == "First tab content" }) - #expect(manager2.tabs.contains { $0.content == "Second tab content" }) - #expect(manager2.activeTabId == tab2.id) - } - - @Test("selectTab persists active tab across instances") - func selectTabPersistsActiveTab() { - let manager1 = freshManager() - let tab1 = manager1.tabs[0] - let _ = manager1.newTab() - let _ = manager1.newTab() - - // Select the first tab - manager1.selectTab(tab1.id) - - // New manager should restore the same active tab - let manager2 = TabManager() - #expect(manager2.activeTabId == tab1.id) - } - - @Test("Active tab falls back to first when saved ID no longer exists") - func activeTabFallsBackWhenSavedIdMissing() { - let manager1 = freshManager() - let _ = manager1.newTab() - - // Manually write a bogus activeId to UserDefaults - UserDefaults.standard.set(UUID().uuidString, forKey: "ghostlyTabs_activeId") - - let manager2 = TabManager() - #expect(manager2.activeTabId == manager2.tabs.first?.id) - } - - // MARK: - Debounce Tests - - @Test("Content changes via binding are debounced, not saved immediately") - func contentChangesAreDebounced() { - let manager = freshManager() - manager.activeTabBinding.wrappedValue = "Debounced content" - - // Without flushing, a new manager should NOT see the debounced content - let manager2 = TabManager() - #expect(manager2.tabs.first?.content != "Debounced content") - } - - @Test("flushPendingSave persists debounced content immediately") - func flushPersistsDebouncedContent() { - let manager = freshManager() - manager.activeTabBinding.wrappedValue = "Flushed content" - manager.flushPendingSave() - - let manager2 = TabManager() - #expect(manager2.tabs.first?.content == "Flushed content") - } - - @Test("Direct save actions cancel pending debounced save") - func directSaveCancelsPendingDebounce() { - let manager = freshManager() - manager.activeTabBinding.wrappedValue = "Will be saved by newTab" - // newTab() cancels debounce and saves immediately (including the content change) - let _ = manager.newTab() + #expect(manager.activeTabId == thirdTab.id) - let manager2 = TabManager() - #expect(manager2.tabs.contains { $0.content == "Will be saved by newTab" }) + manager.selectTab(secondTab.id) + #expect(manager.activeTabId == secondTab.id) } } -// MARK: - Unicode/CJK Title Truncation Tests (Ghostly-r8t) +// MARK: - Unicode/CJK Title Truncation Tests @Suite("Unicode Title Truncation Tests") struct UnicodeTitleTruncationTests { @@ -422,14 +336,12 @@ struct UnicodeTitleTruncationTests { @Test("Latin text exactly at 20 visual width is not truncated") func latinExactly20NotTruncated() { - let tab = GhostlyTab(content: "12345678901234567890") // 20 Latin chars = 20 visual width + let tab = GhostlyTab(content: "12345678901234567890") #expect(tab.title == "12345678901234567890") } @Test("CJK text truncates sooner due to double visual width") func cjkTextTruncatesSooner() { - // 13 CJK/Katakana chars = 26 visual width > 20, should truncate - // Target width = 17. 8 CJK chars = 16 visual (fits), 9th = 18 (exceeds) let tab = GhostlyTab(content: "日本語のテストテキストです") #expect(tab.title.hasSuffix("...")) #expect(tab.title == "日本語のテストテ...") @@ -437,7 +349,6 @@ struct UnicodeTitleTruncationTests { @Test("CJK text at exactly 20 visual width is not truncated") func cjkExactly20VisualWidth() { - // 10 CJK chars = 20 visual width exactly, should NOT truncate let tab = GhostlyTab(content: "日本語テストテキス九") #expect(!tab.title.hasSuffix("...")) #expect(tab.title == "日本語テストテキス九") @@ -445,8 +356,6 @@ struct UnicodeTitleTruncationTests { @Test("Mixed Latin and CJK text truncates by visual width") func mixedLatinCjkTruncation() { - // "Hello" (5) + CJK chars (2 each) = 25 visual > 20 - // Target = 17: "Hello"(5) + 6 CJK(12) = 17, next CJK would exceed let tab = GhostlyTab(content: "Hello日本語テストテキスト") #expect(tab.title.hasSuffix("...")) #expect(tab.title == "Hello日本語テスト...") @@ -454,8 +363,6 @@ struct UnicodeTitleTruncationTests { @Test("Korean Hangul text truncates at correct visual width") func koreanHangulTruncation() { - // 11 Hangul chars = 22 visual width > 20 - // 8 Hangul = 16 visual (fits target 17), 9th = 18 (exceeds) let tab = GhostlyTab(content: "안녕하세요테스트입니다") #expect(tab.title.hasSuffix("...")) #expect(tab.title == "안녕하세요테스트...") @@ -463,19 +370,15 @@ struct UnicodeTitleTruncationTests { @Test("Emoji with ZWJ sequences handled correctly") func emojiZwjSequences() { - // Short text with ZWJ emoji should not truncate let shortEmoji = GhostlyTab(content: "Hi 👨‍👩‍👧‍👦 there") #expect(!shortEmoji.title.hasSuffix("...")) - // Long text with emoji should truncate let longEmoji = GhostlyTab(content: "Hello 👨‍👩‍👧‍👦 World Test Content Here Extra More") #expect(longEmoji.title.hasSuffix("...")) } @Test("Fullwidth Latin characters count as double width") func fullwidthLatinChars() { - // 11 fullwidth chars = 22 visual > 20 - // 8 fullwidth = 16 visual (fits target 17), 9th = 18 (exceeds) let tab = GhostlyTab(content: "ABCDEFGHIJK") #expect(tab.title.hasSuffix("...")) #expect(tab.title == "ABCDEFGH...") @@ -483,7 +386,6 @@ struct UnicodeTitleTruncationTests { @Test("Japanese Hiragana counts as double width") func japaneseHiraganaTruncation() { - // 11 hiragana = 22 visual > 20 let tab = GhostlyTab(content: "あいうえおかきくけこさ") #expect(tab.title.hasSuffix("...")) #expect(tab.title == "あいうえおかきく...") From 127d44bd22c537851be696f180a00a054faa37b4 Mon Sep 17 00:00:00 2001 From: Gabko14 Date: Sun, 1 Mar 2026 17:06:11 +0100 Subject: [PATCH 2/7] chore: sync beads --- .beads/issues.jsonl | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/.beads/issues.jsonl b/.beads/issues.jsonl index 9cfb5e2..2b0629c 100644 --- a/.beads/issues.jsonl +++ b/.beads/issues.jsonl @@ -1,5 +1,6 @@ {"id":"ghostly-04s","title":"Add find/search functionality in editor","description":"Feature: Add a search bar (Cmd+F) to find text within the current note/tab. Should support: 1) Basic text search with match highlighting 2) Navigate between matches (next/previous) 3) Standard Cmd+F shortcut to toggle the search bar 4) Visual feedback showing match count and current position","status":"open","priority":2,"issue_type":"feature","owner":"gabkolistiak@gmail.com","created_at":"2026-02-22T22:45:57Z","created_by":"Gabko14","updated_at":"2026-03-01T14:43:20.569963Z","source_repo":".","compaction_level":0,"original_size":0} {"id":"ghostly-12i","title":"Support live markdown rendering (checkboxes, bullet points)","description":"## Overview\nAdd live markdown rendering so users can type markdown syntax and see it rendered inline.\n\n## Scope\n- `[]` or `[ ]` → renders as an unchecked checkbox\n- `[x]` → renders as a checked checkbox\n- `*` or `-` at line start → renders as a bullet point\n- Potentially: `#`, `##`, etc. for headings\n\n## Behavior\n- Transformation should happen as the user types (live preview)\n- The underlying text should still be markdown (for copy/paste compatibility)\n- Checkboxes should be interactive (clickable to toggle)\n\n## References\n- Similar to Notion, Obsidian, Bear, and other modern note apps","status":"closed","priority":2,"issue_type":"feature","owner":"gabkolistiak@gmail.com","created_at":"2026-01-20T20:06:06Z","created_by":"Gabko14","updated_at":"2026-01-24T00:06:02Z","closed_at":"2026-01-24T00:06:02Z","close_reason":"Implemented live markdown rendering via PR #33","source_repo":".","compaction_level":0,"original_size":0} +{"id":"ghostly-18n","title":"Replace note persistence with durable SQLite store","status":"closed","priority":1,"issue_type":"feature","assignee":"gabko14","created_at":"2026-03-01T15:47:22.883918Z","created_by":"gabko14","updated_at":"2026-03-01T16:05:17.787509Z","closed_at":"2026-03-01T16:05:17.786745Z","close_reason":"Completed","source_repo":".","compaction_level":0,"original_size":0} {"id":"ghostly-1b1","title":"Add accessibility labels to theme buttons","description":"Theme buttons have .help() tooltips and .accessibilityIdentifier() but lack .accessibilityLabel() for VoiceOver users.\n\n## Changes\nAdd .accessibilityLabel() to each theme button in ThemeEditorView.swift:\n- \"System theme\" \n- \"Light theme\"\n- \"Dark theme\"\n\n## File\nThemeEditorView.swift (lines 30, 45, 60)","status":"closed","priority":3,"issue_type":"task","owner":"gabkolistiak@gmail.com","created_at":"2026-01-18T00:24:40Z","created_by":"Gabko14","updated_at":"2026-01-18T01:14:49Z","closed_at":"2026-01-18T01:14:49Z","close_reason":"Completed in PR #16","source_repo":".","compaction_level":0,"original_size":0} {"id":"ghostly-1bg","title":"Improve inline markdown: add header rendering to MarkdownTransformer","description":"Enhance the existing MarkdownTransformer to support headers (e.g. # Title renders with larger font), alongside the existing bullet points, checkboxes, and bold text. This is the Google Keep-style approach — inline rendering in the editor itself, no separate preview mode. Text stays always selectable and editable. Consider adding a formatting toolbar for headers in the future.","status":"closed","priority":2,"issue_type":"feature","owner":"gabkolistiak@gmail.com","created_at":"2026-02-09T11:28:04Z","created_by":"Gabko14","updated_at":"2026-02-14T20:23:33Z","closed_at":"2026-02-14T20:23:33Z","close_reason":"Not feasible with current TextEditor + plain String architecture; true inline header styling requires attributed rich-text editor refactor","source_repo":".","compaction_level":0,"original_size":0} {"id":"ghostly-1ev","title":"selectTab should save to persist active tab across sessions","description":"In TabManager.swift, `selectTab()` changes the active tab but doesn't persist the change:\n\n```swift\nfunc selectTab(_ tabId: UUID) {\n guard tabs.contains(where: { $0.id == tabId }) else { return }\n activeTabId = tabId\n // Missing: saveTabs() or save activeTabId\n}\n```\n\n**Problem:**\n- If the user selects a different tab and the app is killed (not gracefully closed)\n- On next launch, the wrong tab will be active\n- Other methods like `newTab()` and `closeTab()` correctly call `saveTabs()`\n\n**Solution:**\nEither call `saveTabs()` in `selectTab()`, or save just the activeTabId to UserDefaults.\n\n**File:** `Ghostly/TabManager.swift:94-97`","status":"closed","priority":3,"issue_type":"task","owner":"gabkolistiak@gmail.com","created_at":"2026-01-23T23:23:09Z","created_by":"Gabko14","updated_at":"2026-02-18T08:47:22Z","closed_at":"2026-02-18T08:47:22Z","close_reason":"Fixed in PR #48 — saveTabs() added to selectTab and navigation methods, persistence tests added","source_repo":".","compaction_level":0,"original_size":0} @@ -55,7 +56,7 @@ {"id":"ghostly-okv","title":"Create SettingsView overlay","description":"Create a SettingsView that displays as a popover overlay (same pattern as ThemeEditorView).\n\n## Requirements\n- \"Settings\" title at top\n- \"Launch at Login\" toggle with .switch style\n- Same styling as ThemeEditorView (240x240, rounded corners, windowBackgroundColor)\n- Accessibility identifier for toggle\n\n## Files to create\n- Ghostly/Views/SettingsView.swift\n\n## Files to modify\n- Ghostly/Views/ContentView.swift (add settings overlay, same pattern as theme editor)\n\n## Blocked by\n- SettingsManager implementation","status":"closed","priority":2,"issue_type":"feature","owner":"gabkolistiak@gmail.com","created_at":"2026-01-18T01:30:12Z","created_by":"Gabko14","updated_at":"2026-01-19T00:07:16Z","closed_at":"2026-01-19T00:07:16Z","close_reason":"Implemented in PR #18","source_repo":".","compaction_level":0,"original_size":0} {"id":"ghostly-qq8","title":"Fix hardcoded corner radius in InnerGlowModifier","description":"InnerGlowModifier in GhostlyModifiers.swift has a hardcoded `cornerRadius: 12` (line 20), but SettingsView uses it with `cornerRadius: 20` for its background.\n\n**Problem:**\n- The inner glow overlay uses 12px corners\n- The settings view background uses 20px corners\n- Visual mismatch where glow doesn't follow the actual shape\n\n**Current code:**\n```swift\n// GhostlyModifiers.swift:20\nRoundedRectangle(cornerRadius: 12) // hardcoded\n\n// SettingsView.swift:72\n.background(.ultraThinMaterial, in: RoundedRectangle(cornerRadius: 20))\n```\n\n**Solution:**\nMake corner radius a parameter of the InnerGlowModifier, defaulting to 12 for backwards compatibility.\n\n**Files:**\n- `Ghostly/Styles/GhostlyModifiers.swift:20`\n- `Ghostly/Views/SettingsView.swift:70`","status":"closed","priority":2,"issue_type":"bug","owner":"gabkolistiak@gmail.com","created_at":"2026-01-23T23:22:27Z","created_by":"Gabko14","updated_at":"2026-02-14T18:21:38Z","closed_at":"2026-02-14T18:21:38Z","close_reason":"Completed","source_repo":".","compaction_level":0,"original_size":0} {"id":"ghostly-r60","title":"Remove Cmd+, keyboard shortcut for settings","description":"## Task\n\nRemove the Cmd+, keyboard shortcut for opening settings.\n\n## Rationale\n\nThe settings shortcut is unnecessary for a simple menu bar app - users can just click the ellipsis menu button to access settings. Removing it simplifies the codebase and eliminates the intermittent bug where the shortcut only works every other time.\n\n## Changes Required\n\n1. Remove `KeyboardShortcuts.onKeyUp(for: .toggleSettings)` from ContentView.swift\n2. Remove the `.toggleSettings` shortcut definition from KeyboardShortcuts+Extensions.swift (if dedicated)\n3. Remove `.keyboardShortcut(\",\")` from the Settings button in DropdownMenuView.swift\n4. Clean up any related imports or unused code","status":"closed","priority":2,"issue_type":"task","owner":"gabkolistiak@gmail.com","created_at":"2026-01-20T17:54:24Z","created_by":"Gabko14","updated_at":"2026-01-20T19:55:00Z","closed_at":"2026-01-20T19:55:00Z","close_reason":"Fixed in PR #29","source_repo":".","compaction_level":0,"original_size":0} -{"id":"ghostly-r8t","title":"Tab title truncation may split Unicode grapheme clusters","description":"In Tab.swift, the title truncation uses `String.prefix(17)`:\n\n```swift\nreturn String(firstLine.prefix(17)) + \"...\"\n```\n\n**Problem:**\nSwift's `prefix(_:)` operates on Characters which should handle grapheme clusters correctly, but complex emoji sequences could still cause issues in edge cases. More importantly, there's no consideration for visual width - CJK characters are visually wider than Latin characters.\n\n**Example issues:**\n- 17 CJK characters would overflow the UI width\n- Some emoji with zero-width joiners might have unexpected behavior\n\n**Solution:**\nConsider using a width-based truncation rather than character-count-based, or at minimum add tests for edge cases with CJK text and complex emoji.\n\n**File:** `Ghostly/Models/Tab.swift:31-32`","status":"closed","priority":3,"issue_type":"task","owner":"gabkolistiak@gmail.com","created_at":"2026-01-23T23:23:07Z","created_by":"Gabko14","updated_at":"2026-02-18T08:47:49Z","source_repo":".","compaction_level":0,"original_size":0} +{"id":"ghostly-r8t","title":"Tab title truncation may split Unicode grapheme clusters","description":"In Tab.swift, the title truncation uses `String.prefix(17)`:\n\n```swift\nreturn String(firstLine.prefix(17)) + \"...\"\n```\n\n**Problem:**\nSwift's `prefix(_:)` operates on Characters which should handle grapheme clusters correctly, but complex emoji sequences could still cause issues in edge cases. More importantly, there's no consideration for visual width - CJK characters are visually wider than Latin characters.\n\n**Example issues:**\n- 17 CJK characters would overflow the UI width\n- Some emoji with zero-width joiners might have unexpected behavior\n\n**Solution:**\nConsider using a width-based truncation rather than character-count-based, or at minimum add tests for edge cases with CJK text and complex emoji.\n\n**File:** `Ghostly/Models/Tab.swift:31-32`","status":"done","priority":3,"issue_type":"task","owner":"gabkolistiak@gmail.com","created_at":"2026-01-23T23:23:07Z","created_by":"Gabko14","updated_at":"2026-02-18T08:47:49Z","source_repo":".","compaction_level":0,"original_size":0} {"id":"ghostly-scb","title":"Fix deprecated APIs","description":"Update deprecated API usages.\n\n## Step 1: Explore\nSearch for deprecated APIs including:\n- .animation() without value parameter\n- .cornerRadius()\n- PreviewProvider protocol\n\n## Step 2: Update\n- .animation() → .animation(_:value:)\n- .cornerRadius() → .clipShape(RoundedRectangle())\n- PreviewProvider → #Preview macro","status":"closed","priority":2,"issue_type":"task","owner":"gabkolistiak@gmail.com","created_at":"2026-01-12T15:48:39Z","created_by":"Gabko14","updated_at":"2026-01-17T22:10:59Z","closed_at":"2026-01-17T22:10:59Z","close_reason":"Merged PR #13","source_repo":".","compaction_level":0,"original_size":0} {"id":"ghostly-tiq","title":"Add keyboard shortcuts for switching tabs","description":"Add keyboard shortcuts to quickly switch between tabs. Common patterns include Cmd+1/2/3 for specific tabs and Cmd+Shift+[ / ] for previous/next tab navigation.","status":"closed","priority":2,"issue_type":"feature","owner":"gabkolistiak@gmail.com","created_at":"2026-01-30T22:11:38Z","created_by":"Gabko14","updated_at":"2026-01-30T22:24:12Z","closed_at":"2026-01-30T22:24:12Z","close_reason":"Implemented in PR #36","source_repo":".","compaction_level":0,"original_size":0} {"id":"ghostly-uvo","title":"Add comprehensive test coverage","description":"The app currently has basic test coverage. We need extensive tests covering all functionality to reach >80% coverage.\n\n## Current State (as of PR #18)\n- **Unit tests**: 12 tests total\n - ThemeManagerTests: 7 tests\n - SettingsManagerTests: 3 tests\n - GhostlyUITests: 2 tests (app launches/terminates)\n- **Coverage**: ~30% of code\n\n## Required Test Coverage\n\n### Unit Tests\n\n#### ThemeManager (expand existing)\n- [ ] Test bgColor values for each theme\n- [ ] Test textColor values for each theme\n- [ ] Test invalid theme string handling\n\n#### SettingsManager (expand existing)\n- [ ] Test SMAppService registration error handling\n- [ ] Test state sync when system settings change\n\n#### ContentView Tests (new)\n- [ ] Text persistence via @AppStorage\n- [ ] Placeholder visibility when text is empty\n- [ ] Placeholder hidden when text has content\n- [ ] Theme editor overlay shows/hides\n- [ ] Settings overlay shows/hides\n- [ ] Focus state management\n\n### UI Tests (XCUITest)\n\n#### Menu Bar Integration\n- [ ] App appears in menu bar\n- [ ] Clicking menu bar icon opens popover\n- [ ] Popover has correct size\n\n#### Text Editor\n- [ ] Can type text in editor\n- [ ] Text persists after closing/reopening popover\n- [ ] Placeholder shows when empty\n- [ ] Placeholder disappears when typing\n\n#### Settings Flow\n- [ ] Settings button appears in dropdown menu\n- [ ] Clicking Settings opens overlay\n- [ ] Launch at Login toggle works\n- [ ] Clicking outside dismisses overlay\n\n#### Theme Switching\n- [ ] Theme editor opens\n- [ ] Can select System/Light/Dark themes\n- [ ] Theme persists after closing/reopening\n\n#### Dropdown Menu\n- [ ] Menu button is accessible\n- [ ] Quit option works\n\n## Acceptance Criteria\n- All unit tests pass with Swift 6 strict concurrency\n- UI tests cover core user flows\n- Code coverage > 80%\n- Tests run in CI pipeline","status":"closed","priority":1,"issue_type":"feature","owner":"gabkolistiak@gmail.com","created_at":"2026-01-18T01:23:50Z","created_by":"Gabko14","updated_at":"2026-01-20T19:14:08Z","closed_at":"2026-01-20T19:14:08Z","close_reason":"Closed","source_repo":".","compaction_level":0,"original_size":0} From 9d533d111d16894503161490c02495abf45ff867 Mon Sep 17 00:00:00 2001 From: Gabko14 Date: Sun, 1 Mar 2026 17:11:26 +0100 Subject: [PATCH 3/7] fix: tighten notes store durability --- Ghostly/Persistence/NotesStore.swift | 13 ++++++++++--- GhostlyTests/NotesStoreTests.swift | 15 +++++++++++++++ 2 files changed, 25 insertions(+), 3 deletions(-) diff --git a/Ghostly/Persistence/NotesStore.swift b/Ghostly/Persistence/NotesStore.swift index 83110b1..c2abf7c 100644 --- a/Ghostly/Persistence/NotesStore.swift +++ b/Ghostly/Persistence/NotesStore.swift @@ -140,6 +140,7 @@ actor NotesStore { try writeFullSnapshot(snapshot) } + openedFreshStore = false return snapshot } @@ -472,11 +473,17 @@ actor NotesStore { private func writeLatestBackupSnapshot(_ snapshot: NotesSnapshot) throws { let data = try JSONEncoder().encode(snapshot) let tempURL = latestBackupURL.appendingPathExtension("tmp") - try data.write(to: tempURL, options: .atomic) + if fileManager.fileExists(atPath: tempURL.path) { + try fileManager.removeItem(at: tempURL) + } + + try data.write(to: tempURL) + if fileManager.fileExists(atPath: latestBackupURL.path) { - try fileManager.removeItem(at: latestBackupURL) + _ = try fileManager.replaceItemAt(latestBackupURL, withItemAt: tempURL, backupItemName: nil, options: []) + } else { + try fileManager.moveItem(at: tempURL, to: latestBackupURL) } - try fileManager.moveItem(at: tempURL, to: latestBackupURL) } private func writeMigrationBackup(_ snapshot: NotesSnapshot) throws -> URL { diff --git a/GhostlyTests/NotesStoreTests.swift b/GhostlyTests/NotesStoreTests.swift index 2b677dc..181496d 100644 --- a/GhostlyTests/NotesStoreTests.swift +++ b/GhostlyTests/NotesStoreTests.swift @@ -74,6 +74,21 @@ struct NotesStoreTests { #expect(snapshot.activeTabID == snapshot.tabs.first?.id) } + @Test("Fresh store is no longer considered migratable after initial load") + func freshStoreStopsReportingAsFreshAfterLoad() async throws { + let environment = try makeEnvironment() + defer { + UserDefaults(suiteName: environment.suiteName)?.removePersistentDomain(forName: environment.suiteName) + try? FileManager.default.removeItem(at: environment.rootURL) + } + + try await environment.store.open() + _ = try await environment.store.loadSnapshot() + + let secondMigrationCheck = try await environment.store.migrateLegacyUserDefaultsIfNeeded() + #expect(secondMigrationCheck == .notNeeded) + } + @Test("Store round-trips persisted tabs and active tab") func storeRoundTripsSnapshot() async throws { let environment = try makeEnvironment() From 2b93526c7e1caba81d0e6a4a9756c68dce479b22 Mon Sep 17 00:00:00 2001 From: Gabko14 Date: Sun, 1 Mar 2026 17:14:52 +0100 Subject: [PATCH 4/7] fix: harden persistence edge cases --- Ghostly/Persistence/NotesStore.swift | 29 +++++- .../Persistence/PersistenceCoordinator.swift | 88 ++++++++++++++----- GhostlyTests/NotesStoreTests.swift | 25 ++++++ 3 files changed, 119 insertions(+), 23 deletions(-) diff --git a/Ghostly/Persistence/NotesStore.swift b/Ghostly/Persistence/NotesStore.swift index c2abf7c..acade11 100644 --- a/Ghostly/Persistence/NotesStore.swift +++ b/Ghostly/Persistence/NotesStore.swift @@ -207,7 +207,7 @@ actor NotesStore { func migrateLegacyUserDefaultsIfNeeded() throws -> MigrationResult { try open() - guard openedFreshStore else { return .notNeeded } + guard try canMigrateLegacyData() else { return .notNeeded } if let data = userDefaults.data(forKey: legacyTabsKey) { do { @@ -380,6 +380,18 @@ actor NotesStore { try setMetadata(schemaVersion, for: "schema_version") } + private func canMigrateLegacyData() throws -> Bool { + if openedFreshStore { + return true + } + + return try isWorkspaceEmpty() + } + + private func isWorkspaceEmpty() throws -> Bool { + (try querySingleInt("SELECT COUNT(*) FROM tabs;") ?? 0) == 0 + } + private func fetchUserTables() throws -> Set { try queryStrings("SELECT name FROM sqlite_master WHERE type = 'table' AND name NOT LIKE 'sqlite_%';") } @@ -593,6 +605,21 @@ actor NotesStore { return String(cString: cString) } + private func querySingleInt(_ sql: String) throws -> Int? { + guard let db else { throw NotesStoreError.sqlite(message: "Database is not open") } + + var statement: OpaquePointer? + guard sqlite3_prepare_v2(db, sql, -1, &statement, nil) == SQLITE_OK else { + throw NotesStoreError.sqlite(message: Self.sqliteMessage(from: db)) + } + defer { sqlite3_finalize(statement) } + + guard sqlite3_step(statement) == SQLITE_ROW else { + return nil + } + return Int(sqlite3_column_int64(statement, 0)) + } + private func execute(_ sql: String, bind: ((OpaquePointer?) -> Void)? = nil) throws { guard let db else { throw NotesStoreError.sqlite(message: "Database is not open") } diff --git a/Ghostly/Persistence/PersistenceCoordinator.swift b/Ghostly/Persistence/PersistenceCoordinator.swift index 0b47a5c..69c5bb5 100644 --- a/Ghostly/Persistence/PersistenceCoordinator.swift +++ b/Ghostly/Persistence/PersistenceCoordinator.swift @@ -21,6 +21,7 @@ final class PersistenceCoordinator { private let backupInterval: Duration private var autosaveTask: Task? + private var currentFlushTask: Task? private var isReady = false private var isFlushing = false private var pendingFlushReason: FlushReason? @@ -61,37 +62,48 @@ final class PersistenceCoordinator { guard tabManager.hasDirtyChanges else { return } if isFlushing { - pendingFlushReason = reason + enqueuePendingFlush(reason) + await currentFlushTask?.value return } - isFlushing = true - defer { isFlushing = false } + let flushTask = Task { @MainActor [weak self] in + guard let self else { return } - var currentReason: FlushReason? = reason - while let reason = currentReason { - pendingFlushReason = nil - let changeSet = tabManager.currentChangeSet() - guard changeSet.hasChanges else { - currentReason = pendingFlushReason - continue + self.isFlushing = true + defer { + self.isFlushing = false + self.currentFlushTask = nil } - do { - let shouldWriteBackup = shouldWriteBackup(for: reason) - try await notesStore.save(changeSet, writeBackup: shouldWriteBackup) - try await notesStore.flush() - tabManager.markPersisted(changeSet) - if shouldWriteBackup { - lastBackupAt = Date() + var currentReason: FlushReason? = reason + while let reason = currentReason { + self.pendingFlushReason = nil + let changeSet = self.tabManager.currentChangeSet() + guard changeSet.hasChanges else { + currentReason = self.pendingFlushReason + continue + } + + do { + let shouldWriteBackup = self.shouldWriteBackup(for: reason) + try await self.notesStore.save(changeSet, writeBackup: shouldWriteBackup) + try await self.notesStore.flush() + self.tabManager.markPersisted(changeSet) + if shouldWriteBackup { + self.lastBackupAt = Date() + } + } catch { + self.logger.error("Failed to flush notes for \(reason.rawValue, privacy: .public): \(error.localizedDescription, privacy: .public)") + return } - } catch { - logger.error("Failed to flush notes for \(reason.rawValue, privacy: .public): \(error.localizedDescription, privacy: .public)") - return - } - currentReason = pendingFlushReason ?? (tabManager.hasDirtyChanges ? .typingIdle : nil) + currentReason = self.pendingFlushReason ?? (self.tabManager.hasDirtyChanges ? .typingIdle : nil) + } } + + currentFlushTask = flushTask + await flushTask.value } func popoverDidClose() async { @@ -130,6 +142,15 @@ final class PersistenceCoordinator { return true } } + + private func enqueuePendingFlush(_ reason: FlushReason) { + guard let pendingFlushReason else { + self.pendingFlushReason = reason + return + } + + self.pendingFlushReason = pendingFlushReason.priority >= reason.priority ? pendingFlushReason : reason + } } private extension Duration { @@ -137,3 +158,26 @@ private extension Duration { TimeInterval(components.seconds) + (TimeInterval(components.attoseconds) / 1_000_000_000_000_000_000) } } + +private extension FlushReason { + var priority: Int { + switch self { + case .typingIdle: + return 0 + case .tabSwitch: + return 1 + case .tabCreated: + return 2 + case .tabClosed: + return 3 + case .popoverClosed: + return 4 + case .appResignedActive: + return 5 + case .manualRecoveryAction: + return 6 + case .appTermination: + return 7 + } + } +} diff --git a/GhostlyTests/NotesStoreTests.swift b/GhostlyTests/NotesStoreTests.swift index 181496d..df3b08f 100644 --- a/GhostlyTests/NotesStoreTests.swift +++ b/GhostlyTests/NotesStoreTests.swift @@ -150,6 +150,31 @@ struct NotesStoreTests { #expect(await environment.store.lastMigrationBackupIfAvailable() != nil) } + @Test("Schema-only store still migrates legacy data on next launch") + func schemaOnlyStoreStillMigratesLegacyData() async throws { + let environment = try makeEnvironment(now: Date(timeIntervalSince1970: 99)) + defer { + UserDefaults(suiteName: environment.suiteName)?.removePersistentDomain(forName: environment.suiteName) + try? FileManager.default.removeItem(at: environment.rootURL) + } + + let legacyDefaults = try #require(UserDefaults(suiteName: environment.suiteName)) + legacyDefaults.set("Recovered legacy note", forKey: "text") + + try await environment.store.open() + await environment.store.close() + + try await environment.store.open() + let result = try await environment.store.migrateLegacyUserDefaultsIfNeeded() + let snapshot = try await environment.store.loadSnapshot() + + if case .migrated = result { + #expect(snapshot.tabs.first?.content == "Recovered legacy note") + } else { + #expect(Bool(false)) + } + } + @Test("Load normalizes invalid active tab and dense sort indexes") func loadNormalizesMetadataAndSortIndexes() async throws { let environment = try makeEnvironment() From 5dfe24f9429ae0d58a6240e7d70913c61535a463 Mon Sep 17 00:00:00 2001 From: Gabko14 Date: Sun, 1 Mar 2026 17:19:58 +0100 Subject: [PATCH 5/7] fix: preserve empty legacy migration failures --- Ghostly/Persistence/NotesStore.swift | 14 ++++++++++++++ GhostlyTests/NotesStoreTests.swift | 26 ++++++++++++++++++++++++++ 2 files changed, 40 insertions(+) diff --git a/Ghostly/Persistence/NotesStore.swift b/Ghostly/Persistence/NotesStore.swift index acade11..5b92f1c 100644 --- a/Ghostly/Persistence/NotesStore.swift +++ b/Ghostly/Persistence/NotesStore.swift @@ -225,11 +225,15 @@ actor NotesStore { }, activeTabID: activeTabID ) + try validateSnapshotHasTabs(snapshot, failure: NotesStoreError.migrationFailed("Legacy tabs payload is empty.")) let backupURL = try writeMigrationBackup(snapshot) try writeFullSnapshot(snapshot) clearLegacyDefaults() openedFreshStore = false return .migrated(backupURL: backupURL) + } catch let error as NotesStoreError { + try resetFreshStoreAfterFailure() + throw error } catch { try resetFreshStoreAfterFailure() throw NotesStoreError.migrationFailed(error.localizedDescription) @@ -255,6 +259,9 @@ actor NotesStore { clearLegacyDefaults() openedFreshStore = false return .migrated(backupURL: backupURL) + } catch let error as NotesStoreError { + try resetFreshStoreAfterFailure() + throw error } catch { try resetFreshStoreAfterFailure() throw NotesStoreError.migrationFailed(error.localizedDescription) @@ -282,6 +289,7 @@ actor NotesStore { let data = try Data(contentsOf: latestBackupURL) let snapshot = try JSONDecoder().decode(NotesSnapshot.self, from: data) + try validateSnapshotHasTabs(snapshot, failure: NotesStoreError.unreadableStore("Backup snapshot is empty.")) closeConnection() try removeStoreFiles() @@ -380,6 +388,12 @@ actor NotesStore { try setMetadata(schemaVersion, for: "schema_version") } + private func validateSnapshotHasTabs(_ snapshot: NotesSnapshot, failure: @autoclosure () -> Error) throws { + guard !snapshot.tabs.isEmpty else { + throw failure() + } + } + private func canMigrateLegacyData() throws -> Bool { if openedFreshStore { return true diff --git a/GhostlyTests/NotesStoreTests.swift b/GhostlyTests/NotesStoreTests.swift index df3b08f..96a94ff 100644 --- a/GhostlyTests/NotesStoreTests.swift +++ b/GhostlyTests/NotesStoreTests.swift @@ -175,6 +175,32 @@ struct NotesStoreTests { } } + @Test("Empty legacy tabs payload does not clear fallback data") + func emptyLegacyTabsPayloadDoesNotClearFallbackData() async throws { + let environment = try makeEnvironment(now: Date(timeIntervalSince1970: 123)) + defer { + UserDefaults(suiteName: environment.suiteName)?.removePersistentDomain(forName: environment.suiteName) + try? FileManager.default.removeItem(at: environment.rootURL) + } + + let defaults = try #require(UserDefaults(suiteName: environment.suiteName)) + defaults.set(try JSONEncoder().encode([GhostlyTab]()), forKey: "ghostlyTabs") + defaults.set("Fallback note", forKey: "text") + + try await environment.store.open() + + do { + _ = try await environment.store.migrateLegacyUserDefaultsIfNeeded() + #expect(Bool(false)) + } catch let error as NotesStoreError { + #expect(error.errorDescription == "Legacy note migration failed: Legacy tabs payload is empty.") + } + + let verificationDefaults = try #require(UserDefaults(suiteName: environment.suiteName)) + #expect(verificationDefaults.string(forKey: "text") == "Fallback note") + #expect(verificationDefaults.data(forKey: "ghostlyTabs") != nil) + } + @Test("Load normalizes invalid active tab and dense sort indexes") func loadNormalizesMetadataAndSortIndexes() async throws { let environment = try makeEnvironment() From c55ff44b349771db995b862390a01f2fefc56cbe Mon Sep 17 00:00:00 2001 From: Gabko14 Date: Sun, 1 Mar 2026 17:28:13 +0100 Subject: [PATCH 6/7] fix: address persistence review feedback --- Ghostly/GhostlyApp.swift | 8 +++- Ghostly/Persistence/NotesStore.swift | 3 +- .../Persistence/PersistenceCoordinator.swift | 5 +-- Ghostly/Persistence/PersistenceModels.swift | 10 +++-- GhostlyTests/NotesStoreTests.swift | 44 +++++++++++++++++++ 5 files changed, 60 insertions(+), 10 deletions(-) diff --git a/Ghostly/GhostlyApp.swift b/Ghostly/GhostlyApp.swift index f8d9a95..131e30e 100644 --- a/Ghostly/GhostlyApp.swift +++ b/Ghostly/GhostlyApp.swift @@ -12,9 +12,15 @@ import MenuBarExtraAccess @main struct GhostlyApp: App { @NSApplicationDelegateAdaptor(AppLifecycleDelegate.self) private var appLifecycleDelegate - @State private var appState = AppState() + @State private var appState: AppState @State private var statusItemContextMenuController = StatusItemContextMenuController() + init() { + let appState = AppState() + _appState = State(initialValue: appState) + appLifecycleDelegate.persistenceCoordinator = appState.persistenceCoordinator + } + var body: some Scene { MenuBarExtra("Ghostly", image: "MenubarIcon") { Group { diff --git a/Ghostly/Persistence/NotesStore.swift b/Ghostly/Persistence/NotesStore.swift index 5b92f1c..fffb5f6 100644 --- a/Ghostly/Persistence/NotesStore.swift +++ b/Ghostly/Persistence/NotesStore.swift @@ -381,7 +381,8 @@ actor NotesStore { value TEXT NOT NULL ); - CREATE UNIQUE INDEX IF NOT EXISTS tabs_sort_index_idx ON tabs(sort_index); + DROP INDEX IF EXISTS tabs_sort_index_idx; + CREATE INDEX IF NOT EXISTS tabs_sort_index_idx ON tabs(sort_index); """ ) diff --git a/Ghostly/Persistence/PersistenceCoordinator.swift b/Ghostly/Persistence/PersistenceCoordinator.swift index 69c5bb5..9652adb 100644 --- a/Ghostly/Persistence/PersistenceCoordinator.swift +++ b/Ghostly/Persistence/PersistenceCoordinator.swift @@ -8,15 +8,13 @@ import OSLog @MainActor final class PersistenceCoordinator { - static var shared: PersistenceCoordinator? - private let logger = Logger( subsystem: Bundle.main.bundleIdentifier ?? "com.ghostly.Ghostly", category: "PersistenceCoordinator" ) private let notesStore: NotesStore - private unowned let tabManager: TabManager + private let tabManager: TabManager private let autosaveDelay: Duration private let backupInterval: Duration @@ -37,7 +35,6 @@ final class PersistenceCoordinator { self.tabManager = tabManager self.autosaveDelay = autosaveDelay self.backupInterval = backupInterval - Self.shared = self } func setReady(_ isReady: Bool) { diff --git a/Ghostly/Persistence/PersistenceModels.swift b/Ghostly/Persistence/PersistenceModels.swift index ea3169e..0cc028a 100644 --- a/Ghostly/Persistence/PersistenceModels.swift +++ b/Ghostly/Persistence/PersistenceModels.swift @@ -137,18 +137,20 @@ enum NotesStoreError: LocalizedError { @MainActor final class AppLifecycleDelegate: NSObject, NSApplicationDelegate { + weak var persistenceCoordinator: PersistenceCoordinator? + func applicationDidResignActive(_ notification: Notification) { - guard let coordinator = PersistenceCoordinator.shared else { return } - Task { await coordinator.appDidResignActive() } + guard let persistenceCoordinator else { return } + Task { await persistenceCoordinator.appDidResignActive() } } func applicationShouldTerminate(_ sender: NSApplication) -> NSApplication.TerminateReply { - guard let coordinator = PersistenceCoordinator.shared else { + guard let persistenceCoordinator else { return .terminateNow } Task { - let shouldTerminate = await coordinator.handleTerminationRequest() + let shouldTerminate = await persistenceCoordinator.handleTerminationRequest() sender.reply(toApplicationShouldTerminate: shouldTerminate) } return .terminateLater diff --git a/GhostlyTests/NotesStoreTests.swift b/GhostlyTests/NotesStoreTests.swift index 96a94ff..3e00dd9 100644 --- a/GhostlyTests/NotesStoreTests.swift +++ b/GhostlyTests/NotesStoreTests.swift @@ -121,6 +121,50 @@ struct NotesStoreTests { #expect(await environment.store.latestBackupIfAvailable() != nil) } + @Test("Store can reorder tabs in a single save pass") + func storeReordersTabsWithoutSortIndexConflicts() async throws { + let environment = try makeEnvironment() + defer { + UserDefaults(suiteName: environment.suiteName)?.removePersistentDomain(forName: environment.suiteName) + try? FileManager.default.removeItem(at: environment.rootURL) + } + + let first = PersistedTab( + id: UUID(), + content: "First", + createdAt: Date(timeIntervalSince1970: 10), + updatedAt: Date(timeIntervalSince1970: 11), + sortIndex: 0 + ) + let second = PersistedTab( + id: UUID(), + content: "Second", + createdAt: Date(timeIntervalSince1970: 20), + updatedAt: Date(timeIntervalSince1970: 21), + sortIndex: 1 + ) + + try await environment.store.open() + try await environment.store.save(makeChangeSet(tabs: [first, second], activeTabID: first.id), writeBackup: false) + + var reorderedFirst = first + reorderedFirst.sortIndex = 1 + reorderedFirst.updatedAt = Date(timeIntervalSince1970: 30) + + var reorderedSecond = second + reorderedSecond.sortIndex = 0 + reorderedSecond.updatedAt = Date(timeIntervalSince1970: 31) + + try await environment.store.save( + makeChangeSet(tabs: [reorderedFirst, reorderedSecond], activeTabID: second.id), + writeBackup: false + ) + + let loaded = try await environment.store.loadSnapshot() + #expect(loaded.tabs.map(\.id) == [second.id, first.id]) + #expect(loaded.activeTabID == second.id) + } + @Test("Legacy text migrates into SQLite and clears old defaults") func legacyTextMigratesIntoStore() async throws { let environment = try makeEnvironment(now: Date(timeIntervalSince1970: 42)) From 628d845f379ae6924a7539f47a60115dca9f3d24 Mon Sep 17 00:00:00 2001 From: Gabko14 Date: Sun, 1 Mar 2026 17:32:24 +0100 Subject: [PATCH 7/7] chore: polish persistence coordinator cleanup --- Ghostly/Persistence/NotesStore.swift | 1 + .../Persistence/PersistenceCoordinator.swift | 20 +++++++++++++------ 2 files changed, 15 insertions(+), 6 deletions(-) diff --git a/Ghostly/Persistence/NotesStore.swift b/Ghostly/Persistence/NotesStore.swift index fffb5f6..d0257bd 100644 --- a/Ghostly/Persistence/NotesStore.swift +++ b/Ghostly/Persistence/NotesStore.swift @@ -689,4 +689,5 @@ actor NotesStore { } } +// SQLite uses -1 as the sentinel for SQLITE_TRANSIENT, which copies bound text. private let transientDestructor = unsafeBitCast(-1, to: sqlite3_destructor_type.self) diff --git a/Ghostly/Persistence/PersistenceCoordinator.swift b/Ghostly/Persistence/PersistenceCoordinator.swift index 9652adb..2a95fff 100644 --- a/Ghostly/Persistence/PersistenceCoordinator.swift +++ b/Ghostly/Persistence/PersistenceCoordinator.swift @@ -17,6 +17,7 @@ final class PersistenceCoordinator { private let tabManager: TabManager private let autosaveDelay: Duration private let backupInterval: Duration + private let terminationTimeout: Duration private var autosaveTask: Task? private var currentFlushTask: Task? @@ -29,12 +30,19 @@ final class PersistenceCoordinator { notesStore: NotesStore, tabManager: TabManager, autosaveDelay: Duration = .milliseconds(250), - backupInterval: Duration = .seconds(30) + backupInterval: Duration = .seconds(30), + terminationTimeout: Duration = .seconds(2) ) { self.notesStore = notesStore self.tabManager = tabManager self.autosaveDelay = autosaveDelay self.backupInterval = backupInterval + self.terminationTimeout = terminationTimeout + } + + deinit { + autosaveTask?.cancel() + currentFlushTask?.cancel() } func setReady(_ isReady: Bool) { @@ -45,11 +53,11 @@ final class PersistenceCoordinator { guard isReady else { return } autosaveTask?.cancel() - autosaveTask = Task { [weak self] in - guard let self else { return } - try? await Task.sleep(for: self.autosaveDelay) + let autosaveDelay = self.autosaveDelay + autosaveTask = Task { [weak self, autosaveDelay] in + try? await Task.sleep(for: autosaveDelay) guard !Task.isCancelled else { return } - await self.flushNow(reason: .typingIdle) + await self?.flushNow(reason: .typingIdle) } } @@ -121,7 +129,7 @@ final class PersistenceCoordinator { return true } group.addTask { - try? await Task.sleep(for: .seconds(2)) + try? await Task.sleep(for: self.terminationTimeout) return true } let result = await group.next() ?? true