From eee00197bb0f56b04cc722ce371cdb9b18e2c4ff Mon Sep 17 00:00:00 2001 From: psavarmattas Date: Fri, 14 Aug 2026 23:41:00 +0100 Subject: [PATCH 01/22] feat(offline): read-through cache for browse endpoints + offline badge MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit CachingOutlineAPIClient (OutlineKit) wraps LiveOutlineAPIClient at the existing protocol boundary: always tries live, falls back to a SwiftData key/value cache only on failure, and overwrites the cache on every live success. Applies to the read/browse path only (collections, document lists, document content) — search, writes, and sharing pass straight through uncached. Wired in once at SessionStore.makeAPIClient, so no view model needed to change. 5 new tests via a stub OutlineAPIClient (43/43 passing). NetworkMonitor (NWPathMonitor-backed, app target) is unrelated to the cache's own fallback logic — it only drives a new sidebar OfflineBanner so the user knows when they might be looking at stale content. --- .../OutlineKit/Caching/CachedPayload.swift | 18 ++ .../Caching/CachingOutlineAPIClient.swift | 260 ++++++++++++++++++ .../Caching/OfflineCacheStore.swift | 29 ++ .../CachingOutlineAPIClientTests.swift | 177 ++++++++++++ .../Collections/ContentView_macOS.swift | 4 + .../Features/Collections/OfflineBanner.swift | 21 ++ Outpost/Root/SessionStore.swift | 23 +- Outpost/Support/NetworkMonitor.swift | 29 ++ 8 files changed, 554 insertions(+), 7 deletions(-) create mode 100644 OutlineKit/Sources/OutlineKit/Caching/CachedPayload.swift create mode 100644 OutlineKit/Sources/OutlineKit/Caching/CachingOutlineAPIClient.swift create mode 100644 OutlineKit/Sources/OutlineKit/Caching/OfflineCacheStore.swift create mode 100644 OutlineKit/Tests/OutlineKitTests/CachingOutlineAPIClientTests.swift create mode 100644 Outpost/Features/Collections/OfflineBanner.swift create mode 100644 Outpost/Support/NetworkMonitor.swift diff --git a/OutlineKit/Sources/OutlineKit/Caching/CachedPayload.swift b/OutlineKit/Sources/OutlineKit/Caching/CachedPayload.swift new file mode 100644 index 0000000..5c6e8ce --- /dev/null +++ b/OutlineKit/Sources/OutlineKit/Caching/CachedPayload.swift @@ -0,0 +1,18 @@ +import Foundation +import SwiftData + +/// Generic key/value row backing `OfflineCacheStore` — one table for every +/// cached response shape instead of a `@Model` per Outline type, so adding a +/// new cached endpoint never needs a schema migration. +@Model +public final class CachedPayload { + @Attribute(.unique) public var key: String + public var payload: Data + public var cachedAt: Date + + public init(key: String, payload: Data, cachedAt: Date) { + self.key = key + self.payload = payload + self.cachedAt = cachedAt + } +} diff --git a/OutlineKit/Sources/OutlineKit/Caching/CachingOutlineAPIClient.swift b/OutlineKit/Sources/OutlineKit/Caching/CachingOutlineAPIClient.swift new file mode 100644 index 0000000..fb33445 --- /dev/null +++ b/OutlineKit/Sources/OutlineKit/Caching/CachingOutlineAPIClient.swift @@ -0,0 +1,260 @@ +import Foundation + +/// Decorates `LiveOutlineAPIClient` (or any `OutlineAPIClient`) with a +/// read-through cache for the handful of endpoints "browse the workspace +/// offline" actually needs: the collection tree, document lists, and a +/// document's own content. Every other call — search, writes, sharing, +/// pins, etc. — passes straight through, since those either don't make +/// sense offline or aren't worth the staleness risk. +/// +/// Deliberately doesn't ask "are we online?" up front — that's a separate +/// question from "did this specific request fail?", and checking first can +/// be wrong the instant connectivity flaps. Instead: always try live, and +/// fall back to the cache only on failure. A successful live response +/// always overwrites the cache, so it can never get more stale than the +/// last time this actually worked. +public actor CachingOutlineAPIClient: OutlineAPIClient { + private let live: OutlineAPIClient + private let cache: OfflineCacheStore + private let encoder: JSONEncoder + private let decoder: JSONDecoder + private let keyEncoder: JSONEncoder + + public init(live: OutlineAPIClient, cache: OfflineCacheStore) { + self.live = live + self.cache = cache + + let encoder = JSONEncoder() + encoder.dateEncodingStrategy = .iso8601 + self.encoder = encoder + + let decoder = JSONDecoder() + decoder.dateDecodingStrategy = .iso8601 + self.decoder = decoder + + let keyEncoder = JSONEncoder() + keyEncoder.outputFormatting = .sortedKeys + self.keyEncoder = keyEncoder + } + + // MARK: - Cached reads + + public func documentInfo(id: String) async throws -> OutlineDocument { + try await cachedFetch(key: "document:\(id)") { try await self.live.documentInfo(id: id) } + } + + public func listDocuments( + collectionId: String?, + parentDocumentId: String?, + offset: Int, + limit: Int + ) async throws -> [OutlineDocument] { + let key = "documents:\(collectionId ?? "-"):\(parentDocumentId ?? "-"):\(offset):\(limit)" + return try await cachedFetch(key: key) { + try await self.live.listDocuments( + collectionId: collectionId, + parentDocumentId: parentDocumentId, + offset: offset, + limit: limit + ) + } + } + + public func documentsList(_ request: DocumentsListRequest) async throws -> [OutlineDocument] { + try await cachedFetch(key: requestKey("documentsList", request)) { try await self.live.documentsList(request) } + } + + public func listViewedDocuments(offset: Int, limit: Int) async throws -> [OutlineDocument] { + try await cachedFetch(key: "documentsViewed:\(offset):\(limit)") { + try await self.live.listViewedDocuments(offset: offset, limit: limit) + } + } + + public func listCollections(offset: Int, limit: Int) async throws -> [OutlineCollection] { + try await cachedFetch(key: "collections:\(offset):\(limit)") { + try await self.live.listCollections(offset: offset, limit: limit) + } + } + + public func collectionInfo(id: String) async throws -> OutlineCollection { + try await cachedFetch(key: "collection:\(id)") { try await self.live.collectionInfo(id: id) } + } + + // MARK: - Pass-through + + public func authInfo() async throws -> OutlineAuthInfo { + try await live.authInfo() + } + + public func searchDocuments(_ request: DocumentSearchRequest) async throws -> [OutlineDocumentSearchResult] { + try await live.searchDocuments(request) + } + + public func searchDocumentTitles(_ request: DocumentSearchTitlesRequest) async throws -> [OutlineDocument] { + try await live.searchDocumentTitles(request) + } + + public func createDocument(_ request: CreateDocumentRequest) async throws -> OutlineDocument { + try await live.createDocument(request) + } + + public func updateDocument(_ request: UpdateDocumentRequest) async throws -> OutlineDocument { + try await live.updateDocument(request) + } + + public func starDocument(_ request: StarDocumentRequest) async throws -> OutlineStar { + try await live.starDocument(request) + } + + public func templatizeDocument(_ request: TemplatizeDocumentRequest) async throws -> OutlineTemplate { + try await live.templatizeDocument(request) + } + + public func duplicateDocument(_ request: DuplicateDocumentRequest) async throws -> [OutlineDocument] { + try await live.duplicateDocument(request) + } + + public func unpublishDocument(_ request: UnpublishDocumentRequest) async throws -> OutlineDocument { + try await live.unpublishDocument(request) + } + + public func archiveDocument(id: String) async throws -> OutlineDocument { + try await live.archiveDocument(id: id) + } + + public func moveDocument(_ request: MoveDocumentRequest) async throws { + try await live.moveDocument(request) + } + + public func deleteDocument(_ request: DeleteDocumentRequest) async throws { + try await live.deleteDocument(request) + } + + public func documentInsights(_ request: DocumentInsightsRequest) async throws -> [OutlineDocumentInsight] { + try await live.documentInsights(request) + } + + public func listRevisions(_ request: ListRevisionsRequest) async throws -> [OutlineRevision] { + try await live.listRevisions(request) + } + + public func exportDocument(id: String) async throws -> String { + try await live.exportDocument(id: id) + } + + public func createShare(_ request: CreateShareRequest) async throws -> OutlineShare { + try await live.createShare(request) + } + + public func shareInfo(documentId: String) async throws -> OutlineShare? { + try await live.shareInfo(documentId: documentId) + } + + public func updateShare(_ request: UpdateShareRequest) async throws -> OutlineShare { + try await live.updateShare(request) + } + + public func listShares(_ request: ListSharesRequest) async throws -> [OutlineShare] { + try await live.listShares(request) + } + + public func revokeShare(id: String) async throws { + try await live.revokeShare(id: id) + } + + public func createPin(_ request: CreatePinRequest) async throws -> OutlinePin { + try await live.createPin(request) + } + + public func listPins(_ request: ListPinsRequest) async throws -> [OutlinePin] { + try await live.listPins(request) + } + + public func deletePin(id: String) async throws { + try await live.deletePin(id: id) + } + + public func createSubscription(_ request: CreateSubscriptionRequest) async throws -> OutlineSubscription { + try await live.createSubscription(request) + } + + public func listSubscriptions(_ request: ListSubscriptionsRequest) async throws -> [OutlineSubscription] { + try await live.listSubscriptions(request) + } + + public func deleteSubscription(id: String) async throws { + try await live.deleteSubscription(id: id) + } + + public func listViews(_ request: ListViewsRequest) async throws -> [OutlineView] { + try await live.listViews(request) + } + + public func addDocumentUser(_ request: AddDocumentUserRequest) async throws -> OutlineMembership { + try await live.addDocumentUser(request) + } + + public func removeDocumentUser(_ request: RemoveDocumentUserRequest) async throws { + try await live.removeDocumentUser(request) + } + + public func documentUsers(_ request: ListDocumentUsersRequest) async throws -> [OutlineDocumentMember] { + try await live.documentUsers(request) + } + + public func listUsers(_ request: ListUsersRequest) async throws -> [OutlineUser] { + try await live.listUsers(request) + } + + public func updateCollection(_ request: UpdateCollectionRequest) async throws -> OutlineCollection { + try await live.updateCollection(request) + } + + public func deleteCollection(id: String) async throws { + try await live.deleteCollection(id: id) + } + + public func exportCollection(_ request: ExportCollectionRequest) async throws -> OutlineFileOperation { + try await live.exportCollection(request) + } + + public func starCollection(_ request: StarCollectionRequest) async throws -> OutlineStar { + try await live.starCollection(request) + } + + public func listStars(_ request: ListStarsRequest) async throws -> [OutlineStar] { + try await live.listStars(request) + } + + public func deleteStar(id: String) async throws { + try await live.deleteStar(id: id) + } + + public func currentUser() async throws -> OutlineUser { + try await live.currentUser() + } + + // MARK: - Helpers + + private func cachedFetch(key: String, fetch: () async throws -> T) async throws -> T { + do { + let result = try await fetch() + if let data = try? encoder.encode(result) { + await cache.save(data, forKey: key) + } + return result + } catch { + if let data = await cache.load(forKey: key), let cached = try? decoder.decode(T.self, from: data) { + return cached + } + throw error + } + } + + private func requestKey(_ prefix: String, _ request: some Encodable) -> String { + guard let data = try? keyEncoder.encode(request), let json = String(data: data, encoding: .utf8) else { + return prefix + } + return "\(prefix):\(json)" + } +} diff --git a/OutlineKit/Sources/OutlineKit/Caching/OfflineCacheStore.swift b/OutlineKit/Sources/OutlineKit/Caching/OfflineCacheStore.swift new file mode 100644 index 0000000..3ebf693 --- /dev/null +++ b/OutlineKit/Sources/OutlineKit/Caching/OfflineCacheStore.swift @@ -0,0 +1,29 @@ +import Foundation +import SwiftData + +/// Read-through cache backing `CachingOutlineAPIClient` — stores whatever it +/// last fetched successfully, keyed by request shape, so browsing keeps +/// working (stale) once the network stops. +@ModelActor +public actor OfflineCacheStore { + public static func makeContainer(inMemory: Bool = false) throws -> ModelContainer { + let configuration = ModelConfiguration(isStoredInMemoryOnly: inMemory) + return try ModelContainer(for: CachedPayload.self, configurations: configuration) + } + + public func save(_ data: Data, forKey key: String) { + let descriptor = FetchDescriptor(predicate: #Predicate { $0.key == key }) + if let existing = try? modelContext.fetch(descriptor).first { + existing.payload = data + existing.cachedAt = Date() + } else { + modelContext.insert(CachedPayload(key: key, payload: data, cachedAt: Date())) + } + try? modelContext.save() + } + + public func load(forKey key: String) -> Data? { + let descriptor = FetchDescriptor(predicate: #Predicate { $0.key == key }) + return try? modelContext.fetch(descriptor).first?.payload + } +} diff --git a/OutlineKit/Tests/OutlineKitTests/CachingOutlineAPIClientTests.swift b/OutlineKit/Tests/OutlineKitTests/CachingOutlineAPIClientTests.swift new file mode 100644 index 0000000..d96e613 --- /dev/null +++ b/OutlineKit/Tests/OutlineKitTests/CachingOutlineAPIClientTests.swift @@ -0,0 +1,177 @@ +import XCTest +@testable import OutlineKit + +private struct NotStubbed: Error {} + +/// Conforms to the full protocol (so it can stand in for `live`), but only +/// the two methods under test in this file have real behavior — everything +/// else throws loudly if a test accidentally exercises it. +private final class StubOutlineAPIClient: OutlineAPIClient, @unchecked Sendable { + var documentInfoHandler: (@Sendable (String) async throws -> OutlineDocument)? + var listCollectionsHandler: (@Sendable (Int, Int) async throws -> [OutlineCollection])? + + func authInfo() async throws -> OutlineAuthInfo { throw NotStubbed() } + + func documentInfo(id: String) async throws -> OutlineDocument { + guard let handler = documentInfoHandler else { throw NotStubbed() } + return try await handler(id) + } + + func listDocuments(collectionId: String?, parentDocumentId: String?, offset: Int, limit: Int) async throws -> [OutlineDocument] { + throw NotStubbed() + } + + func documentsList(_ request: DocumentsListRequest) async throws -> [OutlineDocument] { throw NotStubbed() } + func listViewedDocuments(offset: Int, limit: Int) async throws -> [OutlineDocument] { throw NotStubbed() } + func searchDocuments(_ request: DocumentSearchRequest) async throws -> [OutlineDocumentSearchResult] { throw NotStubbed() } + func searchDocumentTitles(_ request: DocumentSearchTitlesRequest) async throws -> [OutlineDocument] { throw NotStubbed() } + func createDocument(_ request: CreateDocumentRequest) async throws -> OutlineDocument { throw NotStubbed() } + func updateDocument(_ request: UpdateDocumentRequest) async throws -> OutlineDocument { throw NotStubbed() } + func starDocument(_ request: StarDocumentRequest) async throws -> OutlineStar { throw NotStubbed() } + func templatizeDocument(_ request: TemplatizeDocumentRequest) async throws -> OutlineTemplate { throw NotStubbed() } + func duplicateDocument(_ request: DuplicateDocumentRequest) async throws -> [OutlineDocument] { throw NotStubbed() } + func unpublishDocument(_ request: UnpublishDocumentRequest) async throws -> OutlineDocument { throw NotStubbed() } + func archiveDocument(id: String) async throws -> OutlineDocument { throw NotStubbed() } + func moveDocument(_ request: MoveDocumentRequest) async throws { throw NotStubbed() } + func deleteDocument(_ request: DeleteDocumentRequest) async throws { throw NotStubbed() } + func documentInsights(_ request: DocumentInsightsRequest) async throws -> [OutlineDocumentInsight] { throw NotStubbed() } + func listRevisions(_ request: ListRevisionsRequest) async throws -> [OutlineRevision] { throw NotStubbed() } + func exportDocument(id: String) async throws -> String { throw NotStubbed() } + func createShare(_ request: CreateShareRequest) async throws -> OutlineShare { throw NotStubbed() } + func shareInfo(documentId: String) async throws -> OutlineShare? { throw NotStubbed() } + func updateShare(_ request: UpdateShareRequest) async throws -> OutlineShare { throw NotStubbed() } + func listShares(_ request: ListSharesRequest) async throws -> [OutlineShare] { throw NotStubbed() } + func revokeShare(id: String) async throws { throw NotStubbed() } + func createPin(_ request: CreatePinRequest) async throws -> OutlinePin { throw NotStubbed() } + func listPins(_ request: ListPinsRequest) async throws -> [OutlinePin] { throw NotStubbed() } + func deletePin(id: String) async throws { throw NotStubbed() } + func createSubscription(_ request: CreateSubscriptionRequest) async throws -> OutlineSubscription { throw NotStubbed() } + func listSubscriptions(_ request: ListSubscriptionsRequest) async throws -> [OutlineSubscription] { throw NotStubbed() } + func deleteSubscription(id: String) async throws { throw NotStubbed() } + func listViews(_ request: ListViewsRequest) async throws -> [OutlineView] { throw NotStubbed() } + func addDocumentUser(_ request: AddDocumentUserRequest) async throws -> OutlineMembership { throw NotStubbed() } + func removeDocumentUser(_ request: RemoveDocumentUserRequest) async throws { throw NotStubbed() } + func documentUsers(_ request: ListDocumentUsersRequest) async throws -> [OutlineDocumentMember] { throw NotStubbed() } + func listUsers(_ request: ListUsersRequest) async throws -> [OutlineUser] { throw NotStubbed() } + + func listCollections(offset: Int, limit: Int) async throws -> [OutlineCollection] { + guard let handler = listCollectionsHandler else { throw NotStubbed() } + return try await handler(offset, limit) + } + + func collectionInfo(id: String) async throws -> OutlineCollection { throw NotStubbed() } + func updateCollection(_ request: UpdateCollectionRequest) async throws -> OutlineCollection { throw NotStubbed() } + func deleteCollection(id: String) async throws { throw NotStubbed() } + func exportCollection(_ request: ExportCollectionRequest) async throws -> OutlineFileOperation { throw NotStubbed() } + func starCollection(_ request: StarCollectionRequest) async throws -> OutlineStar { throw NotStubbed() } + func listStars(_ request: ListStarsRequest) async throws -> [OutlineStar] { throw NotStubbed() } + func deleteStar(id: String) async throws { throw NotStubbed() } + func currentUser() async throws -> OutlineUser { throw NotStubbed() } +} + +private struct StubTransportError: Error {} + +final class CachingOutlineAPIClientTests: XCTestCase { + private func makeCache() throws -> OfflineCacheStore { + OfflineCacheStore(modelContainer: try OfflineCacheStore.makeContainer(inMemory: true)) + } + + private func makeDocument(id: String = "doc-1", title: String = "Hello") -> OutlineDocument { + OutlineDocument( + id: id, + title: title, + text: "body", + url: "/doc/\(id)", + createdAt: Date(timeIntervalSince1970: 0), + updatedAt: Date(timeIntervalSince1970: 0) + ) + } + + private func makeCollection(id: String = "col-1", name: String = "Engineering") -> OutlineCollection { + OutlineCollection( + id: id, + name: name, + createdAt: Date(timeIntervalSince1970: 0), + updatedAt: Date(timeIntervalSince1970: 0) + ) + } + + func testDocumentInfoWritesThroughToCacheOnSuccess() async throws { + let stub = StubOutlineAPIClient() + let document = makeDocument() + stub.documentInfoHandler = { _ in document } + let sut = CachingOutlineAPIClient(live: stub, cache: try makeCache()) + + let result = try await sut.documentInfo(id: "doc-1") + + XCTAssertEqual(result, document) + } + + func testDocumentInfoFallsBackToCacheWhenLiveFails() async throws { + let stub = StubOutlineAPIClient() + let document = makeDocument() + var callCount = 0 + stub.documentInfoHandler = { _ in + callCount += 1 + if callCount == 1 { return document } + throw StubTransportError() + } + let sut = CachingOutlineAPIClient(live: stub, cache: try makeCache()) + + // First call succeeds and populates the cache. + _ = try await sut.documentInfo(id: "doc-1") + // Second call fails live — should transparently return the cached value. + let result = try await sut.documentInfo(id: "doc-1") + + XCTAssertEqual(result, document) + XCTAssertEqual(callCount, 2) + } + + func testDocumentInfoRethrowsWhenLiveFailsAndCacheIsEmpty() async throws { + let stub = StubOutlineAPIClient() + stub.documentInfoHandler = { _ in throw StubTransportError() } + let sut = CachingOutlineAPIClient(live: stub, cache: try makeCache()) + + do { + _ = try await sut.documentInfo(id: "doc-1") + XCTFail("Expected an error") + } catch is StubTransportError { + // expected + } + } + + func testListCollectionsFallsBackToCacheWhenLiveFails() async throws { + let stub = StubOutlineAPIClient() + let collections = [makeCollection()] + var callCount = 0 + stub.listCollectionsHandler = { _, _ in + callCount += 1 + if callCount == 1 { return collections } + throw StubTransportError() + } + let sut = CachingOutlineAPIClient(live: stub, cache: try makeCache()) + + _ = try await sut.listCollections(offset: 0, limit: 25) + let result = try await sut.listCollections(offset: 0, limit: 25) + + XCTAssertEqual(result, collections) + } + + func testDifferentDocumentIdsAreCachedSeparately() async throws { + let stub = StubOutlineAPIClient() + let docOne = makeDocument(id: "doc-1", title: "One") + let docTwo = makeDocument(id: "doc-2", title: "Two") + stub.documentInfoHandler = { id in id == "doc-1" ? docOne : docTwo } + let sut = CachingOutlineAPIClient(live: stub, cache: try makeCache()) + + _ = try await sut.documentInfo(id: "doc-1") + _ = try await sut.documentInfo(id: "doc-2") + stub.documentInfoHandler = { _ in throw StubTransportError() } + + let cachedOne = try await sut.documentInfo(id: "doc-1") + let cachedTwo = try await sut.documentInfo(id: "doc-2") + + XCTAssertEqual(cachedOne, docOne) + XCTAssertEqual(cachedTwo, docTwo) + } +} diff --git a/Outpost/Features/Collections/ContentView_macOS.swift b/Outpost/Features/Collections/ContentView_macOS.swift index 3a7d250..01dc410 100644 --- a/Outpost/Features/Collections/ContentView_macOS.swift +++ b/Outpost/Features/Collections/ContentView_macOS.swift @@ -31,6 +31,10 @@ struct ContentView_macOS: View { VStack(spacing: 0) { SidebarSearchField(text: $globalSearchQuery) Divider() + if !session.networkMonitor.isOnline { + OfflineBanner() + Divider() + } sidebar AccountFooter() } diff --git a/Outpost/Features/Collections/OfflineBanner.swift b/Outpost/Features/Collections/OfflineBanner.swift new file mode 100644 index 0000000..8229c52 --- /dev/null +++ b/Outpost/Features/Collections/OfflineBanner.swift @@ -0,0 +1,21 @@ +#if os(macOS) +import SwiftUI + +/// Shown at the top of the sidebar whenever `NetworkMonitor` reports no +/// connection — cached collections/documents keep browsing working, but the +/// user should know they might be looking at stale data. +struct OfflineBanner: View { + var body: some View { + HStack(spacing: 8) { + Image(systemName: "wifi.slash") + .foregroundStyle(.orange) + Text("Offline — showing cached content") + .font(.callout) + Spacer(minLength: 0) + } + .padding(.horizontal, 12) + .padding(.vertical, 8) + .background(Color.orange.opacity(0.12)) + } +} +#endif diff --git a/Outpost/Root/SessionStore.swift b/Outpost/Root/SessionStore.swift index d8563fe..b2af143 100644 --- a/Outpost/Root/SessionStore.swift +++ b/Outpost/Root/SessionStore.swift @@ -17,6 +17,11 @@ final class SessionStore { var teamName: String? var teamAvatarURL: URL? private(set) var apiClient: OutlineAPIClient? + let networkMonitor = NetworkMonitor() + /// `nil` only if the on-disk SwiftData store failed to open (e.g. disk + /// full) — in that case `apiClient` falls back to talking to the server + /// directly with no offline cache, rather than failing sign-in outright. + private let cacheStore: OfflineCacheStore? var serverURL: URL? { defaults.string(forKey: Self.serverURLDefaultsKey).flatMap(URL.init(string:)) @@ -26,23 +31,27 @@ final class SessionStore { self.tokenStore = tokenStore self.defaults = defaults self.isSignedIn = (try? tokenStore.token()) != nil + self.cacheStore = (try? OfflineCacheStore.makeContainer()).map(OfflineCacheStore.init(modelContainer:)) if isSignedIn, let serverURL { - apiClient = LiveOutlineAPIClient( - configuration: OutlineConfiguration(baseURL: serverURL), - tokenStore: tokenStore - ) + apiClient = Self.makeAPIClient(serverURL: serverURL, tokenStore: tokenStore, cache: cacheStore) } } func signIn(serverURL: URL, user: OutlineUser, team: OutlineTeam) { defaults.set(serverURL.absoluteString, forKey: Self.serverURLDefaultsKey) - apiClient = LiveOutlineAPIClient( + apiClient = Self.makeAPIClient(serverURL: serverURL, tokenStore: tokenStore, cache: cacheStore) + apply(user: user, team: team, serverURL: serverURL) + isSignedIn = true + } + + private static func makeAPIClient(serverURL: URL, tokenStore: TokenStoring, cache: OfflineCacheStore?) -> OutlineAPIClient { + let live = LiveOutlineAPIClient( configuration: OutlineConfiguration(baseURL: serverURL), tokenStore: tokenStore ) - apply(user: user, team: team, serverURL: serverURL) - isSignedIn = true + guard let cache else { return live } + return CachingOutlineAPIClient(live: live, cache: cache) } func signOut() { diff --git a/Outpost/Support/NetworkMonitor.swift b/Outpost/Support/NetworkMonitor.swift new file mode 100644 index 0000000..4c2fd5b --- /dev/null +++ b/Outpost/Support/NetworkMonitor.swift @@ -0,0 +1,29 @@ +import Foundation +import Network +import Observation + +/// Backs the sidebar's offline badge — purely a UI signal. Unrelated to +/// `CachingOutlineAPIClient`'s own fallback logic, which reacts to actual +/// request failures rather than pre-checking reachability. +@Observable +@MainActor +final class NetworkMonitor { + private(set) var isOnline = true + + private let monitor = NWPathMonitor() + private let queue = DispatchQueue(label: "com.outpost.network-monitor") + + init() { + monitor.pathUpdateHandler = { [weak self] path in + let online = path.status == .satisfied + Task { @MainActor in + self?.isOnline = online + } + } + monitor.start(queue: queue) + } + + deinit { + monitor.cancel() + } +} From 4769645489e0d8fe0ca2e5ba4bd93f1f6cbd677c Mon Sep 17 00:00:00 2001 From: psavarmattas Date: Sat, 15 Aug 2026 00:12:05 +0100 Subject: [PATCH 02/22] feat(offline): write queue with sync-on-reconnect + settings redesign MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Extends CachingOutlineAPIClient with a scoped offline write queue: updateDocument, updateCollection, pin/unpin, star/unstar, and subscribe/unsubscribe now apply optimistically and queue via a new PendingOperation (SwiftData) when they fail (or when a new manual "Offline Mode" toggle forces it), then replay on reconnect via flushPendingOperations(). Same-target edits coalesce into one queued operation; a pin/unpin pair that never syncs cancels out instead of queuing a delete the server never saw. Actions that would invent new tree structure (create/move/archive/delete/duplicate) stay live-only — reconciling a locally-invented id against the server's real one is a separate, harder problem this pass doesn't take on. Sharing, permissions, search, and export also stay live-only. Added a "Full Local Sync" toggle that eagerly walks and caches the whole workspace instead of only what's been opened, running immediately on enable and every 20 minutes after while online. Settings moved from a popup (Settings {} scene / PreferencesView) to a full-page view rendered inside the root window (AppNavigation), including the ⌘, shortcut. Folds in offline/sync management (storage size, clear cache, pending-sync list with per-item retry) and the About window's content (version, check for updates) so it's all in one place. 8 new OutlineKit tests covering coalescing, cancel-out, flush success/failure, and manual offline mode — 51/51 passing. --- .../Caching/CachingOutlineAPIClient.swift | 411 +++++++++++++++--- .../Caching/OfflineCacheStore.swift | 69 ++- .../OutlineKit/Caching/PendingOperation.swift | 40 ++ .../OutlineKit/Caching/SyncTypes.swift | 49 +++ .../Requests/CreatePinRequest.swift | 2 +- .../Requests/CreateSubscriptionRequest.swift | 2 +- .../Requests/StarCollectionRequest.swift | 2 +- .../Requests/StarDocumentRequest.swift | 2 +- .../Requests/UpdateCollectionRequest.swift | 2 +- .../Requests/UpdateDocumentRequest.swift | 2 +- .../CachingOutlineAPIClientTests.swift | 171 +++++++- Outpost/Features/About/AboutView.swift | 20 +- Outpost/Features/Account/AccountFooter.swift | 10 +- .../Features/Account/PreferencesView.swift | 40 -- Outpost/Features/Account/SettingsView.swift | 311 +++++++++++++ Outpost/OutpostApp.swift | 17 +- Outpost/Root/AppNavigation.swift | 12 + Outpost/Root/RootView.swift | 29 ++ Outpost/Root/SessionStore.swift | 21 +- 19 files changed, 1095 insertions(+), 117 deletions(-) create mode 100644 OutlineKit/Sources/OutlineKit/Caching/PendingOperation.swift create mode 100644 OutlineKit/Sources/OutlineKit/Caching/SyncTypes.swift delete mode 100644 Outpost/Features/Account/PreferencesView.swift create mode 100644 Outpost/Features/Account/SettingsView.swift create mode 100644 Outpost/Root/AppNavigation.swift diff --git a/OutlineKit/Sources/OutlineKit/Caching/CachingOutlineAPIClient.swift b/OutlineKit/Sources/OutlineKit/Caching/CachingOutlineAPIClient.swift index fb33445..9e39a7e 100644 --- a/OutlineKit/Sources/OutlineKit/Caching/CachingOutlineAPIClient.swift +++ b/OutlineKit/Sources/OutlineKit/Caching/CachingOutlineAPIClient.swift @@ -1,28 +1,46 @@ import Foundation -/// Decorates `LiveOutlineAPIClient` (or any `OutlineAPIClient`) with a -/// read-through cache for the handful of endpoints "browse the workspace -/// offline" actually needs: the collection tree, document lists, and a -/// document's own content. Every other call — search, writes, sharing, -/// pins, etc. — passes straight through, since those either don't make -/// sense offline or aren't worth the staleness risk. +/// Decorates `LiveOutlineAPIClient` (or any `OutlineAPIClient`) with offline +/// support at the existing protocol boundary, so no view model needs to know +/// the network exists: /// -/// Deliberately doesn't ask "are we online?" up front — that's a separate -/// question from "did this specific request fail?", and checking first can -/// be wrong the instant connectivity flaps. Instead: always try live, and -/// fall back to the cache only on failure. A successful live response -/// always overwrites the cache, so it can never get more stale than the -/// last time this actually worked. +/// - **Reads** (`documentInfo`, `listDocuments`, `documentsList`, +/// `listViewedDocuments`, `listCollections`, `collectionInfo`): read-through +/// cache. Always tries live first — never pre-checks reachability, since +/// "did this specific request just fail" is a more honest signal than a +/// reachability monitor, and the two can disagree (captive portals, +/// flaky Wi-Fi). Falls back to the cache only on failure; a live success +/// always overwrites the cache, so staleness is bounded by "last time this +/// endpoint actually worked." +/// - **A small set of writes** (`updateDocument`, `updateCollection`, +/// pin/star/subscribe create+delete): applied optimistically against the +/// cache and queued in `OfflineCacheStore`'s `PendingOperation` table on +/// failure, then replayed in order by `flushPendingOperations()` once back +/// online. Deliberately doesn't include anything that creates new tree +/// structure (`createDocument`, `moveDocument`, `archiveDocument`, +/// `deleteDocument`, `deleteCollection`, `duplicateDocument`) — those need +/// real server-assigned ids to stay consistent with the rest of the tree, +/// and reconciling a locally-invented id with the one the server hands +/// back on sync is a much bigger problem than this pass takes on. Sharing, +/// permissions, search, and export also stay live-only — read the request, +/// they need someone else's server session, not just a network. +/// - **Manual offline mode** (`offlineModeDefaultsKey`): when set, skips +/// attempting `live` entirely — same fallback/queue paths as a real +/// failure, just chosen on purpose instead of discovered. public actor CachingOutlineAPIClient: OutlineAPIClient { + public static let offlineModeDefaultsKey = "outpost.offlineModeEnabled" + private let live: OutlineAPIClient private let cache: OfflineCacheStore + private let defaults: UserDefaults private let encoder: JSONEncoder private let decoder: JSONDecoder private let keyEncoder: JSONEncoder - public init(live: OutlineAPIClient, cache: OfflineCacheStore) { + public init(live: OutlineAPIClient, cache: OfflineCacheStore, defaults: UserDefaults = .standard) { self.live = live self.cache = cache + self.defaults = defaults let encoder = JSONEncoder() encoder.dateEncodingStrategy = .iso8601 @@ -37,6 +55,10 @@ public actor CachingOutlineAPIClient: OutlineAPIClient { self.keyEncoder = keyEncoder } + private var isManualOfflineModeEnabled: Bool { + defaults.bool(forKey: Self.offlineModeDefaultsKey) + } + // MARK: - Cached reads public func documentInfo(id: String) async throws -> OutlineDocument { @@ -80,6 +102,104 @@ public actor CachingOutlineAPIClient: OutlineAPIClient { try await cachedFetch(key: "collection:\(id)") { try await self.live.collectionInfo(id: id) } } + // MARK: - Queueable writes + + public func updateDocument(_ request: UpdateDocumentRequest) async throws -> OutlineDocument { + if !isManualOfflineModeEnabled { + do { + let result = try await live.updateDocument(request) + await cacheDocument(result) + return result + } catch { + return try await queueDocumentUpdate(request, dueTo: error) + } + } + return try await queueDocumentUpdate(request, dueTo: nil) + } + + public func updateCollection(_ request: UpdateCollectionRequest) async throws -> OutlineCollection { + if !isManualOfflineModeEnabled { + do { + let result = try await live.updateCollection(request) + await cacheCollection(result) + return result + } catch { + return try await queueCollectionUpdate(request, dueTo: error) + } + } + return try await queueCollectionUpdate(request, dueTo: nil) + } + + public func createPin(_ request: CreatePinRequest) async throws -> OutlinePin { + if !isManualOfflineModeEnabled { + do { return try await live.createPin(request) } catch { return await queuePinCreate(request) } + } + return await queuePinCreate(request) + } + + public func deletePin(id: String) async throws { + if await cancelIfNeverSynced(id: id) { return } + if !isManualOfflineModeEnabled { + do { + try await live.deletePin(id: id) + return + } catch { + await enqueue(.deletePin, payload: IDPayload(id: id), id: "delete-pin-\(id)") + return + } + } + await enqueue(.deletePin, payload: IDPayload(id: id), id: "delete-pin-\(id)") + } + + public func createSubscription(_ request: CreateSubscriptionRequest) async throws -> OutlineSubscription { + if !isManualOfflineModeEnabled { + do { return try await live.createSubscription(request) } catch { return await queueSubscriptionCreate(request) } + } + return await queueSubscriptionCreate(request) + } + + public func deleteSubscription(id: String) async throws { + if await cancelIfNeverSynced(id: id) { return } + if !isManualOfflineModeEnabled { + do { + try await live.deleteSubscription(id: id) + return + } catch { + await enqueue(.deleteSubscription, payload: IDPayload(id: id), id: "delete-subscription-\(id)") + return + } + } + await enqueue(.deleteSubscription, payload: IDPayload(id: id), id: "delete-subscription-\(id)") + } + + public func starDocument(_ request: StarDocumentRequest) async throws -> OutlineStar { + if !isManualOfflineModeEnabled { + do { return try await live.starDocument(request) } catch { return await queueStarDocumentCreate(request) } + } + return await queueStarDocumentCreate(request) + } + + public func starCollection(_ request: StarCollectionRequest) async throws -> OutlineStar { + if !isManualOfflineModeEnabled { + do { return try await live.starCollection(request) } catch { return await queueStarCollectionCreate(request) } + } + return await queueStarCollectionCreate(request) + } + + public func deleteStar(id: String) async throws { + if await cancelIfNeverSynced(id: id) { return } + if !isManualOfflineModeEnabled { + do { + try await live.deleteStar(id: id) + return + } catch { + await enqueue(.deleteStar, payload: IDPayload(id: id), id: "delete-star-\(id)") + return + } + } + await enqueue(.deleteStar, payload: IDPayload(id: id), id: "delete-star-\(id)") + } + // MARK: - Pass-through public func authInfo() async throws -> OutlineAuthInfo { @@ -98,14 +218,6 @@ public actor CachingOutlineAPIClient: OutlineAPIClient { try await live.createDocument(request) } - public func updateDocument(_ request: UpdateDocumentRequest) async throws -> OutlineDocument { - try await live.updateDocument(request) - } - - public func starDocument(_ request: StarDocumentRequest) async throws -> OutlineStar { - try await live.starDocument(request) - } - public func templatizeDocument(_ request: TemplatizeDocumentRequest) async throws -> OutlineTemplate { try await live.templatizeDocument(request) } @@ -162,30 +274,14 @@ public actor CachingOutlineAPIClient: OutlineAPIClient { try await live.revokeShare(id: id) } - public func createPin(_ request: CreatePinRequest) async throws -> OutlinePin { - try await live.createPin(request) - } - public func listPins(_ request: ListPinsRequest) async throws -> [OutlinePin] { try await live.listPins(request) } - public func deletePin(id: String) async throws { - try await live.deletePin(id: id) - } - - public func createSubscription(_ request: CreateSubscriptionRequest) async throws -> OutlineSubscription { - try await live.createSubscription(request) - } - public func listSubscriptions(_ request: ListSubscriptionsRequest) async throws -> [OutlineSubscription] { try await live.listSubscriptions(request) } - public func deleteSubscription(id: String) async throws { - try await live.deleteSubscription(id: id) - } - public func listViews(_ request: ListViewsRequest) async throws -> [OutlineView] { try await live.listViews(request) } @@ -206,10 +302,6 @@ public actor CachingOutlineAPIClient: OutlineAPIClient { try await live.listUsers(request) } - public func updateCollection(_ request: UpdateCollectionRequest) async throws -> OutlineCollection { - try await live.updateCollection(request) - } - public func deleteCollection(id: String) async throws { try await live.deleteCollection(id: id) } @@ -218,22 +310,89 @@ public actor CachingOutlineAPIClient: OutlineAPIClient { try await live.exportCollection(request) } - public func starCollection(_ request: StarCollectionRequest) async throws -> OutlineStar { - try await live.starCollection(request) - } - public func listStars(_ request: ListStarsRequest) async throws -> [OutlineStar] { try await live.listStars(request) } - public func deleteStar(id: String) async throws { - try await live.deleteStar(id: id) - } - public func currentUser() async throws -> OutlineUser { try await live.currentUser() } + // MARK: - Sync management (Settings surface) + + public func pendingOperations() async -> [PendingOperationSummary] { + await cache.pendingOperations().map { + PendingOperationSummary(id: $0.id, kind: $0.kind, createdAt: $0.createdAt, attemptCount: $0.attemptCount, lastError: $0.lastError) + } + } + + public func cacheStorageSummary() async -> CacheStorageSummary { + CacheStorageSummary(itemCount: await cache.itemCount(), totalBytes: await cache.totalBytes()) + } + + public func clearCache() async { + await cache.clearAll() + } + + /// Replays every queued operation against `live`, in the order they were + /// queued. Each is independent — one failing doesn't block the rest. + public func flushPendingOperations() async -> SyncFlushSummary { + let operations = await cache.pendingOperations() + var succeeded = 0 + var failed = 0 + for operation in operations { + do { + try await replay(operation) + await cache.removeOperation(id: operation.id) + succeeded += 1 + } catch { + await cache.recordFailure(id: operation.id, error: errorDescription(error)) + failed += 1 + } + } + return SyncFlushSummary(succeeded: succeeded, failed: failed) + } + + /// "Full Local Sync": eagerly walks every collection and caches every + /// document's full content (not just whatever's been opened), so + /// browsing offline works for the whole workspace, not only what was + /// already viewed. Goes through `self`, not `live`, directly — the + /// existing cached-read methods already do the caching as a side effect, + /// this just has to drive the walk and separately cache each document by + /// id (`listDocuments`'s cache key is the list, not the individual doc). + public func performFullSync() async -> FullSyncSummary { + var documentsCount = 0 + var errors: [String] = [] + var collections: [OutlineCollection] = [] + do { + collections = try await listCollections(offset: 0, limit: 250) + } catch { + errors.append(errorDescription(error)) + } + + for collection in collections { + var offset = 0 + let limit = 100 + while true { + let documents: [OutlineDocument] + do { + documents = try await listDocuments(collectionId: collection.id, parentDocumentId: nil, offset: offset, limit: limit) + } catch { + errors.append("\(collection.name): \(errorDescription(error))") + break + } + for document in documents { + await cacheDocument(document) + } + documentsCount += documents.count + guard documents.count == limit else { break } + offset += limit + } + } + + return FullSyncSummary(collectionsCount: collections.count, documentsCount: documentsCount, errors: errors, finishedAt: Date()) + } + // MARK: - Helpers private func cachedFetch(key: String, fetch: () async throws -> T) async throws -> T { @@ -257,4 +416,158 @@ public actor CachingOutlineAPIClient: OutlineAPIClient { } return "\(prefix):\(json)" } + + private func cacheDocument(_ document: OutlineDocument) async { + if let data = try? encoder.encode(document) { + await cache.save(data, forKey: "document:\(document.id)") + } + } + + private func cacheCollection(_ collection: OutlineCollection) async { + if let data = try? encoder.encode(collection) { + await cache.save(data, forKey: "collection:\(collection.id)") + } + } + + private func enqueue(_ kind: PendingOperationKind, payload: some Encodable, id: String) async { + guard let data = try? encoder.encode(payload) else { return } + await cache.enqueueOperation(id: id, kind: kind.rawValue, payload: data) + } + + /// A delete targeting a `pending-*` id can only mean "cancel the create + /// still sitting in the queue" — the server has never heard of that id, + /// so queuing the delete would just fail once synced. Returns whether it + /// found (and removed) a matching create, meaning the caller is done. + private func cancelIfNeverSynced(id: String) async -> Bool { + guard id.hasPrefix("pending-") else { return false } + return await cache.removeOperation(id: id) + } + + private func queueDocumentUpdate(_ request: UpdateDocumentRequest, dueTo error: Error?) async throws -> OutlineDocument { + guard let baseData = await cache.load(forKey: "document:\(request.id)"), + let base = try? decoder.decode(OutlineDocument.self, from: baseData) else { + throw error ?? OutlineAPIError.transport(URLError(.notConnectedToInternet)) + } + let mergedText = request.append == true ? base.text + (request.text ?? "") : (request.text ?? base.text) + let merged = OutlineDocument( + id: base.id, + title: request.title ?? base.title, + text: mergedText, + emoji: base.emoji, + collectionId: base.collectionId, + parentDocumentId: base.parentDocumentId, + url: base.url, + revision: base.revision, + fullWidth: request.fullWidth ?? base.fullWidth, + createdAt: base.createdAt, + updatedAt: Date(), + publishedAt: base.publishedAt, + archivedAt: base.archivedAt, + deletedAt: base.deletedAt + ) + await cacheDocument(merged) + // Fully resolved (not the original partial request) so replaying just + // this one queued operation reproduces `merged` exactly — that's what + // makes coalescing a second edit onto the same queued id safe. + let resolved = UpdateDocumentRequest(id: merged.id, title: merged.title, text: merged.text, fullWidth: merged.fullWidth) + await enqueue(.updateDocument, payload: resolved, id: "update-document-\(merged.id)") + return merged + } + + private func queueCollectionUpdate(_ request: UpdateCollectionRequest, dueTo error: Error?) async throws -> OutlineCollection { + guard let baseData = await cache.load(forKey: "collection:\(request.id)"), + let base = try? decoder.decode(OutlineCollection.self, from: baseData) else { + throw error ?? OutlineAPIError.transport(URLError(.notConnectedToInternet)) + } + let merged = OutlineCollection( + id: base.id, + name: request.name ?? base.name, + description: request.description ?? base.description, + color: base.color, + icon: base.icon, + createdAt: base.createdAt, + updatedAt: Date() + ) + await cacheCollection(merged) + let resolved = UpdateCollectionRequest(id: merged.id, name: merged.name, description: merged.description) + await enqueue(.updateCollection, payload: resolved, id: "update-collection-\(merged.id)") + return merged + } + + private func queuePinCreate(_ request: CreatePinRequest) async -> OutlinePin { + let pendingId = "pending-\(UUID().uuidString)" + await enqueue(.createPin, payload: request, id: pendingId) + return OutlinePin(id: pendingId, documentId: request.documentId, collectionId: request.collectionId, index: nil) + } + + private func queueSubscriptionCreate(_ request: CreateSubscriptionRequest) async -> OutlineSubscription { + let pendingId = "pending-\(UUID().uuidString)" + await enqueue(.createSubscription, payload: request, id: pendingId) + return OutlineSubscription(id: pendingId, documentId: request.documentId, collectionId: nil, event: request.event) + } + + private func queueStarDocumentCreate(_ request: StarDocumentRequest) async -> OutlineStar { + let pendingId = "pending-\(UUID().uuidString)" + await enqueue(.starDocument, payload: request, id: pendingId) + return OutlineStar(id: pendingId, index: nil, documentId: request.documentId, collectionId: nil) + } + + private func queueStarCollectionCreate(_ request: StarCollectionRequest) async -> OutlineStar { + let pendingId = "pending-\(UUID().uuidString)" + await enqueue(.starCollection, payload: request, id: pendingId) + return OutlineStar(id: pendingId, index: nil, documentId: nil, collectionId: request.collectionId) + } + + private func replay(_ operation: PendingOperation) async throws { + guard let kind = PendingOperationKind(rawValue: operation.kind) else { + throw OutlineAPIError.decoding(DecodingError.dataCorrupted( + DecodingError.Context(codingPath: [], debugDescription: "Unknown pending operation kind: \(operation.kind)") + )) + } + switch kind { + case .updateDocument: + let request = try decoder.decode(UpdateDocumentRequest.self, from: operation.payload) + let result = try await live.updateDocument(request) + await cacheDocument(result) + case .updateCollection: + let request = try decoder.decode(UpdateCollectionRequest.self, from: operation.payload) + let result = try await live.updateCollection(request) + await cacheCollection(result) + case .createPin: + let request = try decoder.decode(CreatePinRequest.self, from: operation.payload) + _ = try await live.createPin(request) + case .deletePin: + let request = try decoder.decode(IDPayload.self, from: operation.payload) + try await live.deletePin(id: request.id) + case .createSubscription: + let request = try decoder.decode(CreateSubscriptionRequest.self, from: operation.payload) + _ = try await live.createSubscription(request) + case .deleteSubscription: + let request = try decoder.decode(IDPayload.self, from: operation.payload) + try await live.deleteSubscription(id: request.id) + case .starDocument: + let request = try decoder.decode(StarDocumentRequest.self, from: operation.payload) + _ = try await live.starDocument(request) + case .deleteStar: + let request = try decoder.decode(IDPayload.self, from: operation.payload) + try await live.deleteStar(id: request.id) + case .starCollection: + let request = try decoder.decode(StarCollectionRequest.self, from: operation.payload) + _ = try await live.starCollection(request) + } + } + + private func errorDescription(_ error: Error) -> String { + if let apiError = error as? OutlineAPIError { + switch apiError { + case .unauthorized: return "Sign-in expired." + case .notFound: return "Not found on the server." + case .server(let status, let message): return message ?? "Server error (\(status))." + case .decoding: return "Unexpected response shape." + case .transport: return "Couldn't reach the server." + case .tokenUnavailable: return "Couldn't access the saved sign-in." + } + } + return String(describing: error) + } } diff --git a/OutlineKit/Sources/OutlineKit/Caching/OfflineCacheStore.swift b/OutlineKit/Sources/OutlineKit/Caching/OfflineCacheStore.swift index 3ebf693..3fd1e8e 100644 --- a/OutlineKit/Sources/OutlineKit/Caching/OfflineCacheStore.swift +++ b/OutlineKit/Sources/OutlineKit/Caching/OfflineCacheStore.swift @@ -3,14 +3,18 @@ import SwiftData /// Read-through cache backing `CachingOutlineAPIClient` — stores whatever it /// last fetched successfully, keyed by request shape, so browsing keeps -/// working (stale) once the network stops. +/// working (stale) once the network stops. Also owns the offline write +/// queue (`PendingOperation`) — same store, same actor, since both need +/// synchronized access to the same on-disk SwiftData container. @ModelActor public actor OfflineCacheStore { public static func makeContainer(inMemory: Bool = false) throws -> ModelContainer { let configuration = ModelConfiguration(isStoredInMemoryOnly: inMemory) - return try ModelContainer(for: CachedPayload.self, configurations: configuration) + return try ModelContainer(for: CachedPayload.self, PendingOperation.self, configurations: configuration) } + // MARK: - Read-through cache + public func save(_ data: Data, forKey key: String) { let descriptor = FetchDescriptor(predicate: #Predicate { $0.key == key }) if let existing = try? modelContext.fetch(descriptor).first { @@ -26,4 +30,65 @@ public actor OfflineCacheStore { let descriptor = FetchDescriptor(predicate: #Predicate { $0.key == key }) return try? modelContext.fetch(descriptor).first?.payload } + + public func itemCount() -> Int { + (try? modelContext.fetchCount(FetchDescriptor())) ?? 0 + } + + public func totalBytes() -> Int { + let descriptor = FetchDescriptor() + let rows = (try? modelContext.fetch(descriptor)) ?? [] + return rows.reduce(0) { $0 + $1.payload.count } + } + + public func clearAll() { + let descriptor = FetchDescriptor() + guard let rows = try? modelContext.fetch(descriptor) else { return } + rows.forEach { modelContext.delete($0) } + try? modelContext.save() + } + + // MARK: - Offline write queue + + /// Upserts by `id` — a second call with the same id (an edit coalescing + /// onto a still-unsynced edit, or a create being cancelled by its own + /// synthesized id) replaces the row in place rather than piling up. + public func enqueueOperation(id: String, kind: String, payload: Data) { + let descriptor = FetchDescriptor(predicate: #Predicate { $0.id == id }) + if let existing = try? modelContext.fetch(descriptor).first { + existing.kind = kind + existing.payload = payload + existing.lastError = nil + existing.attemptCount = 0 + } else { + modelContext.insert(PendingOperation(id: id, kind: kind, payload: payload, createdAt: Date())) + } + try? modelContext.save() + } + + public func pendingOperations() -> [PendingOperation] { + let descriptor = FetchDescriptor(sortBy: [SortDescriptor(\.createdAt)]) + return (try? modelContext.fetch(descriptor)) ?? [] + } + + /// Returns whether a matching operation was actually found and removed — + /// callers use this to detect "this targeted something that never made + /// it to the server in the first place" and skip queuing a delete. + @discardableResult + public func removeOperation(id: String) -> Bool { + let descriptor = FetchDescriptor(predicate: #Predicate { $0.id == id }) + guard let existing = try? modelContext.fetch(descriptor).first else { return false } + modelContext.delete(existing) + try? modelContext.save() + return true + } + + public func recordFailure(id: String, error: String) { + let descriptor = FetchDescriptor(predicate: #Predicate { $0.id == id }) + guard let existing = try? modelContext.fetch(descriptor).first else { return } + existing.lastAttemptAt = Date() + existing.lastError = error + existing.attemptCount += 1 + try? modelContext.save() + } } diff --git a/OutlineKit/Sources/OutlineKit/Caching/PendingOperation.swift b/OutlineKit/Sources/OutlineKit/Caching/PendingOperation.swift new file mode 100644 index 0000000..4fc3aba --- /dev/null +++ b/OutlineKit/Sources/OutlineKit/Caching/PendingOperation.swift @@ -0,0 +1,40 @@ +import Foundation +import SwiftData + +/// A queued mutation made while offline, waiting to replay against the live +/// server. `id` is deliberately overloaded: for actions that create a new +/// server-side record (pin, star, subscription), it's also the synthesized +/// placeholder id handed back to the caller immediately — so a matching +/// delete queued before that create ever syncs can cancel both out by id +/// instead of hitting a server that's never heard of the placeholder. For +/// actions that edit an existing record (document/collection updates), it's +/// deterministic per target id, so a second edit before the first syncs +/// coalesces into one queued operation instead of piling up. +@Model +public final class PendingOperation { + @Attribute(.unique) public var id: String + public var kind: String + public var payload: Data + public var createdAt: Date + public var lastAttemptAt: Date? + public var lastError: String? + public var attemptCount: Int + + public init( + id: String, + kind: String, + payload: Data, + createdAt: Date, + lastAttemptAt: Date? = nil, + lastError: String? = nil, + attemptCount: Int = 0 + ) { + self.id = id + self.kind = kind + self.payload = payload + self.createdAt = createdAt + self.lastAttemptAt = lastAttemptAt + self.lastError = lastError + self.attemptCount = attemptCount + } +} diff --git a/OutlineKit/Sources/OutlineKit/Caching/SyncTypes.swift b/OutlineKit/Sources/OutlineKit/Caching/SyncTypes.swift new file mode 100644 index 0000000..863221b --- /dev/null +++ b/OutlineKit/Sources/OutlineKit/Caching/SyncTypes.swift @@ -0,0 +1,49 @@ +import Foundation + +/// UI-facing snapshot of a queued offline mutation — deliberately not the +/// `@Model` type itself, so Settings can display it without holding a +/// reference into the SwiftData store. +public struct PendingOperationSummary: Identifiable, Sendable { + public let id: String + public let kind: String + public let createdAt: Date + public let attemptCount: Int + public let lastError: String? +} + +public struct CacheStorageSummary: Sendable { + public let itemCount: Int + public let totalBytes: Int +} + +public struct SyncFlushSummary: Sendable { + public let succeeded: Int + public let failed: Int +} + +public struct FullSyncSummary: Sendable { + public let collectionsCount: Int + public let documentsCount: Int + public let errors: [String] + public let finishedAt: Date +} + +/// Every mutation `CachingOutlineAPIClient` knows how to queue offline and +/// replay later. Deliberately a small, explicit set — see its doc comment +/// for what's excluded and why. +enum PendingOperationKind: String, Codable, Sendable { + case updateDocument + case updateCollection + case createPin + case deletePin + case createSubscription + case deleteSubscription + case starDocument + case deleteStar + case starCollection +} + +/// Shared payload shape for the delete-by-id queueable operations. +struct IDPayload: Codable, Sendable { + let id: String +} diff --git a/OutlineKit/Sources/OutlineKit/Requests/CreatePinRequest.swift b/OutlineKit/Sources/OutlineKit/Requests/CreatePinRequest.swift index 29e2701..8aeb607 100644 --- a/OutlineKit/Sources/OutlineKit/Requests/CreatePinRequest.swift +++ b/OutlineKit/Sources/OutlineKit/Requests/CreatePinRequest.swift @@ -2,7 +2,7 @@ import Foundation /// See `OutlinePin`. `collectionId: nil` = "Pin to Home", non-nil = "Pin to /// Collection" — these are distinct actions on the real server. -public struct CreatePinRequest: Encodable, Sendable { +public struct CreatePinRequest: Codable, Sendable { public let documentId: String public let collectionId: String? diff --git a/OutlineKit/Sources/OutlineKit/Requests/CreateSubscriptionRequest.swift b/OutlineKit/Sources/OutlineKit/Requests/CreateSubscriptionRequest.swift index c2cf5f2..10ab05e 100644 --- a/OutlineKit/Sources/OutlineKit/Requests/CreateSubscriptionRequest.swift +++ b/OutlineKit/Sources/OutlineKit/Requests/CreateSubscriptionRequest.swift @@ -1,7 +1,7 @@ import Foundation /// See `OutlineSubscription` — best-effort shape, not in the vendored spec. -public struct CreateSubscriptionRequest: Encodable, Sendable { +public struct CreateSubscriptionRequest: Codable, Sendable { public let documentId: String public let event: String diff --git a/OutlineKit/Sources/OutlineKit/Requests/StarCollectionRequest.swift b/OutlineKit/Sources/OutlineKit/Requests/StarCollectionRequest.swift index b3f3bd8..66289ec 100644 --- a/OutlineKit/Sources/OutlineKit/Requests/StarCollectionRequest.swift +++ b/OutlineKit/Sources/OutlineKit/Requests/StarCollectionRequest.swift @@ -1,6 +1,6 @@ import Foundation -public struct StarCollectionRequest: Encodable, Sendable { +public struct StarCollectionRequest: Codable, Sendable { public let collectionId: String public init(collectionId: String) { diff --git a/OutlineKit/Sources/OutlineKit/Requests/StarDocumentRequest.swift b/OutlineKit/Sources/OutlineKit/Requests/StarDocumentRequest.swift index 54036a4..67ef4b9 100644 --- a/OutlineKit/Sources/OutlineKit/Requests/StarDocumentRequest.swift +++ b/OutlineKit/Sources/OutlineKit/Requests/StarDocumentRequest.swift @@ -1,6 +1,6 @@ import Foundation -public struct StarDocumentRequest: Encodable, Sendable { +public struct StarDocumentRequest: Codable, Sendable { public let documentId: String public init(documentId: String) { diff --git a/OutlineKit/Sources/OutlineKit/Requests/UpdateCollectionRequest.swift b/OutlineKit/Sources/OutlineKit/Requests/UpdateCollectionRequest.swift index 7b094f1..fb19007 100644 --- a/OutlineKit/Sources/OutlineKit/Requests/UpdateCollectionRequest.swift +++ b/OutlineKit/Sources/OutlineKit/Requests/UpdateCollectionRequest.swift @@ -1,6 +1,6 @@ import Foundation -public struct UpdateCollectionRequest: Encodable, Sendable { +public struct UpdateCollectionRequest: Codable, Sendable { public let id: String public let name: String? public let description: String? diff --git a/OutlineKit/Sources/OutlineKit/Requests/UpdateDocumentRequest.swift b/OutlineKit/Sources/OutlineKit/Requests/UpdateDocumentRequest.swift index 5e38781..e1bf459 100644 --- a/OutlineKit/Sources/OutlineKit/Requests/UpdateDocumentRequest.swift +++ b/OutlineKit/Sources/OutlineKit/Requests/UpdateDocumentRequest.swift @@ -1,6 +1,6 @@ import Foundation -public struct UpdateDocumentRequest: Encodable, Sendable { +public struct UpdateDocumentRequest: Codable, Sendable { public let id: String public let title: String? public let text: String? diff --git a/OutlineKit/Tests/OutlineKitTests/CachingOutlineAPIClientTests.swift b/OutlineKit/Tests/OutlineKitTests/CachingOutlineAPIClientTests.swift index d96e613..f53b18e 100644 --- a/OutlineKit/Tests/OutlineKitTests/CachingOutlineAPIClientTests.swift +++ b/OutlineKit/Tests/OutlineKitTests/CachingOutlineAPIClientTests.swift @@ -9,6 +9,9 @@ private struct NotStubbed: Error {} private final class StubOutlineAPIClient: OutlineAPIClient, @unchecked Sendable { var documentInfoHandler: (@Sendable (String) async throws -> OutlineDocument)? var listCollectionsHandler: (@Sendable (Int, Int) async throws -> [OutlineCollection])? + var updateDocumentHandler: (@Sendable (UpdateDocumentRequest) async throws -> OutlineDocument)? + var createPinHandler: (@Sendable (CreatePinRequest) async throws -> OutlinePin)? + var deletePinHandler: (@Sendable (String) async throws -> Void)? func authInfo() async throws -> OutlineAuthInfo { throw NotStubbed() } @@ -26,7 +29,12 @@ private final class StubOutlineAPIClient: OutlineAPIClient, @unchecked Sendable func searchDocuments(_ request: DocumentSearchRequest) async throws -> [OutlineDocumentSearchResult] { throw NotStubbed() } func searchDocumentTitles(_ request: DocumentSearchTitlesRequest) async throws -> [OutlineDocument] { throw NotStubbed() } func createDocument(_ request: CreateDocumentRequest) async throws -> OutlineDocument { throw NotStubbed() } - func updateDocument(_ request: UpdateDocumentRequest) async throws -> OutlineDocument { throw NotStubbed() } + + func updateDocument(_ request: UpdateDocumentRequest) async throws -> OutlineDocument { + guard let handler = updateDocumentHandler else { throw NotStubbed() } + return try await handler(request) + } + func starDocument(_ request: StarDocumentRequest) async throws -> OutlineStar { throw NotStubbed() } func templatizeDocument(_ request: TemplatizeDocumentRequest) async throws -> OutlineTemplate { throw NotStubbed() } func duplicateDocument(_ request: DuplicateDocumentRequest) async throws -> [OutlineDocument] { throw NotStubbed() } @@ -42,9 +50,17 @@ private final class StubOutlineAPIClient: OutlineAPIClient, @unchecked Sendable func updateShare(_ request: UpdateShareRequest) async throws -> OutlineShare { throw NotStubbed() } func listShares(_ request: ListSharesRequest) async throws -> [OutlineShare] { throw NotStubbed() } func revokeShare(id: String) async throws { throw NotStubbed() } - func createPin(_ request: CreatePinRequest) async throws -> OutlinePin { throw NotStubbed() } + func createPin(_ request: CreatePinRequest) async throws -> OutlinePin { + guard let handler = createPinHandler else { throw NotStubbed() } + return try await handler(request) + } + func listPins(_ request: ListPinsRequest) async throws -> [OutlinePin] { throw NotStubbed() } - func deletePin(id: String) async throws { throw NotStubbed() } + + func deletePin(id: String) async throws { + guard let handler = deletePinHandler else { throw NotStubbed() } + try await handler(id) + } func createSubscription(_ request: CreateSubscriptionRequest) async throws -> OutlineSubscription { throw NotStubbed() } func listSubscriptions(_ request: ListSubscriptionsRequest) async throws -> [OutlineSubscription] { throw NotStubbed() } func deleteSubscription(id: String) async throws { throw NotStubbed() } @@ -174,4 +190,153 @@ final class CachingOutlineAPIClientTests: XCTestCase { XCTAssertEqual(cachedOne, docOne) XCTAssertEqual(cachedTwo, docTwo) } + + // MARK: - Offline writes + + func testUpdateDocumentQueuesAndAppliesOptimisticallyWhenLiveFails() async throws { + let stub = StubOutlineAPIClient() + let original = makeDocument(id: "doc-1", title: "Original") + stub.documentInfoHandler = { _ in original } + stub.updateDocumentHandler = { _ in throw StubTransportError() } + let sut = CachingOutlineAPIClient(live: stub, cache: try makeCache()) + + // Populate the cache with the base document first (as a real open would). + _ = try await sut.documentInfo(id: "doc-1") + + let updated = try await sut.updateDocument(UpdateDocumentRequest(id: "doc-1", title: "Edited Offline")) + XCTAssertEqual(updated.title, "Edited Offline") + + // Re-opening the document (still offline) should reflect the optimistic edit. + stub.documentInfoHandler = { _ in throw StubTransportError() } + let reopened = try await sut.documentInfo(id: "doc-1") + XCTAssertEqual(reopened.title, "Edited Offline") + + let pending = await sut.pendingOperations() + XCTAssertEqual(pending.count, 1) + XCTAssertEqual(pending.first?.kind, "updateDocument") + } + + func testUpdateDocumentRethrowsWhenDocumentWasNeverCached() async throws { + let stub = StubOutlineAPIClient() + stub.updateDocumentHandler = { _ in throw StubTransportError() } + let sut = CachingOutlineAPIClient(live: stub, cache: try makeCache()) + + do { + _ = try await sut.updateDocument(UpdateDocumentRequest(id: "never-seen", title: "x")) + XCTFail("Expected an error") + } catch is StubTransportError { + // expected — nothing to optimistically merge onto + } + } + + func testSecondOfflineEditCoalescesIntoOneQueuedOperation() async throws { + let stub = StubOutlineAPIClient() + let original = makeDocument(id: "doc-1", title: "Original") + stub.documentInfoHandler = { _ in original } + stub.updateDocumentHandler = { _ in throw StubTransportError() } + let sut = CachingOutlineAPIClient(live: stub, cache: try makeCache()) + + _ = try await sut.documentInfo(id: "doc-1") + _ = try await sut.updateDocument(UpdateDocumentRequest(id: "doc-1", title: "First Edit")) + _ = try await sut.updateDocument(UpdateDocumentRequest(id: "doc-1", title: "Second Edit")) + + let pending = await sut.pendingOperations() + XCTAssertEqual(pending.count, 1) + } + + func testPinThenUnpinBeforeSyncCancelsOutWithoutQueuingADelete() async throws { + let stub = StubOutlineAPIClient() + stub.createPinHandler = { _ in throw StubTransportError() } + let sut = CachingOutlineAPIClient(live: stub, cache: try makeCache()) + + let pin = try await sut.createPin(CreatePinRequest(documentId: "doc-1")) + XCTAssertTrue(pin.id.hasPrefix("pending-")) + let pendingCount1 = await sut.pendingOperations().count + XCTAssertEqual(pendingCount1, 1) + + try await sut.deletePin(id: pin.id) + + let pendingCount0 = await sut.pendingOperations().count + XCTAssertEqual(pendingCount0, 0) + } + + func testFlushPendingOperationsReplaysAndClearsOnSuccess() async throws { + let stub = StubOutlineAPIClient() + let original = makeDocument(id: "doc-1", title: "Original") + stub.documentInfoHandler = { _ in original } + stub.updateDocumentHandler = { _ in throw StubTransportError() } + let sut = CachingOutlineAPIClient(live: stub, cache: try makeCache()) + + _ = try await sut.documentInfo(id: "doc-1") + _ = try await sut.updateDocument(UpdateDocumentRequest(id: "doc-1", title: "Edited Offline")) + let pendingCount1 = await sut.pendingOperations().count + XCTAssertEqual(pendingCount1, 1) + + // Network's back — replay should now succeed. + stub.updateDocumentHandler = { request in + self.makeDocument(id: request.id, title: request.title ?? "") + } + let summary = await sut.flushPendingOperations() + + XCTAssertEqual(summary.succeeded, 1) + XCTAssertEqual(summary.failed, 0) + let pendingCount0 = await sut.pendingOperations().count + XCTAssertEqual(pendingCount0, 0) + } + + func testFlushPendingOperationsRecordsErrorAndKeepsFailedOperation() async throws { + let stub = StubOutlineAPIClient() + let original = makeDocument(id: "doc-1", title: "Original") + stub.documentInfoHandler = { _ in original } + stub.updateDocumentHandler = { _ in throw StubTransportError() } + let sut = CachingOutlineAPIClient(live: stub, cache: try makeCache()) + + _ = try await sut.documentInfo(id: "doc-1") + _ = try await sut.updateDocument(UpdateDocumentRequest(id: "doc-1", title: "Edited Offline")) + + // Still offline — replay should fail and keep the operation queued. + let summary = await sut.flushPendingOperations() + + XCTAssertEqual(summary.succeeded, 0) + XCTAssertEqual(summary.failed, 1) + let pending = await sut.pendingOperations() + XCTAssertEqual(pending.count, 1) + XCTAssertEqual(pending.first?.attemptCount, 1) + XCTAssertNotNil(pending.first?.lastError) + } + + func testManualOfflineModeSkipsLiveEntirely() async throws { + let defaults = UserDefaults(suiteName: "CachingOutlineAPIClientTests.manualOffline")! + defaults.removePersistentDomain(forName: "CachingOutlineAPIClientTests.manualOffline") + defaults.set(true, forKey: CachingOutlineAPIClient.offlineModeDefaultsKey) + + let stub = StubOutlineAPIClient() + var liveCallCount = 0 + stub.createPinHandler = { _ in + liveCallCount += 1 + return OutlinePin(id: "real-id", documentId: "doc-1", collectionId: nil, index: nil) + } + let sut = CachingOutlineAPIClient(live: stub, cache: try makeCache(), defaults: defaults) + + let pin = try await sut.createPin(CreatePinRequest(documentId: "doc-1")) + + XCTAssertEqual(liveCallCount, 0, "Manual offline mode should never attempt the live call") + XCTAssertTrue(pin.id.hasPrefix("pending-")) + } + + func testCacheStorageSummaryReflectsCachedItems() async throws { + let stub = StubOutlineAPIClient() + stub.documentInfoHandler = { _ in self.makeDocument() } + let sut = CachingOutlineAPIClient(live: stub, cache: try makeCache()) + + _ = try await sut.documentInfo(id: "doc-1") + let summary = await sut.cacheStorageSummary() + + XCTAssertEqual(summary.itemCount, 1) + XCTAssertGreaterThan(summary.totalBytes, 0) + + await sut.clearCache() + let clearedSummary = await sut.cacheStorageSummary() + XCTAssertEqual(clearedSummary.itemCount, 0) + } } diff --git a/Outpost/Features/About/AboutView.swift b/Outpost/Features/About/AboutView.swift index a797b19..6f4c53b 100644 --- a/Outpost/Features/About/AboutView.swift +++ b/Outpost/Features/About/AboutView.swift @@ -2,11 +2,15 @@ import AppKit import SwiftUI -struct AboutView: View { +/// Bare content (icon, name, version, links) with no window chrome — reused +/// by both the standalone "About Outpost" window (`AboutView`, the standard +/// macOS app-menu affordance) and the Settings page's own About section, so +/// the two can't drift out of sync. +struct AboutInfoView: View { private let repositoryURL = URL(string: "https://git.psmattas.com/psmattas/Outpost")! private let releasesURL = URL(string: "https://git.psmattas.com/psmattas/Outpost/releases")! - private var appName: String { + var appName: String { Bundle.main.object(forInfoDictionaryKey: "CFBundleName") as? String ?? "Outpost" } @@ -15,7 +19,7 @@ struct AboutView: View { /// expected to stay a plain dotted-numeric string, not `0.0.1-ALPHA`. private let releaseStage = "ALPHA" - private var versionString: String { + var versionString: String { let shortVersion = Bundle.main.object(forInfoDictionaryKey: "CFBundleShortVersionString") as? String ?? "0.0.1" let buildNumber = Bundle.main.object(forInfoDictionaryKey: "CFBundleVersion") as? String ?? "1" let stageSuffix = releaseStage.isEmpty ? "" : "-\(releaseStage)" @@ -66,8 +70,6 @@ struct AboutView: View { .font(.caption2) .foregroundStyle(.tertiary) } - .padding(32) - .frame(width: 320) } // No Sparkle-style in-app updater yet — this just opens the releases page @@ -76,4 +78,12 @@ struct AboutView: View { NSWorkspace.shared.open(releasesURL) } } + +struct AboutView: View { + var body: some View { + AboutInfoView() + .padding(32) + .frame(width: 320) + } +} #endif diff --git a/Outpost/Features/Account/AccountFooter.swift b/Outpost/Features/Account/AccountFooter.swift index fdc932f..4aac7fc 100644 --- a/Outpost/Features/Account/AccountFooter.swift +++ b/Outpost/Features/Account/AccountFooter.swift @@ -1,5 +1,6 @@ #if os(macOS) import SwiftUI +import OutlineKit /// Uses a plain `Button` + `.popover` rather than `Menu`. A `Menu` whose label /// contains the avatar image reliably broke its own sizing on click (even after @@ -7,10 +8,11 @@ import SwiftUI /// label rendering, which a `Button` doesn't go through. struct AccountFooter: View { @Environment(SessionStore.self) private var session + @Environment(AppNavigation.self) private var navigation @Environment(\.openURL) private var openURL @Environment(\.openWindow) private var openWindow - @Environment(\.openSettings) private var openSettings @AppStorage("outpost.appearance") private var appearance: AppAppearance = .system + @AppStorage(CachingOutlineAPIClient.offlineModeDefaultsKey) private var isOfflineModeEnabled = false @State private var isMenuPresented = false @State private var isShowingLogoutConfirmation = false @State private var isShowingProfile = false @@ -93,8 +95,12 @@ struct AccountFooter: View { .padding(.horizontal, 6) .padding(.vertical, 4) + Toggle("Offline Mode", isOn: $isOfflineModeEnabled) + .padding(.horizontal, 6) + .padding(.vertical, 4) + menuItem("Profile…") { isShowingProfile = true } - menuItem("Preferences…") { openSettings() } + menuItem("Settings…") { navigation.isShowingSettings = true } Divider() diff --git a/Outpost/Features/Account/PreferencesView.swift b/Outpost/Features/Account/PreferencesView.swift deleted file mode 100644 index cfe27e9..0000000 --- a/Outpost/Features/Account/PreferencesView.swift +++ /dev/null @@ -1,40 +0,0 @@ -#if os(macOS) -import SwiftUI - -struct PreferencesView: View { - @Environment(SessionStore.self) private var session - @AppStorage("outpost.appearance") private var appearance: AppAppearance = .system - @State private var isShowingLogoutConfirmation = false - - var body: some View { - Form { - Section("Appearance") { - Picker("Appearance", selection: $appearance) { - ForEach(AppAppearance.allCases) { option in - Text(option.label).tag(option) - } - } - .pickerStyle(.segmented) - .labelsHidden() - } - - Section("Account") { - LabeledContent("Signed in as", value: session.userName ?? "—") - if let email = session.userEmail { - LabeledContent("Email", value: email) - } - if let teamName = session.teamName { - LabeledContent("Workspace", value: teamName) - } - - Button("Log Out…", role: .destructive) { - isShowingLogoutConfirmation = true - } - } - } - .formStyle(.grouped) - .frame(width: 380, height: 300) - .logoutConfirmationDialog(isPresented: $isShowingLogoutConfirmation, session: session) - } -} -#endif diff --git a/Outpost/Features/Account/SettingsView.swift b/Outpost/Features/Account/SettingsView.swift new file mode 100644 index 0000000..a35a71b --- /dev/null +++ b/Outpost/Features/Account/SettingsView.swift @@ -0,0 +1,311 @@ +#if os(macOS) +import SwiftUI +import OutlineKit + +/// Full-page Settings — rendered as a `RootView`-level overlay (see +/// `AppNavigation`), not a separate popup window. Replaces the old +/// `Settings {}` scene / `PreferencesView` and folds in what used to be the +/// standalone "About Outpost" window's content too, so everything about the +/// app lives in one place. +struct SettingsView: View { + let onDone: () -> Void + + @Environment(SessionStore.self) private var session + @AppStorage("outpost.appearance") private var appearance: AppAppearance = .system + @AppStorage(CachingOutlineAPIClient.offlineModeDefaultsKey) private var isOfflineModeEnabled = false + @AppStorage("outpost.fullLocalSyncEnabled") private var isFullLocalSyncEnabled = false + @State private var isShowingLogoutConfirmation = false + @State private var storageSummary: CacheStorageSummary? + @State private var pendingOperations: [PendingOperationSummary] = [] + @State private var isSyncing = false + @State private var isClearingCache = false + @State private var lastFullSyncSummary: FullSyncSummary? + @State private var lastFlushSummary: SyncFlushSummary? + + var body: some View { + VStack(spacing: 0) { + header + Divider() + ScrollView { + VStack(alignment: .leading, spacing: 20) { + appearanceSection + accountSection + offlineSyncSection + aboutSection + } + .padding(24) + .frame(maxWidth: 640) + } + .frame(maxWidth: .infinity) + } + .frame(maxWidth: .infinity, maxHeight: .infinity) + .background(.background) + .task { await refreshSyncState() } + .logoutConfirmationDialog(isPresented: $isShowingLogoutConfirmation, session: session) + } + + private var header: some View { + HStack { + Text("Settings") + .font(.title2.bold()) + Spacer() + Button("Done", action: onDone) + .keyboardShortcut(.cancelAction) + } + .padding(20) + } + + // MARK: - Appearance + + private var appearanceSection: some View { + section("Appearance", icon: "paintbrush") { + Picker("Appearance", selection: $appearance) { + ForEach(AppAppearance.allCases) { option in + Text(option.label).tag(option) + } + } + .pickerStyle(.segmented) + .labelsHidden() + } + } + + // MARK: - Account + + private var accountSection: some View { + section("Account", icon: "person.crop.circle") { + VStack(alignment: .leading, spacing: 10) { + labeledRow("Signed in as", session.userName ?? "—") + if let email = session.userEmail { + labeledRow("Email", email) + } + if let teamName = session.teamName { + labeledRow("Workspace", teamName) + } + + Button("Log Out…", role: .destructive) { + isShowingLogoutConfirmation = true + } + .padding(.top, 4) + } + } + } + + // MARK: - Offline & Sync + + private var offlineSyncSection: some View { + section("Offline & Sync", icon: "arrow.triangle.2.circlepath") { + VStack(alignment: .leading, spacing: 16) { + VStack(alignment: .leading, spacing: 6) { + Toggle("Offline Mode", isOn: $isOfflineModeEnabled) + Text("Skip the network entirely and work from what's already been cached. Turn this off to reconnect.") + .font(.caption) + .foregroundStyle(.secondary) + } + + Divider() + + VStack(alignment: .leading, spacing: 6) { + Toggle("Full Local Sync", isOn: $isFullLocalSyncEnabled) + .onChange(of: isFullLocalSyncEnabled) { _, enabled in + if enabled { Task { await runFullSync() } } + } + Text("Keep a complete local copy of every collection and document, not just what's been opened — the whole workspace stays browsable offline.") + .font(.caption) + .foregroundStyle(.secondary) + } + + if isFullLocalSyncEnabled { + HStack(spacing: 8) { + if isSyncing { + ProgressView().controlSize(.small) + Text("Syncing…") + .font(.caption) + .foregroundStyle(.secondary) + } else { + Button("Sync Now") { Task { await runFullSync() } } + .controlSize(.small) + if let lastFullSyncSummary { + Text(fullSyncSummaryText(lastFullSyncSummary)) + .font(.caption) + .foregroundStyle(.secondary) + } + } + } + } + + Divider() + + storageRow + Divider() + pendingOperationsRow + } + } + } + + private var storageRow: some View { + VStack(alignment: .leading, spacing: 6) { + HStack { + Text("Cache Storage") + .font(.subheadline.weight(.medium)) + Spacer() + Button(role: .destructive) { + Task { await clearCache() } + } label: { + if isClearingCache { + ProgressView().controlSize(.small) + } else { + Text("Clear Cache") + } + } + .buttonStyle(.plain) + .foregroundStyle(.red) + .font(.caption) + .disabled(isClearingCache || (storageSummary?.itemCount ?? 0) == 0) + } + if let storageSummary { + Text("\(storageSummary.itemCount) items — \(formattedBytes(storageSummary.totalBytes))") + .font(.caption) + .foregroundStyle(.secondary) + } else { + Text("—") + .font(.caption) + .foregroundStyle(.secondary) + } + } + } + + private var pendingOperationsRow: some View { + VStack(alignment: .leading, spacing: 8) { + HStack { + Text("Pending Sync") + .font(.subheadline.weight(.medium)) + Spacer() + if isSyncing { + ProgressView().controlSize(.small) + } else { + Button("Retry") { Task { await retrySync() } } + .buttonStyle(.plain) + .font(.caption) + .foregroundStyle(Color.accentColor) + .disabled(pendingOperations.isEmpty) + } + } + + if pendingOperations.isEmpty { + Text("Everything's synced.") + .font(.caption) + .foregroundStyle(.secondary) + } else { + VStack(alignment: .leading, spacing: 6) { + ForEach(pendingOperations) { operation in + pendingOperationRow(operation) + } + } + } + } + } + + private func pendingOperationRow(_ operation: PendingOperationSummary) -> some View { + HStack(alignment: .top, spacing: 6) { + Image(systemName: operation.lastError == nil ? "clock" : "exclamationmark.triangle.fill") + .foregroundStyle(operation.lastError == nil ? Color.secondary : Color.orange) + .font(.caption) + VStack(alignment: .leading, spacing: 2) { + Text(operationLabel(operation.kind)) + .font(.caption) + if let lastError = operation.lastError { + Text(lastError) + .font(.caption2) + .foregroundStyle(.red) + } + } + } + } + + private func operationLabel(_ kind: String) -> String { + switch kind { + case "updateDocument": return "Document edit" + case "updateCollection": return "Collection rename" + case "createPin": return "Pin" + case "deletePin": return "Unpin" + case "createSubscription": return "Subscribe" + case "deleteSubscription": return "Unsubscribe" + case "starDocument", "starCollection": return "Star" + case "deleteStar": return "Unstar" + default: return kind + } + } + + // MARK: - About + + private var aboutSection: some View { + section("About", icon: "info.circle") { + AboutInfoView() + } + } + + // MARK: - Helpers + + private func section(_ title: String, icon: String, @ViewBuilder content: () -> Content) -> some View { + VStack(alignment: .leading, spacing: 12) { + Label(title, systemImage: icon) + .font(.headline) + content() + } + .padding(16) + .frame(maxWidth: .infinity, alignment: .leading) + .background(.fill.tertiary, in: RoundedRectangle(cornerRadius: 10)) + } + + private func labeledRow(_ label: String, _ value: String) -> some View { + HStack { + Text(label) + .foregroundStyle(.secondary) + Spacer() + Text(value) + } + .font(.callout) + } + + private func formattedBytes(_ bytes: Int) -> String { + ByteCountFormatter.string(fromByteCount: Int64(bytes), countStyle: .file) + } + + private func fullSyncSummaryText(_ summary: FullSyncSummary) -> String { + if summary.errors.isEmpty { + return "Synced \(summary.documentsCount) documents across \(summary.collectionsCount) collections." + } + return "Synced with \(summary.errors.count) error\(summary.errors.count == 1 ? "" : "s")." + } + + private func refreshSyncState() async { + guard let cachingClient = session.cachingClient else { return } + storageSummary = await cachingClient.cacheStorageSummary() + pendingOperations = await cachingClient.pendingOperations() + } + + private func runFullSync() async { + guard let cachingClient = session.cachingClient, !isSyncing else { return } + isSyncing = true + defer { isSyncing = false } + lastFullSyncSummary = await cachingClient.performFullSync() + await refreshSyncState() + } + + private func retrySync() async { + guard let cachingClient = session.cachingClient, !isSyncing else { return } + isSyncing = true + defer { isSyncing = false } + lastFlushSummary = await cachingClient.flushPendingOperations() + await refreshSyncState() + } + + private func clearCache() async { + guard let cachingClient = session.cachingClient else { return } + isClearingCache = true + defer { isClearingCache = false } + await cachingClient.clearCache() + await refreshSyncState() + } +} +#endif diff --git a/Outpost/OutpostApp.swift b/Outpost/OutpostApp.swift index 49caa0c..884c94e 100644 --- a/Outpost/OutpostApp.swift +++ b/Outpost/OutpostApp.swift @@ -14,6 +14,7 @@ import AppKit @main struct OutpostApp: App { @State private var session = SessionStore() + @State private var navigation = AppNavigation() @AppStorage("outpost.appearance") private var appearance: AppAppearance = .system #if os(macOS) @@ -25,6 +26,7 @@ struct OutpostApp: App { WindowGroup { RootView() .environment(session) + .environment(navigation) #if os(iOS) .preferredColorScheme(appearance.colorScheme) #endif @@ -41,6 +43,16 @@ struct OutpostApp: App { openWindow(id: "about") } } + // No `Settings {}` scene anymore — Settings renders inside the + // root window (see `AppNavigation`), not a separate popup, so + // ⌘, has to be wired up by hand instead of coming for free. + CommandGroup(replacing: .appSettings) { + Button("Settings…") { + navigation.isShowingSettings = true + } + .keyboardShortcut(",") + .disabled(!session.isSignedIn) + } CommandGroup(after: .appSettings) { Divider() Button("Log Out…") { @@ -63,11 +75,6 @@ struct OutpostApp: App { .disablesFullScreen() } .windowResizability(.contentSize) - - Settings { - PreferencesView() - .environment(session) - } #endif } diff --git a/Outpost/Root/AppNavigation.swift b/Outpost/Root/AppNavigation.swift new file mode 100644 index 0000000..6fcd885 --- /dev/null +++ b/Outpost/Root/AppNavigation.swift @@ -0,0 +1,12 @@ +import Observation + +/// Cross-cutting UI state that doesn't belong to any one screen — currently +/// just "is Settings showing." Lives at `RootView` and is read wherever +/// something needs to open Settings (the profile menu) or render it (RootView +/// itself, as a full-window overlay rather than a separate popup window — +/// `openSettings()`'s `Settings {}` scene doesn't offer that). +@Observable +@MainActor +final class AppNavigation { + var isShowingSettings = false +} diff --git a/Outpost/Root/RootView.swift b/Outpost/Root/RootView.swift index 5b25277..ce508af 100644 --- a/Outpost/Root/RootView.swift +++ b/Outpost/Root/RootView.swift @@ -3,8 +3,10 @@ import OutlineKit struct RootView: View { @Environment(SessionStore.self) private var session + @Environment(AppNavigation.self) private var navigation @State private var welcomeName: String? @State private var starStore = StarStore() + @AppStorage("outpost.fullLocalSyncEnabled") private var isFullLocalSyncEnabled = false var body: some View { ZStack { @@ -19,9 +21,18 @@ struct RootView: View { .transition(.opacity) .zIndex(1) } + + #if os(macOS) + if navigation.isShowingSettings { + SettingsView(onDone: { navigation.isShowingSettings = false }) + .transition(.opacity) + .zIndex(2) + } + #endif } .environment(starStore) .animation(.easeInOut(duration: 0.45), value: welcomeName != nil) + .animation(.easeInOut(duration: 0.2), value: navigation.isShowingSettings) .task { await session.refreshTeamInfoIfNeeded() } @@ -32,6 +43,24 @@ struct RootView: View { starStore.reset() } } + // Replay whatever queued up while offline the moment the network's + // back — no need to wait for the user to open Settings and hit Retry. + .task(id: session.networkMonitor.isOnline) { + guard session.networkMonitor.isOnline, let cachingClient = session.cachingClient else { return } + _ = await cachingClient.flushPendingOperations() + } + // Full Local Sync re-walks the whole workspace periodically while + // enabled, in addition to the immediate sync Settings kicks off when + // the toggle is first switched on — keeps a long-running session from + // slowly drifting stale. + .task(id: isFullLocalSyncEnabled) { + guard isFullLocalSyncEnabled else { return } + while !Task.isCancelled { + try? await Task.sleep(for: .seconds(1200)) + guard !Task.isCancelled, session.networkMonitor.isOnline, let cachingClient = session.cachingClient else { continue } + _ = await cachingClient.performFullSync() + } + } } private func startWelcomeTransition(_ result: AuthViewModel.AuthResult) { diff --git a/Outpost/Root/SessionStore.swift b/Outpost/Root/SessionStore.swift index b2af143..d815378 100644 --- a/Outpost/Root/SessionStore.swift +++ b/Outpost/Root/SessionStore.swift @@ -17,6 +17,11 @@ final class SessionStore { var teamName: String? var teamAvatarURL: URL? private(set) var apiClient: OutlineAPIClient? + /// Same object as `apiClient` when the offline cache is available — kept + /// as a separately-typed reference so Settings can reach cache/sync-queue + /// specific methods (`flushPendingOperations`, `performFullSync`, etc.) + /// without downcasting the protocol-typed `apiClient` everywhere. + private(set) var cachingClient: CachingOutlineAPIClient? let networkMonitor = NetworkMonitor() /// `nil` only if the on-disk SwiftData store failed to open (e.g. disk /// full) — in that case `apiClient` falls back to talking to the server @@ -34,24 +39,29 @@ final class SessionStore { self.cacheStore = (try? OfflineCacheStore.makeContainer()).map(OfflineCacheStore.init(modelContainer:)) if isSignedIn, let serverURL { - apiClient = Self.makeAPIClient(serverURL: serverURL, tokenStore: tokenStore, cache: cacheStore) + (apiClient, cachingClient) = Self.makeAPIClient(serverURL: serverURL, tokenStore: tokenStore, cache: cacheStore) } } func signIn(serverURL: URL, user: OutlineUser, team: OutlineTeam) { defaults.set(serverURL.absoluteString, forKey: Self.serverURLDefaultsKey) - apiClient = Self.makeAPIClient(serverURL: serverURL, tokenStore: tokenStore, cache: cacheStore) + (apiClient, cachingClient) = Self.makeAPIClient(serverURL: serverURL, tokenStore: tokenStore, cache: cacheStore) apply(user: user, team: team, serverURL: serverURL) isSignedIn = true } - private static func makeAPIClient(serverURL: URL, tokenStore: TokenStoring, cache: OfflineCacheStore?) -> OutlineAPIClient { + private static func makeAPIClient( + serverURL: URL, + tokenStore: TokenStoring, + cache: OfflineCacheStore? + ) -> (OutlineAPIClient, CachingOutlineAPIClient?) { let live = LiveOutlineAPIClient( configuration: OutlineConfiguration(baseURL: serverURL), tokenStore: tokenStore ) - guard let cache else { return live } - return CachingOutlineAPIClient(live: live, cache: cache) + guard let cache else { return (live, nil) } + let caching = CachingOutlineAPIClient(live: live, cache: cache) + return (caching, caching) } func signOut() { @@ -64,6 +74,7 @@ final class SessionStore { teamName = nil teamAvatarURL = nil apiClient = nil + cachingClient = nil } /// Re-fetches user/workspace name/logo on relaunch, when the token survived but this From 4f423ef97184b2e6b9d0e209f9acfbc4fe65fa7a Mon Sep 17 00:00:00 2001 From: psavarmattas Date: Sat, 15 Aug 2026 00:17:51 +0100 Subject: [PATCH 03/22] fix(settings): keep the sidebar visible instead of a full-window overlay MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Settings now swaps into the same NavigationSplitView detail pane as Home/collections (gated on AppNavigation.isShowingSettings) instead of covering the whole root window — the sidebar, and the ability to just click something else in it to leave Settings, stays available. Any sidebar navigation (Home, a collection, a document) exits Settings. --- .../Collections/ContentView_macOS.swift | 22 ++++++++++++++++--- Outpost/Root/RootView.swift | 10 --------- 2 files changed, 19 insertions(+), 13 deletions(-) diff --git a/Outpost/Features/Collections/ContentView_macOS.swift b/Outpost/Features/Collections/ContentView_macOS.swift index 01dc410..bbcb029 100644 --- a/Outpost/Features/Collections/ContentView_macOS.swift +++ b/Outpost/Features/Collections/ContentView_macOS.swift @@ -4,6 +4,7 @@ import OutlineKit struct ContentView_macOS: View { @Environment(SessionStore.self) private var session + @Environment(AppNavigation.self) private var navigation /// The landing state — no collection selected yet is what Home actually /// means, so this starts `true` rather than auto-selecting the first /// collection the way this used to work. @@ -75,7 +76,7 @@ struct ContentView_macOS: View { // `CollectionOverviewView`, so on Home it was a dead end: a // user could click it, type, and nothing would happen. The // sidebar's global search already covers "search everything." - if !(isShowingHome && documentPath.isEmpty) { + if !navigation.isShowingSettings && !(isShowingHome && documentPath.isEmpty) { ToolbarItem(placement: .primaryAction) { contextualSearchField } @@ -96,6 +97,7 @@ struct ContentView_macOS: View { isContextualSearchExpanded = false selectedCollection = nil isShowingHome = true + navigation.isShowingSettings = false replaceDocumentPath(with: []) } @@ -144,7 +146,12 @@ struct ContentView_macOS: View { @ViewBuilder private var leadingToolbarContent: some View { Group { - if !trimmedGlobalQuery.isEmpty { + if navigation.isShowingSettings { + HStack(spacing: 6) { + Image(systemName: "gearshape.fill") + Text("Settings") + } + } else if !trimmedGlobalQuery.isEmpty { HStack(spacing: 6) { Image(systemName: "magnifyingglass") Text("Search") @@ -223,6 +230,7 @@ struct ContentView_macOS: View { globalSearchQuery = "" selectedCollection = collection isContextualSearchExpanded = true + navigation.isShowingSettings = false Task { @MainActor in isContextualSearchFocused = true } @@ -233,11 +241,13 @@ struct ContentView_macOS: View { /// hierarchy immediately rather than just the leaf. private func selectDocumentChain(_ collection: OutlineCollection, _ chain: [OutlineDocument]) { selectedCollection = collection + navigation.isShowingSettings = false replaceDocumentPath(with: chain) } /// Flat-list / search-result clicks: no known ancestors, single-level push. private func openDocument(_ document: OutlineDocument) { + navigation.isShowingSettings = false replaceDocumentPath(with: [document]) } @@ -256,7 +266,13 @@ struct ContentView_macOS: View { @ViewBuilder private var detail: some View { - if let apiClient = session.apiClient { + if navigation.isShowingSettings { + // Deliberately not a `NavigationStack` destination or a separate + // window — it's its own page swapped into the same detail pane + // Home/collections use, so the sidebar (and the ability to just + // click something else in it) stays available while it's open. + SettingsView(onDone: { navigation.isShowingSettings = false }) + } else if let apiClient = session.apiClient { // A fresh NavigationStack per collection (or when entering/leaving // search), so switching either also clears any pushed document. NavigationStack(path: $documentPath) { diff --git a/Outpost/Root/RootView.swift b/Outpost/Root/RootView.swift index ce508af..ab32307 100644 --- a/Outpost/Root/RootView.swift +++ b/Outpost/Root/RootView.swift @@ -3,7 +3,6 @@ import OutlineKit struct RootView: View { @Environment(SessionStore.self) private var session - @Environment(AppNavigation.self) private var navigation @State private var welcomeName: String? @State private var starStore = StarStore() @AppStorage("outpost.fullLocalSyncEnabled") private var isFullLocalSyncEnabled = false @@ -21,18 +20,9 @@ struct RootView: View { .transition(.opacity) .zIndex(1) } - - #if os(macOS) - if navigation.isShowingSettings { - SettingsView(onDone: { navigation.isShowingSettings = false }) - .transition(.opacity) - .zIndex(2) - } - #endif } .environment(starStore) .animation(.easeInOut(duration: 0.45), value: welcomeName != nil) - .animation(.easeInOut(duration: 0.2), value: navigation.isShowingSettings) .task { await session.refreshTeamInfoIfNeeded() } From e991f4ed4c16a62f8bda7f109692fd806d33e2cb Mon Sep 17 00:00:00 2001 From: psavarmattas Date: Sat, 15 Aug 2026 00:23:37 +0100 Subject: [PATCH 04/22] style(settings): grid the short cards, keep the tall ones stacked MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Appearance and Account are both short — a LazyVGrid lets them sit side-by-side when the window's wide enough instead of each wasting a full-width row. Offline & Sync and About stay full-width: pairing those with anything in the same grid row looked worse (very uneven row heights) than just stacking them. Also widened the content column (640 -> 900) so the grid actually has room to go two-up. --- Outpost/Features/Account/SettingsView.swift | 24 ++++++++++++++------- 1 file changed, 16 insertions(+), 8 deletions(-) diff --git a/Outpost/Features/Account/SettingsView.swift b/Outpost/Features/Account/SettingsView.swift index a35a71b..76f18b5 100644 --- a/Outpost/Features/Account/SettingsView.swift +++ b/Outpost/Features/Account/SettingsView.swift @@ -2,11 +2,11 @@ import SwiftUI import OutlineKit -/// Full-page Settings — rendered as a `RootView`-level overlay (see -/// `AppNavigation`), not a separate popup window. Replaces the old -/// `Settings {}` scene / `PreferencesView` and folds in what used to be the -/// standalone "About Outpost" window's content too, so everything about the -/// app lives in one place. +/// Full-page Settings — swapped into `ContentView_macOS`'s detail pane (see +/// `AppNavigation`), alongside the sidebar, not a separate popup window. +/// Replaces the old `Settings {}` scene / `PreferencesView` and folds in +/// what used to be the standalone "About Outpost" window's content too, so +/// everything about the app lives in one place. struct SettingsView: View { let onDone: () -> Void @@ -28,13 +28,21 @@ struct SettingsView: View { Divider() ScrollView { VStack(alignment: .leading, spacing: 20) { - appearanceSection - accountSection + // Appearance and Account are short — let them sit + // side-by-side when there's room instead of each + // claiming a full-width row on their own. Offline & Sync + // and About stay full-width, own row each: both are + // taller and visibly uneven height content, paired with + // an adaptive grid, looks worse than just stacking. + LazyVGrid(columns: [GridItem(.adaptive(minimum: 260), spacing: 20)], alignment: .leading, spacing: 20) { + appearanceSection + accountSection + } offlineSyncSection aboutSection } .padding(24) - .frame(maxWidth: 640) + .frame(maxWidth: 900) } .frame(maxWidth: .infinity) } From 19eb1bae69b6abe2dcdb7c0390bac1c412fed068 Mon Sep 17 00:00:00 2001 From: psavarmattas Date: Sat, 15 Aug 2026 00:29:51 +0100 Subject: [PATCH 05/22] redesign(settings): section list + single-section detail Replaces the stacked/gridded cards with the same shape as macOS System Settings: a section list on the left, one section's content in the detail pane on the right. Sections never render next to each other anymore, so there's no card-height mismatch to look weird, and the layout holds up at any window size or aspect ratio without needing a grid to reflow. --- Outpost/Features/Account/SettingsView.swift | 198 ++++++++++++-------- 1 file changed, 122 insertions(+), 76 deletions(-) diff --git a/Outpost/Features/Account/SettingsView.swift b/Outpost/Features/Account/SettingsView.swift index 76f18b5..3f939f8 100644 --- a/Outpost/Features/Account/SettingsView.swift +++ b/Outpost/Features/Account/SettingsView.swift @@ -2,11 +2,41 @@ import SwiftUI import OutlineKit +private enum SettingsSection: String, CaseIterable, Identifiable, Hashable { + case appearance, account, offlineSync, about + + var id: String { rawValue } + + var title: String { + switch self { + case .appearance: return "Appearance" + case .account: return "Account" + case .offlineSync: return "Offline & Sync" + case .about: return "About" + } + } + + var icon: String { + switch self { + case .appearance: return "paintbrush" + case .account: return "person.crop.circle" + case .offlineSync: return "arrow.triangle.2.circlepath" + case .about: return "info.circle" + } + } +} + /// Full-page Settings — swapped into `ContentView_macOS`'s detail pane (see /// `AppNavigation`), alongside the sidebar, not a separate popup window. /// Replaces the old `Settings {}` scene / `PreferencesView` and folds in /// what used to be the standalone "About Outpost" window's content too, so /// everything about the app lives in one place. +/// +/// Own section list + single-section detail (same shape as macOS System +/// Settings) rather than a page of stacked/gridded cards — cards of visibly +/// different heights never sit next to each other for comparison this way, +/// and the layout holds up at any window size or aspect ratio without +/// needing to reflow a grid. struct SettingsView: View { let onDone: () -> Void @@ -14,6 +44,7 @@ struct SettingsView: View { @AppStorage("outpost.appearance") private var appearance: AppAppearance = .system @AppStorage(CachingOutlineAPIClient.offlineModeDefaultsKey) private var isOfflineModeEnabled = false @AppStorage("outpost.fullLocalSyncEnabled") private var isFullLocalSyncEnabled = false + @State private var selectedSection: SettingsSection? = .appearance @State private var isShowingLogoutConfirmation = false @State private var storageSummary: CacheStorageSummary? @State private var pendingOperations: [PendingOperationSummary] = [] @@ -26,25 +57,17 @@ struct SettingsView: View { VStack(spacing: 0) { header Divider() - ScrollView { - VStack(alignment: .leading, spacing: 20) { - // Appearance and Account are short — let them sit - // side-by-side when there's room instead of each - // claiming a full-width row on their own. Offline & Sync - // and About stay full-width, own row each: both are - // taller and visibly uneven height content, paired with - // an adaptive grid, looks worse than just stacking. - LazyVGrid(columns: [GridItem(.adaptive(minimum: 260), spacing: 20)], alignment: .leading, spacing: 20) { - appearanceSection - accountSection - } - offlineSyncSection - aboutSection + HStack(spacing: 0) { + sectionList + .frame(width: 190) + Divider() + ScrollView { + sectionDetail(selectedSection ?? .appearance) + .padding(28) + .frame(maxWidth: .infinity, alignment: .leading) } - .padding(24) - .frame(maxWidth: 900) + .frame(maxWidth: .infinity, maxHeight: .infinity) } - .frame(maxWidth: .infinity) } .frame(maxWidth: .infinity, maxHeight: .infinity) .background(.background) @@ -63,10 +86,34 @@ struct SettingsView: View { .padding(20) } + private var sectionList: some View { + List(SettingsSection.allCases, selection: $selectedSection) { section in + Label(section.title, systemImage: section.icon) + .tag(section) + } + .listStyle(.sidebar) + } + + @ViewBuilder + private func sectionDetail(_ section: SettingsSection) -> some View { + switch section { + case .appearance: appearanceDetail + case .account: accountDetail + case .offlineSync: offlineSyncDetail + case .about: aboutDetail + } + } + + private func sectionHeader(_ section: SettingsSection) -> some View { + Text(section.title) + .font(.title.bold()) + } + // MARK: - Appearance - private var appearanceSection: some View { - section("Appearance", icon: "paintbrush") { + private var appearanceDetail: some View { + VStack(alignment: .leading, spacing: 16) { + sectionHeader(.appearance) Picker("Appearance", selection: $appearance) { ForEach(AppAppearance.allCases) { option in Text(option.label).tag(option) @@ -74,13 +121,15 @@ struct SettingsView: View { } .pickerStyle(.segmented) .labelsHidden() + .frame(maxWidth: 320) } } // MARK: - Account - private var accountSection: some View { - section("Account", icon: "person.crop.circle") { + private var accountDetail: some View { + VStack(alignment: .leading, spacing: 16) { + sectionHeader(.account) VStack(alignment: .leading, spacing: 10) { labeledRow("Signed in as", session.userName ?? "—") if let email = session.userEmail { @@ -89,64 +138,70 @@ struct SettingsView: View { if let teamName = session.teamName { labeledRow("Workspace", teamName) } + } + .frame(maxWidth: 420) - Button("Log Out…", role: .destructive) { - isShowingLogoutConfirmation = true - } - .padding(.top, 4) + Button("Log Out…", role: .destructive) { + isShowingLogoutConfirmation = true } } } // MARK: - Offline & Sync - private var offlineSyncSection: some View { - section("Offline & Sync", icon: "arrow.triangle.2.circlepath") { - VStack(alignment: .leading, spacing: 16) { - VStack(alignment: .leading, spacing: 6) { - Toggle("Offline Mode", isOn: $isOfflineModeEnabled) - Text("Skip the network entirely and work from what's already been cached. Turn this off to reconnect.") - .font(.caption) - .foregroundStyle(.secondary) - } + private var offlineSyncDetail: some View { + VStack(alignment: .leading, spacing: 20) { + sectionHeader(.offlineSync) - Divider() + VStack(alignment: .leading, spacing: 6) { + Toggle("Offline Mode", isOn: $isOfflineModeEnabled) + Text("Skip the network entirely and work from what's already been cached. Turn this off to reconnect.") + .font(.caption) + .foregroundStyle(.secondary) + } + .frame(maxWidth: 480, alignment: .leading) - VStack(alignment: .leading, spacing: 6) { - Toggle("Full Local Sync", isOn: $isFullLocalSyncEnabled) - .onChange(of: isFullLocalSyncEnabled) { _, enabled in - if enabled { Task { await runFullSync() } } - } - Text("Keep a complete local copy of every collection and document, not just what's been opened — the whole workspace stays browsable offline.") - .font(.caption) - .foregroundStyle(.secondary) - } + VStack(alignment: .leading, spacing: 6) { + Toggle("Full Local Sync", isOn: $isFullLocalSyncEnabled) + .onChange(of: isFullLocalSyncEnabled) { _, enabled in + if enabled { Task { await runFullSync() } } + } + Text("Keep a complete local copy of every collection and document, not just what's been opened — the whole workspace stays browsable offline.") + .font(.caption) + .foregroundStyle(.secondary) + } + .frame(maxWidth: 480, alignment: .leading) - if isFullLocalSyncEnabled { - HStack(spacing: 8) { - if isSyncing { - ProgressView().controlSize(.small) - Text("Syncing…") + if isFullLocalSyncEnabled { + HStack(spacing: 8) { + if isSyncing { + ProgressView().controlSize(.small) + Text("Syncing…") + .font(.caption) + .foregroundStyle(.secondary) + } else { + Button("Sync Now") { Task { await runFullSync() } } + .controlSize(.small) + if let lastFullSyncSummary { + Text(fullSyncSummaryText(lastFullSyncSummary)) .font(.caption) .foregroundStyle(.secondary) - } else { - Button("Sync Now") { Task { await runFullSync() } } - .controlSize(.small) - if let lastFullSyncSummary { - Text(fullSyncSummaryText(lastFullSyncSummary)) - .font(.caption) - .foregroundStyle(.secondary) - } } } } - - Divider() - - storageRow - Divider() - pendingOperationsRow } + + Divider() + .frame(maxWidth: 480) + + storageRow + .frame(maxWidth: 480, alignment: .leading) + + Divider() + .frame(maxWidth: 480) + + pendingOperationsRow + .frame(maxWidth: 480, alignment: .leading) } } @@ -246,25 +301,16 @@ struct SettingsView: View { // MARK: - About - private var aboutSection: some View { - section("About", icon: "info.circle") { + private var aboutDetail: some View { + VStack(alignment: .leading, spacing: 16) { + sectionHeader(.about) AboutInfoView() + .frame(maxWidth: 420, alignment: .leading) } } // MARK: - Helpers - private func section(_ title: String, icon: String, @ViewBuilder content: () -> Content) -> some View { - VStack(alignment: .leading, spacing: 12) { - Label(title, systemImage: icon) - .font(.headline) - content() - } - .padding(16) - .frame(maxWidth: .infinity, alignment: .leading) - .background(.fill.tertiary, in: RoundedRectangle(cornerRadius: 10)) - } - private func labeledRow(_ label: String, _ value: String) -> some View { HStack { Text(label) From 45a72f8d10f5fb4936011fa84d9422118ab91928 Mon Sep 17 00:00:00 2001 From: psavarmattas Date: Sat, 15 Aug 2026 00:30:55 +0100 Subject: [PATCH 06/22] fix(settings): surface the actual full-sync error, not just a count "Synced with 1 error" gave no way to tell what failed. Now lists each FullSyncSummary.errors entry underneath the summary line. --- Outpost/Features/Account/SettingsView.swift | 31 +++++++++++++-------- 1 file changed, 20 insertions(+), 11 deletions(-) diff --git a/Outpost/Features/Account/SettingsView.swift b/Outpost/Features/Account/SettingsView.swift index 3f939f8..20d4a4c 100644 --- a/Outpost/Features/Account/SettingsView.swift +++ b/Outpost/Features/Account/SettingsView.swift @@ -173,19 +173,28 @@ struct SettingsView: View { .frame(maxWidth: 480, alignment: .leading) if isFullLocalSyncEnabled { - HStack(spacing: 8) { - if isSyncing { - ProgressView().controlSize(.small) - Text("Syncing…") - .font(.caption) - .foregroundStyle(.secondary) - } else { - Button("Sync Now") { Task { await runFullSync() } } - .controlSize(.small) - if let lastFullSyncSummary { - Text(fullSyncSummaryText(lastFullSyncSummary)) + VStack(alignment: .leading, spacing: 6) { + HStack(spacing: 8) { + if isSyncing { + ProgressView().controlSize(.small) + Text("Syncing…") .font(.caption) .foregroundStyle(.secondary) + } else { + Button("Sync Now") { Task { await runFullSync() } } + .controlSize(.small) + if let lastFullSyncSummary { + Text(fullSyncSummaryText(lastFullSyncSummary)) + .font(.caption) + .foregroundStyle(lastFullSyncSummary.errors.isEmpty ? .secondary : .red) + } + } + } + if let lastFullSyncSummary, !isSyncing { + ForEach(Array(lastFullSyncSummary.errors.enumerated()), id: \.offset) { _, message in + Text(message) + .font(.caption2) + .foregroundStyle(.red) } } } From d1ed52b82528cbb7b8bbdab4145a83778e3f848f Mon Sep 17 00:00:00 2001 From: psavarmattas Date: Sat, 15 Aug 2026 00:36:28 +0100 Subject: [PATCH 07/22] redesign(settings): reuse the real sidebar instead of a mini one MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Settings now swaps the actual app sidebar's content (search field, collections tree, account footer) for a section list, instead of SettingsView drawing its own nested sidebar inside the detail pane. Done, wherever it's triggered from, restores the collections tree and detail content exactly as they were. AppNavigation gains selectedSettingsSection (and the SettingsSection enum moves there, shared by the new list and the sidebar host) so the sidebar's list and the detail pane agree on which section is showing. Also deferred the profile menu's "Settings…" action by one runloop tick — setting isShowingSettings synchronously in the same call that dismisses the popover was very likely the source of the AppKit "CA commit" transaction warnings in the console. --- Outpost/Features/Account/AccountFooter.swift | 7 +- .../Account/SettingsSidebarList.swift | 41 +++++++++ Outpost/Features/Account/SettingsView.swift | 92 ++++--------------- .../Collections/ContentView_macOS.swift | 45 ++++++--- Outpost/Root/AppNavigation.swift | 36 +++++++- 5 files changed, 127 insertions(+), 94 deletions(-) create mode 100644 Outpost/Features/Account/SettingsSidebarList.swift diff --git a/Outpost/Features/Account/AccountFooter.swift b/Outpost/Features/Account/AccountFooter.swift index 4aac7fc..79722ee 100644 --- a/Outpost/Features/Account/AccountFooter.swift +++ b/Outpost/Features/Account/AccountFooter.swift @@ -100,7 +100,12 @@ struct AccountFooter: View { .padding(.vertical, 4) menuItem("Profile…") { isShowingProfile = true } - menuItem("Settings…") { navigation.isShowingSettings = true } + // Deferred a tick: setting this synchronously in the same call + // that dismisses this popover collides two AppKit window/layer + // transactions in the same runloop turn (visible in the console + // as "Invalid attempt to open a new transaction during CA + // commit") — letting the popover's dismissal finish first avoids it. + menuItem("Settings…") { Task { @MainActor in navigation.isShowingSettings = true } } Divider() diff --git a/Outpost/Features/Account/SettingsSidebarList.swift b/Outpost/Features/Account/SettingsSidebarList.swift new file mode 100644 index 0000000..bb0f99c --- /dev/null +++ b/Outpost/Features/Account/SettingsSidebarList.swift @@ -0,0 +1,41 @@ +#if os(macOS) +import SwiftUI + +/// Swapped into the real sidebar's content slot (search field, collections +/// tree, account footer) while Settings is open — same sidebar, different +/// content, rather than a separate mini sidebar nested inside a page. "Done" +/// clears `AppNavigation.isShowingSettings`, which puts the collections tree +/// back. +struct SettingsSidebarList: View { + @Binding var selection: SettingsSection? + let onDone: () -> Void + + var body: some View { + VStack(spacing: 0) { + HStack { + Text("Settings") + .font(.headline) + Spacer() + } + .padding(.horizontal, 16) + .padding(.vertical, 12) + + Divider() + + List(SettingsSection.allCases, selection: $selection) { section in + Label(section.title, systemImage: section.icon) + .tag(section) + } + .listStyle(.sidebar) + + Divider() + + Button("Done", action: onDone) + .keyboardShortcut(.cancelAction) + .buttonStyle(.borderedProminent) + .frame(maxWidth: .infinity) + .padding(12) + } + } +} +#endif diff --git a/Outpost/Features/Account/SettingsView.swift b/Outpost/Features/Account/SettingsView.swift index 20d4a4c..6b63636 100644 --- a/Outpost/Features/Account/SettingsView.swift +++ b/Outpost/Features/Account/SettingsView.swift @@ -2,49 +2,18 @@ import SwiftUI import OutlineKit -private enum SettingsSection: String, CaseIterable, Identifiable, Hashable { - case appearance, account, offlineSync, about - - var id: String { rawValue } - - var title: String { - switch self { - case .appearance: return "Appearance" - case .account: return "Account" - case .offlineSync: return "Offline & Sync" - case .about: return "About" - } - } - - var icon: String { - switch self { - case .appearance: return "paintbrush" - case .account: return "person.crop.circle" - case .offlineSync: return "arrow.triangle.2.circlepath" - case .about: return "info.circle" - } - } -} - -/// Full-page Settings — swapped into `ContentView_macOS`'s detail pane (see -/// `AppNavigation`), alongside the sidebar, not a separate popup window. -/// Replaces the old `Settings {}` scene / `PreferencesView` and folds in -/// what used to be the standalone "About Outpost" window's content too, so -/// everything about the app lives in one place. -/// -/// Own section list + single-section detail (same shape as macOS System -/// Settings) rather than a page of stacked/gridded cards — cards of visibly -/// different heights never sit next to each other for comparison this way, -/// and the layout holds up at any window size or aspect ratio without -/// needing to reflow a grid. +/// Settings *detail* content for one section — the section list itself now +/// lives in `ContentView_macOS`'s real sidebar (swapped in over the +/// collections tree while `AppNavigation.isShowingSettings` is set, not a +/// separate mini sidebar of its own), so this view only ever renders +/// whichever section is currently selected. struct SettingsView: View { - let onDone: () -> Void + let section: SettingsSection @Environment(SessionStore.self) private var session @AppStorage("outpost.appearance") private var appearance: AppAppearance = .system @AppStorage(CachingOutlineAPIClient.offlineModeDefaultsKey) private var isOfflineModeEnabled = false @AppStorage("outpost.fullLocalSyncEnabled") private var isFullLocalSyncEnabled = false - @State private var selectedSection: SettingsSection? = .appearance @State private var isShowingLogoutConfirmation = false @State private var storageSummary: CacheStorageSummary? @State private var pendingOperations: [PendingOperationSummary] = [] @@ -54,20 +23,10 @@ struct SettingsView: View { @State private var lastFlushSummary: SyncFlushSummary? var body: some View { - VStack(spacing: 0) { - header - Divider() - HStack(spacing: 0) { - sectionList - .frame(width: 190) - Divider() - ScrollView { - sectionDetail(selectedSection ?? .appearance) - .padding(28) - .frame(maxWidth: .infinity, alignment: .leading) - } - .frame(maxWidth: .infinity, maxHeight: .infinity) - } + ScrollView { + sectionDetail + .padding(28) + .frame(maxWidth: .infinity, alignment: .leading) } .frame(maxWidth: .infinity, maxHeight: .infinity) .background(.background) @@ -75,27 +34,8 @@ struct SettingsView: View { .logoutConfirmationDialog(isPresented: $isShowingLogoutConfirmation, session: session) } - private var header: some View { - HStack { - Text("Settings") - .font(.title2.bold()) - Spacer() - Button("Done", action: onDone) - .keyboardShortcut(.cancelAction) - } - .padding(20) - } - - private var sectionList: some View { - List(SettingsSection.allCases, selection: $selectedSection) { section in - Label(section.title, systemImage: section.icon) - .tag(section) - } - .listStyle(.sidebar) - } - @ViewBuilder - private func sectionDetail(_ section: SettingsSection) -> some View { + private var sectionDetail: some View { switch section { case .appearance: appearanceDetail case .account: accountDetail @@ -104,7 +44,7 @@ struct SettingsView: View { } } - private func sectionHeader(_ section: SettingsSection) -> some View { + private var sectionHeader: some View { Text(section.title) .font(.title.bold()) } @@ -113,7 +53,7 @@ struct SettingsView: View { private var appearanceDetail: some View { VStack(alignment: .leading, spacing: 16) { - sectionHeader(.appearance) + sectionHeader Picker("Appearance", selection: $appearance) { ForEach(AppAppearance.allCases) { option in Text(option.label).tag(option) @@ -129,7 +69,7 @@ struct SettingsView: View { private var accountDetail: some View { VStack(alignment: .leading, spacing: 16) { - sectionHeader(.account) + sectionHeader VStack(alignment: .leading, spacing: 10) { labeledRow("Signed in as", session.userName ?? "—") if let email = session.userEmail { @@ -151,7 +91,7 @@ struct SettingsView: View { private var offlineSyncDetail: some View { VStack(alignment: .leading, spacing: 20) { - sectionHeader(.offlineSync) + sectionHeader VStack(alignment: .leading, spacing: 6) { Toggle("Offline Mode", isOn: $isOfflineModeEnabled) @@ -312,7 +252,7 @@ struct SettingsView: View { private var aboutDetail: some View { VStack(alignment: .leading, spacing: 16) { - sectionHeader(.about) + sectionHeader AboutInfoView() .frame(maxWidth: 420, alignment: .leading) } diff --git a/Outpost/Features/Collections/ContentView_macOS.swift b/Outpost/Features/Collections/ContentView_macOS.swift index bbcb029..e568151 100644 --- a/Outpost/Features/Collections/ContentView_macOS.swift +++ b/Outpost/Features/Collections/ContentView_macOS.swift @@ -27,17 +27,37 @@ struct ContentView_macOS: View { globalSearchQuery.trimmingCharacters(in: .whitespacesAndNewlines) } + /// `@Environment(AppNavigation.self)` doesn't hand out `$`-bindings on its + /// own (that's `@Bindable`'s job, and introducing one in `body` would + /// mean restructuring it away from a single implicit-return expression) + /// — a manually-built `Binding` over the same reference is simpler here. + private var selectedSettingsSectionBinding: Binding { + Binding( + get: { navigation.selectedSettingsSection }, + set: { navigation.selectedSettingsSection = $0 } + ) + } + var body: some View { NavigationSplitView { - VStack(spacing: 0) { - SidebarSearchField(text: $globalSearchQuery) - Divider() - if !session.networkMonitor.isOnline { - OfflineBanner() - Divider() + Group { + if navigation.isShowingSettings { + SettingsSidebarList( + selection: selectedSettingsSectionBinding, + onDone: { navigation.isShowingSettings = false } + ) + } else { + VStack(spacing: 0) { + SidebarSearchField(text: $globalSearchQuery) + Divider() + if !session.networkMonitor.isOnline { + OfflineBanner() + Divider() + } + sidebar + AccountFooter() + } } - sidebar - AccountFooter() } .navigationSplitViewColumnWidth(min: 220, ideal: 260) } detail: { @@ -268,10 +288,11 @@ struct ContentView_macOS: View { private var detail: some View { if navigation.isShowingSettings { // Deliberately not a `NavigationStack` destination or a separate - // window — it's its own page swapped into the same detail pane - // Home/collections use, so the sidebar (and the ability to just - // click something else in it) stays available while it's open. - SettingsView(onDone: { navigation.isShowingSettings = false }) + // window — it's swapped into the same detail pane Home/ + // collections use, alongside the real sidebar (now showing + // `SettingsSidebarList` instead of the collections tree) rather + // than a page with its own nested mini sidebar. + SettingsView(section: navigation.selectedSettingsSection ?? .appearance) } else if let apiClient = session.apiClient { // A fresh NavigationStack per collection (or when entering/leaving // search), so switching either also clears any pushed document. diff --git a/Outpost/Root/AppNavigation.swift b/Outpost/Root/AppNavigation.swift index 6fcd885..8d31750 100644 --- a/Outpost/Root/AppNavigation.swift +++ b/Outpost/Root/AppNavigation.swift @@ -1,12 +1,38 @@ import Observation -/// Cross-cutting UI state that doesn't belong to any one screen — currently -/// just "is Settings showing." Lives at `RootView` and is read wherever -/// something needs to open Settings (the profile menu) or render it (RootView -/// itself, as a full-window overlay rather than a separate popup window — -/// `openSettings()`'s `Settings {}` scene doesn't offer that). +enum SettingsSection: String, CaseIterable, Identifiable, Hashable { + case appearance, account, offlineSync, about + + var id: String { rawValue } + + var title: String { + switch self { + case .appearance: return "Appearance" + case .account: return "Account" + case .offlineSync: return "Offline & Sync" + case .about: return "About" + } + } + + var icon: String { + switch self { + case .appearance: return "paintbrush" + case .account: return "person.crop.circle" + case .offlineSync: return "arrow.triangle.2.circlepath" + case .about: return "info.circle" + } + } +} + +/// Cross-cutting UI state that doesn't belong to any one screen. Lives at +/// `OutpostApp` (so both `RootView`'s content and its `⌘,` command can reach +/// it) and is read wherever something needs to open Settings (the profile +/// menu) or render it — as a swap of the *existing* sidebar/detail panes in +/// `ContentView_macOS`, not a separate popup window or an overlay that hides +/// the sidebar. @Observable @MainActor final class AppNavigation { var isShowingSettings = false + var selectedSettingsSection: SettingsSection? = .appearance } From ae6cb7dc7d0aebcc4aa143e7f1dbb1bdc81b3964 Mon Sep 17 00:00:00 2001 From: psavarmattas Date: Sat, 15 Aug 2026 00:38:15 +0100 Subject: [PATCH 08/22] fix: real Xcode compile errors from Swift 6 strict concurrency MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - SettingsView: ternary between .secondary (HierarchicalShapeStyle) and .red (Color) doesn't unify — both sides now explicit Color. - CollectionRowView: passing OutlineIconMapping.sfSymbolName as a bare function reference to flatMap loses its (inferred default) MainActor isolation; wrapping it in a closure keeps the call inside body's already-MainActor context. - NetworkMonitor: [weak self] was captured on NWPathMonitor's non-isolated pathUpdateHandler closure, which must cross into the inner @MainActor Task — moved the weak capture onto the Task closure itself instead, which is where it's actually used. --- Outpost/Features/Account/SettingsView.swift | 2 +- Outpost/Features/Collections/CollectionRowView.swift | 2 +- Outpost/Support/NetworkMonitor.swift | 4 ++-- 3 files changed, 4 insertions(+), 4 deletions(-) diff --git a/Outpost/Features/Account/SettingsView.swift b/Outpost/Features/Account/SettingsView.swift index 6b63636..6a9df19 100644 --- a/Outpost/Features/Account/SettingsView.swift +++ b/Outpost/Features/Account/SettingsView.swift @@ -126,7 +126,7 @@ struct SettingsView: View { if let lastFullSyncSummary { Text(fullSyncSummaryText(lastFullSyncSummary)) .font(.caption) - .foregroundStyle(lastFullSyncSummary.errors.isEmpty ? .secondary : .red) + .foregroundStyle(lastFullSyncSummary.errors.isEmpty ? Color.secondary : Color.red) } } } diff --git a/Outpost/Features/Collections/CollectionRowView.swift b/Outpost/Features/Collections/CollectionRowView.swift index 913b9e0..6eb363e 100644 --- a/Outpost/Features/Collections/CollectionRowView.swift +++ b/Outpost/Features/Collections/CollectionRowView.swift @@ -10,7 +10,7 @@ struct CollectionRowView: View { } icon: { if let emoji = collection.emojiIcon { Text(emoji) - } else if let symbolName = collection.icon.flatMap(OutlineIconMapping.sfSymbolName) { + } else if let symbolName = collection.icon.flatMap({ OutlineIconMapping.sfSymbolName(for: $0) }) { Image(systemName: symbolName) .foregroundStyle(tintColor) } else { diff --git a/Outpost/Support/NetworkMonitor.swift b/Outpost/Support/NetworkMonitor.swift index 4c2fd5b..1c37dd9 100644 --- a/Outpost/Support/NetworkMonitor.swift +++ b/Outpost/Support/NetworkMonitor.swift @@ -14,9 +14,9 @@ final class NetworkMonitor { private let queue = DispatchQueue(label: "com.outpost.network-monitor") init() { - monitor.pathUpdateHandler = { [weak self] path in + monitor.pathUpdateHandler = { path in let online = path.status == .satisfied - Task { @MainActor in + Task { @MainActor [weak self] in self?.isOnline = online } } From ce235d656dd64c917da984407c660ad6aec4a389 Mon Sep 17 00:00:00 2001 From: psavarmattas Date: Sat, 15 Aug 2026 00:45:09 +0100 Subject: [PATCH 09/22] fix(offline): pagination-limit bug in full sync, missing manual-mode badge, capture error MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - performFullSync was requesting listCollections with limit: 250 — Outline caps pagination at 100 and rejects anything over that outright, which was the actual "Synced with 1 error" (now visible in the UI as of the last fix: "Pagination limit is too large (max 100)"). Paginate collections in increments of 100 the same way documents already were, continuing until a short page signals the end — the protocol doesn't expose a total count to ask for up front, so this is the only way to know when to stop. 2 new regression tests. - OfflineBanner only ever reflected NetworkMonitor (a real dropped connection) — turning on the manual "Offline Mode" toggle did nothing to it, since that's a separate AppStorage flag the banner never read. ContentView_macOS's sidebar now shows the banner for either condition, with distinct copy for "you turned this on" vs "the network's actually down". - NetworkMonitor: the previous fix (weak self only on the inner Task) traded one Swift 6 error for another ("'weak' ownership of capture 'self' differs from implicitly-captured strong reference in outer scope") since the inner closure's capture forced the outer one to implicitly capture self too. Standard fix: weak capture on the outer closure, guard-let into a strong local immediately, let the inner Task closure capture that plain local instead. --- .../Caching/CachingOutlineAPIClient.swift | 21 ++++++-- .../CachingOutlineAPIClientTests.swift | 48 ++++++++++++++++++- .../Collections/ContentView_macOS.swift | 5 +- .../Features/Collections/OfflineBanner.swift | 14 ++++-- Outpost/Support/NetworkMonitor.swift | 13 +++-- 5 files changed, 87 insertions(+), 14 deletions(-) diff --git a/OutlineKit/Sources/OutlineKit/Caching/CachingOutlineAPIClient.swift b/OutlineKit/Sources/OutlineKit/Caching/CachingOutlineAPIClient.swift index 9e39a7e..d62dca6 100644 --- a/OutlineKit/Sources/OutlineKit/Caching/CachingOutlineAPIClient.swift +++ b/OutlineKit/Sources/OutlineKit/Caching/CachingOutlineAPIClient.swift @@ -364,10 +364,23 @@ public actor CachingOutlineAPIClient: OutlineAPIClient { var documentsCount = 0 var errors: [String] = [] var collections: [OutlineCollection] = [] - do { - collections = try await listCollections(offset: 0, limit: 250) - } catch { - errors.append(errorDescription(error)) + var collectionsOffset = 0 + let collectionsLimit = 100 + // Outline rejects any `limit` over 100 outright — page in increments + // of that instead of guessing a total up front (the protocol doesn't + // expose `pagination`'s total count, only the page itself); a page + // shorter than the limit is what signals "that was the last one". + while true { + let page: [OutlineCollection] + do { + page = try await listCollections(offset: collectionsOffset, limit: collectionsLimit) + } catch { + errors.append(errorDescription(error)) + break + } + collections.append(contentsOf: page) + guard page.count == collectionsLimit else { break } + collectionsOffset += collectionsLimit } for collection in collections { diff --git a/OutlineKit/Tests/OutlineKitTests/CachingOutlineAPIClientTests.swift b/OutlineKit/Tests/OutlineKitTests/CachingOutlineAPIClientTests.swift index f53b18e..a5ab991 100644 --- a/OutlineKit/Tests/OutlineKitTests/CachingOutlineAPIClientTests.swift +++ b/OutlineKit/Tests/OutlineKitTests/CachingOutlineAPIClientTests.swift @@ -12,6 +12,7 @@ private final class StubOutlineAPIClient: OutlineAPIClient, @unchecked Sendable var updateDocumentHandler: (@Sendable (UpdateDocumentRequest) async throws -> OutlineDocument)? var createPinHandler: (@Sendable (CreatePinRequest) async throws -> OutlinePin)? var deletePinHandler: (@Sendable (String) async throws -> Void)? + var listDocumentsHandler: (@Sendable (String?, String?, Int, Int) async throws -> [OutlineDocument])? func authInfo() async throws -> OutlineAuthInfo { throw NotStubbed() } @@ -21,7 +22,8 @@ private final class StubOutlineAPIClient: OutlineAPIClient, @unchecked Sendable } func listDocuments(collectionId: String?, parentDocumentId: String?, offset: Int, limit: Int) async throws -> [OutlineDocument] { - throw NotStubbed() + guard let handler = listDocumentsHandler else { throw NotStubbed() } + return try await handler(collectionId, parentDocumentId, offset, limit) } func documentsList(_ request: DocumentsListRequest) async throws -> [OutlineDocument] { throw NotStubbed() } @@ -339,4 +341,48 @@ final class CachingOutlineAPIClientTests: XCTestCase { let clearedSummary = await sut.cacheStorageSummary() XCTAssertEqual(clearedSummary.itemCount, 0) } + + // MARK: - Full sync + + func testPerformFullSyncNeverRequestsMoreThan100CollectionsPerPage() async throws { + let stub = StubOutlineAPIClient() + var requestedLimits: [Int] = [] + // 105 collections across two pages (100 + 5) — regression test for a + // real bug: this used to ask for `limit: 250` in one shot, which + // Outline's server rejects outright ("Pagination limit is too large + // (max 100)"), turning the whole sync into a single silent failure. + stub.listCollectionsHandler = { offset, limit in + requestedLimits.append(limit) + let remaining = max(0, 105 - offset) + let count = min(limit, remaining) + return (0.. Date: Sat, 15 Aug 2026 00:58:24 +0100 Subject: [PATCH 10/22] feat(settings): Advanced section with safeguards, fully-automatic sync MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Full Local Sync and Clear Cache both need a real connection or a real cache to be safe against — clearing while offline, or letting sync think it should run with nothing to talk to, can leave the app with no local copy and no way to rebuild one. Both are now disabled (Toggle/button, with a tooltip explaining why) whenever the app isn't effectively online (real network down OR the manual Offline Mode toggle). Clear Cache moves out of Offline & Sync entirely into a new Advanced section (sidebar, above About) — the one deliberate escape hatch that works even offline, gated behind an off-by-default "Enable Advanced Options" master toggle that only turns on after a confirmation dialog warning about data loss. A few not-yet-implemented settings sit under it as permanently-dimmed "Coming Soon" rows. Nothing destructive is reachable outside this one screen, so there's no path to breaking Full Local Sync's cache by accident. Full Local Sync itself is now fully automatic: RootView's background task syncs immediately whenever it (re)starts — covers both "just turned on" and "was already on at a fresh launch" — then every 20 minutes, and again immediately on reconnect. No more needing to press Sync Now by hand. --- Outpost/Features/Account/SettingsView.swift | 145 +++++++++++++++++--- Outpost/Root/AppNavigation.swift | 4 +- Outpost/Root/RootView.swift | 22 ++- 3 files changed, 142 insertions(+), 29 deletions(-) diff --git a/Outpost/Features/Account/SettingsView.swift b/Outpost/Features/Account/SettingsView.swift index 6a9df19..07fac73 100644 --- a/Outpost/Features/Account/SettingsView.swift +++ b/Outpost/Features/Account/SettingsView.swift @@ -14,7 +14,9 @@ struct SettingsView: View { @AppStorage("outpost.appearance") private var appearance: AppAppearance = .system @AppStorage(CachingOutlineAPIClient.offlineModeDefaultsKey) private var isOfflineModeEnabled = false @AppStorage("outpost.fullLocalSyncEnabled") private var isFullLocalSyncEnabled = false + @AppStorage("outpost.advancedOptionsEnabled") private var isAdvancedOptionsEnabled = false @State private var isShowingLogoutConfirmation = false + @State private var isShowingAdvancedWarning = false @State private var storageSummary: CacheStorageSummary? @State private var pendingOperations: [PendingOperationSummary] = [] @State private var isSyncing = false @@ -22,6 +24,16 @@ struct SettingsView: View { @State private var lastFullSyncSummary: FullSyncSummary? @State private var lastFlushSummary: SyncFlushSummary? + /// Full Local Sync and cache-clearing both need a real connection to be + /// safe — clearing while offline (or letting Full Local Sync think it + /// should be running) can leave the app with nothing local to show and + /// no way to refetch it. "Offline" here means either a real dropped + /// connection or the user's own manual toggle — both leave the app with + /// no server to talk to. + private var isEffectivelyOnline: Bool { + session.networkMonitor.isOnline && !isOfflineModeEnabled + } + var body: some View { ScrollView { sectionDetail @@ -40,6 +52,7 @@ struct SettingsView: View { case .appearance: appearanceDetail case .account: accountDetail case .offlineSync: offlineSyncDetail + case .advanced: advancedDetail case .about: aboutDetail } } @@ -103,14 +116,18 @@ struct SettingsView: View { VStack(alignment: .leading, spacing: 6) { Toggle("Full Local Sync", isOn: $isFullLocalSyncEnabled) - .onChange(of: isFullLocalSyncEnabled) { _, enabled in - if enabled { Task { await runFullSync() } } - } - Text("Keep a complete local copy of every collection and document, not just what's been opened — the whole workspace stays browsable offline.") + .disabled(!isEffectivelyOnline) + Text("Keep a complete local copy of every collection and document, not just what's been opened — the whole workspace stays browsable offline. Runs automatically in the background once on; no need to trigger it by hand.") .font(.caption) .foregroundStyle(.secondary) + if !isEffectivelyOnline { + Text("Requires an internet connection to turn on or off.") + .font(.caption2) + .foregroundStyle(.orange) + } } .frame(maxWidth: 480, alignment: .leading) + .help(isEffectivelyOnline ? "" : "Full Local Sync needs a real connection — it can't safely turn on (or off) while offline.") if isFullLocalSyncEnabled { VStack(alignment: .leading, spacing: 6) { @@ -123,6 +140,7 @@ struct SettingsView: View { } else { Button("Sync Now") { Task { await runFullSync() } } .controlSize(.small) + .disabled(!isEffectivelyOnline) if let lastFullSyncSummary { Text(fullSyncSummaryText(lastFullSyncSummary)) .font(.caption) @@ -156,24 +174,8 @@ struct SettingsView: View { private var storageRow: some View { VStack(alignment: .leading, spacing: 6) { - HStack { - Text("Cache Storage") - .font(.subheadline.weight(.medium)) - Spacer() - Button(role: .destructive) { - Task { await clearCache() } - } label: { - if isClearingCache { - ProgressView().controlSize(.small) - } else { - Text("Clear Cache") - } - } - .buttonStyle(.plain) - .foregroundStyle(.red) - .font(.caption) - .disabled(isClearingCache || (storageSummary?.itemCount ?? 0) == 0) - } + Text("Cache Storage") + .font(.subheadline.weight(.medium)) if let storageSummary { Text("\(storageSummary.itemCount) items — \(formattedBytes(storageSummary.totalBytes))") .font(.caption) @@ -183,6 +185,9 @@ struct SettingsView: View { .font(.caption) .foregroundStyle(.secondary) } + Text("Clearing the cache is in Advanced Options.") + .font(.caption2) + .foregroundStyle(.secondary) } } @@ -248,6 +253,102 @@ struct SettingsView: View { } } + // MARK: - Advanced + + /// Everything here either does something destructive (clearing the + /// cache Full Local Sync just spent minutes building) or doesn't exist + /// yet — gating all of it behind an off-by-default master toggle plus a + /// confirmation to turn that toggle on is the safeguard: nothing here + /// can be reached by accident, and nothing outside this section can + /// touch the cache at all, so there's no path to "messed up Full Local + /// Sync" that doesn't go through this screen on purpose. + private var advancedDetail: some View { + VStack(alignment: .leading, spacing: 20) { + sectionHeader + + VStack(alignment: .leading, spacing: 6) { + Toggle("Enable Advanced Options", isOn: advancedOptionsBinding) + Text("Off by default on purpose. Turning this on unlocks things that can cause unintended behavior, including permanently losing your local cache.") + .font(.caption) + .foregroundStyle(.secondary) + } + .frame(maxWidth: 480, alignment: .leading) + + Divider() + .frame(maxWidth: 480) + + VStack(alignment: .leading, spacing: 12) { + comingSoonRow("Export All Data") + comingSoonRow("Developer Diagnostics") + comingSoonRow("Reset Local Database") + } + .frame(maxWidth: 480, alignment: .leading) + + Divider() + .frame(maxWidth: 480) + + VStack(alignment: .leading, spacing: 6) { + HStack { + Text("Clear All Cache") + Spacer() + Button(role: .destructive) { + Task { await clearCache() } + } label: { + if isClearingCache { + ProgressView().controlSize(.small) + } else { + Text("Clear") + } + } + } + Text("Deletes every cached collection and document, including anything Full Local Sync built, and anything still waiting to sync. Doesn't touch the server. This is the only place that can — it works even while offline, which is exactly why it's behind this toggle.") + .font(.caption2) + .foregroundStyle(.secondary) + } + .disabled(!isAdvancedOptionsEnabled || isClearingCache) + .opacity(isAdvancedOptionsEnabled ? 1 : 0.4) + .frame(maxWidth: 480, alignment: .leading) + } + .confirmationDialog( + "Enable Advanced Options?", + isPresented: $isShowingAdvancedWarning, + titleVisibility: .visible + ) { + Button("Enable", role: .destructive) { isAdvancedOptionsEnabled = true } + Button("Cancel", role: .cancel) {} + } message: { + Text("These settings can cause unintended behavior, including permanently losing your local cache. Only continue if you know what you're doing.") + } + } + + /// Never writes `true` directly — turning the toggle on only opens the + /// warning dialog; only that dialog's own "Enable" button actually sets + /// it. Turning off doesn't need confirmation. + private var advancedOptionsBinding: Binding { + Binding( + get: { isAdvancedOptionsEnabled }, + set: { newValue in + if newValue { + isShowingAdvancedWarning = true + } else { + isAdvancedOptionsEnabled = false + } + } + ) + } + + private func comingSoonRow(_ title: String) -> some View { + HStack { + Text(title) + Spacer() + Text("Coming Soon") + .font(.caption) + .foregroundStyle(.secondary) + } + .disabled(true) + .opacity(0.5) + } + // MARK: - About private var aboutDetail: some View { diff --git a/Outpost/Root/AppNavigation.swift b/Outpost/Root/AppNavigation.swift index 8d31750..6804aef 100644 --- a/Outpost/Root/AppNavigation.swift +++ b/Outpost/Root/AppNavigation.swift @@ -1,7 +1,7 @@ import Observation enum SettingsSection: String, CaseIterable, Identifiable, Hashable { - case appearance, account, offlineSync, about + case appearance, account, offlineSync, advanced, about var id: String { rawValue } @@ -10,6 +10,7 @@ enum SettingsSection: String, CaseIterable, Identifiable, Hashable { case .appearance: return "Appearance" case .account: return "Account" case .offlineSync: return "Offline & Sync" + case .advanced: return "Advanced" case .about: return "About" } } @@ -19,6 +20,7 @@ enum SettingsSection: String, CaseIterable, Identifiable, Hashable { case .appearance: return "paintbrush" case .account: return "person.crop.circle" case .offlineSync: return "arrow.triangle.2.circlepath" + case .advanced: return "wrench.and.screwdriver" case .about: return "info.circle" } } diff --git a/Outpost/Root/RootView.swift b/Outpost/Root/RootView.swift index ab32307..4df0d61 100644 --- a/Outpost/Root/RootView.swift +++ b/Outpost/Root/RootView.swift @@ -35,20 +35,30 @@ struct RootView: View { } // Replay whatever queued up while offline the moment the network's // back — no need to wait for the user to open Settings and hit Retry. + // Also catches Full Local Sync back up immediately on reconnect, + // rather than leaving it to wait out the rest of the periodic loop + // below. .task(id: session.networkMonitor.isOnline) { guard session.networkMonitor.isOnline, let cachingClient = session.cachingClient else { return } _ = await cachingClient.flushPendingOperations() + if isFullLocalSyncEnabled { + _ = await cachingClient.performFullSync() + } } - // Full Local Sync re-walks the whole workspace periodically while - // enabled, in addition to the immediate sync Settings kicks off when - // the toggle is first switched on — keeps a long-running session from - // slowly drifting stale. + // Fully automatic — this is the only place Full Local Sync actually + // runs from (Settings' "Sync Now" is just an on-demand nudge at the + // same call). Syncs immediately whenever this task (re)starts, which + // covers both "just switched on" and "was already on at launch" — + // `.task(id:)` restarts on either, an `@AppStorage`-backed toggle + // changing anywhere updates every view reading that key — then every + // 20 minutes after, for as long as it stays enabled. .task(id: isFullLocalSyncEnabled) { guard isFullLocalSyncEnabled else { return } while !Task.isCancelled { + if session.networkMonitor.isOnline, let cachingClient = session.cachingClient { + _ = await cachingClient.performFullSync() + } try? await Task.sleep(for: .seconds(1200)) - guard !Task.isCancelled, session.networkMonitor.isOnline, let cachingClient = session.cachingClient else { continue } - _ = await cachingClient.performFullSync() } } } From 1ce2aced9046e5964d241ada80aef4ebda5d6d20 Mon Sep 17 00:00:00 2001 From: psavarmattas Date: Sat, 15 Aug 2026 01:01:19 +0100 Subject: [PATCH 11/22] fix(settings): red Clear Cache button, confirm when it's done MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit role: .destructive alone wasn't rendering red on its own — explicit .buttonStyle(.borderedProminent).tint(.red), matching how other destructive actions in the app (DocumentShareSheet's Revoke) had to be styled explicitly too. Also shows a transient "Cleared" checkmark for 2s after finishing, same timed-reset pattern as the share sheet's copy-link feedback, since the storage count updating alone was easy to miss. --- Outpost/Features/Account/SettingsView.swift | 15 ++++++++++++++- 1 file changed, 14 insertions(+), 1 deletion(-) diff --git a/Outpost/Features/Account/SettingsView.swift b/Outpost/Features/Account/SettingsView.swift index 07fac73..7daffe2 100644 --- a/Outpost/Features/Account/SettingsView.swift +++ b/Outpost/Features/Account/SettingsView.swift @@ -21,6 +21,7 @@ struct SettingsView: View { @State private var pendingOperations: [PendingOperationSummary] = [] @State private var isSyncing = false @State private var isClearingCache = false + @State private var didClearCache = false @State private var lastFullSyncSummary: FullSyncSummary? @State private var lastFlushSummary: SyncFlushSummary? @@ -288,9 +289,14 @@ struct SettingsView: View { .frame(maxWidth: 480) VStack(alignment: .leading, spacing: 6) { - HStack { + HStack(spacing: 10) { Text("Clear All Cache") Spacer() + if didClearCache { + Label("Cleared", systemImage: "checkmark") + .font(.caption) + .foregroundStyle(.secondary) + } Button(role: .destructive) { Task { await clearCache() } } label: { @@ -300,6 +306,8 @@ struct SettingsView: View { Text("Clear") } } + .buttonStyle(.borderedProminent) + .tint(.red) } Text("Deletes every cached collection and document, including anything Full Local Sync built, and anything still waiting to sync. Doesn't touch the server. This is the only place that can — it works even while offline, which is exactly why it's behind this toggle.") .font(.caption2) @@ -410,6 +418,11 @@ struct SettingsView: View { defer { isClearingCache = false } await cachingClient.clearCache() await refreshSyncState() + didClearCache = true + Task { + try? await Task.sleep(for: .seconds(2)) + didClearCache = false + } } } #endif From 924f84525c7fe443f9066b6cf67bbc06db64baf8 Mon Sep 17 00:00:00 2001 From: psavarmattas Date: Sat, 15 Aug 2026 01:08:03 +0100 Subject: [PATCH 12/22] fix(offline): manual mode never skipped live reads, storage/banner cleanup MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The main fix: CachingOutlineAPIClient.cachedFetch (documentInfo, listCollections, listDocuments, etc.) never actually checked manual Offline Mode — only the write path did. With a real connection still up, turning Offline Mode on did nothing for reads: the sidebar kept fetching live, showing collections/documents beyond what was actually cached. Reads now skip `live` entirely under manual offline mode, same as writes already did — the sidebar and document lists are now genuinely limited to whatever's cached, and only those documents are openable, once the toggle is on. 1 new regression test (54/54). Settings: removed the cache-size readout from Offline & Sync (storage management is Advanced-only now, per the "one place that can touch the cache" design) and added it next to Advanced's Clear All Cache instead, where it was missing. Sidebar: replaced the old passive "Offline — showing cached content" banner for a real dropped connection with an actionable prompt (OfflineConnectionPromptBanner, styled like the existing RemoteChangesBanner) offering to turn Offline Mode on — doesn't change any behavior on its own, same as RemoteChangesBanner never auto- refreshes. The informational banner still shows once Offline Mode is actually on (manually, or via this prompt). --- .../Caching/CachingOutlineAPIClient.swift | 11 +++++++ .../CachingOutlineAPIClientTests.swift | 33 +++++++++++++++++++ Outpost/Features/Account/SettingsView.swift | 30 +++-------------- .../Collections/ContentView_macOS.swift | 7 ++-- .../OfflineConnectionPromptBanner.swift | 30 +++++++++++++++++ 5 files changed, 84 insertions(+), 27 deletions(-) create mode 100644 Outpost/Features/Collections/OfflineConnectionPromptBanner.swift diff --git a/OutlineKit/Sources/OutlineKit/Caching/CachingOutlineAPIClient.swift b/OutlineKit/Sources/OutlineKit/Caching/CachingOutlineAPIClient.swift index d62dca6..b5f465a 100644 --- a/OutlineKit/Sources/OutlineKit/Caching/CachingOutlineAPIClient.swift +++ b/OutlineKit/Sources/OutlineKit/Caching/CachingOutlineAPIClient.swift @@ -409,6 +409,17 @@ public actor CachingOutlineAPIClient: OutlineAPIClient { // MARK: - Helpers private func cachedFetch(key: String, fetch: () async throws -> T) async throws -> T { + // Manual offline mode means "skip the network entirely," not just + // "prefer it" — without this check, a read would still hit `live` + // (and succeed, showing content beyond whatever's cached) any time + // the device actually had a connection, defeating the point of + // deliberately testing/working as if offline. + if isManualOfflineModeEnabled { + if let data = await cache.load(forKey: key), let cached = try? decoder.decode(T.self, from: data) { + return cached + } + throw OutlineAPIError.transport(URLError(.notConnectedToInternet)) + } do { let result = try await fetch() if let data = try? encoder.encode(result) { diff --git a/OutlineKit/Tests/OutlineKitTests/CachingOutlineAPIClientTests.swift b/OutlineKit/Tests/OutlineKitTests/CachingOutlineAPIClientTests.swift index a5ab991..45c9841 100644 --- a/OutlineKit/Tests/OutlineKitTests/CachingOutlineAPIClientTests.swift +++ b/OutlineKit/Tests/OutlineKitTests/CachingOutlineAPIClientTests.swift @@ -326,6 +326,39 @@ final class CachingOutlineAPIClientTests: XCTestCase { XCTAssertTrue(pin.id.hasPrefix("pending-")) } + /// Regression test: manual offline mode used to only gate writes — + /// reads (listCollections, documentInfo, etc.) still hit `live` first + /// any time the device actually had a connection, silently defeating + /// "skip the network entirely" for the one thing that mattered most: + /// the sidebar showing more than what was actually cached. + func testManualOfflineModeSkipsLiveForReadsToo() async throws { + let defaults = UserDefaults(suiteName: "CachingOutlineAPIClientTests.manualOfflineReads")! + defaults.removePersistentDomain(forName: "CachingOutlineAPIClientTests.manualOfflineReads") + + let stub = StubOutlineAPIClient() + let cachedCollection = makeCollection(id: "col-1") + var liveCallCount = 0 + stub.listCollectionsHandler = { _, _ in + liveCallCount += 1 + return [cachedCollection] + } + let cache = try makeCache() + let sut = CachingOutlineAPIClient(live: stub, cache: cache, defaults: defaults) + + // Online first — populates the cache normally. + let firstResult = try await sut.listCollections(offset: 0, limit: 25) + XCTAssertEqual(firstResult, [cachedCollection]) + XCTAssertEqual(liveCallCount, 1) + + // Flip manual offline mode on, still "connected" (stub would happily + // answer) — the live call must not be attempted at all. + defaults.set(true, forKey: CachingOutlineAPIClient.offlineModeDefaultsKey) + let secondResult = try await sut.listCollections(offset: 0, limit: 25) + + XCTAssertEqual(secondResult, [cachedCollection]) + XCTAssertEqual(liveCallCount, 1, "listCollections should have served entirely from cache") + } + func testCacheStorageSummaryReflectsCachedItems() async throws { let stub = StubOutlineAPIClient() stub.documentInfoHandler = { _ in self.makeDocument() } diff --git a/Outpost/Features/Account/SettingsView.swift b/Outpost/Features/Account/SettingsView.swift index 7daffe2..a61804d 100644 --- a/Outpost/Features/Account/SettingsView.swift +++ b/Outpost/Features/Account/SettingsView.swift @@ -159,12 +159,6 @@ struct SettingsView: View { } } - Divider() - .frame(maxWidth: 480) - - storageRow - .frame(maxWidth: 480, alignment: .leading) - Divider() .frame(maxWidth: 480) @@ -173,25 +167,6 @@ struct SettingsView: View { } } - private var storageRow: some View { - VStack(alignment: .leading, spacing: 6) { - Text("Cache Storage") - .font(.subheadline.weight(.medium)) - if let storageSummary { - Text("\(storageSummary.itemCount) items — \(formattedBytes(storageSummary.totalBytes))") - .font(.caption) - .foregroundStyle(.secondary) - } else { - Text("—") - .font(.caption) - .foregroundStyle(.secondary) - } - Text("Clearing the cache is in Advanced Options.") - .font(.caption2) - .foregroundStyle(.secondary) - } - } - private var pendingOperationsRow: some View { VStack(alignment: .leading, spacing: 8) { HStack { @@ -309,6 +284,11 @@ struct SettingsView: View { .buttonStyle(.borderedProminent) .tint(.red) } + if let storageSummary { + Text("\(storageSummary.itemCount) items — \(formattedBytes(storageSummary.totalBytes))") + .font(.caption) + .foregroundStyle(.secondary) + } Text("Deletes every cached collection and document, including anything Full Local Sync built, and anything still waiting to sync. Doesn't touch the server. This is the only place that can — it works even while offline, which is exactly why it's behind this toggle.") .font(.caption2) .foregroundStyle(.secondary) diff --git a/Outpost/Features/Collections/ContentView_macOS.swift b/Outpost/Features/Collections/ContentView_macOS.swift index 57a4e22..53b1a8f 100644 --- a/Outpost/Features/Collections/ContentView_macOS.swift +++ b/Outpost/Features/Collections/ContentView_macOS.swift @@ -51,8 +51,11 @@ struct ContentView_macOS: View { VStack(spacing: 0) { SidebarSearchField(text: $globalSearchQuery) Divider() - if isOfflineModeEnabled || !session.networkMonitor.isOnline { - OfflineBanner(isManual: isOfflineModeEnabled) + if isOfflineModeEnabled { + OfflineBanner(isManual: true) + Divider() + } else if !session.networkMonitor.isOnline { + OfflineConnectionPromptBanner(onEnableOfflineMode: { isOfflineModeEnabled = true }) Divider() } sidebar diff --git a/Outpost/Features/Collections/OfflineConnectionPromptBanner.swift b/Outpost/Features/Collections/OfflineConnectionPromptBanner.swift new file mode 100644 index 0000000..9bd51de --- /dev/null +++ b/Outpost/Features/Collections/OfflineConnectionPromptBanner.swift @@ -0,0 +1,30 @@ +#if os(macOS) +import SwiftUI + +/// Shown in the sidebar when the network is actually down and the user +/// hasn't turned on Offline Mode themselves yet. Deliberately doesn't change +/// any fetch behavior on its own — reads still try live and fall back to +/// cache the normal way (see `CachingOutlineAPIClient`) until the user +/// actually taps the button here, same as `RemoteChangesBanner` never +/// auto-refreshes on their behalf either. +struct OfflineConnectionPromptBanner: View { + let onEnableOfflineMode: () -> Void + + var body: some View { + HStack(spacing: 8) { + Image(systemName: "wifi.slash") + .foregroundStyle(.orange) + Text("No connection") + .font(.callout) + Spacer(minLength: 8) + Button("Turn On Offline Mode", action: onEnableOfflineMode) + .buttonStyle(.borderedProminent) + .tint(.orange) + .controlSize(.small) + } + .padding(.horizontal, 12) + .padding(.vertical, 8) + .background(Color.orange.opacity(0.12)) + } +} +#endif From 82ff3b49ff3a34395d483f6cdf350edf45c03e3f Mon Sep 17 00:00:00 2001 From: psavarmattas Date: Sat, 15 Aug 2026 01:10:29 +0100 Subject: [PATCH 13/22] fix(settings): force detail pane teardown when opening/closing Settings MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Opening Settings while a document was pushed left the document visible in the detail pane even though the sidebar correctly switched to the settings list — NavigationSplitView on macOS doesn't reliably replace the detail pane's content on an implicit branch change alone between very different subtrees (a NavigationStack with a real push history vs. a plain view). An explicit .id() keyed to isShowingSettings forces a real teardown/remount; documentPath itself is untouched, so returning via Done still lands back on the same document. --- Outpost/Features/Collections/ContentView_macOS.swift | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/Outpost/Features/Collections/ContentView_macOS.swift b/Outpost/Features/Collections/ContentView_macOS.swift index 53b1a8f..5cefa75 100644 --- a/Outpost/Features/Collections/ContentView_macOS.swift +++ b/Outpost/Features/Collections/ContentView_macOS.swift @@ -66,6 +66,14 @@ struct ContentView_macOS: View { .navigationSplitViewColumnWidth(min: 220, ideal: 260) } detail: { detail + // NavigationSplitView on macOS doesn't reliably tear down the + // detail pane's previous content when swapping between very + // different subtrees (a NavigationStack with a document + // pushed vs. plain SettingsView) off an implicit branch + // alone — an explicit identity change forces a real + // teardown/remount instead of it silently leaving the old + // document visible underneath. + .id(navigation.isShowingSettings) } // Empty rather than a real value: with one, the native title rendered // in the same toolbar row as the custom `.navigation` pill below, From a9db3e1412779ce82104c79ea518190f03783235 Mon Sep 17 00:00:00 2001 From: psavarmattas Date: Sat, 15 Aug 2026 01:13:28 +0100 Subject: [PATCH 14/22] fix(settings): make Settings its own top-level branch, not swapped content MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The .id() fix on the detail pane wasn't enough — still reported as the document staying visible with only the sidebar switching. NavigationSplitView bridges to NSSplitViewController on macOS, and apparently doesn't reliably replace already-mounted detail content (a NavigationStack with real push history) for something unrelated inside one persisting split view instance, identity hints or not. Restructured so `navigation.isShowingSettings` picks between two entirely separate NavigationSplitView instances (settingsContent / mainContent) at the top of body, instead of branching on content inside a single one. A different top-level view hierarchy is a guaranteed full teardown of whatever AppKit was holding onto — no split-view instance persists across the switch for it to get confused about reusing. Also dropped the now-redundant `isShowingSettings` guards scattered through mainContent's toolbar/leadingToolbarContent — moot once mainContent only ever renders while Settings isn't showing. --- .../Collections/ContentView_macOS.swift | 104 ++++++++++-------- 1 file changed, 58 insertions(+), 46 deletions(-) diff --git a/Outpost/Features/Collections/ContentView_macOS.swift b/Outpost/Features/Collections/ContentView_macOS.swift index 5cefa75..ad42a31 100644 --- a/Outpost/Features/Collections/ContentView_macOS.swift +++ b/Outpost/Features/Collections/ContentView_macOS.swift @@ -40,40 +40,67 @@ struct ContentView_macOS: View { } var body: some View { + // A totally separate top-level branch, not content swapped inside + // one persistent `NavigationSplitView` — that was tried first (an + // `if/else` inside a single split view, then an explicit `.id()` on + // just the detail pane) and neither reliably replaced the detail + // pane's content on macOS: `NavigationSplitView` bridges to + // `NSSplitViewController`, and swapping a `NavigationStack` with + // real push history for a plain view inside one persisting instance + // doesn't propagate the way plain SwiftUI identity rules would + // suggest. A different `if` branch is a genuinely different view + // hierarchy, so there's no existing split view instance for AppKit + // to get confused about reusing. + if navigation.isShowingSettings { + settingsContent + } else { + mainContent + } + } + + private var settingsContent: some View { NavigationSplitView { - Group { - if navigation.isShowingSettings { - SettingsSidebarList( - selection: selectedSettingsSectionBinding, - onDone: { navigation.isShowingSettings = false } - ) - } else { - VStack(spacing: 0) { - SidebarSearchField(text: $globalSearchQuery) - Divider() - if isOfflineModeEnabled { - OfflineBanner(isManual: true) - Divider() - } else if !session.networkMonitor.isOnline { - OfflineConnectionPromptBanner(onEnableOfflineMode: { isOfflineModeEnabled = true }) - Divider() - } - sidebar - AccountFooter() - } + SettingsSidebarList( + selection: selectedSettingsSectionBinding, + onDone: { navigation.isShowingSettings = false } + ) + .navigationSplitViewColumnWidth(min: 220, ideal: 260) + } detail: { + SettingsView(section: navigation.selectedSettingsSection ?? .appearance) + } + .navigationTitle("") + .toolbar { + ToolbarItem(placement: .navigation) { + HStack(spacing: 6) { + Image(systemName: "gearshape.fill") + Text("Settings") } + .font(.headline) + .padding(.horizontal, 10) + .padding(.vertical, 4) + .background(.fill.tertiary, in: Capsule()) + } + } + } + + private var mainContent: some View { + NavigationSplitView { + VStack(spacing: 0) { + SidebarSearchField(text: $globalSearchQuery) + Divider() + if isOfflineModeEnabled { + OfflineBanner(isManual: true) + Divider() + } else if !session.networkMonitor.isOnline { + OfflineConnectionPromptBanner(onEnableOfflineMode: { isOfflineModeEnabled = true }) + Divider() + } + sidebar + AccountFooter() } .navigationSplitViewColumnWidth(min: 220, ideal: 260) } detail: { detail - // NavigationSplitView on macOS doesn't reliably tear down the - // detail pane's previous content when swapping between very - // different subtrees (a NavigationStack with a document - // pushed vs. plain SettingsView) off an implicit branch - // alone — an explicit identity change forces a real - // teardown/remount instead of it silently leaving the old - // document visible underneath. - .id(navigation.isShowingSettings) } // Empty rather than a real value: with one, the native title rendered // in the same toolbar row as the custom `.navigation` pill below, @@ -108,7 +135,7 @@ struct ContentView_macOS: View { // `CollectionOverviewView`, so on Home it was a dead end: a // user could click it, type, and nothing would happen. The // sidebar's global search already covers "search everything." - if !navigation.isShowingSettings && !(isShowingHome && documentPath.isEmpty) { + if !(isShowingHome && documentPath.isEmpty) { ToolbarItem(placement: .primaryAction) { contextualSearchField } @@ -178,12 +205,7 @@ struct ContentView_macOS: View { @ViewBuilder private var leadingToolbarContent: some View { Group { - if navigation.isShowingSettings { - HStack(spacing: 6) { - Image(systemName: "gearshape.fill") - Text("Settings") - } - } else if !trimmedGlobalQuery.isEmpty { + if !trimmedGlobalQuery.isEmpty { HStack(spacing: 6) { Image(systemName: "magnifyingglass") Text("Search") @@ -262,7 +284,6 @@ struct ContentView_macOS: View { globalSearchQuery = "" selectedCollection = collection isContextualSearchExpanded = true - navigation.isShowingSettings = false Task { @MainActor in isContextualSearchFocused = true } @@ -273,13 +294,11 @@ struct ContentView_macOS: View { /// hierarchy immediately rather than just the leaf. private func selectDocumentChain(_ collection: OutlineCollection, _ chain: [OutlineDocument]) { selectedCollection = collection - navigation.isShowingSettings = false replaceDocumentPath(with: chain) } /// Flat-list / search-result clicks: no known ancestors, single-level push. private func openDocument(_ document: OutlineDocument) { - navigation.isShowingSettings = false replaceDocumentPath(with: [document]) } @@ -298,14 +317,7 @@ struct ContentView_macOS: View { @ViewBuilder private var detail: some View { - if navigation.isShowingSettings { - // Deliberately not a `NavigationStack` destination or a separate - // window — it's swapped into the same detail pane Home/ - // collections use, alongside the real sidebar (now showing - // `SettingsSidebarList` instead of the collections tree) rather - // than a page with its own nested mini sidebar. - SettingsView(section: navigation.selectedSettingsSection ?? .appearance) - } else if let apiClient = session.apiClient { + if let apiClient = session.apiClient { // A fresh NavigationStack per collection (or when entering/leaving // search), so switching either also clears any pushed document. NavigationStack(path: $documentPath) { From 0d9f7d752dc08e22b77a8bdb77ee3efbb494d182 Mon Sep 17 00:00:00 2001 From: psavarmattas Date: Sat, 15 Aug 2026 01:22:47 +0100 Subject: [PATCH 15/22] fix(reader): grey out share/permissions and other live-only actions offline MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Share, Permissions, Templatize, Duplicate, Unpublish, Archive, Move, New Document, History, Insights (sheet + Viewer Insights toggle), and Download all hit the server directly with no offline path — disabled (with a tooltip on the two toolbar buttons) whenever the app isn't effectively online, instead of failing confusingly on tap. Left enabled: Edit, Pin/Unpin, Star/Unstar, Subscribed, Full Width (all queue via CachingOutlineAPIClient and sync later), Present and Search in Document (both read documents.info, which is cached), and Copy/Print (read already-loaded text directly, no network at all). --- .../Collections/DocumentReaderView.swift | 33 +++++++++++++++++-- 1 file changed, 30 insertions(+), 3 deletions(-) diff --git a/Outpost/Features/Collections/DocumentReaderView.swift b/Outpost/Features/Collections/DocumentReaderView.swift index d99df2d..a4a2dc7 100644 --- a/Outpost/Features/Collections/DocumentReaderView.swift +++ b/Outpost/Features/Collections/DocumentReaderView.swift @@ -12,6 +12,7 @@ import OutlineKit struct DocumentReaderView: View { @Environment(SessionStore.self) private var session @Environment(StarStore.self) private var starStore + @AppStorage(CachingOutlineAPIClient.offlineModeDefaultsKey) private var isOfflineModeEnabled = false @State private var viewModel: DocumentReaderViewModel let apiClient: OutlineAPIClient @@ -56,6 +57,15 @@ struct DocumentReaderView: View { self.onDocumentCreated = onDocumentCreated } + /// Editing, pin/star/subscribe, and Full Width all queue and sync later + /// (see `CachingOutlineAPIClient`) — everything else here (sharing, + /// permissions, move/archive/delete/duplicate/templatize, history, + /// insights, export) hits the server directly with no offline path, so + /// it's disabled rather than left to fail confusingly on tap. + private var isEffectivelyOnline: Bool { + session.networkMonitor.isOnline && !isOfflineModeEnabled + } + var body: some View { ScrollView { VStack(alignment: .leading, spacing: 12) { @@ -110,7 +120,8 @@ struct DocumentReaderView: View { } label: { Image(systemName: "square.and.arrow.up") } - .help("Share") + .help(isEffectivelyOnline ? "Share" : "Sharing needs an internet connection") + .disabled(!isEffectivelyOnline) .popover(isPresented: $isShowingShareSheet, arrowEdge: .bottom) { DocumentShareSheet(apiClient: apiClient, documentId: viewModel.documentId) } @@ -131,7 +142,8 @@ struct DocumentReaderView: View { } label: { Image(systemName: "doc.badge.plus") } - .help("New Document") + .help(isEffectivelyOnline ? "New Document" : "Creating documents needs an internet connection") + .disabled(!isEffectivelyOnline) Menu { menuContent @@ -235,7 +247,8 @@ struct DocumentReaderView: View { viewModel.isPinned, viewModel.isInsightsEnabled ?? false, viewModel.isFullWidth, - viewModel.isEditing + viewModel.isEditing, + isEffectivelyOnline ].map(String.init).joined(separator: "-") } @@ -305,27 +318,33 @@ struct DocumentReaderView: View { Button("Permissions…") { isShowingShareSheet = true } + .disabled(!isEffectivelyOnline) Divider() Button("Templatize") { Task { await templatize() } } + .disabled(!isEffectivelyOnline) Button("Duplicate") { Task { await duplicate() } } + .disabled(!isEffectivelyOnline) Button("Unpublish") { isShowingUnpublishConfirmation = true } + .disabled(!isEffectivelyOnline) Button("Archive…") { isShowingArchiveConfirmation = true } + .disabled(!isEffectivelyOnline) Divider() Button("Move") { isShowingMoveSheet = true } + .disabled(!isEffectivelyOnline) // Multipart file upload is its own subsystem — deferred rather than // half-built here. Button("Import Document…") {} @@ -333,6 +352,7 @@ struct DocumentReaderView: View { Button("New Document") { isShowingNewDocumentSheet = true } + .disabled(!isEffectivelyOnline) Button(viewModel.isPinned ? "Unpin from Home" : "Pin to Home") { Task { await togglePin() } } @@ -342,9 +362,13 @@ struct DocumentReaderView: View { Button("History") { isShowingHistorySheet = true } + .disabled(!isEffectivelyOnline) Button("Insights") { isShowingInsightsSheet = true } + .disabled(!isEffectivelyOnline) + // Present/Search in Document both read `documents.info`, which is + // read-through cached — they work offline on whatever's cached. Button("Present") { isShowingPresentSheet = true } @@ -354,6 +378,7 @@ struct DocumentReaderView: View { Button("Download") { Task { await download() } } + .disabled(!isEffectivelyOnline) Button("Copy") { Task { await copyMarkdown() } } @@ -370,6 +395,7 @@ struct DocumentReaderView: View { get: { viewModel.isInsightsEnabled ?? false }, set: { _ in Task { await toggleInsights() } } )) + .disabled(!isEffectivelyOnline) // Confirmed against a live server: there's no per-document embeds // field. Only a workspace-level setting exists, and that's not // reachable via the API either (no `team.update` endpoint in the @@ -386,6 +412,7 @@ struct DocumentReaderView: View { Button("Delete…", role: .destructive) { isShowingDeleteConfirmation = true } + .disabled(!isEffectivelyOnline) } private func star() async { From e2e4b746c177e187f6cfa857da33a42c244de7be Mon Sep 17 00:00:00 2001 From: psavarmattas Date: Sat, 15 Aug 2026 01:33:54 +0100 Subject: [PATCH 16/22] fix(reader): pass a real per-document id to NativeTextViewWrapper MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Every document was using the library's literal "default" documentId — never our own viewModel.documentId — meaning undo stacks, content- divergence snapshots, scroll-offset memory, and pending inline replacements were all keyed to the same slot across every document instead of being scoped per-document. That's a real, unambiguous bug regardless of the click/cursor issue: opening a second document could replay or discard the wrong document's undo history. Investigated the click-does-nothing-in-edit-mode report by reading through the library's (vendored, external — swift-markdown-engine) own isEditable/isSelectable wiring in both makeNSView and updateNSView directly; both looked correctly applied on every pass, and a clean- relaunch test ruled out the settings/NavigationSplitView bug from earlier in this session as the cause. Couldn't confirm this documentId fix resolves the click issue without being able to run the app here — worth retesting either way. --- Outpost/Features/Collections/DocumentReaderView.swift | 1 + 1 file changed, 1 insertion(+) diff --git a/Outpost/Features/Collections/DocumentReaderView.swift b/Outpost/Features/Collections/DocumentReaderView.swift index a4a2dc7..6a5e56b 100644 --- a/Outpost/Features/Collections/DocumentReaderView.swift +++ b/Outpost/Features/Collections/DocumentReaderView.swift @@ -92,6 +92,7 @@ struct DocumentReaderView: View { NativeTextViewWrapper( text: $viewModel.text, configuration: .init(heightBehavior: .fitsContent), + documentId: viewModel.documentId, isEditable: viewModel.isEditing ) From 8d8c8fead8064d6f95a46e242b7852a03789870c Mon Sep 17 00:00:00 2001 From: psavarmattas Date: Sat, 15 Aug 2026 01:35:24 +0100 Subject: [PATCH 17/22] style(settings): center the About section on the page MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit AboutInfoView (icon/name/version/links) now centers both horizontally and vertically within the detail pane instead of sitting pinned to the top-left like the other sections. Needed a GeometryReader + minHeight on the shared ScrollView wrapper — a ScrollView proposes unbounded height to its content, so maxHeight: .infinity alone doesn't give short content anything to center within; minHeight pinned to the real viewport height does. Harmless for the other sections, which stay top/leading-aligned as before. --- Outpost/Features/Account/SettingsView.swift | 24 +++++++++++++++------ 1 file changed, 18 insertions(+), 6 deletions(-) diff --git a/Outpost/Features/Account/SettingsView.swift b/Outpost/Features/Account/SettingsView.swift index a61804d..228d86b 100644 --- a/Outpost/Features/Account/SettingsView.swift +++ b/Outpost/Features/Account/SettingsView.swift @@ -36,10 +36,21 @@ struct SettingsView: View { } var body: some View { - ScrollView { - sectionDetail - .padding(28) - .frame(maxWidth: .infinity, alignment: .leading) + // GeometryReader + `minHeight` (not `maxHeight`) is the actual fix for + // "center short content inside a ScrollView" — a ScrollView proposes + // effectively unbounded height to its content, so `maxHeight: .infinity` + // alone just resolves to the content's own intrinsic size and does + // nothing; forcing a `minHeight` equal to the real viewport height is + // what gives `aboutDetail`'s own centered alignment somewhere to + // actually center within. Harmless for the other (already + // top/leading-aligned) sections — short ones just get blank space + // below, same as before. + GeometryReader { geometry in + ScrollView { + sectionDetail + .padding(28) + .frame(maxWidth: .infinity, minHeight: geometry.size.height, alignment: .leading) + } } .frame(maxWidth: .infinity, maxHeight: .infinity) .background(.background) @@ -340,11 +351,12 @@ struct SettingsView: View { // MARK: - About private var aboutDetail: some View { - VStack(alignment: .leading, spacing: 16) { + VStack(spacing: 16) { sectionHeader AboutInfoView() - .frame(maxWidth: 420, alignment: .leading) + .frame(maxWidth: 420) } + .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .center) } // MARK: - Helpers From 284ad072516f04455304c953538c710241c291bc Mon Sep 17 00:00:00 2001 From: psavarmattas Date: Sat, 15 Aug 2026 01:40:07 +0100 Subject: [PATCH 18/22] fix(offline): false "New changes available" banner, sync stuck on toggle-off MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Home's checkForRemoteChanges compared fresh pinned docs against what was displayed, but the pinned fetch (listPins isn't a cached endpoint) silently collapsed any failure to [] — every poll while offline compared "[]" against the real non-empty pinned list, which always looked like a change and popped the banner every ~45s. Split fetchPinned into a throwing variant checkForRemoteChanges can bail on (matching the already-correct pattern in CollectionsViewModel/DocumentsViewModel, which don't have this bug), keeping the non-throwing version for the initial load where collapsing to [] is the right behavior. RootView's auto-flush was keyed only to session.networkMonitor.isOnline — turning the manual Offline Mode toggle back off while the real network had been up the whole time never changes that value, so queued operations sat stuck until the next real network blip. Keyed the flush (and the Full Local Sync loop's online check) to a combined isEffectivelyOnline instead, so either signal clearing resumes sync immediately. --- Outpost/Features/Home/HomeViewModel.swift | 22 ++++++++++++------ Outpost/Root/RootView.swift | 28 ++++++++++++++++------- 2 files changed, 35 insertions(+), 15 deletions(-) diff --git a/Outpost/Features/Home/HomeViewModel.swift b/Outpost/Features/Home/HomeViewModel.swift index d1888ce..56543ab 100644 --- a/Outpost/Features/Home/HomeViewModel.swift +++ b/Outpost/Features/Home/HomeViewModel.swift @@ -57,25 +57,33 @@ final class HomeViewModel { /// Fetches fresh pinned docs and the current tab's documents to compare /// against what's displayed, without replacing either. Bails silently /// on a fetch failure rather than treating it as "changed" — a - /// transient network hiccup shouldn't pop the refresh banner. + /// transient network hiccup (or, offline, `listPins` failing outright — + /// it isn't one of the cached endpoints) shouldn't pop the refresh + /// banner. This needs the *throwing* pinned-fetch specifically: the + /// plain `fetchPinned()` used elsewhere collapses any failure to `[]`, + /// which used to read here as "pins changed" against whatever was + /// already displayed and falsely popped the banner on every offline + /// poll. func checkForRemoteChanges(tab: HomeTab) async { - async let freshPinned = fetchPinned() + async let freshPinnedTask = fetchPinnedThrowing() guard let freshTab = try? await fetch(tab: tab) else { return } - let pinned = await freshPinned + guard let pinned = try? await freshPinnedTask else { return } if Self.fingerprint(pinned) != Self.fingerprint(pinnedDocuments) || Self.fingerprint(freshTab) != Self.fingerprint(documents(for: tab)) { hasRemoteChanges = true } } + private func fetchPinned() async -> [OutlineDocument] { + (try? await fetchPinnedThrowing()) ?? [] + } + /// `pins.list` only returns pin records, not the documents themselves — /// fetches each pinned document individually. Pins are a small curated /// set (unlike a full collection tree), so the N+1 here is acceptable /// where it wouldn't be in the sidebar. - private func fetchPinned() async -> [OutlineDocument] { - guard let pins = try? await apiClient.listPins(ListPinsRequest(collectionId: nil)) else { - return [] - } + private func fetchPinnedThrowing() async throws -> [OutlineDocument] { + let pins = try await apiClient.listPins(ListPinsRequest(collectionId: nil)) var documents: [OutlineDocument] = [] for pin in pins { if let document = try? await apiClient.documentInfo(id: pin.documentId) { diff --git a/Outpost/Root/RootView.swift b/Outpost/Root/RootView.swift index 4df0d61..598475c 100644 --- a/Outpost/Root/RootView.swift +++ b/Outpost/Root/RootView.swift @@ -6,6 +6,18 @@ struct RootView: View { @State private var welcomeName: String? @State private var starStore = StarStore() @AppStorage("outpost.fullLocalSyncEnabled") private var isFullLocalSyncEnabled = false + @AppStorage(CachingOutlineAPIClient.offlineModeDefaultsKey) private var isOfflineModeEnabled = false + + /// Real connectivity is only half of "can talk to the server" — the + /// manual Offline Mode toggle is the other half. Syncing needs to + /// resume on either one clearing, not just a real reconnect: turning + /// the toggle off while the network had been up the whole time never + /// changes `networkMonitor.isOnline`, so keying the flush task off that + /// alone left pending operations stuck until the next real network + /// blip. + private var isEffectivelyOnline: Bool { + session.networkMonitor.isOnline && !isOfflineModeEnabled + } var body: some View { ZStack { @@ -33,13 +45,13 @@ struct RootView: View { starStore.reset() } } - // Replay whatever queued up while offline the moment the network's - // back — no need to wait for the user to open Settings and hit Retry. - // Also catches Full Local Sync back up immediately on reconnect, - // rather than leaving it to wait out the rest of the periodic loop - // below. - .task(id: session.networkMonitor.isOnline) { - guard session.networkMonitor.isOnline, let cachingClient = session.cachingClient else { return } + // Replay whatever queued up while offline the moment either signal + // clears — a real reconnect, or the user turning Offline Mode back + // off — no need to wait for the user to open Settings and hit Retry. + // Also catches Full Local Sync back up immediately, rather than + // leaving it to wait out the rest of the periodic loop below. + .task(id: isEffectivelyOnline) { + guard isEffectivelyOnline, let cachingClient = session.cachingClient else { return } _ = await cachingClient.flushPendingOperations() if isFullLocalSyncEnabled { _ = await cachingClient.performFullSync() @@ -55,7 +67,7 @@ struct RootView: View { .task(id: isFullLocalSyncEnabled) { guard isFullLocalSyncEnabled else { return } while !Task.isCancelled { - if session.networkMonitor.isOnline, let cachingClient = session.cachingClient { + if isEffectivelyOnline, let cachingClient = session.cachingClient { _ = await cachingClient.performFullSync() } try? await Task.sleep(for: .seconds(1200)) From 8b4c5852ed52a2ac5ce10a1650473ca2bdb2d33c Mon Sep 17 00:00:00 2001 From: psavarmattas Date: Sat, 15 Aug 2026 01:46:32 +0100 Subject: [PATCH 19/22] feat(offline): support creating documents offline MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit createDocument now queues and syncs like the other offline-capable writes, closing the gap deliberately left open earlier this session. Synthesizes a pending- document (same placeholder scheme as pin/star/subscribe), caches it so it's immediately readable/openable, and queues the real create. On sync, the server's real document replaces the placeholder in the cache — no dead orphan entries. Editing that same still-unsynced document folds the edit into the pending create's payload instead of queuing a separate update, which would otherwise target the placeholder id and 404 forever once flushed. CreateDocumentRequest widened Encodable -> Codable for the queue round-trip. Known limitation: a reader still open on that exact document at the moment it syncs in the background keeps holding the stale placeholder id until navigated away and back. Narrow, not solved generally here. 3 new OutlineKit tests (57/57). Removed the now-unneeded offline restriction on the reader's New Document button/menu item. --- .../Caching/CachingOutlineAPIClient.swift | 76 ++++++++++++++++- .../Caching/OfflineCacheStore.swift | 10 +++ .../OutlineKit/Caching/SyncTypes.swift | 1 + .../Requests/CreateDocumentRequest.swift | 2 +- .../CachingOutlineAPIClientTests.swift | 81 ++++++++++++++++++- .../Collections/DocumentReaderView.swift | 14 ++-- 6 files changed, 170 insertions(+), 14 deletions(-) diff --git a/OutlineKit/Sources/OutlineKit/Caching/CachingOutlineAPIClient.swift b/OutlineKit/Sources/OutlineKit/Caching/CachingOutlineAPIClient.swift index b5f465a..fe30556 100644 --- a/OutlineKit/Sources/OutlineKit/Caching/CachingOutlineAPIClient.swift +++ b/OutlineKit/Sources/OutlineKit/Caching/CachingOutlineAPIClient.swift @@ -104,6 +104,27 @@ public actor CachingOutlineAPIClient: OutlineAPIClient { // MARK: - Queueable writes + /// The one exception to "no new tree structure while offline" — + /// synthesizes a document under a `pending-` id (same scheme as + /// pin/star/subscribe), caches it so it's immediately readable/editable, + /// and queues the real create. On sync, the server-assigned id replaces + /// the placeholder in the cache; anything still referencing the old id + /// directly (a currently-open reader, most likely) won't follow that + /// rename automatically — a known, narrow limitation, not something this + /// pass tries to solve generally. + public func createDocument(_ request: CreateDocumentRequest) async throws -> OutlineDocument { + if !isManualOfflineModeEnabled { + do { + let result = try await live.createDocument(request) + await cacheDocument(result) + return result + } catch { + return await queueDocumentCreate(request) + } + } + return await queueDocumentCreate(request) + } + public func updateDocument(_ request: UpdateDocumentRequest) async throws -> OutlineDocument { if !isManualOfflineModeEnabled { do { @@ -214,10 +235,6 @@ public actor CachingOutlineAPIClient: OutlineAPIClient { try await live.searchDocumentTitles(request) } - public func createDocument(_ request: CreateDocumentRequest) async throws -> OutlineDocument { - try await live.createDocument(request) - } - public func templatizeDocument(_ request: TemplatizeDocumentRequest) async throws -> OutlineTemplate { try await live.templatizeDocument(request) } @@ -467,6 +484,30 @@ public actor CachingOutlineAPIClient: OutlineAPIClient { return await cache.removeOperation(id: id) } + private func queueDocumentCreate(_ request: CreateDocumentRequest) async -> OutlineDocument { + let pendingId = "pending-\(UUID().uuidString)" + let now = Date() + let synthesized = OutlineDocument( + id: pendingId, + title: request.title, + text: request.text, + emoji: nil, + collectionId: request.collectionId, + parentDocumentId: request.parentDocumentId, + url: "/doc/\(pendingId)", + revision: nil, + fullWidth: nil, + createdAt: now, + updatedAt: now, + publishedAt: request.publish ? now : nil, + archivedAt: nil, + deletedAt: nil + ) + await cacheDocument(synthesized) + await enqueue(.createDocument, payload: request, id: pendingId) + return synthesized + } + private func queueDocumentUpdate(_ request: UpdateDocumentRequest, dueTo error: Error?) async throws -> OutlineDocument { guard let baseData = await cache.load(forKey: "document:\(request.id)"), let base = try? decoder.decode(OutlineDocument.self, from: baseData) else { @@ -490,6 +531,26 @@ public actor CachingOutlineAPIClient: OutlineAPIClient { deletedAt: base.deletedAt ) await cacheDocument(merged) + + // Still-unsynced create for this exact document — fold the edit into + // the pending create's payload instead of queuing a separate update. + // A separate update would target `merged.id`, which is still the + // `pending-*` placeholder at replay time; the server has never heard + // of it and the update would just fail every retry. + if merged.id.hasPrefix("pending-"), + let createOp = await cache.pendingOperations().first(where: { $0.id == merged.id && $0.kind == PendingOperationKind.createDocument.rawValue }), + let createRequest = try? decoder.decode(CreateDocumentRequest.self, from: createOp.payload) { + let resolvedCreate = CreateDocumentRequest( + title: merged.title, + text: merged.text, + collectionId: createRequest.collectionId, + parentDocumentId: createRequest.parentDocumentId, + publish: createRequest.publish + ) + await enqueue(.createDocument, payload: resolvedCreate, id: merged.id) + return merged + } + // Fully resolved (not the original partial request) so replaying just // this one queued operation reproduces `merged` exactly — that's what // makes coalescing a second edit onto the same queued id safe. @@ -549,6 +610,13 @@ public actor CachingOutlineAPIClient: OutlineAPIClient { )) } switch kind { + case .createDocument: + let request = try decoder.decode(CreateDocumentRequest.self, from: operation.payload) + let result = try await live.createDocument(request) + await cacheDocument(result) + // The placeholder id (== operation.id) is now a dead orphan — + // nothing server-side will ever answer to it again. + await cache.removeCacheEntry(forKey: "document:\(operation.id)") case .updateDocument: let request = try decoder.decode(UpdateDocumentRequest.self, from: operation.payload) let result = try await live.updateDocument(request) diff --git a/OutlineKit/Sources/OutlineKit/Caching/OfflineCacheStore.swift b/OutlineKit/Sources/OutlineKit/Caching/OfflineCacheStore.swift index 3fd1e8e..1300a18 100644 --- a/OutlineKit/Sources/OutlineKit/Caching/OfflineCacheStore.swift +++ b/OutlineKit/Sources/OutlineKit/Caching/OfflineCacheStore.swift @@ -31,6 +31,16 @@ public actor OfflineCacheStore { return try? modelContext.fetch(descriptor).first?.payload } + /// Used to drop a temporary `pending-*` document's cache entry once a + /// queued create syncs and the server hands back the real id — the + /// placeholder key would otherwise sit around as a dead orphan forever. + public func removeCacheEntry(forKey key: String) { + let descriptor = FetchDescriptor(predicate: #Predicate { $0.key == key }) + guard let existing = try? modelContext.fetch(descriptor).first else { return } + modelContext.delete(existing) + try? modelContext.save() + } + public func itemCount() -> Int { (try? modelContext.fetchCount(FetchDescriptor())) ?? 0 } diff --git a/OutlineKit/Sources/OutlineKit/Caching/SyncTypes.swift b/OutlineKit/Sources/OutlineKit/Caching/SyncTypes.swift index 863221b..601e412 100644 --- a/OutlineKit/Sources/OutlineKit/Caching/SyncTypes.swift +++ b/OutlineKit/Sources/OutlineKit/Caching/SyncTypes.swift @@ -32,6 +32,7 @@ public struct FullSyncSummary: Sendable { /// replay later. Deliberately a small, explicit set — see its doc comment /// for what's excluded and why. enum PendingOperationKind: String, Codable, Sendable { + case createDocument case updateDocument case updateCollection case createPin diff --git a/OutlineKit/Sources/OutlineKit/Requests/CreateDocumentRequest.swift b/OutlineKit/Sources/OutlineKit/Requests/CreateDocumentRequest.swift index 07de5ea..7aafdfc 100644 --- a/OutlineKit/Sources/OutlineKit/Requests/CreateDocumentRequest.swift +++ b/OutlineKit/Sources/OutlineKit/Requests/CreateDocumentRequest.swift @@ -1,6 +1,6 @@ import Foundation -public struct CreateDocumentRequest: Encodable, Sendable { +public struct CreateDocumentRequest: Codable, Sendable { public let title: String public let text: String public let collectionId: String diff --git a/OutlineKit/Tests/OutlineKitTests/CachingOutlineAPIClientTests.swift b/OutlineKit/Tests/OutlineKitTests/CachingOutlineAPIClientTests.swift index 45c9841..ffeb429 100644 --- a/OutlineKit/Tests/OutlineKitTests/CachingOutlineAPIClientTests.swift +++ b/OutlineKit/Tests/OutlineKitTests/CachingOutlineAPIClientTests.swift @@ -13,6 +13,7 @@ private final class StubOutlineAPIClient: OutlineAPIClient, @unchecked Sendable var createPinHandler: (@Sendable (CreatePinRequest) async throws -> OutlinePin)? var deletePinHandler: (@Sendable (String) async throws -> Void)? var listDocumentsHandler: (@Sendable (String?, String?, Int, Int) async throws -> [OutlineDocument])? + var createDocumentHandler: (@Sendable (CreateDocumentRequest) async throws -> OutlineDocument)? func authInfo() async throws -> OutlineAuthInfo { throw NotStubbed() } @@ -30,7 +31,10 @@ private final class StubOutlineAPIClient: OutlineAPIClient, @unchecked Sendable func listViewedDocuments(offset: Int, limit: Int) async throws -> [OutlineDocument] { throw NotStubbed() } func searchDocuments(_ request: DocumentSearchRequest) async throws -> [OutlineDocumentSearchResult] { throw NotStubbed() } func searchDocumentTitles(_ request: DocumentSearchTitlesRequest) async throws -> [OutlineDocument] { throw NotStubbed() } - func createDocument(_ request: CreateDocumentRequest) async throws -> OutlineDocument { throw NotStubbed() } + func createDocument(_ request: CreateDocumentRequest) async throws -> OutlineDocument { + guard let handler = createDocumentHandler else { throw NotStubbed() } + return try await handler(request) + } func updateDocument(_ request: UpdateDocumentRequest) async throws -> OutlineDocument { guard let handler = updateDocumentHandler else { throw NotStubbed() } @@ -418,4 +422,79 @@ final class CachingOutlineAPIClientTests: XCTestCase { let cachedDoc = try await sut.documentInfo(id: "doc-2") XCTAssertEqual(cachedDoc.id, "doc-2") } + + // MARK: - Offline document creation + + func testCreateDocumentQueuesAndReturnsUsableDocumentWhenOffline() async throws { + let stub = StubOutlineAPIClient() + stub.createDocumentHandler = { _ in throw StubTransportError() } + let sut = CachingOutlineAPIClient(live: stub, cache: try makeCache()) + + let created = try await sut.createDocument( + CreateDocumentRequest(title: "New Doc", text: "hello", collectionId: "col-1") + ) + + XCTAssertTrue(created.id.hasPrefix("pending-")) + XCTAssertEqual(created.title, "New Doc") + XCTAssertEqual(created.text, "hello") + + // It's immediately readable, same as any other cached document. + stub.documentInfoHandler = { _ in throw StubTransportError() } + let reopened = try await sut.documentInfo(id: created.id) + XCTAssertEqual(reopened.title, "New Doc") + + let pending = await sut.pendingOperations() + XCTAssertEqual(pending.count, 1) + XCTAssertEqual(pending.first?.kind, "createDocument") + } + + func testEditingAnUnsyncedCreatedDocumentFoldsIntoTheCreateInsteadOfQueuingAnUpdate() async throws { + let stub = StubOutlineAPIClient() + stub.createDocumentHandler = { _ in throw StubTransportError() } + stub.updateDocumentHandler = { _ in throw StubTransportError() } + let sut = CachingOutlineAPIClient(live: stub, cache: try makeCache()) + + let created = try await sut.createDocument( + CreateDocumentRequest(title: "Untitled", text: "", collectionId: "col-1") + ) + _ = try await sut.updateDocument(UpdateDocumentRequest(id: created.id, title: "Real Title", text: "Real body")) + + let pending = await sut.pendingOperations() + XCTAssertEqual(pending.count, 1, "the edit should have folded into the pending create, not added a second operation") + XCTAssertEqual(pending.first?.kind, "createDocument") + + stub.documentInfoHandler = { _ in throw StubTransportError() } + let reopened = try await sut.documentInfo(id: created.id) + XCTAssertEqual(reopened.title, "Real Title") + XCTAssertEqual(reopened.text, "Real body") + } + + func testFlushingAPendingCreateReconcilesThePlaceholderIdToTheRealOne() async throws { + let stub = StubOutlineAPIClient() + stub.createDocumentHandler = { _ in throw StubTransportError() } + let sut = CachingOutlineAPIClient(live: stub, cache: try makeCache()) + + let created = try await sut.createDocument( + CreateDocumentRequest(title: "New Doc", text: "hello", collectionId: "col-1") + ) + let realDocument = makeDocument(id: "real-server-id", title: "New Doc") + stub.createDocumentHandler = { _ in realDocument } + + let summary = await sut.flushPendingOperations() + XCTAssertEqual(summary.succeeded, 1) + XCTAssertEqual(summary.failed, 0) + + // The real document is now readable under its real id... + stub.documentInfoHandler = { _ in throw StubTransportError() } + let byRealId = try await sut.documentInfo(id: "real-server-id") + XCTAssertEqual(byRealId.id, "real-server-id") + + // ...and the placeholder is gone rather than left as a dead orphan. + do { + _ = try await sut.documentInfo(id: created.id) + XCTFail("Expected the placeholder cache entry to have been removed") + } catch { + // expected — nothing left under the old id + } + } } diff --git a/Outpost/Features/Collections/DocumentReaderView.swift b/Outpost/Features/Collections/DocumentReaderView.swift index 6a5e56b..97e9e9c 100644 --- a/Outpost/Features/Collections/DocumentReaderView.swift +++ b/Outpost/Features/Collections/DocumentReaderView.swift @@ -57,11 +57,11 @@ struct DocumentReaderView: View { self.onDocumentCreated = onDocumentCreated } - /// Editing, pin/star/subscribe, and Full Width all queue and sync later - /// (see `CachingOutlineAPIClient`) — everything else here (sharing, - /// permissions, move/archive/delete/duplicate/templatize, history, - /// insights, export) hits the server directly with no offline path, so - /// it's disabled rather than left to fail confusingly on tap. + /// New Document, editing, pin/star/subscribe, and Full Width all queue + /// and sync later (see `CachingOutlineAPIClient`) — everything else here + /// (sharing, permissions, move/archive/delete/duplicate/templatize, + /// history, insights, export) hits the server directly with no offline + /// path, so it's disabled rather than left to fail confusingly on tap. private var isEffectivelyOnline: Bool { session.networkMonitor.isOnline && !isOfflineModeEnabled } @@ -143,8 +143,7 @@ struct DocumentReaderView: View { } label: { Image(systemName: "doc.badge.plus") } - .help(isEffectivelyOnline ? "New Document" : "Creating documents needs an internet connection") - .disabled(!isEffectivelyOnline) + .help("New Document") Menu { menuContent @@ -353,7 +352,6 @@ struct DocumentReaderView: View { Button("New Document") { isShowingNewDocumentSheet = true } - .disabled(!isEffectivelyOnline) Button(viewModel.isPinned ? "Unpin from Home" : "Pin to Home") { Task { await togglePin() } } From 21e81feed7ce4db4aa809ae57c5a0f239fe1c7ec Mon Sep 17 00:00:00 2001 From: psavarmattas Date: Sat, 15 Aug 2026 01:49:05 +0100 Subject: [PATCH 20/22] fix(settings): stop non-About sections from vertically centering MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit .leading as an Alignment means horizontally-leading but vertically centered, not top-left — the minHeight fix for centering About ended up vertically centering every other section too inside that same tall box. .topLeading is what was actually meant; About still centers correctly since its own .frame(maxHeight: .infinity, alignment: .center) fills and self-centers within whatever space it's given regardless. --- Outpost/Features/Account/SettingsView.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Outpost/Features/Account/SettingsView.swift b/Outpost/Features/Account/SettingsView.swift index 228d86b..d62b4f1 100644 --- a/Outpost/Features/Account/SettingsView.swift +++ b/Outpost/Features/Account/SettingsView.swift @@ -49,7 +49,7 @@ struct SettingsView: View { ScrollView { sectionDetail .padding(28) - .frame(maxWidth: .infinity, minHeight: geometry.size.height, alignment: .leading) + .frame(maxWidth: .infinity, minHeight: geometry.size.height, alignment: .topLeading) } } .frame(maxWidth: .infinity, maxHeight: .infinity) From bf9eec6a9b74025472790919fd42d09183663199 Mon Sep 17 00:00:00 2001 From: psavarmattas Date: Sat, 15 Aug 2026 15:43:00 +0100 Subject: [PATCH 21/22] fix(offline): retry pending sync with a cooloff instead of one shot MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The auto-flush that runs when reconnecting (or turning Offline Mode back off) only ever tried once — any operation that still failed on that attempt sat stuck until the user manually hit Retry or connectivity changed again. Now retries every 5 minutes for as long as anything's still pending and the signal stays on, stopping on its own once the queue is empty. Full Local Sync's existing 20-minute loop is unaffected/separate. --- Outpost/Root/RootView.swift | 16 +++++++++++++++- 1 file changed, 15 insertions(+), 1 deletion(-) diff --git a/Outpost/Root/RootView.swift b/Outpost/Root/RootView.swift index 598475c..5fd90d4 100644 --- a/Outpost/Root/RootView.swift +++ b/Outpost/Root/RootView.swift @@ -50,12 +50,26 @@ struct RootView: View { // off — no need to wait for the user to open Settings and hit Retry. // Also catches Full Local Sync back up immediately, rather than // leaving it to wait out the rest of the periodic loop below. + // + // The flush itself gets a retry loop with a cooloff, not just one + // attempt: a single transient failure (one bad operation, a blip + // mid-flush) used to leave everything else stuck until the user + // manually hit Retry or connectivity changed again. Keeps retrying + // every 5 minutes for as long as *anything* is still pending and + // this signal stays on — stops on its own once the queue is empty, + // and `.task(id:)` cancels/restarts it automatically if + // isEffectivelyOnline flips again in the meantime. .task(id: isEffectivelyOnline) { guard isEffectivelyOnline, let cachingClient = session.cachingClient else { return } - _ = await cachingClient.flushPendingOperations() if isFullLocalSyncEnabled { _ = await cachingClient.performFullSync() } + while !Task.isCancelled { + _ = await cachingClient.flushPendingOperations() + let remaining = await cachingClient.pendingOperations() + guard !remaining.isEmpty else { break } + try? await Task.sleep(for: .seconds(300)) + } } // Fully automatic — this is the only place Full Local Sync actually // runs from (Settings' "Sync Now" is just an on-demand nudge at the From badb206f8ad740df4e0d9e3fb916ccf9d95eea79 Mon Sep 17 00:00:00 2001 From: psavarmattas Date: Sat, 15 Aug 2026 15:45:08 +0100 Subject: [PATCH 22/22] chore: bump version to 0.0.3 --- Outpost.xcodeproj/project.pbxproj | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/Outpost.xcodeproj/project.pbxproj b/Outpost.xcodeproj/project.pbxproj index 82b374b..f4596c8 100644 --- a/Outpost.xcodeproj/project.pbxproj +++ b/Outpost.xcodeproj/project.pbxproj @@ -408,7 +408,7 @@ LD_RUNPATH_SEARCH_PATHS = "@executable_path/Frameworks"; "LD_RUNPATH_SEARCH_PATHS[sdk=macosx*]" = "@executable_path/../Frameworks"; MACOSX_DEPLOYMENT_TARGET = 27.0; - MARKETING_VERSION = 0.0.2; + MARKETING_VERSION = 0.0.3; PRODUCT_BUNDLE_IDENTIFIER = com.psmattas.Outpost; PRODUCT_NAME = "$(TARGET_NAME)"; REGISTER_APP_GROUPS = YES; @@ -453,7 +453,7 @@ LD_RUNPATH_SEARCH_PATHS = "@executable_path/Frameworks"; "LD_RUNPATH_SEARCH_PATHS[sdk=macosx*]" = "@executable_path/../Frameworks"; MACOSX_DEPLOYMENT_TARGET = 27.0; - MARKETING_VERSION = 0.0.2; + MARKETING_VERSION = 0.0.3; PRODUCT_BUNDLE_IDENTIFIER = com.psmattas.Outpost; PRODUCT_NAME = "$(TARGET_NAME)"; REGISTER_APP_GROUPS = YES;