git.iko.soy/parseable-macos
on master help sign in
Fix 5 bugs: mid-word autocomplete duplication, stale-data write on manual refresh, clear not resetting error counter, int/double fingerprint inconsistency, and whitespace-only connection fields
commit6535031afed613f24c4734a28fdd0c9df3e5a923 authoriko <mail@iko.soy> dateSun 21 Jun 2026, 01:43 (3 months ago) parenta0cf99e
6 files changed+97−28
ParseableViewer/ViewModels/LiveTailViewModel.swift+24−16 ParseableViewer/Views/AlertsView.swift+7−1 ParseableViewer/Views/ConnectionSheet.swift+19−4 ParseableViewer/Views/SQLAutoComplete.swift+14−1 ParseableViewer/Views/SQLEditorView.swift+14−2 ParseableViewer/Views/StreamDetailView.swift+19−4
ParseableViewer/ViewModels/LiveTailViewModel.swift+24−16
@@ -225,6 +225,10 @@ final class LiveTailViewModel {
225225 lastTimestamp = Date()
226226 allKnownKeys = []
227227 droppedCount = 0
228+ // Reset the failure counter too: the timer keeps polling after a clear, so
229+ // leaving accumulated errors in place could trip the auto-stop threshold
230+ // sooner than the intended N consecutive failures. Matches start()'s reset.
231+ consecutiveErrors = 0
228232 columns = []
229233 columnOrder = []
230234 hiddenColumns = []
@@ -513,6 +517,19 @@ final class LiveTailViewModel {
513517 return String(h0, radix: 36) + String(h1, radix: 36)
514518 }
515519
520+ /// Mixes the 8 bytes of a number's canonical 64-bit representation, prefixed by
521+ /// a shared numeric type byte so ints and equal-valued doubles fingerprint alike.
522+ nonisolated private static func hashNumberBits(_ bitPattern: UInt64, h0: inout UInt64, h1: inout UInt64) {
523+ h0 = (h0 ^ 0x02) &* 1099511628211
524+ h1 = (h1 ^ 0x02) &* 6700417
525+ var bits = bitPattern
526+ for _ in 0..<8 {
527+ h0 = (h0 ^ (bits & 0xFF)) &* 1099511628211
528+ h1 = (h1 ^ (bits & 0xFF)) &* 6700417
529+ bits >>= 8
530+ }
531+ }
532+
516533 /// Recursively hashes a JSONValue tree without allocating intermediate strings.
517534 /// Uses type-discriminator bytes to prevent cross-type collisions.
518535 nonisolated private static func hashJSONValue(_ value: JSONValue, h0: inout UInt64, h1: inout UInt64) {
@@ -526,24 +543,15 @@ final class LiveTailViewModel {
526543 let byte: UInt64 = b ? 1 : 0
527544 h0 = (h0 ^ byte) &* 1099511628211
528545 h1 = (h1 ^ byte) &* 6700417
546+ // `.int` and `.double` share one numeric encoding so the fingerprint stays
547+ // consistent with JSONValue equality, where `.int(5) == .double(5.0)`.
548+ // JSONValue.hash(into:) canonicalizes both through `Double`; mirror that here
549+ // (with NaN folded to a single bit pattern) so a field that serializes as `5`
550+ // in one poll and `5.0` in another doesn't bypass dedup as a phantom row.
529551 case .int(let i):
530− h0 = (h0 ^ 0x02) &* 1099511628211
531− h1 = (h1 ^ 0x02) &* 6700417
532− var bits = UInt64(bitPattern: Int64(i))
533− for _ in 0..<8 {
534− h0 = (h0 ^ (bits & 0xFF)) &* 1099511628211
535− h1 = (h1 ^ (bits & 0xFF)) &* 6700417
536− bits >>= 8
537− }
552+ hashNumberBits(Double(i).bitPattern, h0: &h0, h1: &h1)
538553 case .double(let d):
539− h0 = (h0 ^ 0x03) &* 1099511628211
540− h1 = (h1 ^ 0x03) &* 6700417
541− var bits = d.bitPattern
542− for _ in 0..<8 {
543− h0 = (h0 ^ UInt64(bits & 0xFF)) &* 1099511628211
544− h1 = (h1 ^ UInt64(bits & 0xFF)) &* 6700417
545− bits >>= 8
546− }
554+ hashNumberBits((d.isNaN ? Double.nan : d).bitPattern, h0: &h0, h1: &h1)
547555 case .string(let s):
548556 h0 = (h0 ^ 0x04) &* 1099511628211
549557 h1 = (h1 ^ 0x04) &* 6700417
ParseableViewer/Views/AlertsView.swift+7−1
@@ -81,8 +81,14 @@ struct AlertsView: View {
8181 alertConfig = nil
8282
8383 do {
84− alertConfig = try await client.getAlerts(stream: stream)
84+ let config = try await client.getAlerts(stream: stream)
85+ // 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+ // can resolve after a switch. Drop stale results.
88+ guard stream == appState.selectedStream else { return }
89+ alertConfig = config
8590 } catch {
91+ guard stream == appState.selectedStream else { return }
8692 errorMessage = ParseableError.userFriendlyMessage(for: error)
8793 }
8894
ParseableViewer/Views/ConnectionSheet.swift+19−4
@@ -127,7 +127,13 @@ struct ConnectionSheet: View {
127127 saveAndConnect()
128128 }
129129 .keyboardShortcut(.defaultAction)
130− .disabled(name.isEmpty || url.isEmpty || username.isEmpty || isSaving || urlValidationError != nil)
130+ .disabled(
131+ name.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty
132+ || url.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty
133+ || username.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty
134+ || isSaving
135+ || urlValidationError != nil
136+ )
131137 }
132138 .padding()
133139 }
@@ -152,7 +158,12 @@ struct ConnectionSheet: View {
152158
153159 Task {
154160 do {
155− let conn = ServerConnection(name: name, url: url, username: username, password: password)
161+ let conn = ServerConnection(
162+ name: name.trimmingCharacters(in: .whitespacesAndNewlines),
163+ url: url.trimmingCharacters(in: .whitespacesAndNewlines),
164+ username: username.trimmingCharacters(in: .whitespacesAndNewlines),
165+ password: password
166+ )
156167 let client = try ParseableClient(connection: conn)
157168 try await client.checkHealth()
158169 await MainActor.run {
@@ -171,12 +182,16 @@ struct ConnectionSheet: View {
171182 private func saveAndConnect() {
172183 guard !isSaving else { return }
173184
185+ let trimmedName = name.trimmingCharacters(in: .whitespacesAndNewlines)
186+ let trimmedURL = url.trimmingCharacters(in: .whitespacesAndNewlines)
187+ let trimmedUsername = username.trimmingCharacters(in: .whitespacesAndNewlines)
188+
174189 let conn: ServerConnection
175190 if let existing = connection ?? createdConnection {
176− conn = ServerConnection(id: existing.id, name: name, url: url, username: username, password: password)
191+ conn = ServerConnection(id: existing.id, name: trimmedName, url: trimmedURL, username: trimmedUsername, password: password)
177192 appState.updateConnection(conn)
178193 } else {
179− conn = ServerConnection(name: name, url: url, username: username, password: password)
194+ conn = ServerConnection(name: trimmedName, url: trimmedURL, username: trimmedUsername, password: password)
180195 appState.addConnection(conn)
181196 createdConnection = conn
182197 }
ParseableViewer/Views/SQLAutoComplete.swift+14−1
@@ -86,8 +86,21 @@ enum SQLCompletionProvider {
8686 }
8787 }
8888
89+ // The matching prefix is only the text before the caret (what the user has
90+ // typed so far), but the replacement range spans the whole word — scan
91+ // forward past any trailing word characters so a mid-token completion
92+ // replaces the entire token instead of duplicating its tail.
93+ var wordEnd = cursorPosition
94+ while wordEnd < nsText.length {
95+ if isWordCharacter(nsText.character(at: wordEnd)) {
96+ wordEnd += 1
97+ } else {
98+ break
99+ }
100+ }
101+
89102 let prefix = nsText.substring(with: NSRange(location: wordStart, length: cursorPosition - wordStart))
90− let range = NSRange(location: wordStart, length: cursorPosition - wordStart)
103+ let range = NSRange(location: wordStart, length: wordEnd - wordStart)
91104
92105 guard !prefix.isEmpty else {
93106 return ([], "", range)
ParseableViewer/Views/SQLEditorView.swift+14−2
@@ -192,7 +192,9 @@ struct SQLEditorView: NSViewRepresentable {
192192 let cursorPosition = textView.selectedRange().location
193193 let nsText = textView.string as NSString
194194
195− // Find the prefix range to replace
195+ // Find the full word range to replace. Scan backward for the start and
196+ // forward for the end so a mid-token caret (e.g. SEL|ECT) replaces the
197+ // entire word rather than leaving the trailing fragment ("SELECTECT").
196198 var wordStart = cursorPosition
197199 while wordStart > 0 {
198200 let ch = nsText.character(at: wordStart - 1)
@@ -203,7 +205,17 @@ struct SQLEditorView: NSViewRepresentable {
203205 }
204206 }
205207
206− let replaceRange = NSRange(location: wordStart, length: cursorPosition - wordStart)
208+ var wordEnd = cursorPosition
209+ while wordEnd < nsText.length {
210+ let ch = nsText.character(at: wordEnd)
211+ if SQLCompletionProvider.isWordCharacter(ch) {
212+ wordEnd += 1
213+ } else {
214+ break
215+ }
216+ }
217+
218+ let replaceRange = NSRange(location: wordStart, length: wordEnd - wordStart)
207219
208220 isInsertingCompletion = true
209221 defer { isInsertingCompletion = false }
ParseableViewer/Views/StreamDetailView.swift+19−4
@@ -188,15 +188,30 @@ struct StreamDetailView: View {
188188
189189 var failures: [String] = []
190190 var sawNotFound = false
191+ var newSchema: StreamSchema?
192+ var newStats: StreamStats?
193+ var newInfo: StreamInfo?
194+ var newRetention: [RetentionConfig] = []
191195
192196 func checkNotFound(_ error: Error) {
193197 if case .serverError(let code, _) = error as? ParseableError, code == 404 { sawNotFound = true }
194198 }
195199
196− do { schema = try await schemaFetch } catch { schema = nil; failures.append("schema"); checkNotFound(error) }
197− do { stats = try await statsFetch } catch { stats = nil; failures.append("stats"); checkNotFound(error) }
198− do { info = try await infoFetch } catch { info = nil; failures.append("info"); checkNotFound(error) }
199− do { retention = try await retentionFetch } catch { retention = []; failures.append("retention"); checkNotFound(error) }
200+ do { newSchema = try await schemaFetch } catch { failures.append("schema"); checkNotFound(error) }
201+ do { newStats = try await statsFetch } catch { failures.append("stats"); checkNotFound(error) }
202+ do { newInfo = try await infoFetch } catch { failures.append("info"); checkNotFound(error) }
203+ do { newRetention = try await retentionFetch } catch { failures.append("retention"); checkNotFound(error) }
204+
205+ // The manual Refresh/Retry buttons spawn detached Tasks that aren't tied to
206+ // `.task(id: stream)` cancellation, so a slow refresh for the old stream can
207+ // 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 }
210+
211+ schema = newSchema
212+ stats = newStats
213+ info = newInfo
214+ retention = newRetention
200215
201216 // A deleted stream makes these endpoints 404. Requiring *all four* to
202217 // fail was too strict (e.g. retention can succeed with an empty result),