git.iko.soy/parseable-macos
on master help sign in
Fix 5 bugs: wedged stream detail spinner, cross-server stale data, double rendering, contradictory tail filters, stale poll time
commit5433a102e8c0b1f103755b2d60b7a111df441afa authoriko <mail@iko.soy> dateSun 21 Jun 2026, 21:17 (3 months ago) parent3b58d2f
3 files changed+31−7
ParseableViewer/ViewModels/LiveTailViewModel.swift+9−2 ParseableViewer/Views/LogDetailView.swift+5−2 ParseableViewer/Views/StreamDetailView.swift+17−3
ParseableViewer/ViewModels/LiveTailViewModel.swift+9−2
@@ -150,8 +150,12 @@ final class LiveTailViewModel {
150150 }
151151
152152 func addColumnFilter(column: String, value: JSONValue?, exclude: Bool) {
153− // Remove any existing filter on the same column with the same value
154− columnFilters.removeAll { $0.column == column && $0.value == value && $0.exclude == exclude }
153+ // Remove any existing filter on the same column+value regardless of its
154+ // exclude flag. Include (col = X) and exclude (col ≠ X) on the same value
155+ // are mutually exclusive; without dropping the `exclude` match too, toggling
156+ // include↔exclude stacks both and the AND'd filters match zero rows, so the
157+ // tail silently empties.
158+ columnFilters.removeAll { $0.column == column && $0.value == value }
155159 columnFilters.append(ColumnFilter(column: column, value: value, exclude: exclude))
156160 rebuildFilteredEntries()
157161 }
@@ -245,6 +249,9 @@ final class LiveTailViewModel {
245249 // leaving accumulated errors in place could trip the auto-stop threshold
246250 // sooner than the intended N consecutive failures. Matches start()'s reset.
247251 consecutiveErrors = 0
252+ // Match start(): a freshly emptied tail has no last-poll timestamp, so any
253+ // "last updated at…" indicator shouldn't keep showing the pre-clear time.
254+ lastPollTime = nil
248255 columns = []
249256 columnOrder = []
250257 hiddenColumns = []
ParseableViewer/Views/LogDetailView.swift+5−2
@@ -157,8 +157,11 @@ struct JSONValueView: View {
157157 Text(String(v))
158158 .font(.system(.caption, design: .monospaced))
159159 .foregroundStyle(Color(nsColor: .systemPurple))
160− case .double(let v):
161− Text(String(v))
160+ case .double:
161+ // Use displayString so whole-number doubles render as "3" (not "3.0"),
162+ // scientific notation is avoided, and inf/nan map to "null" — matching
163+ // the table cells, CSV/JSON export, and the rest of the app.
164+ Text(value.displayString)
162165 .font(.system(.caption, design: .monospaced))
163166 .foregroundStyle(Color(nsColor: .systemPurple))
164167 case .string(let v):
ParseableViewer/Views/StreamDetailView.swift+17−3
@@ -157,7 +157,11 @@ struct StreamDetailView: View {
157157 }
158158 .padding()
159159 }
160− .task(id: stream) {
160+ // Key on the connection too: switching to a different server that
161+ // exposes a same-named stream leaves `stream` unchanged, so without
162+ // the connection id the task wouldn't refire and the old server's
163+ // data would linger.
164+ .task(id: "\(appState.activeConnection?.id.uuidString ?? "")|\(stream)") {
161165 await loadData(stream: stream)
162166 }
163167 } else {
@@ -172,6 +176,11 @@ struct StreamDetailView: View {
172176
173177 private func loadData(stream: String) async {
174178 guard let client = appState.client else { return }
179+ // Capture the connection this fetch belongs to. Two servers can expose a
180+ // same-named stream, so guarding on the stream name alone would let a
181+ // slow fetch write the previous server's data under the new server's
182+ // stream after a server switch.
183+ let connID = appState.activeConnection?.id
175184 isLoading = true
176185 errorMessage = nil
177186 // Clear previous stream's data so it isn't shown under the new header
@@ -205,8 +214,13 @@ struct StreamDetailView: View {
205214 // The manual Refresh/Retry buttons spawn detached Tasks that aren't tied to
206215 // `.task(id: stream)` cancellation, so a slow refresh for the old stream can
207216 // resolve after the user switched. Drop stale results instead of writing
208− // them under the new stream's header.
209− guard stream == appState.selectedStream else { return }
217+ // them under the new stream's header. Also check the connection so a
218+ // 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.
220+ guard stream == appState.selectedStream, connID == appState.activeConnection?.id else {
221+ isLoading = false
222+ return
223+ }
210224
211225 schema = newSchema
212226 stats = newStats