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
|
// 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
|
||||||
Reference in New Issue
Block a user