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:
@@ -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 —
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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: "|")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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: "|")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
Reference in New Issue
Block a user