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