fix(offline): manual mode never skipped live reads, storage/banner cleanup

The main fix: CachingOutlineAPIClient.cachedFetch (documentInfo,
listCollections, listDocuments, etc.) never actually checked manual
Offline Mode — only the write path did. With a real connection still
up, turning Offline Mode on did nothing for reads: the sidebar kept
fetching live, showing collections/documents beyond what was actually
cached. Reads now skip `live` entirely under manual offline mode, same
as writes already did — the sidebar and document lists are now
genuinely limited to whatever's cached, and only those documents are
openable, once the toggle is on. 1 new regression test (54/54).

Settings: removed the cache-size readout from Offline & Sync (storage
management is Advanced-only now, per the "one place that can touch the
cache" design) and added it next to Advanced's Clear All Cache instead,
where it was missing.

Sidebar: replaced the old passive "Offline — showing cached content"
banner for a real dropped connection with an actionable prompt
(OfflineConnectionPromptBanner, styled like the existing
RemoteChangesBanner) offering to turn Offline Mode on — doesn't change
any behavior on its own, same as RemoteChangesBanner never auto-
refreshes. The informational banner still shows once Offline Mode is
actually on (manually, or via this prompt).
This commit is contained in:
2026-08-15 01:08:03 +01:00
parent 1ce2aced90
commit 924f84525c
5 changed files with 84 additions and 27 deletions
@@ -409,6 +409,17 @@ public actor CachingOutlineAPIClient: OutlineAPIClient {
// MARK: - Helpers
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 {
let result = try await fetch()
if let data = try? encoder.encode(result) {
@@ -326,6 +326,39 @@ final class CachingOutlineAPIClientTests: XCTestCase {
XCTAssertTrue(pin.id.hasPrefix("pending-"))
}
/// Regression test: manual offline mode used to only gate writes
/// reads (listCollections, documentInfo, etc.) still hit `live` first
/// any time the device actually had a connection, silently defeating
/// "skip the network entirely" for the one thing that mattered most:
/// the sidebar showing more than what was actually cached.
func testManualOfflineModeSkipsLiveForReadsToo() async throws {
let defaults = UserDefaults(suiteName: "CachingOutlineAPIClientTests.manualOfflineReads")!
defaults.removePersistentDomain(forName: "CachingOutlineAPIClientTests.manualOfflineReads")
let stub = StubOutlineAPIClient()
let cachedCollection = makeCollection(id: "col-1")
var liveCallCount = 0
stub.listCollectionsHandler = { _, _ in
liveCallCount += 1
return [cachedCollection]
}
let cache = try makeCache()
let sut = CachingOutlineAPIClient(live: stub, cache: cache, defaults: defaults)
// Online first populates the cache normally.
let firstResult = try await sut.listCollections(offset: 0, limit: 25)
XCTAssertEqual(firstResult, [cachedCollection])
XCTAssertEqual(liveCallCount, 1)
// Flip manual offline mode on, still "connected" (stub would happily
// answer) the live call must not be attempted at all.
defaults.set(true, forKey: CachingOutlineAPIClient.offlineModeDefaultsKey)
let secondResult = try await sut.listCollections(offset: 0, limit: 25)
XCTAssertEqual(secondResult, [cachedCollection])
XCTAssertEqual(liveCallCount, 1, "listCollections should have served entirely from cache")
}
func testCacheStorageSummaryReflectsCachedItems() async throws {
let stub = StubOutlineAPIClient()
stub.documentInfoHandler = { _ in self.makeDocument() }
+5 -25
View File
@@ -159,12 +159,6 @@ struct SettingsView: View {
}
}
Divider()
.frame(maxWidth: 480)
storageRow
.frame(maxWidth: 480, alignment: .leading)
Divider()
.frame(maxWidth: 480)
@@ -173,25 +167,6 @@ struct SettingsView: View {
}
}
private var storageRow: some View {
VStack(alignment: .leading, spacing: 6) {
Text("Cache Storage")
.font(.subheadline.weight(.medium))
if let storageSummary {
Text("\(storageSummary.itemCount) items — \(formattedBytes(storageSummary.totalBytes))")
.font(.caption)
.foregroundStyle(.secondary)
} else {
Text("")
.font(.caption)
.foregroundStyle(.secondary)
}
Text("Clearing the cache is in Advanced Options.")
.font(.caption2)
.foregroundStyle(.secondary)
}
}
private var pendingOperationsRow: some View {
VStack(alignment: .leading, spacing: 8) {
HStack {
@@ -309,6 +284,11 @@ struct SettingsView: View {
.buttonStyle(.borderedProminent)
.tint(.red)
}
if let storageSummary {
Text("\(storageSummary.itemCount) items — \(formattedBytes(storageSummary.totalBytes))")
.font(.caption)
.foregroundStyle(.secondary)
}
Text("Deletes every cached collection and document, including anything Full Local Sync built, and anything still waiting to sync. Doesn't touch the server. This is the only place that can — it works even while offline, which is exactly why it's behind this toggle.")
.font(.caption2)
.foregroundStyle(.secondary)
@@ -51,8 +51,11 @@ struct ContentView_macOS: View {
VStack(spacing: 0) {
SidebarSearchField(text: $globalSearchQuery)
Divider()
if isOfflineModeEnabled || !session.networkMonitor.isOnline {
OfflineBanner(isManual: isOfflineModeEnabled)
if isOfflineModeEnabled {
OfflineBanner(isManual: true)
Divider()
} else if !session.networkMonitor.isOnline {
OfflineConnectionPromptBanner(onEnableOfflineMode: { isOfflineModeEnabled = true })
Divider()
}
sidebar
@@ -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