From cb37e6ac009b5cbc365373d7df6e062a55d2c34e Mon Sep 17 00:00:00 2001 From: psavarmattas Date: Fri, 14 Aug 2026 01:42:58 +0100 Subject: [PATCH] feat(collections): detect remote changes, offer a refresh banner MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Every 45s, a background check fetches fresh collections/documents and compares an id+updatedAt fingerprint against what's displayed — on a mismatch, shows a "New changes available" banner instead of silently swapping content out from under whoever's looking at it (which would also lose sidebar scroll position / expanded rows). Tapping Refresh does the actual reload. --- .../Collections/CollectionOverviewView.swift | 13 ++++++++++ .../Collections/CollectionsTreeView.swift | 19 ++++++++++++++ .../Collections/CollectionsViewModel.swift | 20 ++++++++++++++ .../Collections/DocumentsViewModel.swift | 19 ++++++++++++++ .../Collections/RemoteChangesBanner.swift | 26 +++++++++++++++++++ 5 files changed, 97 insertions(+) create mode 100644 Outpost/Features/Collections/RemoteChangesBanner.swift diff --git a/Outpost/Features/Collections/CollectionOverviewView.swift b/Outpost/Features/Collections/CollectionOverviewView.swift index a4eaf78..6b16aea 100644 --- a/Outpost/Features/Collections/CollectionOverviewView.swift +++ b/Outpost/Features/Collections/CollectionOverviewView.swift @@ -34,6 +34,12 @@ struct CollectionOverviewView: View { // No in-content header — the collection's icon/title live in the // window toolbar now (via `ContentView_macOS`), so this doesn't // duplicate it directly below. + if viewModel.hasRemoteChanges { + RemoteChangesBanner { + Task { await viewModel.load() } + } + } + if trimmedSearchQuery.isEmpty { tabBar } @@ -62,6 +68,13 @@ struct CollectionOverviewView: View { guard !Task.isCancelled else { return } await searchViewModel.search(query: trimmedSearchQuery) } + .task { + while !Task.isCancelled { + try? await Task.sleep(for: .seconds(45)) + guard !Task.isCancelled else { break } + await viewModel.checkForRemoteChanges() + } + } } // Spans the full window width, centered, directly under the toolbar — diff --git a/Outpost/Features/Collections/CollectionsTreeView.swift b/Outpost/Features/Collections/CollectionsTreeView.swift index 2663378..d7d4451 100644 --- a/Outpost/Features/Collections/CollectionsTreeView.swift +++ b/Outpost/Features/Collections/CollectionsTreeView.swift @@ -25,6 +25,25 @@ struct CollectionsTreeView: View { } var body: some View { + VStack(spacing: 0) { + if viewModel.hasRemoteChanges { + RemoteChangesBanner { + Task { await viewModel.load() } + } + } + content + } + .task { + while !Task.isCancelled { + try? await Task.sleep(for: .seconds(45)) + guard !Task.isCancelled else { break } + await viewModel.checkForRemoteChanges() + } + } + } + + @ViewBuilder + private var content: some View { Group { if viewModel.isLoading && viewModel.collections.isEmpty { ProgressView() diff --git a/Outpost/Features/Collections/CollectionsViewModel.swift b/Outpost/Features/Collections/CollectionsViewModel.swift index f450ff5..981706f 100644 --- a/Outpost/Features/Collections/CollectionsViewModel.swift +++ b/Outpost/Features/Collections/CollectionsViewModel.swift @@ -8,6 +8,7 @@ final class CollectionsViewModel { private(set) var collections: [OutlineCollection] = [] var isLoading = false var errorMessage: String? + var hasRemoteChanges = false let apiClient: OutlineAPIClient @@ -22,8 +23,27 @@ final class CollectionsViewModel { do { collections = try await apiClient.listCollections(offset: 0, limit: 100) + hasRemoteChanges = false } catch { errorMessage = "Couldn't load collections. Check your connection and try again." } } + + /// Fetches fresh data to compare against what's displayed, without + /// replacing it — `hasRemoteChanges` drives a "Refresh" banner instead of + /// silently swapping content (and losing scroll position/expanded state) + /// out from under whoever's looking at it. + func checkForRemoteChanges() async { + guard let fresh = try? await apiClient.listCollections(offset: 0, limit: 100) else { return } + if Self.fingerprint(fresh) != Self.fingerprint(collections) { + hasRemoteChanges = true + } + } + + private static func fingerprint(_ collections: [OutlineCollection]) -> String { + collections + .map { "\($0.id):\($0.updatedAt.timeIntervalSince1970)" } + .sorted() + .joined(separator: "|") + } } diff --git a/Outpost/Features/Collections/DocumentsViewModel.swift b/Outpost/Features/Collections/DocumentsViewModel.swift index de21577..02825c5 100644 --- a/Outpost/Features/Collections/DocumentsViewModel.swift +++ b/Outpost/Features/Collections/DocumentsViewModel.swift @@ -8,6 +8,7 @@ final class DocumentsViewModel { private(set) var documents: [OutlineDocument] = [] var isLoading = false var errorMessage: String? + var hasRemoteChanges = false let collection: OutlineCollection private let apiClient: OutlineAPIClient @@ -24,8 +25,26 @@ final class DocumentsViewModel { do { documents = try await apiClient.listDocuments(collectionId: collection.id, offset: 0, limit: 100) + hasRemoteChanges = false } catch { errorMessage = "Couldn't load documents. Check your connection and try again." } } + + /// See CollectionsViewModel.checkForRemoteChanges — same reasoning. + func checkForRemoteChanges() async { + guard let fresh = try? await apiClient.listDocuments(collectionId: collection.id, offset: 0, limit: 100) else { + return + } + if Self.fingerprint(fresh) != Self.fingerprint(documents) { + hasRemoteChanges = true + } + } + + private static func fingerprint(_ documents: [OutlineDocument]) -> String { + documents + .map { "\($0.id):\($0.updatedAt.timeIntervalSince1970)" } + .sorted() + .joined(separator: "|") + } } diff --git a/Outpost/Features/Collections/RemoteChangesBanner.swift b/Outpost/Features/Collections/RemoteChangesBanner.swift new file mode 100644 index 0000000..4bd8046 --- /dev/null +++ b/Outpost/Features/Collections/RemoteChangesBanner.swift @@ -0,0 +1,26 @@ +#if os(macOS) +import SwiftUI + +/// Shown when a periodic background check finds the server has changes we +/// don't have — doesn't auto-refresh, since that would silently replace +/// what's on screen (losing scroll position, expanded rows) without asking. +struct RemoteChangesBanner: View { + let onRefresh: () -> Void + + var body: some View { + HStack(spacing: 8) { + Image(systemName: "arrow.triangle.2.circlepath") + .foregroundStyle(Color.accentColor) + Text("New changes available") + .font(.callout) + Spacer(minLength: 8) + Button("Refresh", action: onRefresh) + .buttonStyle(.borderedProminent) + .controlSize(.small) + } + .padding(.horizontal, 12) + .padding(.vertical, 8) + .background(Color.accentColor.opacity(0.12)) + } +} +#endif