git.iko.soy/parseable-macos
on master help sign in
Fix 7 bugs: pending filter SQL, comment highlighting, dedup hash, schema overwrite, column sampling, NaN hashing, and stale confirmation
commita36e08c6aefb9a5b70c794fc7e3c70de0c67ad3a authoriko <mail@iko.soy> dateSun 21 Jun 2026, 00:58 (3 months ago) parent9c46d1d
7 files changed+78−12
ParseableViewer/Models/JSONValue.swift+4−1 ParseableViewer/ViewModels/LiveTailViewModel.swift+1 ParseableViewer/ViewModels/QueryViewModel.swift+9 ParseableViewer/Views/LogDetailView.swift+6 ParseableViewer/Views/LogTableView.swift+5−3 ParseableViewer/Views/QueryView.swift+18−2 ParseableViewer/Views/SyntaxHighlighting.swift+35−6
ParseableViewer/Models/JSONValue.swift+4−1
@@ -305,7 +305,10 @@ enum JSONValue: Codable, Hashable, Sendable, Comparable {
305305 // Hash integers via their Double value so `.int(1)` and `.double(1.0)`
306306 // — which are `==` — produce the same hash.
307307 case .int(let v): hasher.combine(Double(v))
308− case .double(let v): hasher.combine(v)
308+ // Canonicalize NaN: `==` treats all NaNs as equal, so every NaN must hash
309+ // identically to satisfy the Hashable contract (differing NaN bit patterns
310+ // would otherwise hash differently).
311+ case .double(let v): hasher.combine(v.isNaN ? Double.nan : v)
309312 case .string(let v): hasher.combine(v)
310313 case .array(let v): hasher.combine(v)
311314 case .object(let v): hasher.combine(v)
ParseableViewer/ViewModels/LiveTailViewModel.swift+1
@@ -529,6 +529,7 @@ final class LiveTailViewModel {
529529 for element in arr {
530530 hashJSONValue(element, h0: &h0, h1: &h1)
531531 h0 = (h0 ^ 0xFE) &* 1099511628211 // element separator
532+ h1 = (h1 ^ 0xFE) &* 6700417
532533 }
533534 case .object(let dict):
534535 h0 = (h0 ^ 0x06) &* 1099511628211
ParseableViewer/ViewModels/QueryViewModel.swift+9
@@ -54,6 +54,9 @@ final class QueryViewModel {
5454
5555 // Schema fields for autocomplete
5656 var schemaFields: [SchemaField] = []
57+ /// The stream of the most recent `loadSchema` call, used to drop a stale
58+ /// in-flight response when the user rapidly switches streams.
59+ private var latestSchemaStream: String?
5760
5861 // Query task for cancellation
5962 private var queryTask: Task<Void, Never>?
@@ -436,14 +439,20 @@ final class QueryViewModel {
436439
437440 @MainActor
438441 func loadSchema(client: ParseableClient?, stream: String?) async {
442+ latestSchemaStream = stream
439443 guard let client, let stream else {
440444 schemaFields = []
441445 return
442446 }
443447 do {
444448 let schema = try await client.getStreamSchema(stream: stream)
449+ // A newer load (e.g. a rapid stream switch) may have superseded this
450+ // one; only commit if this is still the stream last requested,
451+ // otherwise we'd show the wrong stream's fields in autocomplete.
452+ guard latestSchemaStream == stream else { return }
445453 schemaFields = schema.fields
446454 } catch {
455+ guard latestSchemaStream == stream else { return }
447456 // Schema load is best-effort; don't surface errors
448457 schemaFields = []
449458 }
ParseableViewer/Views/LogDetailView.swift+6
@@ -50,6 +50,12 @@ struct LogDetailView: View {
5050 }
5151 }
5252 .background(Color(nsColor: .controlBackgroundColor))
53+ .onChange(of: record) { _, _ in
54+ // Selecting a different row reuses this view with a new record; clear
55+ // any lingering checkmark/timer so the confirmation doesn't carry over.
56+ copyConfirmationTask?.cancel()
57+ showCopyConfirmation = false
58+ }
5359 }
5460
5561 private func copyToClipboard() {
ParseableViewer/Views/LogTableView.swift+5−3
@@ -22,9 +22,11 @@ func idealColumnWidth(for column: String, records: [LogRecord]) -> CGFloat {
2222 var widest = measureTextWidth(column, font: headerMeasureFont)
2323 // Sample evenly across the whole result set, not just the first N rows —
2424 // otherwise a wide value that sorts beyond row N is never measured and the
25− // column auto-fits too narrow after sorting.
26− let step = max(1, (records.count + sampleCount - 1) / max(sampleCount, 1))
27− for i in stride(from: 0, to: records.count, by: step).prefix(sampleCount) {
25+ // column auto-fits too narrow after sorting. Map each of the `sampleCount`
26+ // samples to a spread-out index so we hit exactly `sampleCount` distinct rows
27+ // (a stride with an integer step would skip rows yet still undersample).
28+ for s in 0..<sampleCount {
29+ let i = s * records.count / sampleCount
2830 let text = records[i][column]?.displayString ?? ""
2931 widest = max(widest, measureTextWidth(text, font: cellMeasureFont))
3032 }
ParseableViewer/Views/QueryView.swift+18−2
@@ -284,14 +284,30 @@ struct QueryView: View {
284284 await viewModel.loadSchema(client: appState.client, stream: stream)
285285 }
286286 }
287− let didSetDefault = viewModel.setDefaultQuery(stream: stream)
288− if didSetDefault && viewModel.results.isEmpty {
287+ // A saved filter may have set pendingFilterSQL before this view was
288+ // mounted (e.g. the filter was launched from another tab). .onChange
289+ // doesn't fire for a value that changed before the view entered the
290+ // hierarchy, so honor it here instead of overwriting the editor with
291+ // the default query.
292+ if let sql = appState.pendingFilterSQL {
293+ viewModel.sqlQuery = sql
294+ appState.pendingFilterSQL = nil
289295 Task {
290296 await viewModel.executeQuery(
291297 client: appState.client,
292298 stream: stream
293299 )
294300 }
301+ } else {
302+ let didSetDefault = viewModel.setDefaultQuery(stream: stream)
303+ if didSetDefault && viewModel.results.isEmpty {
304+ Task {
305+ await viewModel.executeQuery(
306+ client: appState.client,
307+ stream: stream
308+ )
309+ }
310+ }
295311 }
296312 }
297313 }
ParseableViewer/Views/SyntaxHighlighting.swift+35−6
@@ -76,18 +76,21 @@ enum SQLSyntaxHighlighter {
7676 let nsText = text as NSString
7777 var protectedRanges: [NSRange] = []
7878
79− // 1. Comments (-- to end of line)
80− applyRegex(commentRegex, to: textStorage, text: nsText,
81− color: .secondaryLabelColor, protectedRanges: &protectedRanges)
82−
83− // 2. Single-quoted strings
79+ // 1. Single-quoted strings
8480 applyRegex(singleQuoteRegex, to: textStorage, text: nsText,
8581 color: .systemRed, protectedRanges: &protectedRanges)
8682
87− // 3. Double-quoted identifiers
83+ // 2. Double-quoted identifiers
8884 applyRegex(doubleQuoteRegex, to: textStorage, text: nsText,
8985 color: .systemRed, protectedRanges: &protectedRanges)
9086
87+ // 3. Comments (-- to end of line). Highlighted AFTER strings, and a `--`
88+ // that begins inside a string literal (protectedRanges currently holds
89+ // only string ranges) is skipped — otherwise `'a--b' AND y = 5` would
90+ // bleed a gray comment over the trailing real SQL.
91+ applyCommentRegex(commentRegex, to: textStorage, text: nsText,
92+ color: .secondaryLabelColor, protectedRanges: &protectedRanges)
93+
9194 // 4. Numbers (outside protected ranges)
9295 applyUnprotectedRegex(numberRegex, to: textStorage, text: nsText,
9396 color: .systemPurple, protectedRanges: protectedRanges)
@@ -117,6 +120,32 @@ enum SQLSyntaxHighlighter {
117120 }
118121 }
119122
123+ /// Like `applyRegex`, but skips any match whose start falls inside an
124+ /// already-claimed range (used for comments so a `--` inside a string literal
125+ /// doesn't start a comment).
126+ private static func applyCommentRegex(
127+ _ regex: NSRegularExpression,
128+ to textStorage: NSTextStorage,
129+ text: NSString,
130+ color: NSColor,
131+ protectedRanges: inout [NSRange]
132+ ) {
133+ let matches = regex.matches(in: text as String, range: NSRange(location: 0, length: text.length))
134+ for match in matches where !isStartProtected(match.range.location, by: protectedRanges) {
135+ textStorage.addAttribute(.foregroundColor, value: color, range: match.range)
136+ protectedRanges.append(match.range)
137+ }
138+ }
139+
140+ private static func isStartProtected(_ location: Int, by protectedRanges: [NSRange]) -> Bool {
141+ for protected in protectedRanges {
142+ if location >= protected.location && location < NSMaxRange(protected) {
143+ return true
144+ }
145+ }
146+ return false
147+ }
148+
120149 private static func applyUnprotectedRegex(
121150 _ regex: NSRegularExpression,
122151 to textStorage: NSTextStorage,