feat(collections): detect remote changes, offer a refresh banner

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.
This commit is contained in:
2026-08-14 01:42:58 +01:00
parent 2c2f86f79b
commit cb37e6ac00
5 changed files with 97 additions and 0 deletions
@@ -34,6 +34,12 @@ struct CollectionOverviewView: View {
// No in-content header the collection's icon/title live in the // No in-content header the collection's icon/title live in the
// window toolbar now (via `ContentView_macOS`), so this doesn't // window toolbar now (via `ContentView_macOS`), so this doesn't
// duplicate it directly below. // duplicate it directly below.
if viewModel.hasRemoteChanges {
RemoteChangesBanner {
Task { await viewModel.load() }
}
}
if trimmedSearchQuery.isEmpty { if trimmedSearchQuery.isEmpty {
tabBar tabBar
} }
@@ -62,6 +68,13 @@ struct CollectionOverviewView: View {
guard !Task.isCancelled else { return } guard !Task.isCancelled else { return }
await searchViewModel.search(query: trimmedSearchQuery) 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 // Spans the full window width, centered, directly under the toolbar
@@ -25,6 +25,25 @@ struct CollectionsTreeView: View {
} }
var body: some 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 { Group {
if viewModel.isLoading && viewModel.collections.isEmpty { if viewModel.isLoading && viewModel.collections.isEmpty {
ProgressView() ProgressView()
@@ -8,6 +8,7 @@ final class CollectionsViewModel {
private(set) var collections: [OutlineCollection] = [] private(set) var collections: [OutlineCollection] = []
var isLoading = false var isLoading = false
var errorMessage: String? var errorMessage: String?
var hasRemoteChanges = false
let apiClient: OutlineAPIClient let apiClient: OutlineAPIClient
@@ -22,8 +23,27 @@ final class CollectionsViewModel {
do { do {
collections = try await apiClient.listCollections(offset: 0, limit: 100) collections = try await apiClient.listCollections(offset: 0, limit: 100)
hasRemoteChanges = false
} catch { } catch {
errorMessage = "Couldn't load collections. Check your connection and try again." 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: "|")
}
} }
@@ -8,6 +8,7 @@ final class DocumentsViewModel {
private(set) var documents: [OutlineDocument] = [] private(set) var documents: [OutlineDocument] = []
var isLoading = false var isLoading = false
var errorMessage: String? var errorMessage: String?
var hasRemoteChanges = false
let collection: OutlineCollection let collection: OutlineCollection
private let apiClient: OutlineAPIClient private let apiClient: OutlineAPIClient
@@ -24,8 +25,26 @@ final class DocumentsViewModel {
do { do {
documents = try await apiClient.listDocuments(collectionId: collection.id, offset: 0, limit: 100) documents = try await apiClient.listDocuments(collectionId: collection.id, offset: 0, limit: 100)
hasRemoteChanges = false
} catch { } catch {
errorMessage = "Couldn't load documents. Check your connection and try again." 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: "|")
}
} }
@@ -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