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:
2026-08-15 00:12:05 +01:00
parent eee00197bb
commit 4769645489
19 changed files with 1095 additions and 117 deletions
@@ -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<T: Codable>(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)
}
}
@@ -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<CachedPayload>(predicate: #Predicate { $0.key == key })
if let existing = try? modelContext.fetch(descriptor).first {
@@ -26,4 +30,65 @@ public actor OfflineCacheStore {
let descriptor = FetchDescriptor<CachedPayload>(predicate: #Predicate { $0.key == key })
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
/// 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?
@@ -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
@@ -1,6 +1,6 @@
import Foundation
public struct StarCollectionRequest: Encodable, Sendable {
public struct StarCollectionRequest: Codable, Sendable {
public let collectionId: String
public init(collectionId: String) {
@@ -1,6 +1,6 @@
import Foundation
public struct StarDocumentRequest: Encodable, Sendable {
public struct StarDocumentRequest: Codable, Sendable {
public let documentId: String
public init(documentId: String) {
@@ -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?
@@ -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?
@@ -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)
}
}
+15 -5
View File
@@ -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
+8 -2
View File
@@ -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()
@@ -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
+311
View File
@@ -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
+12 -5
View File
@@ -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
}
+12
View File
@@ -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
}
+29
View File
@@ -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) {
+16 -5
View File
@@ -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