git.iko.soy/parseable-macos
on master help sign in
Fix 5 bugs: export filter divergence, silent deleteFilter, AlertsView cross-server, stale selection, qualifier-dot context
commit956274bf8fe8835440a6d55f68fcc14cc6f86518 authoriko <mail@iko.soy> dateSun 21 Jun 2026, 15:20 (3 months ago) parentf609dc4
5 files changed+41−8
ParseableViewer/App/AppState.swift+6−2 ParseableViewer/ViewModels/QueryViewModel.swift+9−3 ParseableViewer/Views/AlertsView.swift+8−3 ParseableViewer/Views/LiveTailView.swift+7 ParseableViewer/Views/SQLAutoComplete.swift+11
ParseableViewer/App/AppState.swift+6−2
@@ -362,8 +362,12 @@ final class AppState {
362362 @MainActor
363363 func deleteFilter(_ filter: ParseableFilter) async throws {
364364 guard let client else { throw ParseableError.notConnected }
365− guard let filterId = filter.filterId else { return }
366− try await client.deleteFilter(id: filterId)
365+ // A filter without a server-assigned id was never persisted remotely, so
366+ // there's nothing to delete on the server — just drop it locally. Returning
367+ // early here would silently leave the row in place with no error.
368+ if let filterId = filter.filterId {
369+ try await client.deleteFilter(id: filterId)
370+ }
367371 filters.removeAll { $0.id == filter.id }
368372 }
369373
ParseableViewer/ViewModels/QueryViewModel.swift+9−3
@@ -523,19 +523,25 @@ final class QueryViewModel {
523523 return empty
524524 }
525525
526+ /// Exports the on-screen rows as JSON. Uses `filteredResults` (not the full
527+ /// `results`) so the output matches what the user sees with an active result
528+ /// filter, mirroring the view's export path.
526529 func exportAsJSON() -> String {
527− guard !results.isEmpty else { return "[]" }
530+ let rows = filteredResults
531+ guard !rows.isEmpty else { return "[]" }
528532 let encoder = JSONEncoder()
529533 encoder.outputFormatting = [.prettyPrinted, .sortedKeys]
530− if let data = try? encoder.encode(Self.projectRecords(results, to: visibleColumns)),
534+ if let data = try? encoder.encode(Self.projectRecords(rows, to: visibleColumns)),
531535 let string = String(data: data, encoding: .utf8) {
532536 return string
533537 }
534538 return "[]"
535539 }
536540
541+ /// Exports the on-screen rows as CSV. Uses `filteredResults` for the same
542+ /// reason as `exportAsJSON`.
537543 func exportAsCSV() -> String {
538− Self.buildCSV(records: results, columns: visibleColumns)
544+ Self.buildCSV(records: filteredResults, columns: visibleColumns)
539545 }
540546
541547 /// Projects each record down to the given columns so JSON export, like CSV,
ParseableViewer/Views/AlertsView.swift+8−3
@@ -76,6 +76,7 @@ struct AlertsView: View {
7676
7777 private func loadAlerts(stream: String) async {
7878 guard let client = appState.client else { return }
79+ let connID = appState.activeConnection?.id
7980 isLoading = true
8081 errorMessage = nil
8182 alertConfig = nil
@@ -84,13 +85,17 @@ struct AlertsView: View {
8485 let config = try await client.getAlerts(stream: stream)
8586 // The manual Refresh/Retry buttons spawn detached Tasks not tied to
8687 // `.task(id: stream)` cancellation, so a slow refresh for the old stream
87− // can resolve after a switch. Drop stale results.
88+ // can resolve after a switch. Drop stale results. Check the connection
89+ // id too: two servers can expose a same-named stream, so a name-only
90+ // guard could write the previous server's alerts after a server switch.
8891 // Still clear isLoading on the stale path: this task set the spinner,
8992 // so leaving it true wedges the ProgressView and disables Refresh.
90− guard stream == appState.selectedStream else { isLoading = false; return }
93+ guard stream == appState.selectedStream,
94+ connID == appState.activeConnection?.id else { isLoading = false; return }
9195 alertConfig = config
9296 } catch {
93− guard stream == appState.selectedStream else { isLoading = false; return }
97+ guard stream == appState.selectedStream,
98+ connID == appState.activeConnection?.id else { isLoading = false; return }
9499 errorMessage = ParseableError.userFriendlyMessage(for: error)
95100 }
96101
ParseableViewer/Views/LiveTailView.swift+7
@@ -299,6 +299,13 @@ struct LiveTailView: View {
299299 private func rebuildSortedEntries() {
300300 ensureColumnWidths()
301301 let entries = viewModel.cachedFilteredEntries
302+ // Clear a selection whose entry no longer exists — filtered out by a new
303+ // column/text filter, or aged out of the capped buffer during streaming.
304+ // Without this the detail pane silently vanishes while the stale id lingers
305+ // (LogTableView solves the same problem with reconcileSelection()).
306+ if let id = selectedEntryID, !entries.contains(where: { $0.id == id }) {
307+ selectedEntryID = nil
308+ }
302309 let cols = viewModel.visibleColumns
303310 let sevCols = buildSeverityColumnSet(columns: cols)
304311 // Precompute severity per entry; sorting doesn't change the set.
ParseableViewer/Views/SQLAutoComplete.swift+11
@@ -198,6 +198,17 @@ enum SQLCompletionProvider {
198198 // NSString too. `String.prefix` counts grapheme clusters and would slice
199199 // at the wrong place when non-BMP characters (e.g. emoji) precede it.
200200 let before = (text as NSString).substring(to: position)
201+
202+ // A qualifier dot directly before the caret (`t.co|`, `"stream".le|`) means
203+ // we're completing the column half of a table-qualified reference. The word
204+ // scan that produced `position` stops at the dot (it's not a word character),
205+ // while the tokenizer keeps the dot attached to the preceding token — so
206+ // without this the last token is `"t."`, which matches no keyword and falls
207+ // through to `.general`, mixing keywords/tables into a column position.
208+ if before.hasSuffix(".") {
209+ return .columnRef
210+ }
211+
201212 let tokens = tokenize(before)
202213
203214 guard let lastToken = tokens.last?.uppercased() else {