diff --git a/OutlineKit/Sources/OutlineKit/Caching/CachingOutlineAPIClient.swift b/OutlineKit/Sources/OutlineKit/Caching/CachingOutlineAPIClient.swift index d62dca6..b5f465a 100644 --- a/OutlineKit/Sources/OutlineKit/Caching/CachingOutlineAPIClient.swift +++ b/OutlineKit/Sources/OutlineKit/Caching/CachingOutlineAPIClient.swift @@ -409,6 +409,17 @@ public actor CachingOutlineAPIClient: OutlineAPIClient { // MARK: - Helpers private func cachedFetch(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) { diff --git a/OutlineKit/Tests/OutlineKitTests/CachingOutlineAPIClientTests.swift b/OutlineKit/Tests/OutlineKitTests/CachingOutlineAPIClientTests.swift index a5ab991..45c9841 100644 --- a/OutlineKit/Tests/OutlineKitTests/CachingOutlineAPIClientTests.swift +++ b/OutlineKit/Tests/OutlineKitTests/CachingOutlineAPIClientTests.swift @@ -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() } diff --git a/Outpost/Features/Account/SettingsView.swift b/Outpost/Features/Account/SettingsView.swift index 7daffe2..a61804d 100644 --- a/Outpost/Features/Account/SettingsView.swift +++ b/Outpost/Features/Account/SettingsView.swift @@ -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) diff --git a/Outpost/Features/Collections/ContentView_macOS.swift b/Outpost/Features/Collections/ContentView_macOS.swift index 57a4e22..53b1a8f 100644 --- a/Outpost/Features/Collections/ContentView_macOS.swift +++ b/Outpost/Features/Collections/ContentView_macOS.swift @@ -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 diff --git a/Outpost/Features/Collections/OfflineConnectionPromptBanner.swift b/Outpost/Features/Collections/OfflineConnectionPromptBanner.swift new file mode 100644 index 0000000..9bd51de --- /dev/null +++ b/Outpost/Features/Collections/OfflineConnectionPromptBanner.swift @@ -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