feat(offline): write queue with sync-on-reconnect + settings redesign
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.
This commit is contained in:
@@ -1,28 +1,46 @@
|
|||||||
import Foundation
|
import Foundation
|
||||||
|
|
||||||
/// Decorates `LiveOutlineAPIClient` (or any `OutlineAPIClient`) with a
|
/// Decorates `LiveOutlineAPIClient` (or any `OutlineAPIClient`) with offline
|
||||||
/// read-through cache for the handful of endpoints "browse the workspace
|
/// support at the existing protocol boundary, so no view model needs to know
|
||||||
/// offline" actually needs: the collection tree, document lists, and a
|
/// the network exists:
|
||||||
/// 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
|
/// - **Reads** (`documentInfo`, `listDocuments`, `documentsList`,
|
||||||
/// question from "did this specific request fail?", and checking first can
|
/// `listViewedDocuments`, `listCollections`, `collectionInfo`): read-through
|
||||||
/// be wrong the instant connectivity flaps. Instead: always try live, and
|
/// cache. Always tries live first — never pre-checks reachability, since
|
||||||
/// fall back to the cache only on failure. A successful live response
|
/// "did this specific request just fail" is a more honest signal than a
|
||||||
/// always overwrites the cache, so it can never get more stale than the
|
/// reachability monitor, and the two can disagree (captive portals,
|
||||||
/// last time this actually worked.
|
/// 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 actor CachingOutlineAPIClient: OutlineAPIClient {
|
||||||
|
public static let offlineModeDefaultsKey = "outpost.offlineModeEnabled"
|
||||||
|
|
||||||
private let live: OutlineAPIClient
|
private let live: OutlineAPIClient
|
||||||
private let cache: OfflineCacheStore
|
private let cache: OfflineCacheStore
|
||||||
|
private let defaults: UserDefaults
|
||||||
private let encoder: JSONEncoder
|
private let encoder: JSONEncoder
|
||||||
private let decoder: JSONDecoder
|
private let decoder: JSONDecoder
|
||||||
private let keyEncoder: JSONEncoder
|
private let keyEncoder: JSONEncoder
|
||||||
|
|
||||||
public init(live: OutlineAPIClient, cache: OfflineCacheStore) {
|
public init(live: OutlineAPIClient, cache: OfflineCacheStore, defaults: UserDefaults = .standard) {
|
||||||
self.live = live
|
self.live = live
|
||||||
self.cache = cache
|
self.cache = cache
|
||||||
|
self.defaults = defaults
|
||||||
|
|
||||||
let encoder = JSONEncoder()
|
let encoder = JSONEncoder()
|
||||||
encoder.dateEncodingStrategy = .iso8601
|
encoder.dateEncodingStrategy = .iso8601
|
||||||
@@ -37,6 +55,10 @@ public actor CachingOutlineAPIClient: OutlineAPIClient {
|
|||||||
self.keyEncoder = keyEncoder
|
self.keyEncoder = keyEncoder
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private var isManualOfflineModeEnabled: Bool {
|
||||||
|
defaults.bool(forKey: Self.offlineModeDefaultsKey)
|
||||||
|
}
|
||||||
|
|
||||||
// MARK: - Cached reads
|
// MARK: - Cached reads
|
||||||
|
|
||||||
public func documentInfo(id: String) async throws -> OutlineDocument {
|
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) }
|
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
|
// MARK: - Pass-through
|
||||||
|
|
||||||
public func authInfo() async throws -> OutlineAuthInfo {
|
public func authInfo() async throws -> OutlineAuthInfo {
|
||||||
@@ -98,14 +218,6 @@ public actor CachingOutlineAPIClient: OutlineAPIClient {
|
|||||||
try await live.createDocument(request)
|
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 {
|
public func templatizeDocument(_ request: TemplatizeDocumentRequest) async throws -> OutlineTemplate {
|
||||||
try await live.templatizeDocument(request)
|
try await live.templatizeDocument(request)
|
||||||
}
|
}
|
||||||
@@ -162,30 +274,14 @@ public actor CachingOutlineAPIClient: OutlineAPIClient {
|
|||||||
try await live.revokeShare(id: id)
|
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] {
|
public func listPins(_ request: ListPinsRequest) async throws -> [OutlinePin] {
|
||||||
try await live.listPins(request)
|
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] {
|
public func listSubscriptions(_ request: ListSubscriptionsRequest) async throws -> [OutlineSubscription] {
|
||||||
try await live.listSubscriptions(request)
|
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] {
|
public func listViews(_ request: ListViewsRequest) async throws -> [OutlineView] {
|
||||||
try await live.listViews(request)
|
try await live.listViews(request)
|
||||||
}
|
}
|
||||||
@@ -206,10 +302,6 @@ public actor CachingOutlineAPIClient: OutlineAPIClient {
|
|||||||
try await live.listUsers(request)
|
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 {
|
public func deleteCollection(id: String) async throws {
|
||||||
try await live.deleteCollection(id: id)
|
try await live.deleteCollection(id: id)
|
||||||
}
|
}
|
||||||
@@ -218,22 +310,89 @@ public actor CachingOutlineAPIClient: OutlineAPIClient {
|
|||||||
try await live.exportCollection(request)
|
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] {
|
public func listStars(_ request: ListStarsRequest) async throws -> [OutlineStar] {
|
||||||
try await live.listStars(request)
|
try await live.listStars(request)
|
||||||
}
|
}
|
||||||
|
|
||||||
public func deleteStar(id: String) async throws {
|
|
||||||
try await live.deleteStar(id: id)
|
|
||||||
}
|
|
||||||
|
|
||||||
public func currentUser() async throws -> OutlineUser {
|
public func currentUser() async throws -> OutlineUser {
|
||||||
try await live.currentUser()
|
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
|
// MARK: - Helpers
|
||||||
|
|
||||||
private func cachedFetch<T: Codable>(key: String, fetch: () async throws -> T) async throws -> T {
|
private func cachedFetch<T: Codable>(key: String, fetch: () async throws -> T) async throws -> T {
|
||||||
@@ -257,4 +416,158 @@ public actor CachingOutlineAPIClient: OutlineAPIClient {
|
|||||||
}
|
}
|
||||||
return "\(prefix):\(json)"
|
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)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,14 +3,18 @@ import SwiftData
|
|||||||
|
|
||||||
/// Read-through cache backing `CachingOutlineAPIClient` — stores whatever it
|
/// Read-through cache backing `CachingOutlineAPIClient` — stores whatever it
|
||||||
/// last fetched successfully, keyed by request shape, so browsing keeps
|
/// 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
|
@ModelActor
|
||||||
public actor OfflineCacheStore {
|
public actor OfflineCacheStore {
|
||||||
public static func makeContainer(inMemory: Bool = false) throws -> ModelContainer {
|
public static func makeContainer(inMemory: Bool = false) throws -> ModelContainer {
|
||||||
let configuration = ModelConfiguration(isStoredInMemoryOnly: inMemory)
|
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) {
|
public func save(_ data: Data, forKey key: String) {
|
||||||
let descriptor = FetchDescriptor<CachedPayload>(predicate: #Predicate { $0.key == key })
|
let descriptor = FetchDescriptor<CachedPayload>(predicate: #Predicate { $0.key == key })
|
||||||
if let existing = try? modelContext.fetch(descriptor).first {
|
if let existing = try? modelContext.fetch(descriptor).first {
|
||||||
@@ -26,4 +30,65 @@ public actor OfflineCacheStore {
|
|||||||
let descriptor = FetchDescriptor<CachedPayload>(predicate: #Predicate { $0.key == key })
|
let descriptor = FetchDescriptor<CachedPayload>(predicate: #Predicate { $0.key == key })
|
||||||
return try? modelContext.fetch(descriptor).first?.payload
|
return try? modelContext.fetch(descriptor).first?.payload
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public func itemCount() -> Int {
|
||||||
|
(try? modelContext.fetchCount(FetchDescriptor<CachedPayload>())) ?? 0
|
||||||
|
}
|
||||||
|
|
||||||
|
public func totalBytes() -> Int {
|
||||||
|
let descriptor = FetchDescriptor<CachedPayload>()
|
||||||
|
let rows = (try? modelContext.fetch(descriptor)) ?? []
|
||||||
|
return rows.reduce(0) { $0 + $1.payload.count }
|
||||||
|
}
|
||||||
|
|
||||||
|
public func clearAll() {
|
||||||
|
let descriptor = FetchDescriptor<CachedPayload>()
|
||||||
|
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<PendingOperation>(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<PendingOperation>(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<PendingOperation>(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<PendingOperation>(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()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -2,7 +2,7 @@ import Foundation
|
|||||||
|
|
||||||
/// See `OutlinePin`. `collectionId: nil` = "Pin to Home", non-nil = "Pin to
|
/// See `OutlinePin`. `collectionId: nil` = "Pin to Home", non-nil = "Pin to
|
||||||
/// Collection" — these are distinct actions on the real server.
|
/// 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 documentId: String
|
||||||
public let collectionId: String?
|
public let collectionId: String?
|
||||||
|
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import Foundation
|
import Foundation
|
||||||
|
|
||||||
/// See `OutlineSubscription` — best-effort shape, not in the vendored spec.
|
/// 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 documentId: String
|
||||||
public let event: String
|
public let event: String
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import Foundation
|
import Foundation
|
||||||
|
|
||||||
public struct StarCollectionRequest: Encodable, Sendable {
|
public struct StarCollectionRequest: Codable, Sendable {
|
||||||
public let collectionId: String
|
public let collectionId: String
|
||||||
|
|
||||||
public init(collectionId: String) {
|
public init(collectionId: String) {
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import Foundation
|
import Foundation
|
||||||
|
|
||||||
public struct StarDocumentRequest: Encodable, Sendable {
|
public struct StarDocumentRequest: Codable, Sendable {
|
||||||
public let documentId: String
|
public let documentId: String
|
||||||
|
|
||||||
public init(documentId: String) {
|
public init(documentId: String) {
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import Foundation
|
import Foundation
|
||||||
|
|
||||||
public struct UpdateCollectionRequest: Encodable, Sendable {
|
public struct UpdateCollectionRequest: Codable, Sendable {
|
||||||
public let id: String
|
public let id: String
|
||||||
public let name: String?
|
public let name: String?
|
||||||
public let description: String?
|
public let description: String?
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import Foundation
|
import Foundation
|
||||||
|
|
||||||
public struct UpdateDocumentRequest: Encodable, Sendable {
|
public struct UpdateDocumentRequest: Codable, Sendable {
|
||||||
public let id: String
|
public let id: String
|
||||||
public let title: String?
|
public let title: String?
|
||||||
public let text: String?
|
public let text: String?
|
||||||
|
|||||||
@@ -9,6 +9,9 @@ private struct NotStubbed: Error {}
|
|||||||
private final class StubOutlineAPIClient: OutlineAPIClient, @unchecked Sendable {
|
private final class StubOutlineAPIClient: OutlineAPIClient, @unchecked Sendable {
|
||||||
var documentInfoHandler: (@Sendable (String) async throws -> OutlineDocument)?
|
var documentInfoHandler: (@Sendable (String) async throws -> OutlineDocument)?
|
||||||
var listCollectionsHandler: (@Sendable (Int, Int) async throws -> [OutlineCollection])?
|
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() }
|
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 searchDocuments(_ request: DocumentSearchRequest) async throws -> [OutlineDocumentSearchResult] { throw NotStubbed() }
|
||||||
func searchDocumentTitles(_ request: DocumentSearchTitlesRequest) async throws -> [OutlineDocument] { 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 { 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 starDocument(_ request: StarDocumentRequest) async throws -> OutlineStar { throw NotStubbed() }
|
||||||
func templatizeDocument(_ request: TemplatizeDocumentRequest) async throws -> OutlineTemplate { throw NotStubbed() }
|
func templatizeDocument(_ request: TemplatizeDocumentRequest) async throws -> OutlineTemplate { throw NotStubbed() }
|
||||||
func duplicateDocument(_ request: DuplicateDocumentRequest) async throws -> [OutlineDocument] { 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 updateShare(_ request: UpdateShareRequest) async throws -> OutlineShare { throw NotStubbed() }
|
||||||
func listShares(_ request: ListSharesRequest) async throws -> [OutlineShare] { throw NotStubbed() }
|
func listShares(_ request: ListSharesRequest) async throws -> [OutlineShare] { throw NotStubbed() }
|
||||||
func revokeShare(id: String) async throws { 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 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 createSubscription(_ request: CreateSubscriptionRequest) async throws -> OutlineSubscription { throw NotStubbed() }
|
||||||
func listSubscriptions(_ request: ListSubscriptionsRequest) async throws -> [OutlineSubscription] { throw NotStubbed() }
|
func listSubscriptions(_ request: ListSubscriptionsRequest) async throws -> [OutlineSubscription] { throw NotStubbed() }
|
||||||
func deleteSubscription(id: String) async throws { throw NotStubbed() }
|
func deleteSubscription(id: String) async throws { throw NotStubbed() }
|
||||||
@@ -174,4 +190,153 @@ final class CachingOutlineAPIClientTests: XCTestCase {
|
|||||||
XCTAssertEqual(cachedOne, docOne)
|
XCTAssertEqual(cachedOne, docOne)
|
||||||
XCTAssertEqual(cachedTwo, docTwo)
|
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)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,11 +2,15 @@
|
|||||||
import AppKit
|
import AppKit
|
||||||
import SwiftUI
|
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 repositoryURL = URL(string: "https://git.psmattas.com/psmattas/Outpost")!
|
||||||
private let releasesURL = URL(string: "https://git.psmattas.com/psmattas/Outpost/releases")!
|
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"
|
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`.
|
/// expected to stay a plain dotted-numeric string, not `0.0.1-ALPHA`.
|
||||||
private let releaseStage = "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 shortVersion = Bundle.main.object(forInfoDictionaryKey: "CFBundleShortVersionString") as? String ?? "0.0.1"
|
||||||
let buildNumber = Bundle.main.object(forInfoDictionaryKey: "CFBundleVersion") as? String ?? "1"
|
let buildNumber = Bundle.main.object(forInfoDictionaryKey: "CFBundleVersion") as? String ?? "1"
|
||||||
let stageSuffix = releaseStage.isEmpty ? "" : "-\(releaseStage)"
|
let stageSuffix = releaseStage.isEmpty ? "" : "-\(releaseStage)"
|
||||||
@@ -66,8 +70,6 @@ struct AboutView: View {
|
|||||||
.font(.caption2)
|
.font(.caption2)
|
||||||
.foregroundStyle(.tertiary)
|
.foregroundStyle(.tertiary)
|
||||||
}
|
}
|
||||||
.padding(32)
|
|
||||||
.frame(width: 320)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// No Sparkle-style in-app updater yet — this just opens the releases page
|
// No Sparkle-style in-app updater yet — this just opens the releases page
|
||||||
@@ -76,4 +78,12 @@ struct AboutView: View {
|
|||||||
NSWorkspace.shared.open(releasesURL)
|
NSWorkspace.shared.open(releasesURL)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
struct AboutView: View {
|
||||||
|
var body: some View {
|
||||||
|
AboutInfoView()
|
||||||
|
.padding(32)
|
||||||
|
.frame(width: 320)
|
||||||
|
}
|
||||||
|
}
|
||||||
#endif
|
#endif
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
#if os(macOS)
|
#if os(macOS)
|
||||||
import SwiftUI
|
import SwiftUI
|
||||||
|
import OutlineKit
|
||||||
|
|
||||||
/// Uses a plain `Button` + `.popover` rather than `Menu`. A `Menu` whose label
|
/// 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
|
/// 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.
|
/// label rendering, which a `Button` doesn't go through.
|
||||||
struct AccountFooter: View {
|
struct AccountFooter: View {
|
||||||
@Environment(SessionStore.self) private var session
|
@Environment(SessionStore.self) private var session
|
||||||
|
@Environment(AppNavigation.self) private var navigation
|
||||||
@Environment(\.openURL) private var openURL
|
@Environment(\.openURL) private var openURL
|
||||||
@Environment(\.openWindow) private var openWindow
|
@Environment(\.openWindow) private var openWindow
|
||||||
@Environment(\.openSettings) private var openSettings
|
|
||||||
@AppStorage("outpost.appearance") private var appearance: AppAppearance = .system
|
@AppStorage("outpost.appearance") private var appearance: AppAppearance = .system
|
||||||
|
@AppStorage(CachingOutlineAPIClient.offlineModeDefaultsKey) private var isOfflineModeEnabled = false
|
||||||
@State private var isMenuPresented = false
|
@State private var isMenuPresented = false
|
||||||
@State private var isShowingLogoutConfirmation = false
|
@State private var isShowingLogoutConfirmation = false
|
||||||
@State private var isShowingProfile = false
|
@State private var isShowingProfile = false
|
||||||
@@ -93,8 +95,12 @@ struct AccountFooter: View {
|
|||||||
.padding(.horizontal, 6)
|
.padding(.horizontal, 6)
|
||||||
.padding(.vertical, 4)
|
.padding(.vertical, 4)
|
||||||
|
|
||||||
|
Toggle("Offline Mode", isOn: $isOfflineModeEnabled)
|
||||||
|
.padding(.horizontal, 6)
|
||||||
|
.padding(.vertical, 4)
|
||||||
|
|
||||||
menuItem("Profile…") { isShowingProfile = true }
|
menuItem("Profile…") { isShowingProfile = true }
|
||||||
menuItem("Preferences…") { openSettings() }
|
menuItem("Settings…") { navigation.isShowingSettings = true }
|
||||||
|
|
||||||
Divider()
|
Divider()
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
|
||||||
@@ -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<Content: View>(_ 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
|
||||||
@@ -14,6 +14,7 @@ import AppKit
|
|||||||
@main
|
@main
|
||||||
struct OutpostApp: App {
|
struct OutpostApp: App {
|
||||||
@State private var session = SessionStore()
|
@State private var session = SessionStore()
|
||||||
|
@State private var navigation = AppNavigation()
|
||||||
@AppStorage("outpost.appearance") private var appearance: AppAppearance = .system
|
@AppStorage("outpost.appearance") private var appearance: AppAppearance = .system
|
||||||
|
|
||||||
#if os(macOS)
|
#if os(macOS)
|
||||||
@@ -25,6 +26,7 @@ struct OutpostApp: App {
|
|||||||
WindowGroup {
|
WindowGroup {
|
||||||
RootView()
|
RootView()
|
||||||
.environment(session)
|
.environment(session)
|
||||||
|
.environment(navigation)
|
||||||
#if os(iOS)
|
#if os(iOS)
|
||||||
.preferredColorScheme(appearance.colorScheme)
|
.preferredColorScheme(appearance.colorScheme)
|
||||||
#endif
|
#endif
|
||||||
@@ -41,6 +43,16 @@ struct OutpostApp: App {
|
|||||||
openWindow(id: "about")
|
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) {
|
CommandGroup(after: .appSettings) {
|
||||||
Divider()
|
Divider()
|
||||||
Button("Log Out…") {
|
Button("Log Out…") {
|
||||||
@@ -63,11 +75,6 @@ struct OutpostApp: App {
|
|||||||
.disablesFullScreen()
|
.disablesFullScreen()
|
||||||
}
|
}
|
||||||
.windowResizability(.contentSize)
|
.windowResizability(.contentSize)
|
||||||
|
|
||||||
Settings {
|
|
||||||
PreferencesView()
|
|
||||||
.environment(session)
|
|
||||||
}
|
|
||||||
#endif
|
#endif
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -3,8 +3,10 @@ import OutlineKit
|
|||||||
|
|
||||||
struct RootView: View {
|
struct RootView: View {
|
||||||
@Environment(SessionStore.self) private var session
|
@Environment(SessionStore.self) private var session
|
||||||
|
@Environment(AppNavigation.self) private var navigation
|
||||||
@State private var welcomeName: String?
|
@State private var welcomeName: String?
|
||||||
@State private var starStore = StarStore()
|
@State private var starStore = StarStore()
|
||||||
|
@AppStorage("outpost.fullLocalSyncEnabled") private var isFullLocalSyncEnabled = false
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
ZStack {
|
ZStack {
|
||||||
@@ -19,9 +21,18 @@ struct RootView: View {
|
|||||||
.transition(.opacity)
|
.transition(.opacity)
|
||||||
.zIndex(1)
|
.zIndex(1)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#if os(macOS)
|
||||||
|
if navigation.isShowingSettings {
|
||||||
|
SettingsView(onDone: { navigation.isShowingSettings = false })
|
||||||
|
.transition(.opacity)
|
||||||
|
.zIndex(2)
|
||||||
|
}
|
||||||
|
#endif
|
||||||
}
|
}
|
||||||
.environment(starStore)
|
.environment(starStore)
|
||||||
.animation(.easeInOut(duration: 0.45), value: welcomeName != nil)
|
.animation(.easeInOut(duration: 0.45), value: welcomeName != nil)
|
||||||
|
.animation(.easeInOut(duration: 0.2), value: navigation.isShowingSettings)
|
||||||
.task {
|
.task {
|
||||||
await session.refreshTeamInfoIfNeeded()
|
await session.refreshTeamInfoIfNeeded()
|
||||||
}
|
}
|
||||||
@@ -32,6 +43,24 @@ struct RootView: View {
|
|||||||
starStore.reset()
|
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) {
|
private func startWelcomeTransition(_ result: AuthViewModel.AuthResult) {
|
||||||
|
|||||||
@@ -17,6 +17,11 @@ final class SessionStore {
|
|||||||
var teamName: String?
|
var teamName: String?
|
||||||
var teamAvatarURL: URL?
|
var teamAvatarURL: URL?
|
||||||
private(set) var apiClient: OutlineAPIClient?
|
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()
|
let networkMonitor = NetworkMonitor()
|
||||||
/// `nil` only if the on-disk SwiftData store failed to open (e.g. disk
|
/// `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
|
/// 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:))
|
self.cacheStore = (try? OfflineCacheStore.makeContainer()).map(OfflineCacheStore.init(modelContainer:))
|
||||||
|
|
||||||
if isSignedIn, let serverURL {
|
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) {
|
func signIn(serverURL: URL, user: OutlineUser, team: OutlineTeam) {
|
||||||
defaults.set(serverURL.absoluteString, forKey: Self.serverURLDefaultsKey)
|
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)
|
apply(user: user, team: team, serverURL: serverURL)
|
||||||
isSignedIn = true
|
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(
|
let live = LiveOutlineAPIClient(
|
||||||
configuration: OutlineConfiguration(baseURL: serverURL),
|
configuration: OutlineConfiguration(baseURL: serverURL),
|
||||||
tokenStore: tokenStore
|
tokenStore: tokenStore
|
||||||
)
|
)
|
||||||
guard let cache else { return live }
|
guard let cache else { return (live, nil) }
|
||||||
return CachingOutlineAPIClient(live: live, cache: cache)
|
let caching = CachingOutlineAPIClient(live: live, cache: cache)
|
||||||
|
return (caching, caching)
|
||||||
}
|
}
|
||||||
|
|
||||||
func signOut() {
|
func signOut() {
|
||||||
@@ -64,6 +74,7 @@ final class SessionStore {
|
|||||||
teamName = nil
|
teamName = nil
|
||||||
teamAvatarURL = nil
|
teamAvatarURL = nil
|
||||||
apiClient = nil
|
apiClient = nil
|
||||||
|
cachingClient = nil
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Re-fetches user/workspace name/logo on relaunch, when the token survived but this
|
/// Re-fetches user/workspace name/logo on relaunch, when the token survived but this
|
||||||
|
|||||||
Reference in New Issue
Block a user