git.iko.soy/parseable-macos
on master help sign in
Fix stale loading states, cached refreshes and results table state

- Refresh/Retry on Stream Info and Server Info bypass the 60 s response cache; they repainted cached values before. - Alerts, Stream Info, Users and Server Info ignore superseded loads, so a cancelled load can't clear the newer load's spinner. - Healthy alerts ("not-triggered", "resolved") are no longer shown in warning orange. - Results table: clear the row highlight when the selection is dropped by a re-query, drop a stale sort column when the table remounts, cancel a pending debounced sort, stop writing @State from the context menu builder during view updates, and only move a column when the dropped payload is the dragged header. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_015p9SN94YqDtQkEisiPQ3xJ

commit0eb08986788cdbf7bb54bc4f89b61ce82568e347 authorClaude <noreply@anthropic.com> dateWed 30 Sep 2026, 10:49 (1 week ago) parent1c8b1da
5 files changed+101−13
unifiedsplit
ParseableViewer/Views/AlertsView.swift+12−1 ParseableViewer/Views/LogTableView.swift+53−6 ParseableViewer/Views/ServerInfoView.swift+12−2 ParseableViewer/Views/StreamDetailView.swift+18−4 ParseableViewer/Views/UsersView.swift+6
ParseableViewer/Views/AlertsView.swift+12−1
@@ -5,6 +5,10 @@ struct AlertsView: View {
55 @State private var alertConfig: AlertConfig?
66 @State private var isLoading = false
77 @State private var errorMessage: String?
8+ /// Bumped by every load. SwiftUI cancels the previous `.task(id:)` load on a
9+ /// stream switch, but that load still resumes (with a cancellation error) —
10+ /// and must not touch `isLoading`, which now belongs to the newer load.
11+ @State private var loadGeneration = 0
812
913 var body: some View {
1014 Group {
@@ -77,12 +81,16 @@ struct AlertsView: View {
7781 private func loadAlerts(stream: String) async {
7882 guard let client = appState.client else { return }
7983 let connID = appState.activeConnection?.id
84+ loadGeneration &+= 1
85+ let generation = loadGeneration
8086 isLoading = true
8187 errorMessage = nil
8288 alertConfig = nil
8389
8490 do {
8591 let config = try await client.getAlerts(stream: stream)
92+ // Superseded by a newer load: leave its spinner and state alone.
93+ guard generation == loadGeneration else { return }
8694 // The manual Refresh/Retry buttons spawn detached Tasks not tied to
8795 // `.task(id: stream)` cancellation, so a slow refresh for the old stream
8896 // can resolve after a switch. Drop stale results. Check the connection
@@ -94,6 +102,7 @@ struct AlertsView: View {
94102 connID == appState.activeConnection?.id else { isLoading = false; return }
95103 alertConfig = config
96104 } catch {
105+ guard generation == loadGeneration else { return }
97106 guard stream == appState.selectedStream,
98107 connID == appState.activeConnection?.id else { isLoading = false; return }
99108 errorMessage = ParseableError.userFriendlyMessage(for: error)
@@ -203,7 +212,9 @@ struct AlertRowView: View {
203212 private var stateColor: Color {
204213 switch alert.state?.lowercased() {
205214 case "triggered": return .red
206− case "disabled": return .gray
215+ case "disabled", "silenced": return .gray
216+ // Parseable's default, healthy state (and 2.x's "resolved") — not a warning.
217+ case "not-triggered", "resolved": return .green
207218 // A reported-but-unrecognized state is a genuine "needs attention" signal,
208219 // so tint it orange; a missing state is simply unknown and stays neutral so
209220 // the bell icon isn't painted as a warning for every stateless alert.
ParseableViewer/Views/LogTableView.swift+53−6
@@ -315,7 +315,23 @@ struct LogTableView: View {
315315 }
316316 .onChange(of: sortColumn) { _, _ in debouncedRebuildSort() }
317317 .onChange(of: sortAscending) { _, _ in debouncedRebuildSort() }
318+ .onChange(of: selectedRecord) { _, newValue in
319+ // The owner can drop the selection on its own (QueryViewModel
320+ // clears it after every query). When the new result set equals the
321+ // old one, `onChange(of: records)` doesn't fire, so nothing would
322+ // reconcile the row highlight: the row stayed highlighted with no
323+ // detail pane, and the next click on it merely "deselected" it.
324+ if newValue == nil { selectedIndex = nil }
325+ }
318326 .onAppear {
327+ // This branch is unmounted while results are empty (e.g. across a
328+ // stream switch, which clears them first), so `onChange(of: columns)`
329+ // never sees the switch. Drop a sort column the new columns don't
330+ // have — or hide — before sorting, or rows would be ordered by an
331+ // invisible column with no header showing the sort.
332+ if let sortColumn, !columns.contains(sortColumn) {
333+ self.sortColumn = nil
334+ }
319335 recomputeColumnWidths(columns: columns, records: records)
320336 rebuildSort()
321337 }
@@ -401,6 +417,9 @@ struct LogTableView: View {
401417 }
402418
403419 private func rebuildSort() {
420+ // A debounced rebuild scheduled before the table unmounted must not land
421+ // after (and overwrite) this synchronous one.
422+ sortTask?.cancel()
404423 let sevCols = buildSeverityColumnSet(columns: columns)
405424 let enumerated = Array(records.enumerated())
406425 let sorted: [(offset: Int, element: LogRecord)]
@@ -476,12 +495,28 @@ struct LogHeaderView: View {
476495
477496 @State private var draggedColumn: String?
478497 @State private var dropTargetColumn: String?
479− @State private var uniqueValuesCache: [String: [JSONValue]] = [:]
498+ /// `.contextMenu`'s builder runs during body evaluation, so the filter menus
499+ /// compute their values while the view is being rendered. Writing an `@State`
500+ /// dictionary from there is "modifying state during view update" (undefined
501+ /// behavior, and it re-invalidates the header). The cache is therefore a plain
502+ /// reference that isn't observed; `recordsGeneration` — bumped outside body
503+ /// evaluation — is what invalidates it and triggers the re-render.
504+ @State private var uniqueValuesCache = UniqueValuesCache()
505+ @State private var recordsGeneration = 0
506+
507+ private final class UniqueValuesCache {
508+ var generation = -1
509+ var values: [String: [JSONValue]] = [:]
510+ }
480511
481512 private static let maxFilterValues = 20
482513
483514 private func uniqueValues(for column: String) -> [JSONValue] {
484− if let cached = uniqueValuesCache[column] {
515+ if uniqueValuesCache.generation != recordsGeneration {
516+ uniqueValuesCache.generation = recordsGeneration
517+ uniqueValuesCache.values = [:]
518+ }
519+ if let cached = uniqueValuesCache.values[column] {
485520 return cached
486521 }
487522 var seen = Set<JSONValue>()
@@ -493,7 +528,7 @@ struct LogHeaderView: View {
493528 }
494529 }
495530 let sorted = result.sorted()
496− uniqueValuesCache[column] = sorted
531+ uniqueValuesCache.values[column] = sorted
497532 return sorted
498533 }
499534
@@ -636,7 +671,7 @@ struct LogHeaderView: View {
636671 .background(Color(nsColor: .controlBackgroundColor))
637672 .border(Color.secondary.opacity(0.2), width: 0.5)
638673 .onChange(of: records) { _, _ in
639− uniqueValuesCache = [:]
674+ recordsGeneration &+= 1
640675 }
641676 }
642677}
@@ -669,14 +704,26 @@ private struct ColumnDropDelegate: DropDelegate {
669704 }
670705
671706 func performDrop(info: DropInfo) -> Bool {
672− guard let dragged = draggedColumn, dragged != column else {
707+ guard let dragged = draggedColumn, dragged != column,
708+ let provider = info.itemProviders(for: [UTType.plainText]).first else {
673709 draggedColumn = nil
674710 dropTargetColumn = nil
675711 return false
676712 }
677− onMoveColumn?(dragged, column)
678713 draggedColumn = nil
679714 dropTargetColumn = nil
715+ // `draggedColumn` is only cleared by a drop on a header, so a header drag
716+ // that was cancelled (or dropped elsewhere) leaves it set, and any later
717+ // plain-text drag onto a header would move that stale column. Only move
718+ // when the dropped payload really is the column header being dragged.
719+ let target = column
720+ let move = onMoveColumn
721+ _ = provider.loadObject(ofClass: NSString.self) { object, _ in
722+ guard let payload = object as? NSString, payload as String == dragged else { return }
723+ DispatchQueue.main.async {
724+ move?(dragged, target)
725+ }
726+ }
680727 return true
681728 }
682729
ParseableViewer/Views/ServerInfoView.swift+12−2
@@ -9,6 +9,9 @@ struct ServerInfoView: View {
99 @State private var isLoading = true
1010 @State private var isHealthy = false
1111 @State private var errorMessage: String?
12+ /// Bumped by every load so a superseded (cancelled) load can't clear the
13+ /// newer load's spinner or overwrite its results.
14+ @State private var loadGeneration = 0
1215
1316 var body: some View {
1417 ScrollView {
@@ -19,7 +22,7 @@ struct ServerInfoView: View {
1922 .fontWeight(.semibold)
2023 Spacer()
2124 Button {
22− Task { await loadInfo() }
25+ Task { await loadInfo(forceRefresh: true) }
2326 } label: {
2427 Image(systemName: "arrow.clockwise")
2528 }
@@ -153,12 +156,18 @@ struct ServerInfoView: View {
153156 }
154157 }
155158
156− private func loadInfo() async {
159+ private func loadInfo(forceRefresh: Bool = false) async {
157160 // Clear isLoading on the no-client path: since isLoading now starts `true`
158161 // (to avoid a red "Unhealthy" flash before the first check), an early return
159162 // without resetting it would wedge the spinner permanently.
160163 guard let client = appState.client else { isLoading = false; return }
164+ // `getAbout()` is cached for 60 s; the Refresh button must bypass that.
165+ if forceRefresh {
166+ await client.invalidateCache()
167+ }
161168 let connID = appState.activeConnection?.id
169+ loadGeneration &+= 1
170+ let generation = loadGeneration
162171 isLoading = true
163172 errorMessage = nil
164173 // Seed from the app-wide cache so first load isn't blank, but never fall
@@ -189,6 +198,7 @@ struct ServerInfoView: View {
189198 // load for the previous server can't overwrite the current one's state.
190199 // Still clear isLoading: this task owns the spinner it set, and leaving it
191200 // true on the stale path wedges the ProgressView and disables Refresh.
201+ guard generation == loadGeneration else { return }
192202 guard connID == appState.activeConnection?.id else { isLoading = false; return }
193203 isHealthy = healthy
194204 about = aboutValue
ParseableViewer/Views/StreamDetailView.swift+18−4
@@ -8,6 +8,9 @@ struct StreamDetailView: View {
88 @State private var retention: [RetentionConfig] = []
99 @State private var isLoading = false
1010 @State private var errorMessage: String?
11+ /// Bumped by every load. A superseded load (its `.task(id:)` cancelled by a
12+ /// stream switch) still resumes, and must not clear the newer load's spinner.
13+ @State private var loadGeneration = 0
1114
1215 var body: some View {
1316 Group {
@@ -25,7 +28,7 @@ struct StreamDetailView: View {
2528 Spacer()
2629
2730 Button {
28− Task { await loadData(stream: stream) }
31+ Task { await loadData(stream: stream, forceRefresh: true) }
2932 } label: {
3033 Image(systemName: "arrow.clockwise")
3134 }
@@ -44,7 +47,7 @@ struct StreamDetailView: View {
4447 Text(error)
4548 Spacer()
4649 Button("Retry") {
47− Task { await loadData(stream: stream) }
50+ Task { await loadData(stream: stream, forceRefresh: true) }
4851 }
4952 .buttonStyle(.bordered)
5053 .controlSize(.small)
@@ -174,13 +177,22 @@ struct StreamDetailView: View {
174177 }
175178 }
176179
177− private func loadData(stream: String) async {
180+ private func loadData(stream: String, forceRefresh: Bool = false) async {
178181 guard let client = appState.client else { return }
182+ // The client caches schema/stats/info for 60 s. That's right for the
183+ // automatic load on appear, but an explicit Refresh/Retry must hit the
184+ // server — otherwise it repaints the same cached numbers and looks like a
185+ // refresh that found nothing new.
186+ if forceRefresh {
187+ await client.invalidateCache()
188+ }
179189 // Capture the connection this fetch belongs to. Two servers can expose a
180190 // same-named stream, so guarding on the stream name alone would let a
181191 // slow fetch write the previous server's data under the new server's
182192 // stream after a server switch.
183193 let connID = appState.activeConnection?.id
194+ loadGeneration &+= 1
195+ let generation = loadGeneration
184196 isLoading = true
185197 errorMessage = nil
186198 // Clear previous stream's data so it isn't shown under the new header
@@ -216,7 +228,9 @@ struct StreamDetailView: View {
216228 // resolve after the user switched. Drop stale results instead of writing
217229 // them under the new stream's header. Also check the connection so a
218230 // same-named stream on a different server doesn't accept the old server's
219− // data. Reset `isLoading` on the stale path or the spinner wedges forever.
231+ // data. Reset `isLoading` on the stale path or the spinner wedges forever —
232+ // unless a newer load has started, which owns the spinner now.
233+ guard generation == loadGeneration else { return }
220234 guard stream == appState.selectedStream, connID == appState.activeConnection?.id else {
221235 isLoading = false
222236 return
ParseableViewer/Views/UsersView.swift+6
@@ -5,6 +5,9 @@ struct UsersView: View {
55 @State private var users: [UserInfo] = []
66 @State private var isLoading = false
77 @State private var errorMessage: String?
8+ /// Bumped by every load so a superseded (cancelled) load can't clear the
9+ /// newer load's spinner.
10+ @State private var loadGeneration = 0
811
912 var body: some View {
1013 VStack(spacing: 0) {
@@ -82,6 +85,8 @@ struct UsersView: View {
8285 private func loadUsers() async {
8386 guard let client = appState.client else { return }
8487 let connID = appState.activeConnection?.id
88+ loadGeneration &+= 1
89+ let generation = loadGeneration
8590 isLoading = true
8691 errorMessage = nil
8792 // Clear the previous server's list up front so it can't briefly show through
@@ -102,6 +107,7 @@ struct UsersView: View {
102107 // Drop results if the active connection changed while awaiting. Still clear
103108 // isLoading: this task set the spinner, so leaving it true on the stale path
104109 // wedges the ProgressView and disables Refresh.
110+ guard generation == loadGeneration else { return }
105111 guard connID == appState.activeConnection?.id else { isLoading = false; return }
106112 if let loaded { users = loaded }
107113 errorMessage = loadError