Compare commits
10
Commits
d4d81bb848
...
21e81feed7
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
21e81feed7
|
||
|
|
8b4c5852ed
|
||
|
|
284ad07251
|
||
|
|
8d8c8fead8
|
||
|
|
e2e4b746c1
|
||
|
|
0d9f7d752d
|
||
|
|
a9db3e1412
|
||
|
|
82ff3b49ff
|
||
|
|
924f84525c
|
||
|
|
1ce2aced90
|
@@ -104,6 +104,27 @@ public actor CachingOutlineAPIClient: OutlineAPIClient {
|
|||||||
|
|
||||||
// MARK: - Queueable writes
|
// MARK: - Queueable writes
|
||||||
|
|
||||||
|
/// The one exception to "no new tree structure while offline" —
|
||||||
|
/// synthesizes a document under a `pending-<uuid>` id (same scheme as
|
||||||
|
/// pin/star/subscribe), caches it so it's immediately readable/editable,
|
||||||
|
/// and queues the real create. On sync, the server-assigned id replaces
|
||||||
|
/// the placeholder in the cache; anything still referencing the old id
|
||||||
|
/// directly (a currently-open reader, most likely) won't follow that
|
||||||
|
/// rename automatically — a known, narrow limitation, not something this
|
||||||
|
/// pass tries to solve generally.
|
||||||
|
public func createDocument(_ request: CreateDocumentRequest) async throws -> OutlineDocument {
|
||||||
|
if !isManualOfflineModeEnabled {
|
||||||
|
do {
|
||||||
|
let result = try await live.createDocument(request)
|
||||||
|
await cacheDocument(result)
|
||||||
|
return result
|
||||||
|
} catch {
|
||||||
|
return await queueDocumentCreate(request)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return await queueDocumentCreate(request)
|
||||||
|
}
|
||||||
|
|
||||||
public func updateDocument(_ request: UpdateDocumentRequest) async throws -> OutlineDocument {
|
public func updateDocument(_ request: UpdateDocumentRequest) async throws -> OutlineDocument {
|
||||||
if !isManualOfflineModeEnabled {
|
if !isManualOfflineModeEnabled {
|
||||||
do {
|
do {
|
||||||
@@ -214,10 +235,6 @@ public actor CachingOutlineAPIClient: OutlineAPIClient {
|
|||||||
try await live.searchDocumentTitles(request)
|
try await live.searchDocumentTitles(request)
|
||||||
}
|
}
|
||||||
|
|
||||||
public func createDocument(_ request: CreateDocumentRequest) async throws -> OutlineDocument {
|
|
||||||
try await live.createDocument(request)
|
|
||||||
}
|
|
||||||
|
|
||||||
public func templatizeDocument(_ request: TemplatizeDocumentRequest) async throws -> OutlineTemplate {
|
public func templatizeDocument(_ request: TemplatizeDocumentRequest) async throws -> OutlineTemplate {
|
||||||
try await live.templatizeDocument(request)
|
try await live.templatizeDocument(request)
|
||||||
}
|
}
|
||||||
@@ -409,6 +426,17 @@ public actor CachingOutlineAPIClient: OutlineAPIClient {
|
|||||||
// 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 {
|
||||||
|
// Manual offline mode means "skip the network entirely," not just
|
||||||
|
// "prefer it" — without this check, a read would still hit `live`
|
||||||
|
// (and succeed, showing content beyond whatever's cached) any time
|
||||||
|
// the device actually had a connection, defeating the point of
|
||||||
|
// deliberately testing/working as if offline.
|
||||||
|
if isManualOfflineModeEnabled {
|
||||||
|
if let data = await cache.load(forKey: key), let cached = try? decoder.decode(T.self, from: data) {
|
||||||
|
return cached
|
||||||
|
}
|
||||||
|
throw OutlineAPIError.transport(URLError(.notConnectedToInternet))
|
||||||
|
}
|
||||||
do {
|
do {
|
||||||
let result = try await fetch()
|
let result = try await fetch()
|
||||||
if let data = try? encoder.encode(result) {
|
if let data = try? encoder.encode(result) {
|
||||||
@@ -456,6 +484,30 @@ public actor CachingOutlineAPIClient: OutlineAPIClient {
|
|||||||
return await cache.removeOperation(id: id)
|
return await cache.removeOperation(id: id)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private func queueDocumentCreate(_ request: CreateDocumentRequest) async -> OutlineDocument {
|
||||||
|
let pendingId = "pending-\(UUID().uuidString)"
|
||||||
|
let now = Date()
|
||||||
|
let synthesized = OutlineDocument(
|
||||||
|
id: pendingId,
|
||||||
|
title: request.title,
|
||||||
|
text: request.text,
|
||||||
|
emoji: nil,
|
||||||
|
collectionId: request.collectionId,
|
||||||
|
parentDocumentId: request.parentDocumentId,
|
||||||
|
url: "/doc/\(pendingId)",
|
||||||
|
revision: nil,
|
||||||
|
fullWidth: nil,
|
||||||
|
createdAt: now,
|
||||||
|
updatedAt: now,
|
||||||
|
publishedAt: request.publish ? now : nil,
|
||||||
|
archivedAt: nil,
|
||||||
|
deletedAt: nil
|
||||||
|
)
|
||||||
|
await cacheDocument(synthesized)
|
||||||
|
await enqueue(.createDocument, payload: request, id: pendingId)
|
||||||
|
return synthesized
|
||||||
|
}
|
||||||
|
|
||||||
private func queueDocumentUpdate(_ request: UpdateDocumentRequest, dueTo error: Error?) async throws -> OutlineDocument {
|
private func queueDocumentUpdate(_ request: UpdateDocumentRequest, dueTo error: Error?) async throws -> OutlineDocument {
|
||||||
guard let baseData = await cache.load(forKey: "document:\(request.id)"),
|
guard let baseData = await cache.load(forKey: "document:\(request.id)"),
|
||||||
let base = try? decoder.decode(OutlineDocument.self, from: baseData) else {
|
let base = try? decoder.decode(OutlineDocument.self, from: baseData) else {
|
||||||
@@ -479,6 +531,26 @@ public actor CachingOutlineAPIClient: OutlineAPIClient {
|
|||||||
deletedAt: base.deletedAt
|
deletedAt: base.deletedAt
|
||||||
)
|
)
|
||||||
await cacheDocument(merged)
|
await cacheDocument(merged)
|
||||||
|
|
||||||
|
// Still-unsynced create for this exact document — fold the edit into
|
||||||
|
// the pending create's payload instead of queuing a separate update.
|
||||||
|
// A separate update would target `merged.id`, which is still the
|
||||||
|
// `pending-*` placeholder at replay time; the server has never heard
|
||||||
|
// of it and the update would just fail every retry.
|
||||||
|
if merged.id.hasPrefix("pending-"),
|
||||||
|
let createOp = await cache.pendingOperations().first(where: { $0.id == merged.id && $0.kind == PendingOperationKind.createDocument.rawValue }),
|
||||||
|
let createRequest = try? decoder.decode(CreateDocumentRequest.self, from: createOp.payload) {
|
||||||
|
let resolvedCreate = CreateDocumentRequest(
|
||||||
|
title: merged.title,
|
||||||
|
text: merged.text,
|
||||||
|
collectionId: createRequest.collectionId,
|
||||||
|
parentDocumentId: createRequest.parentDocumentId,
|
||||||
|
publish: createRequest.publish
|
||||||
|
)
|
||||||
|
await enqueue(.createDocument, payload: resolvedCreate, id: merged.id)
|
||||||
|
return merged
|
||||||
|
}
|
||||||
|
|
||||||
// Fully resolved (not the original partial request) so replaying just
|
// Fully resolved (not the original partial request) so replaying just
|
||||||
// this one queued operation reproduces `merged` exactly — that's what
|
// this one queued operation reproduces `merged` exactly — that's what
|
||||||
// makes coalescing a second edit onto the same queued id safe.
|
// makes coalescing a second edit onto the same queued id safe.
|
||||||
@@ -538,6 +610,13 @@ public actor CachingOutlineAPIClient: OutlineAPIClient {
|
|||||||
))
|
))
|
||||||
}
|
}
|
||||||
switch kind {
|
switch kind {
|
||||||
|
case .createDocument:
|
||||||
|
let request = try decoder.decode(CreateDocumentRequest.self, from: operation.payload)
|
||||||
|
let result = try await live.createDocument(request)
|
||||||
|
await cacheDocument(result)
|
||||||
|
// The placeholder id (== operation.id) is now a dead orphan —
|
||||||
|
// nothing server-side will ever answer to it again.
|
||||||
|
await cache.removeCacheEntry(forKey: "document:\(operation.id)")
|
||||||
case .updateDocument:
|
case .updateDocument:
|
||||||
let request = try decoder.decode(UpdateDocumentRequest.self, from: operation.payload)
|
let request = try decoder.decode(UpdateDocumentRequest.self, from: operation.payload)
|
||||||
let result = try await live.updateDocument(request)
|
let result = try await live.updateDocument(request)
|
||||||
|
|||||||
@@ -31,6 +31,16 @@ public actor OfflineCacheStore {
|
|||||||
return try? modelContext.fetch(descriptor).first?.payload
|
return try? modelContext.fetch(descriptor).first?.payload
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Used to drop a temporary `pending-*` document's cache entry once a
|
||||||
|
/// queued create syncs and the server hands back the real id — the
|
||||||
|
/// placeholder key would otherwise sit around as a dead orphan forever.
|
||||||
|
public func removeCacheEntry(forKey key: String) {
|
||||||
|
let descriptor = FetchDescriptor<CachedPayload>(predicate: #Predicate { $0.key == key })
|
||||||
|
guard let existing = try? modelContext.fetch(descriptor).first else { return }
|
||||||
|
modelContext.delete(existing)
|
||||||
|
try? modelContext.save()
|
||||||
|
}
|
||||||
|
|
||||||
public func itemCount() -> Int {
|
public func itemCount() -> Int {
|
||||||
(try? modelContext.fetchCount(FetchDescriptor<CachedPayload>())) ?? 0
|
(try? modelContext.fetchCount(FetchDescriptor<CachedPayload>())) ?? 0
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -32,6 +32,7 @@ public struct FullSyncSummary: Sendable {
|
|||||||
/// replay later. Deliberately a small, explicit set — see its doc comment
|
/// replay later. Deliberately a small, explicit set — see its doc comment
|
||||||
/// for what's excluded and why.
|
/// for what's excluded and why.
|
||||||
enum PendingOperationKind: String, Codable, Sendable {
|
enum PendingOperationKind: String, Codable, Sendable {
|
||||||
|
case createDocument
|
||||||
case updateDocument
|
case updateDocument
|
||||||
case updateCollection
|
case updateCollection
|
||||||
case createPin
|
case createPin
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import Foundation
|
import Foundation
|
||||||
|
|
||||||
public struct CreateDocumentRequest: Encodable, Sendable {
|
public struct CreateDocumentRequest: Codable, Sendable {
|
||||||
public let title: String
|
public let title: String
|
||||||
public let text: String
|
public let text: String
|
||||||
public let collectionId: String
|
public let collectionId: String
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ private final class StubOutlineAPIClient: OutlineAPIClient, @unchecked Sendable
|
|||||||
var createPinHandler: (@Sendable (CreatePinRequest) async throws -> OutlinePin)?
|
var createPinHandler: (@Sendable (CreatePinRequest) async throws -> OutlinePin)?
|
||||||
var deletePinHandler: (@Sendable (String) async throws -> Void)?
|
var deletePinHandler: (@Sendable (String) async throws -> Void)?
|
||||||
var listDocumentsHandler: (@Sendable (String?, String?, Int, Int) async throws -> [OutlineDocument])?
|
var listDocumentsHandler: (@Sendable (String?, String?, Int, Int) async throws -> [OutlineDocument])?
|
||||||
|
var createDocumentHandler: (@Sendable (CreateDocumentRequest) async throws -> OutlineDocument)?
|
||||||
|
|
||||||
func authInfo() async throws -> OutlineAuthInfo { throw NotStubbed() }
|
func authInfo() async throws -> OutlineAuthInfo { throw NotStubbed() }
|
||||||
|
|
||||||
@@ -30,7 +31,10 @@ private final class StubOutlineAPIClient: OutlineAPIClient, @unchecked Sendable
|
|||||||
func listViewedDocuments(offset: Int, limit: Int) async throws -> [OutlineDocument] { throw NotStubbed() }
|
func listViewedDocuments(offset: Int, limit: Int) async throws -> [OutlineDocument] { throw NotStubbed() }
|
||||||
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 {
|
||||||
|
guard let handler = createDocumentHandler else { throw NotStubbed() }
|
||||||
|
return try await handler(request)
|
||||||
|
}
|
||||||
|
|
||||||
func updateDocument(_ request: UpdateDocumentRequest) async throws -> OutlineDocument {
|
func updateDocument(_ request: UpdateDocumentRequest) async throws -> OutlineDocument {
|
||||||
guard let handler = updateDocumentHandler else { throw NotStubbed() }
|
guard let handler = updateDocumentHandler else { throw NotStubbed() }
|
||||||
@@ -326,6 +330,39 @@ final class CachingOutlineAPIClientTests: XCTestCase {
|
|||||||
XCTAssertTrue(pin.id.hasPrefix("pending-"))
|
XCTAssertTrue(pin.id.hasPrefix("pending-"))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Regression test: manual offline mode used to only gate writes —
|
||||||
|
/// reads (listCollections, documentInfo, etc.) still hit `live` first
|
||||||
|
/// any time the device actually had a connection, silently defeating
|
||||||
|
/// "skip the network entirely" for the one thing that mattered most:
|
||||||
|
/// the sidebar showing more than what was actually cached.
|
||||||
|
func testManualOfflineModeSkipsLiveForReadsToo() async throws {
|
||||||
|
let defaults = UserDefaults(suiteName: "CachingOutlineAPIClientTests.manualOfflineReads")!
|
||||||
|
defaults.removePersistentDomain(forName: "CachingOutlineAPIClientTests.manualOfflineReads")
|
||||||
|
|
||||||
|
let stub = StubOutlineAPIClient()
|
||||||
|
let cachedCollection = makeCollection(id: "col-1")
|
||||||
|
var liveCallCount = 0
|
||||||
|
stub.listCollectionsHandler = { _, _ in
|
||||||
|
liveCallCount += 1
|
||||||
|
return [cachedCollection]
|
||||||
|
}
|
||||||
|
let cache = try makeCache()
|
||||||
|
let sut = CachingOutlineAPIClient(live: stub, cache: cache, defaults: defaults)
|
||||||
|
|
||||||
|
// Online first — populates the cache normally.
|
||||||
|
let firstResult = try await sut.listCollections(offset: 0, limit: 25)
|
||||||
|
XCTAssertEqual(firstResult, [cachedCollection])
|
||||||
|
XCTAssertEqual(liveCallCount, 1)
|
||||||
|
|
||||||
|
// Flip manual offline mode on, still "connected" (stub would happily
|
||||||
|
// answer) — the live call must not be attempted at all.
|
||||||
|
defaults.set(true, forKey: CachingOutlineAPIClient.offlineModeDefaultsKey)
|
||||||
|
let secondResult = try await sut.listCollections(offset: 0, limit: 25)
|
||||||
|
|
||||||
|
XCTAssertEqual(secondResult, [cachedCollection])
|
||||||
|
XCTAssertEqual(liveCallCount, 1, "listCollections should have served entirely from cache")
|
||||||
|
}
|
||||||
|
|
||||||
func testCacheStorageSummaryReflectsCachedItems() async throws {
|
func testCacheStorageSummaryReflectsCachedItems() async throws {
|
||||||
let stub = StubOutlineAPIClient()
|
let stub = StubOutlineAPIClient()
|
||||||
stub.documentInfoHandler = { _ in self.makeDocument() }
|
stub.documentInfoHandler = { _ in self.makeDocument() }
|
||||||
@@ -385,4 +422,79 @@ final class CachingOutlineAPIClientTests: XCTestCase {
|
|||||||
let cachedDoc = try await sut.documentInfo(id: "doc-2")
|
let cachedDoc = try await sut.documentInfo(id: "doc-2")
|
||||||
XCTAssertEqual(cachedDoc.id, "doc-2")
|
XCTAssertEqual(cachedDoc.id, "doc-2")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// MARK: - Offline document creation
|
||||||
|
|
||||||
|
func testCreateDocumentQueuesAndReturnsUsableDocumentWhenOffline() async throws {
|
||||||
|
let stub = StubOutlineAPIClient()
|
||||||
|
stub.createDocumentHandler = { _ in throw StubTransportError() }
|
||||||
|
let sut = CachingOutlineAPIClient(live: stub, cache: try makeCache())
|
||||||
|
|
||||||
|
let created = try await sut.createDocument(
|
||||||
|
CreateDocumentRequest(title: "New Doc", text: "hello", collectionId: "col-1")
|
||||||
|
)
|
||||||
|
|
||||||
|
XCTAssertTrue(created.id.hasPrefix("pending-"))
|
||||||
|
XCTAssertEqual(created.title, "New Doc")
|
||||||
|
XCTAssertEqual(created.text, "hello")
|
||||||
|
|
||||||
|
// It's immediately readable, same as any other cached document.
|
||||||
|
stub.documentInfoHandler = { _ in throw StubTransportError() }
|
||||||
|
let reopened = try await sut.documentInfo(id: created.id)
|
||||||
|
XCTAssertEqual(reopened.title, "New Doc")
|
||||||
|
|
||||||
|
let pending = await sut.pendingOperations()
|
||||||
|
XCTAssertEqual(pending.count, 1)
|
||||||
|
XCTAssertEqual(pending.first?.kind, "createDocument")
|
||||||
|
}
|
||||||
|
|
||||||
|
func testEditingAnUnsyncedCreatedDocumentFoldsIntoTheCreateInsteadOfQueuingAnUpdate() async throws {
|
||||||
|
let stub = StubOutlineAPIClient()
|
||||||
|
stub.createDocumentHandler = { _ in throw StubTransportError() }
|
||||||
|
stub.updateDocumentHandler = { _ in throw StubTransportError() }
|
||||||
|
let sut = CachingOutlineAPIClient(live: stub, cache: try makeCache())
|
||||||
|
|
||||||
|
let created = try await sut.createDocument(
|
||||||
|
CreateDocumentRequest(title: "Untitled", text: "", collectionId: "col-1")
|
||||||
|
)
|
||||||
|
_ = try await sut.updateDocument(UpdateDocumentRequest(id: created.id, title: "Real Title", text: "Real body"))
|
||||||
|
|
||||||
|
let pending = await sut.pendingOperations()
|
||||||
|
XCTAssertEqual(pending.count, 1, "the edit should have folded into the pending create, not added a second operation")
|
||||||
|
XCTAssertEqual(pending.first?.kind, "createDocument")
|
||||||
|
|
||||||
|
stub.documentInfoHandler = { _ in throw StubTransportError() }
|
||||||
|
let reopened = try await sut.documentInfo(id: created.id)
|
||||||
|
XCTAssertEqual(reopened.title, "Real Title")
|
||||||
|
XCTAssertEqual(reopened.text, "Real body")
|
||||||
|
}
|
||||||
|
|
||||||
|
func testFlushingAPendingCreateReconcilesThePlaceholderIdToTheRealOne() async throws {
|
||||||
|
let stub = StubOutlineAPIClient()
|
||||||
|
stub.createDocumentHandler = { _ in throw StubTransportError() }
|
||||||
|
let sut = CachingOutlineAPIClient(live: stub, cache: try makeCache())
|
||||||
|
|
||||||
|
let created = try await sut.createDocument(
|
||||||
|
CreateDocumentRequest(title: "New Doc", text: "hello", collectionId: "col-1")
|
||||||
|
)
|
||||||
|
let realDocument = makeDocument(id: "real-server-id", title: "New Doc")
|
||||||
|
stub.createDocumentHandler = { _ in realDocument }
|
||||||
|
|
||||||
|
let summary = await sut.flushPendingOperations()
|
||||||
|
XCTAssertEqual(summary.succeeded, 1)
|
||||||
|
XCTAssertEqual(summary.failed, 0)
|
||||||
|
|
||||||
|
// The real document is now readable under its real id...
|
||||||
|
stub.documentInfoHandler = { _ in throw StubTransportError() }
|
||||||
|
let byRealId = try await sut.documentInfo(id: "real-server-id")
|
||||||
|
XCTAssertEqual(byRealId.id, "real-server-id")
|
||||||
|
|
||||||
|
// ...and the placeholder is gone rather than left as a dead orphan.
|
||||||
|
do {
|
||||||
|
_ = try await sut.documentInfo(id: created.id)
|
||||||
|
XCTFail("Expected the placeholder cache entry to have been removed")
|
||||||
|
} catch {
|
||||||
|
// expected — nothing left under the old id
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -21,6 +21,7 @@ struct SettingsView: View {
|
|||||||
@State private var pendingOperations: [PendingOperationSummary] = []
|
@State private var pendingOperations: [PendingOperationSummary] = []
|
||||||
@State private var isSyncing = false
|
@State private var isSyncing = false
|
||||||
@State private var isClearingCache = false
|
@State private var isClearingCache = false
|
||||||
|
@State private var didClearCache = false
|
||||||
@State private var lastFullSyncSummary: FullSyncSummary?
|
@State private var lastFullSyncSummary: FullSyncSummary?
|
||||||
@State private var lastFlushSummary: SyncFlushSummary?
|
@State private var lastFlushSummary: SyncFlushSummary?
|
||||||
|
|
||||||
@@ -35,10 +36,21 @@ struct SettingsView: View {
|
|||||||
}
|
}
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
|
// GeometryReader + `minHeight` (not `maxHeight`) is the actual fix for
|
||||||
|
// "center short content inside a ScrollView" — a ScrollView proposes
|
||||||
|
// effectively unbounded height to its content, so `maxHeight: .infinity`
|
||||||
|
// alone just resolves to the content's own intrinsic size and does
|
||||||
|
// nothing; forcing a `minHeight` equal to the real viewport height is
|
||||||
|
// what gives `aboutDetail`'s own centered alignment somewhere to
|
||||||
|
// actually center within. Harmless for the other (already
|
||||||
|
// top/leading-aligned) sections — short ones just get blank space
|
||||||
|
// below, same as before.
|
||||||
|
GeometryReader { geometry in
|
||||||
ScrollView {
|
ScrollView {
|
||||||
sectionDetail
|
sectionDetail
|
||||||
.padding(28)
|
.padding(28)
|
||||||
.frame(maxWidth: .infinity, alignment: .leading)
|
.frame(maxWidth: .infinity, minHeight: geometry.size.height, alignment: .topLeading)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
||||||
.background(.background)
|
.background(.background)
|
||||||
@@ -158,12 +170,6 @@ struct SettingsView: View {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
Divider()
|
|
||||||
.frame(maxWidth: 480)
|
|
||||||
|
|
||||||
storageRow
|
|
||||||
.frame(maxWidth: 480, alignment: .leading)
|
|
||||||
|
|
||||||
Divider()
|
Divider()
|
||||||
.frame(maxWidth: 480)
|
.frame(maxWidth: 480)
|
||||||
|
|
||||||
@@ -172,25 +178,6 @@ struct SettingsView: View {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private var storageRow: some View {
|
|
||||||
VStack(alignment: .leading, spacing: 6) {
|
|
||||||
Text("Cache Storage")
|
|
||||||
.font(.subheadline.weight(.medium))
|
|
||||||
if let storageSummary {
|
|
||||||
Text("\(storageSummary.itemCount) items — \(formattedBytes(storageSummary.totalBytes))")
|
|
||||||
.font(.caption)
|
|
||||||
.foregroundStyle(.secondary)
|
|
||||||
} else {
|
|
||||||
Text("—")
|
|
||||||
.font(.caption)
|
|
||||||
.foregroundStyle(.secondary)
|
|
||||||
}
|
|
||||||
Text("Clearing the cache is in Advanced Options.")
|
|
||||||
.font(.caption2)
|
|
||||||
.foregroundStyle(.secondary)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private var pendingOperationsRow: some View {
|
private var pendingOperationsRow: some View {
|
||||||
VStack(alignment: .leading, spacing: 8) {
|
VStack(alignment: .leading, spacing: 8) {
|
||||||
HStack {
|
HStack {
|
||||||
@@ -288,9 +275,14 @@ struct SettingsView: View {
|
|||||||
.frame(maxWidth: 480)
|
.frame(maxWidth: 480)
|
||||||
|
|
||||||
VStack(alignment: .leading, spacing: 6) {
|
VStack(alignment: .leading, spacing: 6) {
|
||||||
HStack {
|
HStack(spacing: 10) {
|
||||||
Text("Clear All Cache")
|
Text("Clear All Cache")
|
||||||
Spacer()
|
Spacer()
|
||||||
|
if didClearCache {
|
||||||
|
Label("Cleared", systemImage: "checkmark")
|
||||||
|
.font(.caption)
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
}
|
||||||
Button(role: .destructive) {
|
Button(role: .destructive) {
|
||||||
Task { await clearCache() }
|
Task { await clearCache() }
|
||||||
} label: {
|
} label: {
|
||||||
@@ -300,6 +292,13 @@ struct SettingsView: View {
|
|||||||
Text("Clear")
|
Text("Clear")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
.buttonStyle(.borderedProminent)
|
||||||
|
.tint(.red)
|
||||||
|
}
|
||||||
|
if let storageSummary {
|
||||||
|
Text("\(storageSummary.itemCount) items — \(formattedBytes(storageSummary.totalBytes))")
|
||||||
|
.font(.caption)
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
}
|
}
|
||||||
Text("Deletes every cached collection and document, including anything Full Local Sync built, and anything still waiting to sync. Doesn't touch the server. This is the only place that can — it works even while offline, which is exactly why it's behind this toggle.")
|
Text("Deletes every cached collection and document, including anything Full Local Sync built, and anything still waiting to sync. Doesn't touch the server. This is the only place that can — it works even while offline, which is exactly why it's behind this toggle.")
|
||||||
.font(.caption2)
|
.font(.caption2)
|
||||||
@@ -352,11 +351,12 @@ struct SettingsView: View {
|
|||||||
// MARK: - About
|
// MARK: - About
|
||||||
|
|
||||||
private var aboutDetail: some View {
|
private var aboutDetail: some View {
|
||||||
VStack(alignment: .leading, spacing: 16) {
|
VStack(spacing: 16) {
|
||||||
sectionHeader
|
sectionHeader
|
||||||
AboutInfoView()
|
AboutInfoView()
|
||||||
.frame(maxWidth: 420, alignment: .leading)
|
.frame(maxWidth: 420)
|
||||||
}
|
}
|
||||||
|
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .center)
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - Helpers
|
// MARK: - Helpers
|
||||||
@@ -410,6 +410,11 @@ struct SettingsView: View {
|
|||||||
defer { isClearingCache = false }
|
defer { isClearingCache = false }
|
||||||
await cachingClient.clearCache()
|
await cachingClient.clearCache()
|
||||||
await refreshSyncState()
|
await refreshSyncState()
|
||||||
|
didClearCache = true
|
||||||
|
Task {
|
||||||
|
try? await Task.sleep(for: .seconds(2))
|
||||||
|
didClearCache = false
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
#endif
|
#endif
|
||||||
|
|||||||
@@ -40,26 +40,64 @@ struct ContentView_macOS: View {
|
|||||||
}
|
}
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
NavigationSplitView {
|
// A totally separate top-level branch, not content swapped inside
|
||||||
Group {
|
// one persistent `NavigationSplitView` — that was tried first (an
|
||||||
|
// `if/else` inside a single split view, then an explicit `.id()` on
|
||||||
|
// just the detail pane) and neither reliably replaced the detail
|
||||||
|
// pane's content on macOS: `NavigationSplitView` bridges to
|
||||||
|
// `NSSplitViewController`, and swapping a `NavigationStack` with
|
||||||
|
// real push history for a plain view inside one persisting instance
|
||||||
|
// doesn't propagate the way plain SwiftUI identity rules would
|
||||||
|
// suggest. A different `if` branch is a genuinely different view
|
||||||
|
// hierarchy, so there's no existing split view instance for AppKit
|
||||||
|
// to get confused about reusing.
|
||||||
if navigation.isShowingSettings {
|
if navigation.isShowingSettings {
|
||||||
|
settingsContent
|
||||||
|
} else {
|
||||||
|
mainContent
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private var settingsContent: some View {
|
||||||
|
NavigationSplitView {
|
||||||
SettingsSidebarList(
|
SettingsSidebarList(
|
||||||
selection: selectedSettingsSectionBinding,
|
selection: selectedSettingsSectionBinding,
|
||||||
onDone: { navigation.isShowingSettings = false }
|
onDone: { navigation.isShowingSettings = false }
|
||||||
)
|
)
|
||||||
} else {
|
.navigationSplitViewColumnWidth(min: 220, ideal: 260)
|
||||||
|
} detail: {
|
||||||
|
SettingsView(section: navigation.selectedSettingsSection ?? .appearance)
|
||||||
|
}
|
||||||
|
.navigationTitle("")
|
||||||
|
.toolbar {
|
||||||
|
ToolbarItem(placement: .navigation) {
|
||||||
|
HStack(spacing: 6) {
|
||||||
|
Image(systemName: "gearshape.fill")
|
||||||
|
Text("Settings")
|
||||||
|
}
|
||||||
|
.font(.headline)
|
||||||
|
.padding(.horizontal, 10)
|
||||||
|
.padding(.vertical, 4)
|
||||||
|
.background(.fill.tertiary, in: Capsule())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private var mainContent: some View {
|
||||||
|
NavigationSplitView {
|
||||||
VStack(spacing: 0) {
|
VStack(spacing: 0) {
|
||||||
SidebarSearchField(text: $globalSearchQuery)
|
SidebarSearchField(text: $globalSearchQuery)
|
||||||
Divider()
|
Divider()
|
||||||
if isOfflineModeEnabled || !session.networkMonitor.isOnline {
|
if isOfflineModeEnabled {
|
||||||
OfflineBanner(isManual: isOfflineModeEnabled)
|
OfflineBanner(isManual: true)
|
||||||
|
Divider()
|
||||||
|
} else if !session.networkMonitor.isOnline {
|
||||||
|
OfflineConnectionPromptBanner(onEnableOfflineMode: { isOfflineModeEnabled = true })
|
||||||
Divider()
|
Divider()
|
||||||
}
|
}
|
||||||
sidebar
|
sidebar
|
||||||
AccountFooter()
|
AccountFooter()
|
||||||
}
|
}
|
||||||
}
|
|
||||||
}
|
|
||||||
.navigationSplitViewColumnWidth(min: 220, ideal: 260)
|
.navigationSplitViewColumnWidth(min: 220, ideal: 260)
|
||||||
} detail: {
|
} detail: {
|
||||||
detail
|
detail
|
||||||
@@ -97,7 +135,7 @@ struct ContentView_macOS: View {
|
|||||||
// `CollectionOverviewView`, so on Home it was a dead end: a
|
// `CollectionOverviewView`, so on Home it was a dead end: a
|
||||||
// user could click it, type, and nothing would happen. The
|
// user could click it, type, and nothing would happen. The
|
||||||
// sidebar's global search already covers "search everything."
|
// sidebar's global search already covers "search everything."
|
||||||
if !navigation.isShowingSettings && !(isShowingHome && documentPath.isEmpty) {
|
if !(isShowingHome && documentPath.isEmpty) {
|
||||||
ToolbarItem(placement: .primaryAction) {
|
ToolbarItem(placement: .primaryAction) {
|
||||||
contextualSearchField
|
contextualSearchField
|
||||||
}
|
}
|
||||||
@@ -167,12 +205,7 @@ struct ContentView_macOS: View {
|
|||||||
@ViewBuilder
|
@ViewBuilder
|
||||||
private var leadingToolbarContent: some View {
|
private var leadingToolbarContent: some View {
|
||||||
Group {
|
Group {
|
||||||
if navigation.isShowingSettings {
|
if !trimmedGlobalQuery.isEmpty {
|
||||||
HStack(spacing: 6) {
|
|
||||||
Image(systemName: "gearshape.fill")
|
|
||||||
Text("Settings")
|
|
||||||
}
|
|
||||||
} else if !trimmedGlobalQuery.isEmpty {
|
|
||||||
HStack(spacing: 6) {
|
HStack(spacing: 6) {
|
||||||
Image(systemName: "magnifyingglass")
|
Image(systemName: "magnifyingglass")
|
||||||
Text("Search")
|
Text("Search")
|
||||||
@@ -251,7 +284,6 @@ struct ContentView_macOS: View {
|
|||||||
globalSearchQuery = ""
|
globalSearchQuery = ""
|
||||||
selectedCollection = collection
|
selectedCollection = collection
|
||||||
isContextualSearchExpanded = true
|
isContextualSearchExpanded = true
|
||||||
navigation.isShowingSettings = false
|
|
||||||
Task { @MainActor in
|
Task { @MainActor in
|
||||||
isContextualSearchFocused = true
|
isContextualSearchFocused = true
|
||||||
}
|
}
|
||||||
@@ -262,13 +294,11 @@ struct ContentView_macOS: View {
|
|||||||
/// hierarchy immediately rather than just the leaf.
|
/// hierarchy immediately rather than just the leaf.
|
||||||
private func selectDocumentChain(_ collection: OutlineCollection, _ chain: [OutlineDocument]) {
|
private func selectDocumentChain(_ collection: OutlineCollection, _ chain: [OutlineDocument]) {
|
||||||
selectedCollection = collection
|
selectedCollection = collection
|
||||||
navigation.isShowingSettings = false
|
|
||||||
replaceDocumentPath(with: chain)
|
replaceDocumentPath(with: chain)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Flat-list / search-result clicks: no known ancestors, single-level push.
|
/// Flat-list / search-result clicks: no known ancestors, single-level push.
|
||||||
private func openDocument(_ document: OutlineDocument) {
|
private func openDocument(_ document: OutlineDocument) {
|
||||||
navigation.isShowingSettings = false
|
|
||||||
replaceDocumentPath(with: [document])
|
replaceDocumentPath(with: [document])
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -287,14 +317,7 @@ struct ContentView_macOS: View {
|
|||||||
|
|
||||||
@ViewBuilder
|
@ViewBuilder
|
||||||
private var detail: some View {
|
private var detail: some View {
|
||||||
if navigation.isShowingSettings {
|
if let apiClient = session.apiClient {
|
||||||
// Deliberately not a `NavigationStack` destination or a separate
|
|
||||||
// window — it's swapped into the same detail pane Home/
|
|
||||||
// collections use, alongside the real sidebar (now showing
|
|
||||||
// `SettingsSidebarList` instead of the collections tree) rather
|
|
||||||
// than a page with its own nested mini sidebar.
|
|
||||||
SettingsView(section: navigation.selectedSettingsSection ?? .appearance)
|
|
||||||
} else if let apiClient = session.apiClient {
|
|
||||||
// A fresh NavigationStack per collection (or when entering/leaving
|
// A fresh NavigationStack per collection (or when entering/leaving
|
||||||
// search), so switching either also clears any pushed document.
|
// search), so switching either also clears any pushed document.
|
||||||
NavigationStack(path: $documentPath) {
|
NavigationStack(path: $documentPath) {
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ import OutlineKit
|
|||||||
struct DocumentReaderView: View {
|
struct DocumentReaderView: View {
|
||||||
@Environment(SessionStore.self) private var session
|
@Environment(SessionStore.self) private var session
|
||||||
@Environment(StarStore.self) private var starStore
|
@Environment(StarStore.self) private var starStore
|
||||||
|
@AppStorage(CachingOutlineAPIClient.offlineModeDefaultsKey) private var isOfflineModeEnabled = false
|
||||||
|
|
||||||
@State private var viewModel: DocumentReaderViewModel
|
@State private var viewModel: DocumentReaderViewModel
|
||||||
let apiClient: OutlineAPIClient
|
let apiClient: OutlineAPIClient
|
||||||
@@ -56,6 +57,15 @@ struct DocumentReaderView: View {
|
|||||||
self.onDocumentCreated = onDocumentCreated
|
self.onDocumentCreated = onDocumentCreated
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// New Document, editing, pin/star/subscribe, and Full Width all queue
|
||||||
|
/// and sync later (see `CachingOutlineAPIClient`) — everything else here
|
||||||
|
/// (sharing, permissions, move/archive/delete/duplicate/templatize,
|
||||||
|
/// history, insights, export) hits the server directly with no offline
|
||||||
|
/// path, so it's disabled rather than left to fail confusingly on tap.
|
||||||
|
private var isEffectivelyOnline: Bool {
|
||||||
|
session.networkMonitor.isOnline && !isOfflineModeEnabled
|
||||||
|
}
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
ScrollView {
|
ScrollView {
|
||||||
VStack(alignment: .leading, spacing: 12) {
|
VStack(alignment: .leading, spacing: 12) {
|
||||||
@@ -82,6 +92,7 @@ struct DocumentReaderView: View {
|
|||||||
NativeTextViewWrapper(
|
NativeTextViewWrapper(
|
||||||
text: $viewModel.text,
|
text: $viewModel.text,
|
||||||
configuration: .init(heightBehavior: .fitsContent),
|
configuration: .init(heightBehavior: .fitsContent),
|
||||||
|
documentId: viewModel.documentId,
|
||||||
isEditable: viewModel.isEditing
|
isEditable: viewModel.isEditing
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -110,7 +121,8 @@ struct DocumentReaderView: View {
|
|||||||
} label: {
|
} label: {
|
||||||
Image(systemName: "square.and.arrow.up")
|
Image(systemName: "square.and.arrow.up")
|
||||||
}
|
}
|
||||||
.help("Share")
|
.help(isEffectivelyOnline ? "Share" : "Sharing needs an internet connection")
|
||||||
|
.disabled(!isEffectivelyOnline)
|
||||||
.popover(isPresented: $isShowingShareSheet, arrowEdge: .bottom) {
|
.popover(isPresented: $isShowingShareSheet, arrowEdge: .bottom) {
|
||||||
DocumentShareSheet(apiClient: apiClient, documentId: viewModel.documentId)
|
DocumentShareSheet(apiClient: apiClient, documentId: viewModel.documentId)
|
||||||
}
|
}
|
||||||
@@ -235,7 +247,8 @@ struct DocumentReaderView: View {
|
|||||||
viewModel.isPinned,
|
viewModel.isPinned,
|
||||||
viewModel.isInsightsEnabled ?? false,
|
viewModel.isInsightsEnabled ?? false,
|
||||||
viewModel.isFullWidth,
|
viewModel.isFullWidth,
|
||||||
viewModel.isEditing
|
viewModel.isEditing,
|
||||||
|
isEffectivelyOnline
|
||||||
].map(String.init).joined(separator: "-")
|
].map(String.init).joined(separator: "-")
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -305,27 +318,33 @@ struct DocumentReaderView: View {
|
|||||||
Button("Permissions…") {
|
Button("Permissions…") {
|
||||||
isShowingShareSheet = true
|
isShowingShareSheet = true
|
||||||
}
|
}
|
||||||
|
.disabled(!isEffectivelyOnline)
|
||||||
|
|
||||||
Divider()
|
Divider()
|
||||||
|
|
||||||
Button("Templatize") {
|
Button("Templatize") {
|
||||||
Task { await templatize() }
|
Task { await templatize() }
|
||||||
}
|
}
|
||||||
|
.disabled(!isEffectivelyOnline)
|
||||||
Button("Duplicate") {
|
Button("Duplicate") {
|
||||||
Task { await duplicate() }
|
Task { await duplicate() }
|
||||||
}
|
}
|
||||||
|
.disabled(!isEffectivelyOnline)
|
||||||
Button("Unpublish") {
|
Button("Unpublish") {
|
||||||
isShowingUnpublishConfirmation = true
|
isShowingUnpublishConfirmation = true
|
||||||
}
|
}
|
||||||
|
.disabled(!isEffectivelyOnline)
|
||||||
Button("Archive…") {
|
Button("Archive…") {
|
||||||
isShowingArchiveConfirmation = true
|
isShowingArchiveConfirmation = true
|
||||||
}
|
}
|
||||||
|
.disabled(!isEffectivelyOnline)
|
||||||
|
|
||||||
Divider()
|
Divider()
|
||||||
|
|
||||||
Button("Move") {
|
Button("Move") {
|
||||||
isShowingMoveSheet = true
|
isShowingMoveSheet = true
|
||||||
}
|
}
|
||||||
|
.disabled(!isEffectivelyOnline)
|
||||||
// Multipart file upload is its own subsystem — deferred rather than
|
// Multipart file upload is its own subsystem — deferred rather than
|
||||||
// half-built here.
|
// half-built here.
|
||||||
Button("Import Document…") {}
|
Button("Import Document…") {}
|
||||||
@@ -342,9 +361,13 @@ struct DocumentReaderView: View {
|
|||||||
Button("History") {
|
Button("History") {
|
||||||
isShowingHistorySheet = true
|
isShowingHistorySheet = true
|
||||||
}
|
}
|
||||||
|
.disabled(!isEffectivelyOnline)
|
||||||
Button("Insights") {
|
Button("Insights") {
|
||||||
isShowingInsightsSheet = true
|
isShowingInsightsSheet = true
|
||||||
}
|
}
|
||||||
|
.disabled(!isEffectivelyOnline)
|
||||||
|
// Present/Search in Document both read `documents.info`, which is
|
||||||
|
// read-through cached — they work offline on whatever's cached.
|
||||||
Button("Present") {
|
Button("Present") {
|
||||||
isShowingPresentSheet = true
|
isShowingPresentSheet = true
|
||||||
}
|
}
|
||||||
@@ -354,6 +377,7 @@ struct DocumentReaderView: View {
|
|||||||
Button("Download") {
|
Button("Download") {
|
||||||
Task { await download() }
|
Task { await download() }
|
||||||
}
|
}
|
||||||
|
.disabled(!isEffectivelyOnline)
|
||||||
Button("Copy") {
|
Button("Copy") {
|
||||||
Task { await copyMarkdown() }
|
Task { await copyMarkdown() }
|
||||||
}
|
}
|
||||||
@@ -370,6 +394,7 @@ struct DocumentReaderView: View {
|
|||||||
get: { viewModel.isInsightsEnabled ?? false },
|
get: { viewModel.isInsightsEnabled ?? false },
|
||||||
set: { _ in Task { await toggleInsights() } }
|
set: { _ in Task { await toggleInsights() } }
|
||||||
))
|
))
|
||||||
|
.disabled(!isEffectivelyOnline)
|
||||||
// Confirmed against a live server: there's no per-document embeds
|
// Confirmed against a live server: there's no per-document embeds
|
||||||
// field. Only a workspace-level setting exists, and that's not
|
// field. Only a workspace-level setting exists, and that's not
|
||||||
// reachable via the API either (no `team.update` endpoint in the
|
// reachable via the API either (no `team.update` endpoint in the
|
||||||
@@ -386,6 +411,7 @@ struct DocumentReaderView: View {
|
|||||||
Button("Delete…", role: .destructive) {
|
Button("Delete…", role: .destructive) {
|
||||||
isShowingDeleteConfirmation = true
|
isShowingDeleteConfirmation = true
|
||||||
}
|
}
|
||||||
|
.disabled(!isEffectivelyOnline)
|
||||||
}
|
}
|
||||||
|
|
||||||
private func star() async {
|
private func star() async {
|
||||||
|
|||||||
@@ -0,0 +1,30 @@
|
|||||||
|
#if os(macOS)
|
||||||
|
import SwiftUI
|
||||||
|
|
||||||
|
/// Shown in the sidebar when the network is actually down and the user
|
||||||
|
/// hasn't turned on Offline Mode themselves yet. Deliberately doesn't change
|
||||||
|
/// any fetch behavior on its own — reads still try live and fall back to
|
||||||
|
/// cache the normal way (see `CachingOutlineAPIClient`) until the user
|
||||||
|
/// actually taps the button here, same as `RemoteChangesBanner` never
|
||||||
|
/// auto-refreshes on their behalf either.
|
||||||
|
struct OfflineConnectionPromptBanner: View {
|
||||||
|
let onEnableOfflineMode: () -> Void
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
HStack(spacing: 8) {
|
||||||
|
Image(systemName: "wifi.slash")
|
||||||
|
.foregroundStyle(.orange)
|
||||||
|
Text("No connection")
|
||||||
|
.font(.callout)
|
||||||
|
Spacer(minLength: 8)
|
||||||
|
Button("Turn On Offline Mode", action: onEnableOfflineMode)
|
||||||
|
.buttonStyle(.borderedProminent)
|
||||||
|
.tint(.orange)
|
||||||
|
.controlSize(.small)
|
||||||
|
}
|
||||||
|
.padding(.horizontal, 12)
|
||||||
|
.padding(.vertical, 8)
|
||||||
|
.background(Color.orange.opacity(0.12))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
#endif
|
||||||
@@ -57,25 +57,33 @@ final class HomeViewModel {
|
|||||||
/// Fetches fresh pinned docs and the current tab's documents to compare
|
/// Fetches fresh pinned docs and the current tab's documents to compare
|
||||||
/// against what's displayed, without replacing either. Bails silently
|
/// against what's displayed, without replacing either. Bails silently
|
||||||
/// on a fetch failure rather than treating it as "changed" — a
|
/// on a fetch failure rather than treating it as "changed" — a
|
||||||
/// transient network hiccup shouldn't pop the refresh banner.
|
/// transient network hiccup (or, offline, `listPins` failing outright —
|
||||||
|
/// it isn't one of the cached endpoints) shouldn't pop the refresh
|
||||||
|
/// banner. This needs the *throwing* pinned-fetch specifically: the
|
||||||
|
/// plain `fetchPinned()` used elsewhere collapses any failure to `[]`,
|
||||||
|
/// which used to read here as "pins changed" against whatever was
|
||||||
|
/// already displayed and falsely popped the banner on every offline
|
||||||
|
/// poll.
|
||||||
func checkForRemoteChanges(tab: HomeTab) async {
|
func checkForRemoteChanges(tab: HomeTab) async {
|
||||||
async let freshPinned = fetchPinned()
|
async let freshPinnedTask = fetchPinnedThrowing()
|
||||||
guard let freshTab = try? await fetch(tab: tab) else { return }
|
guard let freshTab = try? await fetch(tab: tab) else { return }
|
||||||
let pinned = await freshPinned
|
guard let pinned = try? await freshPinnedTask else { return }
|
||||||
if Self.fingerprint(pinned) != Self.fingerprint(pinnedDocuments)
|
if Self.fingerprint(pinned) != Self.fingerprint(pinnedDocuments)
|
||||||
|| Self.fingerprint(freshTab) != Self.fingerprint(documents(for: tab)) {
|
|| Self.fingerprint(freshTab) != Self.fingerprint(documents(for: tab)) {
|
||||||
hasRemoteChanges = true
|
hasRemoteChanges = true
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private func fetchPinned() async -> [OutlineDocument] {
|
||||||
|
(try? await fetchPinnedThrowing()) ?? []
|
||||||
|
}
|
||||||
|
|
||||||
/// `pins.list` only returns pin records, not the documents themselves —
|
/// `pins.list` only returns pin records, not the documents themselves —
|
||||||
/// fetches each pinned document individually. Pins are a small curated
|
/// fetches each pinned document individually. Pins are a small curated
|
||||||
/// set (unlike a full collection tree), so the N+1 here is acceptable
|
/// set (unlike a full collection tree), so the N+1 here is acceptable
|
||||||
/// where it wouldn't be in the sidebar.
|
/// where it wouldn't be in the sidebar.
|
||||||
private func fetchPinned() async -> [OutlineDocument] {
|
private func fetchPinnedThrowing() async throws -> [OutlineDocument] {
|
||||||
guard let pins = try? await apiClient.listPins(ListPinsRequest(collectionId: nil)) else {
|
let pins = try await apiClient.listPins(ListPinsRequest(collectionId: nil))
|
||||||
return []
|
|
||||||
}
|
|
||||||
var documents: [OutlineDocument] = []
|
var documents: [OutlineDocument] = []
|
||||||
for pin in pins {
|
for pin in pins {
|
||||||
if let document = try? await apiClient.documentInfo(id: pin.documentId) {
|
if let document = try? await apiClient.documentInfo(id: pin.documentId) {
|
||||||
|
|||||||
@@ -6,6 +6,18 @@ struct RootView: View {
|
|||||||
@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
|
@AppStorage("outpost.fullLocalSyncEnabled") private var isFullLocalSyncEnabled = false
|
||||||
|
@AppStorage(CachingOutlineAPIClient.offlineModeDefaultsKey) private var isOfflineModeEnabled = false
|
||||||
|
|
||||||
|
/// Real connectivity is only half of "can talk to the server" — the
|
||||||
|
/// manual Offline Mode toggle is the other half. Syncing needs to
|
||||||
|
/// resume on either one clearing, not just a real reconnect: turning
|
||||||
|
/// the toggle off while the network had been up the whole time never
|
||||||
|
/// changes `networkMonitor.isOnline`, so keying the flush task off that
|
||||||
|
/// alone left pending operations stuck until the next real network
|
||||||
|
/// blip.
|
||||||
|
private var isEffectivelyOnline: Bool {
|
||||||
|
session.networkMonitor.isOnline && !isOfflineModeEnabled
|
||||||
|
}
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
ZStack {
|
ZStack {
|
||||||
@@ -33,13 +45,13 @@ struct RootView: View {
|
|||||||
starStore.reset()
|
starStore.reset()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
// Replay whatever queued up while offline the moment the network's
|
// Replay whatever queued up while offline the moment either signal
|
||||||
// back — no need to wait for the user to open Settings and hit Retry.
|
// clears — a real reconnect, or the user turning Offline Mode back
|
||||||
// Also catches Full Local Sync back up immediately on reconnect,
|
// off — no need to wait for the user to open Settings and hit Retry.
|
||||||
// rather than leaving it to wait out the rest of the periodic loop
|
// Also catches Full Local Sync back up immediately, rather than
|
||||||
// below.
|
// leaving it to wait out the rest of the periodic loop below.
|
||||||
.task(id: session.networkMonitor.isOnline) {
|
.task(id: isEffectivelyOnline) {
|
||||||
guard session.networkMonitor.isOnline, let cachingClient = session.cachingClient else { return }
|
guard isEffectivelyOnline, let cachingClient = session.cachingClient else { return }
|
||||||
_ = await cachingClient.flushPendingOperations()
|
_ = await cachingClient.flushPendingOperations()
|
||||||
if isFullLocalSyncEnabled {
|
if isFullLocalSyncEnabled {
|
||||||
_ = await cachingClient.performFullSync()
|
_ = await cachingClient.performFullSync()
|
||||||
@@ -55,7 +67,7 @@ struct RootView: View {
|
|||||||
.task(id: isFullLocalSyncEnabled) {
|
.task(id: isFullLocalSyncEnabled) {
|
||||||
guard isFullLocalSyncEnabled else { return }
|
guard isFullLocalSyncEnabled else { return }
|
||||||
while !Task.isCancelled {
|
while !Task.isCancelled {
|
||||||
if session.networkMonitor.isOnline, let cachingClient = session.cachingClient {
|
if isEffectivelyOnline, let cachingClient = session.cachingClient {
|
||||||
_ = await cachingClient.performFullSync()
|
_ = await cachingClient.performFullSync()
|
||||||
}
|
}
|
||||||
try? await Task.sleep(for: .seconds(1200))
|
try? await Task.sleep(for: .seconds(1200))
|
||||||
|
|||||||
Reference in New Issue
Block a user