From ce235d656dd64c917da984407c660ad6aec4a389 Mon Sep 17 00:00:00 2001 From: psavarmattas Date: Sat, 15 Aug 2026 00:45:09 +0100 Subject: [PATCH] fix(offline): pagination-limit bug in full sync, missing manual-mode badge, capture error MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - performFullSync was requesting listCollections with limit: 250 — Outline caps pagination at 100 and rejects anything over that outright, which was the actual "Synced with 1 error" (now visible in the UI as of the last fix: "Pagination limit is too large (max 100)"). Paginate collections in increments of 100 the same way documents already were, continuing until a short page signals the end — the protocol doesn't expose a total count to ask for up front, so this is the only way to know when to stop. 2 new regression tests. - OfflineBanner only ever reflected NetworkMonitor (a real dropped connection) — turning on the manual "Offline Mode" toggle did nothing to it, since that's a separate AppStorage flag the banner never read. ContentView_macOS's sidebar now shows the banner for either condition, with distinct copy for "you turned this on" vs "the network's actually down". - NetworkMonitor: the previous fix (weak self only on the inner Task) traded one Swift 6 error for another ("'weak' ownership of capture 'self' differs from implicitly-captured strong reference in outer scope") since the inner closure's capture forced the outer one to implicitly capture self too. Standard fix: weak capture on the outer closure, guard-let into a strong local immediately, let the inner Task closure capture that plain local instead. --- .../Caching/CachingOutlineAPIClient.swift | 21 ++++++-- .../CachingOutlineAPIClientTests.swift | 48 ++++++++++++++++++- .../Collections/ContentView_macOS.swift | 5 +- .../Features/Collections/OfflineBanner.swift | 14 ++++-- Outpost/Support/NetworkMonitor.swift | 13 +++-- 5 files changed, 87 insertions(+), 14 deletions(-) diff --git a/OutlineKit/Sources/OutlineKit/Caching/CachingOutlineAPIClient.swift b/OutlineKit/Sources/OutlineKit/Caching/CachingOutlineAPIClient.swift index 9e39a7e..d62dca6 100644 --- a/OutlineKit/Sources/OutlineKit/Caching/CachingOutlineAPIClient.swift +++ b/OutlineKit/Sources/OutlineKit/Caching/CachingOutlineAPIClient.swift @@ -364,10 +364,23 @@ public actor CachingOutlineAPIClient: OutlineAPIClient { var documentsCount = 0 var errors: [String] = [] var collections: [OutlineCollection] = [] - do { - collections = try await listCollections(offset: 0, limit: 250) - } catch { - errors.append(errorDescription(error)) + var collectionsOffset = 0 + let collectionsLimit = 100 + // Outline rejects any `limit` over 100 outright — page in increments + // of that instead of guessing a total up front (the protocol doesn't + // expose `pagination`'s total count, only the page itself); a page + // shorter than the limit is what signals "that was the last one". + while true { + let page: [OutlineCollection] + do { + page = try await listCollections(offset: collectionsOffset, limit: collectionsLimit) + } catch { + errors.append(errorDescription(error)) + break + } + collections.append(contentsOf: page) + guard page.count == collectionsLimit else { break } + collectionsOffset += collectionsLimit } for collection in collections { diff --git a/OutlineKit/Tests/OutlineKitTests/CachingOutlineAPIClientTests.swift b/OutlineKit/Tests/OutlineKitTests/CachingOutlineAPIClientTests.swift index f53b18e..a5ab991 100644 --- a/OutlineKit/Tests/OutlineKitTests/CachingOutlineAPIClientTests.swift +++ b/OutlineKit/Tests/OutlineKitTests/CachingOutlineAPIClientTests.swift @@ -12,6 +12,7 @@ private final class StubOutlineAPIClient: OutlineAPIClient, @unchecked Sendable var updateDocumentHandler: (@Sendable (UpdateDocumentRequest) async throws -> OutlineDocument)? var createPinHandler: (@Sendable (CreatePinRequest) async throws -> OutlinePin)? var deletePinHandler: (@Sendable (String) async throws -> Void)? + var listDocumentsHandler: (@Sendable (String?, String?, Int, Int) async throws -> [OutlineDocument])? func authInfo() async throws -> OutlineAuthInfo { throw NotStubbed() } @@ -21,7 +22,8 @@ private final class StubOutlineAPIClient: OutlineAPIClient, @unchecked Sendable } func listDocuments(collectionId: String?, parentDocumentId: String?, offset: Int, limit: Int) async throws -> [OutlineDocument] { - throw NotStubbed() + guard let handler = listDocumentsHandler else { throw NotStubbed() } + return try await handler(collectionId, parentDocumentId, offset, limit) } func documentsList(_ request: DocumentsListRequest) async throws -> [OutlineDocument] { throw NotStubbed() } @@ -339,4 +341,48 @@ final class CachingOutlineAPIClientTests: XCTestCase { let clearedSummary = await sut.cacheStorageSummary() XCTAssertEqual(clearedSummary.itemCount, 0) } + + // MARK: - Full sync + + func testPerformFullSyncNeverRequestsMoreThan100CollectionsPerPage() async throws { + let stub = StubOutlineAPIClient() + var requestedLimits: [Int] = [] + // 105 collections across two pages (100 + 5) — regression test for a + // real bug: this used to ask for `limit: 250` in one shot, which + // Outline's server rejects outright ("Pagination limit is too large + // (max 100)"), turning the whole sync into a single silent failure. + stub.listCollectionsHandler = { offset, limit in + requestedLimits.append(limit) + let remaining = max(0, 105 - offset) + let count = min(limit, remaining) + return (0..