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
956274biko
ParseableViewer/App/AppState.swift+6−2
@@ -362,8 +362,12 @@ final class AppState {
362 @MainActor
362 @MainActor
363 func deleteFilter(_ filter: ParseableFilter) async throws {
363 func deleteFilter(_ filter: ParseableFilter) async throws {
364 guard let client else { throw ParseableError.notConnected }
364 guard let client else { throw ParseableError.notConnected }
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.
365− guard let filterId = filter.filterId else { return }
368+ if let filterId = filter.filterId {
366− try await client.deleteFilter(id: filterId)
369+ try await client.deleteFilter(id: filterId)
370+ }
367 filters.removeAll { $0.id == filter.id }
371 filters.removeAll { $0.id == filter.id }
368 }
372 }
369
373
ParseableViewer/ViewModels/QueryViewModel.swift+9−3
@@ -523,19 +523,25 @@ final class QueryViewModel {
523 return empty
523 return empty
524 }
524 }
525
525
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.
526 func exportAsJSON() -> String {
529 func exportAsJSON() -> String {
530+ let rows = filteredResults
527− guard !results.isEmpty else { return "[]" }
531+ guard !rows.isEmpty else { return "[]" }
528 let encoder = JSONEncoder()
532 let encoder = JSONEncoder()
529 encoder.outputFormatting = [.prettyPrinted, .sortedKeys]
533 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)),
531 let string = String(data: data, encoding: .utf8) {
535 let string = String(data: data, encoding: .utf8) {
532 return string
536 return string
533 }
537 }
534 return "[]"
538 return "[]"
535 }
539 }
536
540
541+ /// Exports the on-screen rows as CSV. Uses `filteredResults` for the same
542+ /// reason as `exportAsJSON`.
537 func exportAsCSV() -> String {
543 func exportAsCSV() -> String {
538− Self.buildCSV(records: results, columns: visibleColumns)
544+ Self.buildCSV(records: filteredResults, columns: visibleColumns)
539 }
545 }
540
546
541 /// Projects each record down to the given columns so JSON export, like CSV,
547 /// 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 {
76
76
77 private func loadAlerts(stream: String) async {
77 private func loadAlerts(stream: String) async {
78 guard let client = appState.client else { return }
78 guard let client = appState.client else { return }
79+ let connID = appState.activeConnection?.id
79 isLoading = true
80 isLoading = true
80 errorMessage = nil
81 errorMessage = nil
81 alertConfig = nil
82 alertConfig = nil
@@ -84,13 +85,17 @@ struct AlertsView: View {
84 let config = try await client.getAlerts(stream: stream)
85 let config = try await client.getAlerts(stream: stream)
85 // The manual Refresh/Retry buttons spawn detached Tasks not tied to
86 // The manual Refresh/Retry buttons spawn detached Tasks not tied to
86 // `.task(id: stream)` cancellation, so a slow refresh for the old stream
87 // `.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.
88 // Still clear isLoading on the stale path: this task set the spinner,
91 // Still clear isLoading on the stale path: this task set the spinner,
89 // so leaving it true wedges the ProgressView and disables Refresh.
92 // 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 }
91 alertConfig = config
95 alertConfig = config
92 } catch {
96 } catch {
93− guard stream == appState.selectedStream else { isLoading = false; return }
97+ guard stream == appState.selectedStream,
98+ connID == appState.activeConnection?.id else { isLoading = false; return }
94 errorMessage = ParseableError.userFriendlyMessage(for: error)
99 errorMessage = ParseableError.userFriendlyMessage(for: error)
95 }
100 }
96
101
ParseableViewer/Views/LiveTailView.swift+7
@@ -299,6 +299,13 @@ struct LiveTailView: View {
299 private func rebuildSortedEntries() {
299 private func rebuildSortedEntries() {
300 ensureColumnWidths()
300 ensureColumnWidths()
301 let entries = viewModel.cachedFilteredEntries
301 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+ }
302 let cols = viewModel.visibleColumns
309 let cols = viewModel.visibleColumns
303 let sevCols = buildSeverityColumnSet(columns: cols)
310 let sevCols = buildSeverityColumnSet(columns: cols)
304 // Precompute severity per entry; sorting doesn't change the set.
311 // Precompute severity per entry; sorting doesn't change the set.
ParseableViewer/Views/SQLAutoComplete.swift+11
@@ -198,6 +198,17 @@ enum SQLCompletionProvider {
198 // NSString too. `String.prefix` counts grapheme clusters and would slice
198 // NSString too. `String.prefix` counts grapheme clusters and would slice
199 // at the wrong place when non-BMP characters (e.g. emoji) precede it.
199 // at the wrong place when non-BMP characters (e.g. emoji) precede it.
200 let before = (text as NSString).substring(to: position)
200 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+
201 let tokens = tokenize(before)
212 let tokens = tokenize(before)
202
213
203 guard let lastToken = tokens.last?.uppercased() else {
214 guard let lastToken = tokens.last?.uppercased() else {