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
6535031iko
ParseableViewer/ViewModels/LiveTailViewModel.swift+24−16
@@ -225,6 +225,10 @@ final class LiveTailViewModel {
225 lastTimestamp = Date()
225 lastTimestamp = Date()
226 allKnownKeys = []
226 allKnownKeys = []
227 droppedCount = 0
227 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
228 columns = []
232 columns = []
229 columnOrder = []
233 columnOrder = []
230 hiddenColumns = []
234 hiddenColumns = []
@@ -513,6 +517,19 @@ final class LiveTailViewModel {
513 return String(h0, radix: 36) + String(h1, radix: 36)
517 return String(h0, radix: 36) + String(h1, radix: 36)
514 }
518 }
515
519
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+
516 /// Recursively hashes a JSONValue tree without allocating intermediate strings.
533 /// Recursively hashes a JSONValue tree without allocating intermediate strings.
517 /// Uses type-discriminator bytes to prevent cross-type collisions.
534 /// Uses type-discriminator bytes to prevent cross-type collisions.
518 nonisolated private static func hashJSONValue(_ value: JSONValue, h0: inout UInt64, h1: inout UInt64) {
535 nonisolated private static func hashJSONValue(_ value: JSONValue, h0: inout UInt64, h1: inout UInt64) {
@@ -526,24 +543,15 @@ final class LiveTailViewModel {
526 let byte: UInt64 = b ? 1 : 0
543 let byte: UInt64 = b ? 1 : 0
527 h0 = (h0 ^ byte) &* 1099511628211
544 h0 = (h0 ^ byte) &* 1099511628211
528 h1 = (h1 ^ byte) &* 6700417
545 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.
529 case .int(let i):
551 case .int(let i):
530− h0 = (h0 ^ 0x02) &* 1099511628211
552+ hashNumberBits(Double(i).bitPattern, h0: &h0, h1: &h1)
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− }
538 case .double(let d):
553 case .double(let d):
539− h0 = (h0 ^ 0x03) &* 1099511628211
554+ hashNumberBits((d.isNaN ? Double.nan : d).bitPattern, h0: &h0, h1: &h1)
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− }
547 case .string(let s):
555 case .string(let s):
548 h0 = (h0 ^ 0x04) &* 1099511628211
556 h0 = (h0 ^ 0x04) &* 1099511628211
549 h1 = (h1 ^ 0x04) &* 6700417
557 h1 = (h1 ^ 0x04) &* 6700417
ParseableViewer/Views/AlertsView.swift+7−1
@@ -81,8 +81,14 @@ struct AlertsView: View {
81 alertConfig = nil
81 alertConfig = nil
82
82
83 do {
83 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
85 } catch {
90 } catch {
91+ guard stream == appState.selectedStream else { return }
86 errorMessage = ParseableError.userFriendlyMessage(for: error)
92 errorMessage = ParseableError.userFriendlyMessage(for: error)
87 }
93 }
88
94
ParseableViewer/Views/ConnectionSheet.swift+19−4
@@ -127,7 +127,13 @@ struct ConnectionSheet: View {
127 saveAndConnect()
127 saveAndConnect()
128 }
128 }
129 .keyboardShortcut(.defaultAction)
129 .keyboardShortcut(.defaultAction)
130+ .disabled(
131+ name.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty
132+ || url.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty
133+ || username.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty
134+ || isSaving
130− .disabled(name.isEmpty || url.isEmpty || username.isEmpty || isSaving || urlValidationError != nil)
135+ || urlValidationError != nil
136+ )
131 }
137 }
132 .padding()
138 .padding()
133 }
139 }
@@ -152,7 +158,12 @@ struct ConnectionSheet: View {
152
158
153 Task {
159 Task {
154 do {
160 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+ )
156 let client = try ParseableClient(connection: conn)
167 let client = try ParseableClient(connection: conn)
157 try await client.checkHealth()
168 try await client.checkHealth()
158 await MainActor.run {
169 await MainActor.run {
@@ -171,12 +182,16 @@ struct ConnectionSheet: View {
171 private func saveAndConnect() {
182 private func saveAndConnect() {
172 guard !isSaving else { return }
183 guard !isSaving else { return }
173
184
185+ let trimmedName = name.trimmingCharacters(in: .whitespacesAndNewlines)
186+ let trimmedURL = url.trimmingCharacters(in: .whitespacesAndNewlines)
187+ let trimmedUsername = username.trimmingCharacters(in: .whitespacesAndNewlines)
188+
174 let conn: ServerConnection
189 let conn: ServerConnection
175 if let existing = connection ?? createdConnection {
190 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)
177 appState.updateConnection(conn)
192 appState.updateConnection(conn)
178 } else {
193 } else {
179− conn = ServerConnection(name: name, url: url, username: username, password: password)
194+ conn = ServerConnection(name: trimmedName, url: trimmedURL, username: trimmedUsername, password: password)
180 appState.addConnection(conn)
195 appState.addConnection(conn)
181 createdConnection = conn
196 createdConnection = conn
182 }
197 }
ParseableViewer/Views/SQLAutoComplete.swift+14−1
@@ -86,8 +86,21 @@ enum SQLCompletionProvider {
86 }
86 }
87 }
87 }
88
88
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+
89 let prefix = nsText.substring(with: NSRange(location: wordStart, length: cursorPosition - wordStart))
102 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)
91
104
92 guard !prefix.isEmpty else {
105 guard !prefix.isEmpty else {
93 return ([], "", range)
106 return ([], "", range)
ParseableViewer/Views/SQLEditorView.swift+14−2
@@ -192,7 +192,9 @@ struct SQLEditorView: NSViewRepresentable {
192 let cursorPosition = textView.selectedRange().location
192 let cursorPosition = textView.selectedRange().location
193 let nsText = textView.string as NSString
193 let nsText = textView.string as NSString
194
194
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").
196 var wordStart = cursorPosition
198 var wordStart = cursorPosition
197 while wordStart > 0 {
199 while wordStart > 0 {
198 let ch = nsText.character(at: wordStart - 1)
200 let ch = nsText.character(at: wordStart - 1)
@@ -203,7 +205,17 @@ struct SQLEditorView: NSViewRepresentable {
203 }
205 }
204 }
206 }
205
207
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+
206− let replaceRange = NSRange(location: wordStart, length: cursorPosition - wordStart)
218+ let replaceRange = NSRange(location: wordStart, length: wordEnd - wordStart)
207
219
208 isInsertingCompletion = true
220 isInsertingCompletion = true
209 defer { isInsertingCompletion = false }
221 defer { isInsertingCompletion = false }
ParseableViewer/Views/StreamDetailView.swift+19−4
@@ -188,15 +188,30 @@ struct StreamDetailView: View {
188
188
189 var failures: [String] = []
189 var failures: [String] = []
190 var sawNotFound = false
190 var sawNotFound = false
191+ var newSchema: StreamSchema?
192+ var newStats: StreamStats?
193+ var newInfo: StreamInfo?
194+ var newRetention: [RetentionConfig] = []
191
195
192 func checkNotFound(_ error: Error) {
196 func checkNotFound(_ error: Error) {
193 if case .serverError(let code, _) = error as? ParseableError, code == 404 { sawNotFound = true }
197 if case .serverError(let code, _) = error as? ParseableError, code == 404 { sawNotFound = true }
194 }
198 }
195
199
196− do { schema = try await schemaFetch } catch { schema = nil; failures.append("schema"); checkNotFound(error) }
200+ do { newSchema = try await schemaFetch } catch { failures.append("schema"); checkNotFound(error) }
197− do { stats = try await statsFetch } catch { stats = nil; failures.append("stats"); checkNotFound(error) }
201+ do { newStats = try await statsFetch } catch { failures.append("stats"); checkNotFound(error) }
198− do { info = try await infoFetch } catch { info = nil; failures.append("info"); checkNotFound(error) }
202+ do { newInfo = try await infoFetch } catch { failures.append("info"); checkNotFound(error) }
199− do { retention = try await retentionFetch } catch { retention = []; failures.append("retention"); 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
200
215
201 // A deleted stream makes these endpoints 404. Requiring *all four* to
216 // A deleted stream makes these endpoints 404. Requiring *all four* to
202 // fail was too strict (e.g. retention can succeed with an empty result),
217 // fail was too strict (e.g. retention can succeed with an empty result),