git.iko.soy/parseable-macos
on master help sign in
ParseableViewer/App/AppState.swift+158−13
since splitsnapshots
unifiedsplit
@@ -15,6 +15,10 @@ final class AppState {
15 private let networkMonitor = NWPathMonitor()
15 private let networkMonitor = NWPathMonitor()
16 private let networkMonitorQueue = DispatchQueue(label: "com.parseableviewer.network-monitor")
16 private let networkMonitorQueue = DispatchQueue(label: "com.parseableviewer.network-monitor")
17 private var connectTask: Task<Bool, Never>?
17 private var connectTask: Task<Bool, Never>?
18+ /// The connection `connectTask` is connecting to. `activeConnection` only
19+ /// switches once the new server answers, so this is what identifies an
20+ /// in-flight connect whose target the user deletes (see `removeConnection`).
21+ private var connectingConnectionID: UUID?
18
22
19 // MARK: - Stream State
23 // MARK: - Stream State
20 var streams: [LogStream] = []
24 var streams: [LogStream] = []
@@ -96,6 +100,7 @@ final class AppState {
96
100
97 if let activeID = ConnectionStore.loadActiveConnectionID(),
101 if let activeID = ConnectionStore.loadActiveConnectionID(),
98 let connection = connections.first(where: { $0.id == activeID }) {
102 let connection = connections.first(where: { $0.id == activeID }) {
103+ connectingConnectionID = connection.id
99 connectTask = Task { @MainActor [weak self] in
104 connectTask = Task { @MainActor [weak self] in
100 await self?.performConnect(to: connection) ?? false
105 await self?.performConnect(to: connection) ?? false
101 }
106 }
@@ -115,6 +120,7 @@ final class AppState {
115 existing.cancel()
120 existing.cancel()
116 _ = await existing.value
121 _ = await existing.value
117 }
122 }
123+ connectingConnectionID = connection.id
118 let task = Task { @MainActor [weak self] in
124 let task = Task { @MainActor [weak self] in
119 await self?.performConnect(to: connection) ?? false
125 await self?.performConnect(to: connection) ?? false
120 }
126 }
@@ -124,9 +130,16 @@ final class AppState {
124
130
125 @MainActor
131 @MainActor
126 private func performConnect(to connection: ServerConnection) async -> Bool {
132 private func performConnect(to connection: ServerConnection) async -> Bool {
133+ defer {
134+ if connectingConnectionID == connection.id { connectingConnectionID = nil }
135+ }
127 guard !isConnecting else { return false }
136 guard !isConnecting else { return false }
128
137
138+ // NWPathMonitor ignores loopback, so with no active network interface it
139+ // reports "unsatisfied" even though a local server (the common docker /
140+ // dev setup) is reachable. Don't block those; a genuinely unreachable
141+ // host still fails below with the same offline message.
129− if !isNetworkAvailable {
142+ if !isNetworkAvailable && !Self.isLoopback(connection.baseURL) {
130 self.errorMessage = "No internet connection. Check your network and try again."
143 self.errorMessage = "No internet connection. Check your network and try again."
131 self.showError = true
144 self.showError = true
132 return false
145 return false
@@ -134,13 +147,29 @@ final class AppState {
134
147
135 isConnecting = true
148 isConnecting = true
136 defer { isConnecting = false }
149 defer { isConnecting = false }
150+ // Reset the flag with the message: a previous failed connect left
151+ // `showError` set, and if its alert couldn't present (e.g. behind the
152+ // connection sheet), it would pop up later — after a successful retry —
153+ // reading "An unknown error occurred".
137 errorMessage = nil
154 errorMessage = nil
155+ showError = false
138
156
139 // When switching to a different server, clear stream-specific state
157 // When switching to a different server, clear stream-specific state
140 // so stale selections don't reference streams that don't exist.
158 // so stale selections don't reference streams that don't exist.
141 if activeConnection?.id != connection.id {
159 if activeConnection?.id != connection.id {
142 selectedStream = nil
160 selectedStream = nil
143 streamLoadError = nil
161 streamLoadError = nil
162+ // Drop the previous server's stream list and filters too — otherwise the
163+ // sidebar keeps rendering server A's streams/filters (with selection
164+ // already nilled) throughout the reconnect window. Same rationale as
165+ // `serverAbout` below; matches the failure path and `disconnect()`. Both
166+ // are repopulated once the new server's listStreams/listFilters respond.
167+ streams = []
168+ filters = []
169+ // Drop the previous server's cached `about` so views seeding from it
170+ // (ServerInfoView) don't paint the old server's details while the new
171+ // one loads. It's repopulated below once the new server responds.
172+ serverAbout = nil
144 // Reset the sidebar search filter and tab too: a stale filter would
173 // Reset the sidebar search filter and tab too: a stale filter would
145 // silently hide the new server's streams, and a stream-specific tab
174 // silently hide the new server's streams, and a stream-specific tab
146 // would have no valid selection behind it.
175 // would have no valid selection behind it.
@@ -153,6 +182,7 @@ final class AppState {
153
182
154 // Test connection — throws on non-200 or network failure
183 // Test connection — throws on non-200 or network failure
155 try await newClient.checkHealth()
184 try await newClient.checkHealth()
185+ try await Self.verifyCredentials(with: newClient)
156
186
157 // If the task was cancelled while awaiting (e.g. user clicked
187 // If the task was cancelled while awaiting (e.g. user clicked
158 // Disconnect during auto-reconnect), bail before installing the
188 // Disconnect during auto-reconnect), bail before installing the
@@ -189,6 +219,12 @@ final class AppState {
189 case .success(let list):
219 case .success(let list):
190 self.streams = list
220 self.streams = list
191 self.streamLoadError = nil
221 self.streamLoadError = nil
222+ // Like refreshStreams: a selection that doesn't exist on this server
223+ // (e.g. one picked from a stale list while the connect was pending)
224+ // would keep every stream tab querying a missing stream.
225+ if let selected = selectedStream, !list.contains(where: { $0.name == selected }) {
226+ selectedStream = nil
227+ }
192 case .failure(let error):
228 case .failure(let error):
193 self.streams = []
229 self.streams = []
194 self.streamLoadError = ParseableError.userFriendlyMessage(for: error)
230 self.streamLoadError = ParseableError.userFriendlyMessage(for: error)
@@ -227,16 +263,41 @@ final class AppState {
227 selectedStream = nil
263 selectedStream = nil
228 serverAbout = nil
264 serverAbout = nil
229 filters = []
265 filters = []
266+ // Clear transient stream/UI state too, otherwise a leftover stream-load
267+ // error keeps rendering in the sidebar after disconnect and a stale search
268+ // filter / tab carries into the next connection.
269+ streamLoadError = nil
270+ isLoadingStreams = false
271+ streamSearchText = ""
272+ currentTab = .query
230 ConnectionStore.saveActiveConnectionID(nil)
273 ConnectionStore.saveActiveConnectionID(nil)
231 }
274 }
232
275
233 @MainActor
276 @MainActor
234 func refreshStreams() async {
277 func refreshStreams() async {
235 guard let client else { return }
278 guard let client else { return }
279+ // Capture the connection this fetch belongs to. A disconnect during the
280+ // await would otherwise let the (now-stale) result overwrite the cleanup
281+ // disconnect() just performed (streams = [], client = nil, etc.), leaving
282+ // the disconnected UI showing the old server's streams and re-enabling
283+ // stream-scoped tabs. Mirrors the connID guard used in performConnect,
284+ // AlertsView.loadAlerts, UsersView.loadUsers, StreamDetailView.loadData,
285+ // and ServerInfoView.loadInfo.
286+ let connID = activeConnection?.id
236 isLoadingStreams = true
287 isLoadingStreams = true
237 streamLoadError = nil
288 streamLoadError = nil
238 do {
289 do {
239− streams = try await client.listStreams()
290+ let loaded = try await client.listStreams()
291+ // Drop results if the active connection changed while awaiting. Still
292+ // clear isLoadingStreams: this task set the spinner, so leaving it true
293+ // wedges the indicator and disables Refresh. Compare the client too:
294+ // editing the active connection's URL/credentials keeps its id but
295+ // installs a new client for a possibly different server.
296+ guard connID == activeConnection?.id, client === self.client else {
297+ isLoadingStreams = false
298+ return
299+ }
300+ streams = loaded
240 // Drop a selection that no longer exists server-side (e.g. the stream
301 // Drop a selection that no longer exists server-side (e.g. the stream
241 // was deleted from another client); otherwise the detail tabs keep
302 // was deleted from another client); otherwise the detail tabs keep
242 // querying a missing stream and surface 404s.
303 // querying a missing stream and surface 404s.
@@ -244,6 +305,10 @@ final class AppState {
244 selectedStream = nil
305 selectedStream = nil
245 }
306 }
246 } catch {
307 } catch {
308+ guard connID == activeConnection?.id, client === self.client else {
309+ isLoadingStreams = false
310+ return
311+ }
247 let message = ParseableError.userFriendlyMessage(for: error)
312 let message = ParseableError.userFriendlyMessage(for: error)
248 streamLoadError = message
313 streamLoadError = message
249 self.errorMessage = "Failed to load streams: \(message)"
314 self.errorMessage = "Failed to load streams: \(message)"
@@ -262,18 +327,44 @@ final class AppState {
262
327
263 @MainActor
328 @MainActor
264 func updateConnection(_ connection: ServerConnection) {
329 func updateConnection(_ connection: ServerConnection) {
330+ let previous = connections.first(where: { $0.id == connection.id })
265 if let index = connections.firstIndex(where: { $0.id == connection.id }) {
331 if let index = connections.firstIndex(where: { $0.id == connection.id }) {
266 connections[index] = connection
332 connections[index] = connection
267 ConnectionStore.saveConnections(connections)
333 ConnectionStore.saveConnections(connections)
268 }
334 }
269− // If the active connection was edited, refresh the displayed metadata and
335+ // If the active connection was edited, its URL or credentials may now point
270− // rebuild the live client so subsequent requests use the new URL/credentials
336+ // at a different server, so swapping the client alone isn't enough — the
271− // instead of the stale ones captured when the client was first created.
337+ // displayed streams/selection/server-info would be stale and queries would
272− if activeConnection?.id == connection.id {
338+ // 404. The caller is responsible for the actual reconnect (re-validate
273− activeConnection = connection
339+ // health, reload streams/filters/about); we only clear the now-possibly-
274− if let newClient = try? ParseableClient(connection: connection) {
340+ // stale UI state here, since `performConnect` skips its own cleanup when the
275− client = newClient
341+ // connection id is unchanged. We must NOT also spawn a connect here: the
276− }
342+ // sole caller (ConnectionSheet.saveAndConnect) already connects right after,
343+ // and a second connect would run two full reconnects and flash the UI twice.
344+ //
345+ // Only the URL/username/password identify the *server*; a cosmetic edit (e.g.
346+ // renaming the connection) points at the same server, so wiping the stream
347+ // selection/tab/streams would needlessly kick the user out of their current
348+ // view. Restrict the reset to edits that actually change the server target.
349+ let serverChanged = previous.map {
350+ $0.url != connection.url
351+ || $0.username != connection.username
352+ || $0.password != connection.password
353+ } ?? true
354+ if serverChanged, activeConnection?.id == connection.id {
355+ // Clear the stream-specific UI state so a leftover sidebar search filter
356+ // doesn't hide the new server's streams and a stream-specific tab
357+ // doesn't linger with no valid selection.
358+ selectedStream = nil
359+ streamSearchText = ""
360+ currentTab = .query
361+ // Drop the previous server's cached `about`, stream list, and filters —
362+ // performConnect skips its own clear (id unchanged), so without this the
363+ // sidebar keeps rendering server A's streams/filters and ServerInfoView
364+ // paints the old version/storage until the new server's responses land.
365+ serverAbout = nil
366+ streams = []
367+ filters = []
277 }
368 }
278 }
369 }
279
370
@@ -284,9 +375,49 @@ final class AppState {
284 ConnectionStore.saveConnections(connections)
375 ConnectionStore.saveConnections(connections)
285 if activeConnection?.id == connection.id {
376 if activeConnection?.id == connection.id {
286 disconnect()
377 disconnect()
378+ } else if connectingConnectionID == connection.id {
379+ // The removed connection is still being connected to (e.g. Cancel in the
380+ // connection sheet, or deleting it in Settings during the launch
381+ // reconnect). `activeConnection` doesn't point at it yet, so without
382+ // this the connect would carry on and install a client for a
383+ // connection that no longer exists (or tear down the current session
384+ // when it fails) after the user abandoned it.
385+ connectTask?.cancel()
386+ connectTask = nil
387+ connectingConnectionID = nil
388+ // Starting that connect already cleared the current server's stream
389+ // list and filters; reload them if we're still connected to it.
390+ if client != nil {
391+ Task { @MainActor [weak self] in
392+ await self?.refreshStreams()
393+ await self?.refreshFilters()
394+ }
395+ }
396+ }
397+ }
398+
399+ /// `/api/v1/liveness` is unauthenticated on Parseable (it answers 200 to
400+ /// anyone, for load-balancer / k8s probes), so a health check alone accepts
401+ /// any username and password. Probe an authenticated endpoint and fail on
402+ /// 401. Other failures — e.g. a 403 for a role without the about permission —
403+ /// don't show the credentials are wrong, so they're ignored here. The client
404+ /// caches the response, so the follow-up `getAbout()` load reuses it.
405+ static func verifyCredentials(with client: ParseableClient) async throws {
406+ do {
407+ _ = try await client.getAbout()
408+ } catch ParseableError.unauthorized {
409+ throw ParseableError.unauthorized
410+ } catch {
411+ // Not an authentication failure; leave it to the best-effort loads.
287 }
412 }
288 }
413 }
289
414
415+ private static func isLoopback(_ url: URL?) -> Bool {
416+ guard let host = url?.host?.lowercased() else { return false }
417+ return host == "localhost" || host.hasSuffix(".localhost")
418+ || host == "::1" || host.hasPrefix("127.")
419+ }
420+
290 // MARK: - Stream Management
421 // MARK: - Stream Management
291
422
292 @MainActor
423 @MainActor
@@ -312,8 +443,18 @@ final class AppState {
312 @MainActor
443 @MainActor
313 func refreshFilters() async {
444 func refreshFilters() async {
314 guard let client else { return }
445 guard let client else { return }
446+ // Capture the connection this fetch belongs to. A disconnect during the
447+ // await would otherwise let the (now-stale) result overwrite the cleanup
448+ // disconnect() performed, resurrecting the old server's filters in the
449+ // disconnected sidebar. Same guard pattern as refreshStreams.
450+ let connID = activeConnection?.id
315 isLoadingFilters = true
451 isLoadingFilters = true
316− filters = (try? await client.listFilters()) ?? []
452+ let loaded = (try? await client.listFilters()) ?? []
453+ guard connID == activeConnection?.id, client === self.client else {
454+ isLoadingFilters = false
455+ return
456+ }
457+ filters = loaded
317 isLoadingFilters = false
458 isLoadingFilters = false
318 }
459 }
319
460
@@ -332,8 +473,12 @@ final class AppState {
332 @MainActor
473 @MainActor
333 func deleteFilter(_ filter: ParseableFilter) async throws {
474 func deleteFilter(_ filter: ParseableFilter) async throws {
334 guard let client else { throw ParseableError.notConnected }
475 guard let client else { throw ParseableError.notConnected }
476+ // A filter without a server-assigned id was never persisted remotely, so
477+ // there's nothing to delete on the server — just drop it locally. Returning
478+ // early here would silently leave the row in place with no error.
335− guard let filterId = filter.filterId else { return }
479+ if let filterId = filter.filterId {
336− try await client.deleteFilter(id: filterId)
480+ try await client.deleteFilter(id: filterId)
481+ }
337 filters.removeAll { $0.id == filter.id }
482 filters.removeAll { $0.id == filter.id }
338 }
483 }
339
484
ParseableViewer/App/ParseableViewerApp.swift+13−1
@@ -4,6 +4,13 @@ import SwiftUI
4struct ParseableViewerApp: App {
4struct ParseableViewerApp: App {
5 @State private var appState = AppState()
5 @State private var appState = AppState()
6
6
7+ init() {
8+ // All window content shares one AppState (see the File > New group below),
9+ // so don't offer window tabs either: the tab bar's "+" would open a second
10+ // window just like File > New Window.
11+ NSWindow.allowsAutomaticWindowTabbing = false
12+ }
13+
7 var body: some Scene {
14 var body: some Scene {
8 WindowGroup {
15 WindowGroup {
9 ContentView()
16 ContentView()
@@ -17,7 +24,12 @@ struct ParseableViewerApp: App {
17 AboutCommand()
24 AboutCommand()
18 }
25 }
19
26
27+ // Replace (rather than extend) the File > New group to drop "New Window":
28+ // connection, sheet, alert, selection and pending-filter state all live
29+ // in the single shared AppState, so a second window would present every
30+ // sheet and alert twice and race the first window's QueryView for
31+ // saved-filter SQL.
20− CommandGroup(after: .newItem) {
32+ CommandGroup(replacing: .newItem) {
21 Button("New Connection...") {
33 Button("New Connection...") {
22 appState.editingConnection = nil
34 appState.editingConnection = nil
23 appState.showConnectionSheet = true
35 appState.showConnectionSheet = true
ParseableViewer/Info.plist+5
@@ -24,6 +24,11 @@
24 <string>public.app-category.developer-tools</string>
24 <string>public.app-category.developer-tools</string>
25 <key>LSMinimumSystemVersion</key>
25 <key>LSMinimumSystemVersion</key>
26 <string>$(MACOSX_DEPLOYMENT_TARGET)</string>
26 <string>$(MACOSX_DEPLOYMENT_TARGET)</string>
27+ <key>NSAppTransportSecurity</key>
28+ <dict>
29+ <key>NSAllowsArbitraryLoads</key>
30+ <true/>
31+ </dict>
27 <key>NSMainNibFile</key>
32 <key>NSMainNibFile</key>
28 <string></string>
33 <string></string>
29 <key>NSPrincipalClass</key>
34 <key>NSPrincipalClass</key>
ParseableViewer/Models/AlertModels.swift+21
@@ -85,6 +85,27 @@ struct AlertRule: Identifiable, Codable, Sendable {
85struct AlertRuleSpec: Codable, Sendable {
85struct AlertRuleSpec: Codable, Sendable {
86 let type: String?
86 let type: String?
87 let config: String?
87 let config: String?
88+
89+ private enum CodingKeys: String, CodingKey {
90+ case type, config
91+ }
92+
93+ init(from decoder: Decoder) throws {
94+ let container = try decoder.container(keyedBy: CodingKeys.self)
95+ self.type = try? container.decode(String.self, forKey: .type)
96+ // The legacy per-stream rule is serialized as `{"type": …, "config": …}`
97+ // where only composite rules carry a string config; column rules carry an
98+ // object (`{"column": …, "operator": …, "value": …}`). Decoding that as a
99+ // String threw, which made the whole spec — and its type badge — vanish.
100+ // Keep non-string configs as compact JSON instead.
101+ if let string = try? container.decode(String.self, forKey: .config) {
102+ self.config = string
103+ } else if let value = try? container.decode(JSONValue.self, forKey: .config), value != .null {
104+ self.config = value.compactJSON
105+ } else {
106+ self.config = nil
107+ }
108+ }
88}
109}
89
110
90struct AlertTarget: Codable, Sendable {
111struct AlertTarget: Codable, Sendable {
ParseableViewer/Models/JSONValue.swift+79−8
@@ -37,7 +37,14 @@ enum JSONValue: Codable, Hashable, Sendable, Comparable {
37 case .null: try container.encodeNil()
37 case .null: try container.encodeNil()
38 case .bool(let v): try container.encode(v)
38 case .bool(let v): try container.encode(v)
39 case .int(let v): try container.encode(v)
39 case .int(let v): try container.encode(v)
40− case .double(let v): try container.encode(v)
40+ case .double(let v):
41+ // JSON has no representation for infinity/NaN, and JSONEncoder's
42+ // default `.throw` strategy would abort the *entire* encode — turning
43+ // a single non-finite value into a total export failure (`exportAsJSON`
44+ // collapses to "[]"). Encode it as null to match displayString,
45+ // compactJSON/jsonNumber, and the CSV export, which all render
46+ // non-finite doubles as "null".
47+ if v.isFinite { try container.encode(v) } else { try container.encodeNil() }
41 case .string(let v): try container.encode(v)
48 case .string(let v): try container.encode(v)
42 case .array(let v): try container.encode(v)
49 case .array(let v): try container.encode(v)
43 case .object(let v): try container.encode(v)
50 case .object(let v): try container.encode(v)
@@ -50,7 +57,14 @@ enum JSONValue: Codable, Hashable, Sendable, Comparable {
50 case .bool(let v): return v ? "true" : "false"
57 case .bool(let v): return v ? "true" : "false"
51 case .int(let v): return String(v)
58 case .int(let v): return String(v)
52 case .double(let v):
59 case .double(let v):
60+ // Non-finite doubles have no display (and no JSON) representation;
61+ // render them as "null" to match jsonNumber/exportString rather than
62+ // letting String(_:) emit "inf"/"-inf"/"nan" into the UI and CSV cells.
63+ if !v.isFinite { return "null" }
53 if v == v.rounded() && abs(v) < 1e15 {
64 if v == v.rounded() && abs(v) < 1e15 {
65+ // Normalize negative zero so it renders as "0", not "-0"
66+ // (`%.0f` preserves the sign bit of -0.0).
67+ if v == 0 { return "0" }
54 return String(format: "%.0f", v)
68 return String(format: "%.0f", v)
55 }
69 }
56 return String(v)
70 return String(v)
@@ -211,17 +225,51 @@ enum JSONValue: Codable, Hashable, Sendable, Comparable {
211 case (.int(let a), .double(let b)): return intLessThanDouble(a, b)
225 case (.int(let a), .double(let b)): return intLessThanDouble(a, b)
212 case (.double(let a), .int(let b)): return doubleLessThanInt(a, b)
226 case (.double(let a), .int(let b)): return doubleLessThanInt(a, b)
213 case (.string(let a), .string(let b)):
227 case (.string(let a), .string(let b)):
228+ // Two distinct strings can fold to `.orderedSame` under
229+ // `localizedStandardCompare` (e.g. full-width vs half-width forms that
230+ // Swift's `==` still treats as different). Break that tie with the raw
231+ // `<` so order-equivalent values stay `==`, as `Comparable` requires —
232+ // the same fallback `LogStream.<` already applies.
214− return a.localizedStandardCompare(b) == .orderedAscending
233+ let order = a.localizedStandardCompare(b)
234+ return order == .orderedAscending || (order == .orderedSame && a < b)
215− case (.array, .array), (.object, .object):
235+ case (.array(let a), .array(let b)):
216− // Same rank, structured: equal values must not be ordered; otherwise
236+ return arrayLessThan(a, b)
217− // use a deterministic serialization tiebreaker.
237+ case (.object(let a), .object(let b)):
218− if lhs == rhs { return false }
238+ return objectLessThan(a, b)
219− return lhs.compactJSON < rhs.compactJSON
220 default:
239 default:
221 return false
240 return false
222 }
241 }
223 }
242 }
224
243
244+ /// Lexicographic order over array elements using the element-level `<`/`==`.
245+ /// A serialized-string tiebreaker (`compactJSON < compactJSON`) was wrong:
246+ /// `.int(1) == .double(1.0)` are equal yet serialize as `1` vs `1.0`, so two
247+ /// non-equal values could share a serialization (or two equal ones differ),
248+ /// breaking the strict-weak-ordering `Comparable` requires. Comparing
249+ /// elements directly stays consistent with `==` (arrays are equal iff same
250+ /// length and equal elements).
251+ private static func arrayLessThan(_ a: [JSONValue], _ b: [JSONValue]) -> Bool {
252+ for (x, y) in zip(a, b) where x != y {
253+ return x < y
254+ }
255+ return a.count < b.count
256+ }
257+
258+ /// Deterministic order over objects: compare key/value pairs in sorted-key
259+ /// order. Independent of dictionary iteration order and consistent with `==`
260+ /// (objects are equal iff the same keys map to equal values).
261+ private static func objectLessThan(_ a: [String: JSONValue], _ b: [String: JSONValue]) -> Bool {
262+ let aKeys = a.keys.sorted()
263+ let bKeys = b.keys.sorted()
264+ for (ak, bk) in zip(aKeys, bKeys) {
265+ if ak != bk { return ak < bk }
266+ // Force-unwrap is safe: ak/bk come from each dictionary's own keys.
267+ let av = a[ak]!, bv = b[bk]!
268+ if av != bv { return av < bv }
269+ }
270+ return aKeys.count < bKeys.count
271+ }
272+
225 /// Total ordering for doubles that keeps `sort()` from trapping on NaN
273 /// Total ordering for doubles that keeps `sort()` from trapping on NaN
226 /// (IEEE comparisons with NaN are always false, which violates the strict
274 /// (IEEE comparisons with NaN are always false, which violates the strict
227 /// weak ordering `Comparable` requires). NaN is ordered after all numbers.
275 /// weak ordering `Comparable` requires). NaN is ordered after all numbers.
@@ -270,7 +318,16 @@ enum JSONValue: Codable, Hashable, Sendable, Comparable {
270 /// non-finite values serialize as `null` rather than the invalid `nan`/`inf`
318 /// non-finite values serialize as `null` rather than the invalid `nan`/`inf`
271 /// that `String(_:)` would emit.
319 /// that `String(_:)` would emit.
272 static func jsonNumber(_ v: Double) -> String {
320 static func jsonNumber(_ v: Double) -> String {
321+ // Match `displayString`'s whole-number normalization so a value like
322+ // 100.0 serializes identically whether it's a top-level cell (which goes
323+ // through displayString) or nested in an array/object (which goes through
324+ // compactJSON). Emitting "100" instead of "100.0" is still valid JSON.
273− v.isFinite ? String(v) : "null"
325+ guard v.isFinite else { return "null" }
326+ if v == v.rounded() && abs(v) < 1e15 {
327+ if v == 0 { return "0" } // normalize -0.0 → "0"
328+ return String(format: "%.0f", v)
329+ }
330+ return String(v)
274 }
331 }
275
332
276 // MARK: - Equatable / Hashable
333 // MARK: - Equatable / Hashable
@@ -322,4 +379,18 @@ typealias LogRecord = [String: JSONValue]
322struct QueryResponse: Codable, Sendable {
379struct QueryResponse: Codable, Sendable {
323 let records: [LogRecord]
380 let records: [LogRecord]
324 let fields: [String]?
381 let fields: [String]?
382+
383+ enum CodingKeys: String, CodingKey {
384+ case records, fields
385+ }
386+
387+ init(from decoder: Decoder) throws {
388+ let container = try decoder.container(keyedBy: CodingKeys.self)
389+ // A wrapped response can legitimately omit `records` for a zero-row result
390+ // (e.g. `{"fields": [...]}`). Default to an empty array rather than letting
391+ // the synthesized decoder throw, which would surface as a spurious
392+ // "Unexpected query response format" error instead of an empty result set.
393+ records = try container.decodeIfPresent([LogRecord].self, forKey: .records) ?? []
394+ fields = try container.decodeIfPresent([String].self, forKey: .fields)
395+ }
325}
396}
ParseableViewer/Models/LogStream.swift+137−16
@@ -80,11 +80,111 @@ struct SchemaField: Identifiable, Codable, Hashable, Sendable {
80 if let typeStr = try? container.decode(String.self, forKey: .dataType) {
80 if let typeStr = try? container.decode(String.self, forKey: .dataType) {
81 self.dataType = typeStr
81 self.dataType = typeStr
82 } else if let typeObj = try? container.decode(JSONValue.self, forKey: .dataType) {
82 } else if let typeObj = try? container.decode(JSONValue.self, forKey: .dataType) {
83− self.dataType = typeObj.displayString
83+ self.dataType = Self.describeArrowType(typeObj)
84 } else {
84 } else {
85 self.dataType = "Unknown"
85 self.dataType = "Unknown"
86 }
86 }
87 }
87 }
88+
89+ /// Renders a serde-serialized Arrow `DataType`. Parameterized types are
90+ /// externally tagged objects — `p_timestamp`, present in every Parseable
91+ /// stream, arrives as `{"Timestamp": ["Millisecond", null]}` and a list as
92+ /// `{"List": {<field>}}` — which `displayString` collapsed to the placeholder
93+ /// "{1 fields}". Produce Arrow's own notation instead, e.g.
94+ /// `Timestamp(Millisecond, None)` / `List(Utf8)`.
95+ static func describeArrowType(_ value: JSONValue) -> String {
96+ switch value {
97+ case .null:
98+ return "None"
99+ case .string(let s):
100+ return s
101+ case .array(let items):
102+ return items.map { describeArrowType($0) }.joined(separator: ", ")
103+ case .object(let dict):
104+ // A nested `Field` (list/map element, struct member): show its type.
105+ if let fieldType = dict["data_type"] {
106+ return describeArrowType(fieldType)
107+ }
108+ if dict.count == 1, let entry = dict.first {
109+ return "\(entry.key)(\(describeArrowType(entry.value)))"
110+ }
111+ return value.compactJSON
112+ default:
113+ return value.displayString
114+ }
115+ }
116+}
117+
118+/// Formats a byte count that may arrive from the server as a fractional or
119+/// out-of-range Double. `Int64(_:)` from a `Double` is a *trapping* conversion —
120+/// it crashes on NaN, infinity, or values outside `Int64`'s range — so a valid
121+/// but huge JSON number like `1e30` would crash the whole decode. Guard first.
122+private func formatByteSize(_ value: Double) -> String? {
123+ guard value.isFinite, value >= 0, value < 9.223372036854776e18 else { return nil }
124+ return ByteCountFormatter.string(fromByteCount: Int64(value), countStyle: .file)
125+}
126+
127+private func formatByteSize(_ value: Int) -> String? {
128+ // A negative byte count isn't meaningful; `ByteCountFormatter` would render it
129+ // as a bogus "-1 KB"/"Zero KB" string, so drop it like the `Double` path does.
130+ guard value >= 0 else { return nil }
131+ return ByteCountFormatter.string(fromByteCount: Int64(value), countStyle: .file)
132+}
133+
134+/// Normalizes a byte count that arrived as a JSON *string*. A bare numeric string
135+/// (e.g. "1048576") is a raw byte count and is formatted like the numeric branches
136+/// so identical sizes render consistently ("1 MB") regardless of whether the server
137+/// quoted the value. Parseable 1.x — the versions that quote sizes — sends them as
138+/// "<n> Bytes" (e.g. "1048576 Bytes"), so that unit suffix is accepted too. Any
139+/// other already-formatted string (e.g. "1.2 GB") is passed through unchanged.
140+private func formatByteSize(_ value: String) -> String {
141+ var trimmed = value.trimmingCharacters(in: .whitespacesAndNewlines)
142+ if trimmed.lowercased().hasSuffix(" bytes") {
143+ trimmed = String(trimmed.dropLast(" bytes".count))
144+ }
145+ if let n = Int(trimmed) { return formatByteSize(n) ?? value }
146+ return value
147+}
148+
149+/// A string-backed coding key for fields whose name differs across Parseable
150+/// versions (kebab-case, snake_case, and camelCase spellings of the same field).
151+private struct FlexibleCodingKey: CodingKey {
152+ let stringValue: String
153+ var intValue: Int? { nil }
154+ init(stringValue: String) { self.stringValue = stringValue }
155+ init?(intValue: Int) { nil }
156+}
157+
158+private extension KeyedDecodingContainer where Key == FlexibleCodingKey {
159+ /// The first of `keys` that decodes as a string.
160+ func firstString(_ keys: String...) -> String? {
161+ for key in keys {
162+ if let value = try? decode(String.self, forKey: FlexibleCodingKey(stringValue: key)) {
163+ return value
164+ }
165+ }
166+ return nil
167+ }
168+
169+ /// The first of `keys` that decodes as a boolean — either a JSON bool or the
170+ /// strings "true"/"false" (Parseable serializes `static_schema_flag` as the
171+ /// string "true").
172+ func firstBool(_ keys: String...) -> Bool? {
173+ for key in keys {
174+ let codingKey = FlexibleCodingKey(stringValue: key)
175+ if let value = try? decode(Bool.self, forKey: codingKey) {
176+ return value
177+ }
178+ if let string = try? decode(String.self, forKey: codingKey) {
179+ switch string.lowercased() {
180+ case "true": return true
181+ case "false": return false
182+ default: continue
183+ }
184+ }
185+ }
186+ return nil
187+ }
88}
188}
89
189
90struct StreamStats: Codable, Sendable {
190struct StreamStats: Codable, Sendable {
@@ -107,16 +207,20 @@ struct StreamStats: Codable, Sendable {
107 self.lifetime_count = try? container.decode(Int.self, forKey: .lifetime_count)
207 self.lifetime_count = try? container.decode(Int.self, forKey: .lifetime_count)
108 // Handle size as string or number
208 // Handle size as string or number
109 if let s = try? container.decode(String.self, forKey: .size) {
209 if let s = try? container.decode(String.self, forKey: .size) {
110− self.size = s
210+ self.size = formatByteSize(s)
111 } else if let n = try? container.decode(Int.self, forKey: .size) {
211 } else if let n = try? container.decode(Int.self, forKey: .size) {
112− self.size = ByteCountFormatter.string(fromByteCount: Int64(n), countStyle: .file)
212+ self.size = formatByteSize(n)
213+ } else if let d = try? container.decode(Double.self, forKey: .size) {
214+ self.size = formatByteSize(d)
113 } else {
215 } else {
114 self.size = nil
216 self.size = nil
115 }
217 }
116 if let s = try? container.decode(String.self, forKey: .lifetime_size) {
218 if let s = try? container.decode(String.self, forKey: .lifetime_size) {
117− self.lifetime_size = s
219+ self.lifetime_size = formatByteSize(s)
118 } else if let n = try? container.decode(Int.self, forKey: .lifetime_size) {
220 } else if let n = try? container.decode(Int.self, forKey: .lifetime_size) {
119− self.lifetime_size = ByteCountFormatter.string(fromByteCount: Int64(n), countStyle: .file)
221+ self.lifetime_size = formatByteSize(n)
222+ } else if let d = try? container.decode(Double.self, forKey: .lifetime_size) {
223+ self.lifetime_size = formatByteSize(d)
120 } else {
224 } else {
121 self.lifetime_size = nil
225 self.lifetime_size = nil
122 }
226 }
@@ -134,18 +238,26 @@ struct StreamStats: Codable, Sendable {
134
238
135 init(from decoder: Decoder) throws {
239 init(from decoder: Decoder) throws {
136 let container = try decoder.container(keyedBy: CodingKeys.self)
240 let container = try decoder.container(keyedBy: CodingKeys.self)
137− self.type = try? container.decode(String.self, forKey: .type)
241+ // Parseable names this field `format` (e.g. "parquet") in every
242+ // version; `type` never appears, so "Storage Type" always read N/A.
243+ // Keep `type` as a fallback.
244+ self.type = (try? decoder.container(keyedBy: FlexibleCodingKey.self))?
245+ .firstString("format", "type")
138 if let s = try? container.decode(String.self, forKey: .size) {
246 if let s = try? container.decode(String.self, forKey: .size) {
139− self.size = s
247+ self.size = formatByteSize(s)
140 } else if let n = try? container.decode(Int.self, forKey: .size) {
248 } else if let n = try? container.decode(Int.self, forKey: .size) {
141− self.size = ByteCountFormatter.string(fromByteCount: Int64(n), countStyle: .file)
249+ self.size = formatByteSize(n)
250+ } else if let d = try? container.decode(Double.self, forKey: .size) {
251+ self.size = formatByteSize(d)
142 } else {
252 } else {
143 self.size = nil
253 self.size = nil
144 }
254 }
145 if let s = try? container.decode(String.self, forKey: .lifetime_size) {
255 if let s = try? container.decode(String.self, forKey: .lifetime_size) {
146− self.lifetime_size = s
256+ self.lifetime_size = formatByteSize(s)
147 } else if let n = try? container.decode(Int.self, forKey: .lifetime_size) {
257 } else if let n = try? container.decode(Int.self, forKey: .lifetime_size) {
148− self.lifetime_size = ByteCountFormatter.string(fromByteCount: Int64(n), countStyle: .file)
258+ self.lifetime_size = formatByteSize(n)
259+ } else if let d = try? container.decode(Double.self, forKey: .lifetime_size) {
260+ self.lifetime_size = formatByteSize(d)
149 } else {
261 } else {
150 self.lifetime_size = nil
262 self.lifetime_size = nil
151 }
263 }
@@ -173,11 +285,20 @@ struct StreamInfo: Codable, Sendable {
173 }
285 }
174
286
175 init(from decoder: Decoder) throws {
287 init(from decoder: Decoder) throws {
288+ // The /info key spellings vary by Parseable version: 1.x–2.x send
289+ // `created-at`/`first-event-at` with the other fields in snake_case
290+ // (`cache_enabled`, `time_partition`, `static_schema_flag`), while newer
291+ // servers send camelCase throughout (`createdAt`, `timePartition`, …).
292+ // The kebab-case `cache-enabled`/`time-partition`/`static-schema-flag`
293+ // decoded previously were never sent by any version, so those rows always
294+ // showed "N/A". The schema flag also arrives as the string "true".
176− let container = try decoder.container(keyedBy: CodingKeys.self)
295+ let container = try decoder.container(keyedBy: FlexibleCodingKey.self)
177− self.createdAt = try? container.decode(String.self, forKey: .createdAt)
296+ self.createdAt = container.firstString("created-at", "createdAt", "created_at")
178− self.firstEventAt = try? container.decode(String.self, forKey: .firstEventAt)
297+ self.firstEventAt = container.firstString("first-event-at", "firstEventAt", "first_event_at")
179− self.cacheEnabled = try? container.decode(Bool.self, forKey: .cacheEnabled)
298+ self.cacheEnabled = container.firstBool("cache_enabled", "cacheEnabled", "cache-enabled")
180− self.timePartition = try? container.decode(String.self, forKey: .timePartition)
299+ self.timePartition = container.firstString("time_partition", "timePartition", "time-partition")
181− self.staticSchemaFlag = try? container.decode(Bool.self, forKey: .staticSchemaFlag)
300+ self.staticSchemaFlag = container.firstBool(
301+ "static_schema_flag", "staticSchemaFlag", "static-schema-flag"
302+ )
182 }
303 }
183}
304}
ParseableViewer/Models/SQLTokenizer.swift+46−9
@@ -80,13 +80,20 @@ struct SQLTokenizer: Sendable {
80
80
81 switch c {
81 switch c {
82 // Whitespace
82 // Whitespace
83− case " ", "\t", "\n", "\r":
83+ case _ where isWhitespace(c):
84− advance(&i, in: sql, while: { $0 == " " || $0 == "\t" || $0 == "\n" || $0 == "\r" })
84+ advance(&i, in: sql, while: isWhitespace)
85 tokens.append(Token(kind: .whitespace, range: start..<i))
85 tokens.append(Token(kind: .whitespace, range: start..<i))
86
86
87 // Line comment
87 // Line comment
88 case "-" where peek(sql, at: i, offset: 1) == "-":
88 case "-" where peek(sql, at: i, offset: 1) == "-":
89+ // Stop at CR as well as LF: with CRLF (`\r\n`) line endings, consuming
90+ // the trailing `\r` into the comment token would push its boundary past
91+ // where `characterOffset`/DataFusion treat the line as ending, shifting
92+ // the server-error highlight range. The line break is then tokenized as
93+ // whitespace. `isLineBreak` also matches the single `"\r\n"` Character
94+ // Swift forms from a CRLF pair — comparing against "\n"/"\r" alone never
95+ // matches it, so the comment would swallow every following line.
89− advance(&i, in: sql, while: { $0 != "\n" })
96+ advance(&i, in: sql, while: { !isLineBreak($0) })
90 tokens.append(Token(kind: .lineComment, range: start..<i))
97 tokens.append(Token(kind: .lineComment, range: start..<i))
91
98
92 // Block comment
99 // Block comment
@@ -126,13 +133,13 @@ struct SQLTokenizer: Sendable {
126 tokens.append(Token(kind: .star, range: start..<i))
133 tokens.append(Token(kind: .star, range: start..<i))
127
134
128 // Number (digit or leading dot followed by digit)
135 // Number (digit or leading dot followed by digit)
129− case _ where c.isNumber || (c == "." && peek(sql, at: i, offset: 1)?.isNumber == true):
136+ case _ where isASCIIDigit(c) || (c == "." && peek(sql, at: i, offset: 1).map(isASCIIDigit) == true):
130 consumeNumber(&i, in: sql)
137 consumeNumber(&i, in: sql)
131 tokens.append(Token(kind: .number(String(sql[start..<i])), range: start..<i))
138 tokens.append(Token(kind: .number(String(sql[start..<i])), range: start..<i))
132
139
133 // Identifier or keyword
140 // Identifier or keyword
134− case _ where c.isLetter || c == "_":
141+ case _ where isASCIILetter(c) || c == "_":
135− advance(&i, in: sql, while: { $0.isLetter || $0.isNumber || $0 == "_" })
142+ advance(&i, in: sql, while: { isASCIILetter($0) || isASCIIDigit($0) || $0 == "_" })
136 let word = String(sql[start..<i])
143 let word = String(sql[start..<i])
137 if keywords.contains(word.uppercased()) {
144 if keywords.contains(word.uppercased()) {
138 tokens.append(Token(kind: .keyword(word.uppercased()), range: start..<i))
145 tokens.append(Token(kind: .keyword(word.uppercased()), range: start..<i))
@@ -255,6 +262,36 @@ struct SQLTokenizer: Sendable {
255 return value // unterminated
262 return value // unterminated
256 }
263 }
257
264
265+ /// Swift iterates grapheme clusters, and a CRLF pair is the single Character
266+ /// `"\r\n"`, which is equal to neither `"\r"` nor `"\n"`. Match it explicitly
267+ /// so Windows line endings (e.g. pasted text) tokenize like LF/CR ones.
268+ private static func isLineBreak(_ c: Character) -> Bool {
269+ c == "\n" || c == "\r" || c == "\r\n"
270+ }
271+
272+ private static func isWhitespace(_ c: Character) -> Bool {
273+ c == " " || c == "\t" || isLineBreak(c)
274+ }
275+
276+ /// SQL number literals use only ASCII digits. `Character.isNumber` also
277+ /// matches Unicode numerics (`²`, `½`, Arabic-Indic digits, etc.), which
278+ /// DataFusion's lexer rejects — accepting them here would tokenize those
279+ /// characters as numbers and diverge token offsets from the server, throwing
280+ /// off `errorHighlightRange` mapping.
281+ private static func isASCIIDigit(_ c: Character) -> Bool {
282+ c.isASCII && c.isNumber
283+ }
284+
285+ /// Bare (unquoted) SQL identifiers use only ASCII letters. `Character.isLetter`
286+ /// matches the full Unicode letter range (`café`, `naïve`, etc.), which
287+ /// DataFusion's lexer would require to be double-quoted — accepting them bare
288+ /// here would tokenize them as identifiers and diverge token offsets from the
289+ /// server, throwing off `errorHighlightRange` mapping (same rationale as
290+ /// `isASCIIDigit`).
291+ private static func isASCIILetter(_ c: Character) -> Bool {
292+ c.isASCII && c.isLetter
293+ }
294+
258 private static func consumeNumber(_ i: inout String.Index, in sql: String) {
295 private static func consumeNumber(_ i: inout String.Index, in sql: String) {
259 // Digits with at most one decimal point, so malformed input like
296 // Digits with at most one decimal point, so malformed input like
260 // `1.2.3` doesn't get swallowed into a single number token.
297 // `1.2.3` doesn't get swallowed into a single number token.
@@ -265,7 +302,7 @@ struct SQLTokenizer: Sendable {
265 seenDot = true
302 seenDot = true
266 return true
303 return true
267 }
304 }
268− return c.isNumber
305+ return isASCIIDigit(c)
269 })
306 })
270 // Exponent — only consume `e`/`E` (and an optional sign) when actual
307 // Exponent — only consume `e`/`E` (and an optional sign) when actual
271 // exponent digits follow, so `1e` or `1e+` aren't swallowed whole and
308 // exponent digits follow, so `1e` or `1e+` aren't swallowed whole and
@@ -275,9 +312,9 @@ struct SQLTokenizer: Sendable {
275 if lookahead < sql.endIndex, sql[lookahead] == "+" || sql[lookahead] == "-" {
312 if lookahead < sql.endIndex, sql[lookahead] == "+" || sql[lookahead] == "-" {
276 sql.formIndex(after: &lookahead)
313 sql.formIndex(after: &lookahead)
277 }
314 }
278− if lookahead < sql.endIndex, sql[lookahead].isNumber {
315+ if lookahead < sql.endIndex, isASCIIDigit(sql[lookahead]) {
279 i = lookahead
316 i = lookahead
280− advance(&i, in: sql, while: { $0.isNumber })
317+ advance(&i, in: sql, while: { isASCIIDigit($0) })
281 }
318 }
282 }
319 }
283 }
320 }
ParseableViewer/Models/ServerConnection.swift+19−2
@@ -56,15 +56,32 @@ struct ServerConnection: Identifiable, Codable, Hashable, Sendable {
56 var baseURL: URL? {
56 var baseURL: URL? {
57 var urlString = url.trimmingCharacters(in: .whitespacesAndNewlines)
57 var urlString = url.trimmingCharacters(in: .whitespacesAndNewlines)
58 if urlString.isEmpty { return nil }
58 if urlString.isEmpty { return nil }
59− if !urlString.hasPrefix("http://") && !urlString.hasPrefix("https://") {
59+ if let schemeRange = Self.urlSchemeRange(in: urlString) {
60+ // Normalize the (case-insensitive) scheme so `HTTP://host` behaves
61+ // exactly like `http://host` everywhere downstream.
62+ urlString.replaceSubrange(schemeRange, with: urlString[schemeRange].lowercased())
63+ } else {
60 urlString = "https://\(urlString)"
64 urlString = "https://\(urlString)"
61 }
65 }
62 while urlString.hasSuffix("/") {
66 while urlString.hasSuffix("/") {
63 urlString.removeLast()
67 urlString.removeLast()
64 }
68 }
65− guard let parsed = URL(string: urlString), parsed.host != nil else {
69+ guard let parsed = URL(string: urlString), parsed.host != nil,
70+ parsed.scheme == "http" || parsed.scheme == "https" else {
66 return nil
71 return nil
67 }
72 }
68 return parsed
73 return parsed
69 }
74 }
75+
76+ /// Whether `string` already starts with a URL scheme (`http://`, `HTTPS://`,
77+ /// `ftp://`, …). Schemes are case-insensitive, so a case-sensitive
78+ /// `hasPrefix("http://")` check turned `HTTP://host` into
79+ /// `https://HTTP://host` — a URL whose host is "HTTP".
80+ static func hasURLScheme(_ string: String) -> Bool {
81+ urlSchemeRange(in: string) != nil
82+ }
83+
84+ private static func urlSchemeRange(in string: String) -> Range<String.Index>? {
85+ string.range(of: "^[A-Za-z][A-Za-z0-9+.-]*://", options: .regularExpression)
86+ }
70}
87}
ParseableViewer/Models/ServerInfo.swift+47−10
@@ -15,33 +15,59 @@ struct ServerAbout: Codable, Sendable {
15 let llmActive: Bool?
15 let llmActive: Bool?
16 let oidcActive: Bool?
16 let oidcActive: Bool?
17
17
18+ // Parseable's /about handler has always sent camelCase keys (`uiVersion`,
19+ // `deploymentId`, `grpcPort`), like the other fields here. Decoding only the
20+ // snake_case spellings — which the server never sends — left UI Version and
21+ // Deployment ID permanently "N/A" and hid the gRPC Port row. The snake_case
22+ // forms are kept as a fallback.
18 private enum CodingKeys: String, CodingKey {
23 private enum CodingKeys: String, CodingKey {
19 case version, commit, mode, staging, store, license
24 case version, commit, mode, staging, store, license
20− case uiVersion = "ui_version"
25+ case uiVersion
21− case deploymentId = "deployment_id"
26+ case deploymentId
22− case grpcPort = "grpc_port"
27+ case grpcPort
23 case updateAvailable
28 case updateAvailable
24 case latestVersion
29 case latestVersion
25 case llmActive
30 case llmActive
26 case oidcActive
31 case oidcActive
27 }
32 }
28
33
34+ private enum SnakeCaseKeys: String, CodingKey {
35+ case uiVersion = "ui_version"
36+ case deploymentId = "deployment_id"
37+ case grpcPort = "grpc_port"
38+ }
39+
29 init(from decoder: Decoder) throws {
40 init(from decoder: Decoder) throws {
30 let container = try decoder.container(keyedBy: CodingKeys.self)
41 let container = try decoder.container(keyedBy: CodingKeys.self)
42+ let snakeCase = try? decoder.container(keyedBy: SnakeCaseKeys.self)
31 self.version = try? container.decode(String.self, forKey: .version)
43 self.version = try? container.decode(String.self, forKey: .version)
32− self.uiVersion = try? container.decode(String.self, forKey: .uiVersion)
44+ self.uiVersion = (try? container.decode(String.self, forKey: .uiVersion))
45+ ?? (try? snakeCase?.decode(String.self, forKey: .uiVersion))
33 self.commit = try? container.decode(String.self, forKey: .commit)
46 self.commit = try? container.decode(String.self, forKey: .commit)
34− self.deploymentId = try? container.decode(String.self, forKey: .deploymentId)
47+ self.deploymentId = (try? container.decode(String.self, forKey: .deploymentId))
48+ ?? (try? snakeCase?.decode(String.self, forKey: .deploymentId))
35 self.mode = try? container.decode(String.self, forKey: .mode)
49 self.mode = try? container.decode(String.self, forKey: .mode)
36 self.staging = try? container.decode(String.self, forKey: .staging)
50 self.staging = try? container.decode(String.self, forKey: .staging)
37 self.store = try? container.decode(StoreInfo.self, forKey: .store)
51 self.store = try? container.decode(StoreInfo.self, forKey: .store)
38− self.license = try? container.decode(String.self, forKey: .license)
52+ self.license = (try? container.decode(String.self, forKey: .license))
53+ ?? (try? container.decode(JSONValue.self, forKey: .license)).flatMap(Self.describeLicense)
39− self.grpcPort = try? container.decode(Int.self, forKey: .grpcPort)
54+ self.grpcPort = (try? container.decode(Int.self, forKey: .grpcPort))
55+ ?? (try? snakeCase?.decode(Int.self, forKey: .grpcPort))
40 self.updateAvailable = try? container.decode(Bool.self, forKey: .updateAvailable)
56 self.updateAvailable = try? container.decode(Bool.self, forKey: .updateAvailable)
41 self.latestVersion = try? container.decode(String.self, forKey: .latestVersion)
57 self.latestVersion = try? container.decode(String.self, forKey: .latestVersion)
42 self.llmActive = try? container.decode(Bool.self, forKey: .llmActive)
58 self.llmActive = try? container.decode(Bool.self, forKey: .llmActive)
43 self.oidcActive = try? container.decode(Bool.self, forKey: .oidcActive)
59 self.oidcActive = try? container.decode(Bool.self, forKey: .oidcActive)
44 }
60 }
61+
62+ /// Current servers send `license` as an object (`{"name": "AGPL-3.0-only",
63+ /// "plan": "OSS", …}`) rather than a string, which decoded to nil ("N/A").
64+ private static func describeLicense(_ value: JSONValue) -> String? {
65+ guard case .object(let fields) = value, let name = fields["name"]?.stringValue else { return nil }
66+ if let plan = fields["plan"]?.stringValue, !plan.isEmpty {
67+ return "\(name) (\(plan))"
68+ }
69+ return name
70+ }
45}
71}
46
72
47struct StoreInfo: Codable, Sendable {
73struct StoreInfo: Codable, Sendable {
@@ -85,9 +111,20 @@ struct UserInfo: Identifiable, Codable, Sendable {
85 }
111 }
86
112
87 init(from decoder: Decoder) throws {
113 init(from decoder: Decoder) throws {
88− // Handle both {"id": "x", ...} and variations
114+ // Handle both {"id": "x", ...} and a bare "x" string element.
89− if let container = try? decoder.container(keyedBy: CodingKeys.self) {
115+ if let container = try? decoder.container(keyedBy: CodingKeys.self),
116+ container.contains(.id) {
117+ // Decode `id` defensively: the server may quote it as a string or
118+ // send it as a number/other JSON. Fall back to JSONValue.displayString
119+ // so a single odd entry doesn't fail the whole user list (matching the
120+ // file's otherwise-defensive decoding).
90− self.id = try container.decode(String.self, forKey: .id)
121+ if let stringID = try? container.decode(String.self, forKey: .id) {
122+ self.id = stringID
123+ } else if let valueID = try? container.decode(JSONValue.self, forKey: .id) {
124+ self.id = valueID.displayString
125+ } else {
126+ self.id = ""
127+ }
91 self.method = try? container.decode(String.self, forKey: .method)
128 self.method = try? container.decode(String.self, forKey: .method)
92 } else {
129 } else {
93 let container = try decoder.singleValueContainer()
130 let container = try decoder.singleValueContainer()
ParseableViewer/Services/ParseableClient.swift+152−15
@@ -88,6 +88,58 @@ private actor ResponseCache {
88 }
88 }
89}
89}
90
90
91+/// Strips the `Authorization` header on redirects that cross to a different
92+/// origin. By default `URLSession` re-sends the original request's headers —
93+/// including the `Basic` credentials this client attaches to every request —
94+/// to the redirect target. A server (or a malicious/misconfigured proxy)
95+/// answering with a 3xx to an unrelated host would otherwise harvest the user's
96+/// credentials. Same-origin redirects keep the header so normal auth still works.
97+///
98+/// Holds only an immutable `URL`, so it is safely `Sendable` despite the
99+/// NSObject base (which isn't formally `Sendable`).
100+private final class RedirectSanitizer: NSObject, URLSessionTaskDelegate, @unchecked Sendable {
101+ private let baseURL: URL
102+
103+ init(baseURL: URL) {
104+ self.baseURL = baseURL
105+ }
106+
107+ func urlSession(
108+ _ session: URLSession,
109+ task: URLSessionTask,
110+ willPerformHTTPRedirection response: HTTPURLResponse,
111+ newRequest request: URLRequest,
112+ completionHandler: @escaping (URLRequest?) -> Void
113+ ) {
114+ guard !RedirectSanitizer.sameOrigin(baseURL, request.url) else {
115+ completionHandler(request)
116+ return
117+ }
118+ var sanitized = request
119+ sanitized.setValue(nil, forHTTPHeaderField: "Authorization")
120+ completionHandler(sanitized)
121+ }
122+
123+ /// Origin equality per scheme/host/port, normalizing the implicit default
124+ /// port so `https://host` and `https://host:443` are treated as the same
125+ /// origin (and aren't stripped on a legitimate same-host redirect).
126+ static func sameOrigin(_ a: URL, _ b: URL?) -> Bool {
127+ guard let b else { return false }
128+ return a.scheme?.lowercased() == b.scheme?.lowercased()
129+ && a.host?.lowercased() == b.host?.lowercased()
130+ && effectivePort(a) == effectivePort(b)
131+ }
132+
133+ private static func effectivePort(_ url: URL) -> Int? {
134+ if let port = url.port { return port }
135+ switch url.scheme?.lowercased() {
136+ case "https": return 443
137+ case "http": return 80
138+ default: return nil
139+ }
140+ }
141+}
142+
91final class ParseableClient: Sendable {
143final class ParseableClient: Sendable {
92 let baseURL: URL
144 let baseURL: URL
93 let username: String
145 let username: String
@@ -96,13 +148,20 @@ final class ParseableClient: Sendable {
96 private let authHeader: String
148 private let authHeader: String
97 private let cache = ResponseCache()
149 private let cache = ResponseCache()
98
150
151+ // A fresh decoder per call. JSONDecoder.decode isn't documented as thread-safe,
152+ // and these methods run concurrently (e.g. schema/stats/info fetched in parallel),
153+ // so a single shared instance would be a data race. Constructing one is cheap.
99− private static nonisolated(unsafe) let jsonDecoder = JSONDecoder()
154+ private static var jsonDecoder: JSONDecoder { JSONDecoder() }
100
155
156+ // A fresh formatter per access, for the same reason as `jsonDecoder` above:
157+ // `query` can run concurrently and `ISO8601DateFormatter.string(from:)` isn't
158+ // documented as thread-safe, so a single shared instance would contradict the
159+ // file's per-call concurrency discipline. Constructing one is cheap.
101− private static let isoFormatter: ISO8601DateFormatter = {
160+ private static var isoFormatter: ISO8601DateFormatter {
102 let f = ISO8601DateFormatter()
161 let f = ISO8601DateFormatter()
103 f.formatOptions = [.withInternetDateTime, .withFractionalSeconds]
162 f.formatOptions = [.withInternetDateTime, .withFractionalSeconds]
104 return f
163 return f
105− }()
164+ }
106
165
107 init(url: URL, username: String, password: String) {
166 init(url: URL, username: String, password: String) {
108 self.baseURL = url
167 self.baseURL = url
@@ -119,7 +178,15 @@ final class ParseableClient: Sendable {
119 let config = URLSessionConfiguration.default
178 let config = URLSessionConfiguration.default
120 config.timeoutIntervalForRequest = 30
179 config.timeoutIntervalForRequest = 30
121 config.timeoutIntervalForResource = 120
180 config.timeoutIntervalForResource = 120
181+ // Install a delegate that drops the Authorization header on cross-origin
182+ // redirects so Basic credentials can't be forwarded to an unrelated host.
183+ // The session retains the delegate until `finishTasksAndInvalidate()` in
184+ // deinit releases it, so there's no leak.
122− self.session = URLSession(configuration: config)
185+ self.session = URLSession(
186+ configuration: config,
187+ delegate: RedirectSanitizer(baseURL: url),
188+ delegateQueue: nil
189+ )
123 }
190 }
124
191
125 convenience init(connection: ServerConnection) throws {
192 convenience init(connection: ServerConnection) throws {
@@ -175,7 +242,11 @@ final class ParseableClient: Sendable {
175 }
242 }
176
243
177 guard (200...299).contains(httpResponse.statusCode) else {
244 guard (200...299).contains(httpResponse.statusCode) else {
245+ // A proxy/load balancer can answer with a multi-KB HTML body; surfacing
246+ // it verbatim floods the error alert. Trim and cap to a readable prefix.
178− let message = String(data: data, encoding: .utf8) ?? "Unknown error"
247+ let raw = (String(data: data, encoding: .utf8) ?? "")
248+ .trimmingCharacters(in: .whitespacesAndNewlines)
249+ let message = raw.isEmpty ? "Unknown error" : String(raw.prefix(500))
179 throw ParseableError.serverError(httpResponse.statusCode, message)
250 throw ParseableError.serverError(httpResponse.statusCode, message)
180 }
251 }
181
252
@@ -281,10 +352,11 @@ final class ParseableClient: Sendable {
281 // MARK: - Query
352 // MARK: - Query
282
353
283 func query(sql: String, startTime: Date, endTime: Date) async throws -> [LogRecord] {
354 func query(sql: String, startTime: Date, endTime: Date) async throws -> [LogRecord] {
355+ let formatter = Self.isoFormatter
284 let body: [String: Any] = [
356 let body: [String: Any] = [
285 "query": sql,
357 "query": sql,
286− "startTime": Self.isoFormatter.string(from: startTime),
358+ "startTime": formatter.string(from: startTime),
287− "endTime": Self.isoFormatter.string(from: endTime)
359+ "endTime": formatter.string(from: endTime)
288 ]
360 ]
289
361
290 let bodyData = try JSONSerialization.data(withJSONObject: body)
362 let bodyData = try JSONSerialization.data(withJSONObject: body)
@@ -304,6 +376,10 @@ final class ParseableClient: Sendable {
304 sniff = sniff.dropFirst(3)
376 sniff = sniff.dropFirst(3)
305 }
377 }
306 let firstByte = sniff.first(where: { $0 != 0x20 && $0 != 0x0A && $0 != 0x0D && $0 != 0x09 })
378 let firstByte = sniff.first(where: { $0 != 0x20 && $0 != 0x0A && $0 != 0x0D && $0 != 0x09 })
379+ // A body of only whitespace/BOM carries no records — treat it like an
380+ // empty response rather than feeding it to the array decoder, which would
381+ // throw a confusing "Unexpected query response format".
382+ guard let firstByte else { return [] }
307 if firstByte == 0x7B { // '{'
383 if firstByte == 0x7B { // '{'
308 do {
384 do {
309 return try Self.jsonDecoder.decode(QueryResponse.self, from: responseData).records
385 return try Self.jsonDecoder.decode(QueryResponse.self, from: responseData).records
@@ -333,26 +409,81 @@ final class ParseableClient: Sendable {
333 return try Self.jsonDecoder.decode(AlertConfig.self, from: data)
409 return try Self.jsonDecoder.decode(AlertConfig.self, from: data)
334 }
410 }
335
411
412+ // `/api/v1/alerts` paginates (default page size 100, max 1000) over ALL
413+ // alerts server-wide, ordered by state/severity/title. Filtering only the
414+ // first page by stream (below) silently missed any stream whose alerts sort
415+ // past it. Request full pages until a short one. Servers that predate
416+ // pagination ignore the parameters and return everything each time, so a
417+ // page that adds nothing new also ends the loop.
418+ func modernAlerts() async throws -> (data: Data, config: AlertConfig) {
419+ let pageSize = 1000
420+ var firstData: Data?
421+ var version: String?
422+ var collected: [AlertRule] = []
423+ var seenIDs = Set<String>()
424+ var offset = 0
425+ for _ in 0..<50 {
426+ let data: Data
427+ do {
428+ data = try await performRequest(
429+ method: "GET",
430+ path: "/api/v1/alerts",
431+ queryItems: [
432+ URLQueryItem(name: "limit", value: String(pageSize)),
433+ URLQueryItem(name: "offset", value: String(offset)),
434+ ]
435+ )
436+ } catch ParseableError.serverError(let code, _) where code == 400 && offset == 0 {
437+ // A server that rejects the paging parameters: one plain request.
438+ let data = try await performRequest(method: "GET", path: "/api/v1/alerts")
439+ return (data, try Self.jsonDecoder.decode(AlertConfig.self, from: data))
440+ }
441+ let page = try Self.jsonDecoder.decode(AlertConfig.self, from: data)
442+ if firstData == nil {
443+ firstData = data
444+ version = page.version
445+ }
446+ let alerts = page.alerts ?? []
447+ let fresh = alerts.filter { seenIDs.insert($0.id).inserted }
448+ collected.append(contentsOf: fresh)
449+ if alerts.count < pageSize || fresh.isEmpty { break }
450+ offset += alerts.count
451+ }
452+ return (firstData ?? Data(), AlertConfig(alerts: collected, version: version))
453+ }
454+
336 do {
455 do {
337− let data = try await performRequest(method: "GET", path: "/api/v1/alerts")
456+ let (data, config) = try await modernAlerts()
338− let config = try Self.jsonDecoder.decode(AlertConfig.self, from: data)
339 // AlertConfig decodes leniently (every field via try?), so a 200 with
457 // AlertConfig decodes leniently (every field via try?), so a 200 with
340 // an unexpected body yields an empty, non-throwing config rather than
458 // an unexpected body yields an empty, non-throwing config rather than
341 // a DecodingError — indistinguishable from a server that genuinely has
459 // a DecodingError — indistinguishable from a server that genuinely has
342 // no alerts. Only fall back to the legacy per-stream endpoint when the
460 // no alerts. Only fall back to the legacy per-stream endpoint when the
343− // body ISN'T a recognizable alerts container (a top-level array, or an
461+ // body ISN'T a recognizable alerts container. Any top-level JSON array
344− // object carrying an "alerts" key). When it is, an empty result is
462+ // OR object is the modern endpoint answering — including an empty object
463+ // `{}` or `{"alerts": []}` meaning "no alerts" — and is authoritative, so
345− // authoritative and must not be replaced with unrelated legacy alerts.
464+ // it must not be replaced with unrelated legacy per-stream alerts. Only a
465+ // non-JSON / unparseable body counts as unrecognized.
346 if config.alerts?.isEmpty ?? true {
466 if config.alerts?.isEmpty ?? true {
347 let json = try? JSONSerialization.jsonObject(with: data)
467 let json = try? JSONSerialization.jsonObject(with: data)
348− let isRecognizedShape = json is [Any]
468+ let isRecognizedShape = json is [Any] || json is [String: Any]
349− || (json as? [String: Any])?["alerts"] != nil
350 if !isRecognizedShape,
469 if !isRecognizedShape,
351 let legacy = try? await legacyAlerts(), !(legacy.alerts?.isEmpty ?? true) {
470 let legacy = try? await legacyAlerts(), !(legacy.alerts?.isEmpty ?? true) {
352 return legacy
471 return legacy
353 }
472 }
354 }
473 }
355− return config
474+ // `/api/v1/alerts` is server-global; the legacy endpoint above is already
475+ // per-stream. Scope the modern result to the requested stream via each
476+ // alert's `datasets` membership so every stream doesn't show the same
477+ // unfiltered global list.
478+ let scoped = config.alerts?.filter { rule in
479+ // A missing or empty `datasets` means the alert isn't scoped to
480+ // specific streams (the summary endpoint frequently omits it), so
481+ // treat it as global and surface it for every stream. Only exclude
482+ // an alert when it explicitly lists datasets, none of which match.
483+ guard let datasets = rule.datasets, !datasets.isEmpty else { return true }
484+ return datasets.contains(stream)
485+ }
486+ return AlertConfig(alerts: scoped, version: config.version)
356 } catch let newEndpointError {
487 } catch let newEndpointError {
357 // An auth failure won't be fixed by hitting the legacy endpoint (it
488 // An auth failure won't be fixed by hitting the legacy endpoint (it
358 // would just 401 again), so surface it immediately rather than
489 // would just 401 again), so surface it immediately rather than
@@ -381,6 +512,12 @@ final class ParseableClient: Sendable {
381 }
512 }
382 do {
513 do {
383 let single = try Self.jsonDecoder.decode(RetentionConfig.self, from: data)
514 let single = try Self.jsonDecoder.decode(RetentionConfig.self, from: data)
515+ // RetentionConfig decodes every field with `try?`, so any JSON object
516+ // (including `{}` or an error body) decodes to an all-nil config. Treat
517+ // an empty config as "no retention" rather than a phantom policy entry.
518+ guard single.description != nil || single.duration != nil || single.action != nil else {
519+ return []
520+ }
384 return [single]
521 return [single]
385 } catch {
522 } catch {
386 throw ParseableError.decodingError("Unexpected retention response format")
523 throw ParseableError.decodingError("Unexpected retention response format")
ParseableViewer/ViewModels/LiveTailViewModel.swift+178−55
@@ -27,14 +27,26 @@ final class LiveTailViewModel {
27 /// column hidden last session would reappear visible when it shows up in a
27 /// column hidden last session would reappear visible when it shows up in a
28 /// later poll. This retains the intent so it stays hidden when it appears.
28 /// later poll. This retains the intent so it stays hidden when it appears.
29 private var savedHiddenColumns: Set<String> = []
29 private var savedHiddenColumns: Set<String> = []
30+ /// The full persisted column order, including columns not yet seen this
31+ /// session — the order counterpart of `savedHiddenColumns`. A column that
32+ /// first shows up in a later poll is slotted back into its saved position
33+ /// (instead of being appended), and saving keeps unseen columns' positions
34+ /// rather than dropping them from the stored order.
35+ private var savedColumnOrder: [String] = []
30 private var currentStream: String?
36 private var currentStream: String?
31
37
32 // nonisolated(unsafe) so deinit can invalidate the timer.
38 // nonisolated(unsafe) so deinit can invalidate the timer.
33 // Only mutated from @MainActor methods (start/stop).
39 // Only mutated from @MainActor methods (start/stop).
34 nonisolated(unsafe) private var timer: Timer?
40 nonisolated(unsafe) private var timer: Timer?
35 private var lastTimestamp: Date?
41 private var lastTimestamp: Date?
42+ /// How many records with each content fingerprint have been delivered. A
43+ /// count rather than a set: Parseable stamps every event of one ingest request
44+ /// with the same millisecond `p_timestamp`, so genuinely repeated events (the
45+ /// same log line emitted three times) fingerprint identically. A set collapsed
46+ /// them into one row; counting keeps each occurrence while still suppressing
47+ /// the re-fetch of the same rows by the next overlapping poll window.
36− private var seenFingerprints: Set<String> = []
48+ private var seenFingerprints: [String: Int] = [:]
37− /// Insertion-ordered mirror of `seenFingerprints` (oldest first), used to
49+ /// Insertion-ordered mirror of `seenFingerprints` keys (oldest first), used to
38 /// evict the least-recently-seen fingerprints when the set is trimmed.
50 /// evict the least-recently-seen fingerprints when the set is trimmed.
39 private var seenFingerprintOrder: [String] = []
51 private var seenFingerprintOrder: [String] = []
40 /// Upper bound on the dedup fingerprint set. Kept well above `maxEntries` so
52 /// Upper bound on the dedup fingerprint set. Kept well above `maxEntries` so
@@ -57,7 +69,15 @@ final class LiveTailViewModel {
57 private var pollGeneration = 0
69 private var pollGeneration = 0
58
70
59 deinit {
71 deinit {
60− timer?.invalidate()
72+ // Timer.invalidate() must run on the run loop the timer was scheduled on
73+ // (RunLoop.main, see start()). deinit is nonisolated and can run off the
74+ // main thread, so hop to the main queue rather than invalidating inline.
75+ // The scheduled timer only weakly references self, so deferring teardown
76+ // past deallocation is safe — at worst it fires once more as a no-op
77+ // before being invalidated. stop() remains the normal teardown path.
78+ if let timer {
79+ DispatchQueue.main.async { timer.invalidate() }
80+ }
61 }
81 }
62
82
63 // Cached formatters — nonisolated(unsafe) so static methods can use them from Task.detached
83 // Cached formatters — nonisolated(unsafe) so static methods can use them from Task.detached
@@ -150,8 +170,21 @@ final class LiveTailViewModel {
150 }
170 }
151
171
152 func addColumnFilter(column: String, value: JSONValue?, exclude: Bool) {
172 func addColumnFilter(column: String, value: JSONValue?, exclude: Bool) {
153− // Remove any existing filter on the same column with the same value
173+ // Remove any existing filter on the same column+value regardless of its
174+ // exclude flag. Include (col = X) and exclude (col ≠ X) on the same value
175+ // are mutually exclusive; without dropping the `exclude` match too, toggling
176+ // include↔exclude stacks both and the AND'd filters match zero rows, so the
177+ // tail silently empties.
178+ columnFilters.removeAll { $0.column == column && $0.value == value }
179+ if !exclude {
180+ // Include filters are AND-combined in rebuildFilteredEntries, but a column
181+ // can only equal one value, so two includes on the same column with
182+ // different values (e.g. level = info then level = error) can never both
183+ // match and the tail would silently empty. A new include therefore
184+ // replaces any prior include on that column. (Excludes legitimately stack:
185+ // col ≠ a AND col ≠ b is meaningful, so they are left untouched here.)
154− columnFilters.removeAll { $0.column == column && $0.value == value && $0.exclude == exclude }
186+ columnFilters.removeAll { $0.column == column && !$0.exclude }
187+ }
155 columnFilters.append(ColumnFilter(column: column, value: value, exclude: exclude))
188 columnFilters.append(ColumnFilter(column: column, value: value, exclude: exclude))
156 rebuildFilteredEntries()
189 rebuildFilteredEntries()
157 }
190 }
@@ -181,9 +214,14 @@ final class LiveTailViewModel {
181 pollGeneration += 1
214 pollGeneration += 1
182 isRunning = true
215 isRunning = true
183 isPaused = false
216 isPaused = false
217+ // Reset the in-flight guard: if a poll was awaiting its network round-trip
218+ // when the tail was stopped, its `defer { isPolling = false }` only runs when
219+ // that request finally returns. Without clearing it here, the new timer's
220+ // first tick would hit `guard !isPolling` and be skipped for a whole interval.
221+ isPolling = false
184 errorMessage = nil
222 errorMessage = nil
185 entries = []
223 entries = []
186− seenFingerprints = []
224+ seenFingerprints = [:]
187 seenFingerprintOrder = []
225 seenFingerprintOrder = []
188 allKnownKeys = []
226 allKnownKeys = []
189 droppedCount = 0
227 droppedCount = 0
@@ -194,16 +232,28 @@ final class LiveTailViewModel {
194 columnOrder = []
232 columnOrder = []
195 hiddenColumns = []
233 hiddenColumns = []
196 savedHiddenColumns = []
234 savedHiddenColumns = []
235+ savedColumnOrder = []
197 columnFilters = []
236 columnFilters = []
237+ // Clear the text filter too. Leaving it set while clearing the column-filter
238+ // chips is inconsistent and would let a stale search from a previous stream
239+ // make the fresh tail come up appearing empty or partially filtered.
240+ filterText = ""
198 currentStream = stream
241 currentStream = stream
199 rebuildFilteredEntries()
242 rebuildFilteredEntries()
200
243
244+ // Install the timer in `.common` modes so polling keeps firing while the
245+ // main run loop is in event-tracking mode (scrolling, dragging a column,
246+ // an open menu/popover). A `.default`-only timer — what
247+ // `scheduledTimer(withTimeInterval:)` installs — silently pauses during
248+ // those interactions, freezing the "live" tail until the user lets go.
201− timer = Timer.scheduledTimer(withTimeInterval: pollInterval, repeats: true) { [weak self] _ in
249+ let pollTimer = Timer(timeInterval: pollInterval, repeats: true) { [weak self] _ in
202 Task { @MainActor [weak self] in
250 Task { @MainActor [weak self] in
203 guard let self, !self.isPaused else { return }
251 guard let self, !self.isPaused else { return }
204 await self.poll(client: client, stream: stream)
252 await self.poll(client: client, stream: stream)
205 }
253 }
206 }
254 }
255+ RunLoop.main.add(pollTimer, forMode: .common)
256+ timer = pollTimer
207 }
257 }
208
258
209 func stop() {
259 func stop() {
@@ -220,13 +270,17 @@ final class LiveTailViewModel {
220
270
221 func clear() {
271 func clear() {
222 pollGeneration += 1
272 pollGeneration += 1
273+ // Match start(): drop the in-flight guard so a poll still awaiting its
274+ // response (whose results this generation bump will discard) can't make the
275+ // next timer tick skip itself via `guard !isPolling`.
276+ isPolling = false
223 entries = []
277 entries = []
224− seenFingerprints = []
278+ // Keep the dedup fingerprints (unlike start(), which begins a new session):
225− seenFingerprintOrder = []
279+ // every poll looks back at least 90 s regardless of `lastTimestamp` (see
226− // Advance the look-back floor to now: without this, the next poll queries
280+ // `safeStart` in poll()), so the records just cleared are re-fetched by the
227− // from the old `lastTimestamp` with an empty fingerprint set and re-inserts
281+ // next poll. With an emptied fingerprint set they all passed dedup again and
228− // every just-cleared record still inside the ~90 s window, repopulating the
282+ // the tail refilled with exactly what the user cleared. The set stays
229− // view the user just emptied. Matches start()'s reset.
283+ // bounded by the eviction in poll().
230 lastTimestamp = Date()
284 lastTimestamp = Date()
231 allKnownKeys = []
285 allKnownKeys = []
232 droppedCount = 0
286 droppedCount = 0
@@ -234,11 +288,18 @@ final class LiveTailViewModel {
234 // leaving accumulated errors in place could trip the auto-stop threshold
288 // leaving accumulated errors in place could trip the auto-stop threshold
235 // sooner than the intended N consecutive failures. Matches start()'s reset.
289 // sooner than the intended N consecutive failures. Matches start()'s reset.
236 consecutiveErrors = 0
290 consecutiveErrors = 0
291+ // Match start(): a freshly emptied tail has no last-poll timestamp, so any
292+ // "last updated at…" indicator shouldn't keep showing the pre-clear time.
293+ lastPollTime = nil
237 columns = []
294 columns = []
238 columnOrder = []
295 columnOrder = []
239 hiddenColumns = []
296 hiddenColumns = []
240 savedHiddenColumns = []
297 savedHiddenColumns = []
298+ savedColumnOrder = []
241 columnFilters = []
299 columnFilters = []
300+ // Match start(): clear the text filter alongside the column-filter chips so
301+ // a clear()ed tail isn't immediately re-hidden by a lingering search string.
302+ filterText = ""
242 rebuildFilteredEntries()
303 rebuildFilteredEntries()
243 }
304 }
244
305
@@ -278,12 +339,19 @@ final class LiveTailViewModel {
278 let toIndex = columnOrder.firstIndex(of: targetColumn),
339 let toIndex = columnOrder.firstIndex(of: targetColumn),
279 fromIndex != toIndex else { return }
340 fromIndex != toIndex else { return }
280 let item = columnOrder.remove(at: fromIndex)
341 let item = columnOrder.remove(at: fromIndex)
342+ // Match the drop-indicator semantics: a rightward move (fromIndex < toIndex)
343+ // lands the column *after* the target, a leftward move lands it *before*.
344+ // After removing an earlier element the target shifts down by one, so
345+ // inserting at `toIndex` lands after the target for rightward moves; for
346+ // leftward moves the target is unshifted and `toIndex` lands before it.
347+ let adjusted = toIndex
281− columnOrder.insert(item, at: toIndex)
348+ columnOrder.insert(item, at: adjusted)
282 saveColumnConfig()
349 saveColumnConfig()
283 }
350 }
284
351
285 func resetColumnConfig() {
352 func resetColumnConfig() {
286 columnOrder = columns
353 columnOrder = columns
354+ savedColumnOrder = []
287 hiddenColumns.removeAll()
355 hiddenColumns.removeAll()
288 savedHiddenColumns.removeAll()
356 savedHiddenColumns.removeAll()
289 saveColumnConfig()
357 saveColumnConfig()
@@ -297,9 +365,16 @@ final class LiveTailViewModel {
297
365
298 private func saveColumnConfig() {
366 private func saveColumnConfig() {
299 guard let stream = currentStream else { return }
367 guard let stream = currentStream else { return }
300− // Persist the full intended hidden set (including columns not yet seen
368+ // Persist the full intended hidden set and order (including columns not
301− // this session) so it survives across restarts and incremental discovery.
369+ // yet seen this session) so they survive restarts and incremental discovery.
370+ let seen = Set(columnOrder)
371+ let order = QueryViewModel.mergedColumnOrder(
372+ columnOrder,
373+ inserting: savedColumnOrder.filter { !seen.contains($0) },
374+ reference: savedColumnOrder
375+ )
376+ savedColumnOrder = order
302− let config = QueryViewModel.ColumnConfiguration(order: columnOrder, hidden: savedHiddenColumns)
377+ let config = QueryViewModel.ColumnConfiguration(order: order, hidden: savedHiddenColumns)
303 if let data = try? JSONEncoder().encode(config) {
378 if let data = try? JSONEncoder().encode(config) {
304 UserDefaults.standard.set(data, forKey: Self.columnConfigKey(for: stream))
379 UserDefaults.standard.set(data, forKey: Self.columnConfigKey(for: stream))
305 }
380 }
@@ -341,10 +416,12 @@ final class LiveTailViewModel {
341 merged.append(col)
416 merged.append(col)
342 }
417 }
343 columnOrder = merged
418 columnOrder = merged
419+ savedColumnOrder = config.order
344 savedHiddenColumns = config.hidden
420 savedHiddenColumns = config.hidden
345 hiddenColumns = config.hidden.intersection(extractedSet)
421 hiddenColumns = config.hidden.intersection(extractedSet)
346 } else {
422 } else {
347 columnOrder = extracted
423 columnOrder = extracted
424+ savedColumnOrder = []
348 savedHiddenColumns = []
425 savedHiddenColumns = []
349 hiddenColumns = []
426 hiddenColumns = []
350 }
427 }
@@ -357,7 +434,7 @@ final class LiveTailViewModel {
357 }
434 }
358 if !newCols.isEmpty {
435 if !newCols.isEmpty {
359 columns.append(contentsOf: newCols)
436 columns.append(contentsOf: newCols)
360− columnOrder.append(contentsOf: newCols)
437+ columnOrder = QueryViewModel.mergedColumnOrder(columnOrder, inserting: newCols, reference: savedColumnOrder)
361 // Re-hide any newly appearing column that was hidden in a prior
438 // Re-hide any newly appearing column that was hidden in a prior
362 // session but hadn't been seen yet this run.
439 // session but hadn't been seen yet this run.
363 hiddenColumns.formUnion(newCols.filter { savedHiddenColumns.contains($0) })
440 hiddenColumns.formUnion(newCols.filter { savedHiddenColumns.contains($0) })
@@ -376,10 +453,18 @@ final class LiveTailViewModel {
376 }
453 }
377
454
378 private func poll(client: ParseableClient, stream: String) async {
455 private func poll(client: ParseableClient, stream: String) async {
456+ let generation = pollGeneration
379 guard !isPolling else { return }
457 guard !isPolling else { return }
380 isPolling = true
458 isPolling = true
381− defer { isPolling = false }
459+ // Only release the guard if this poll is still the current generation. A
382− let generation = pollGeneration
460+ // stop()/start() during the network await can supersede this poll while a
461+ // newer-generation poll is already in flight; clearing isPolling
462+ // unconditionally would let a subsequent timer tick start a second poll
463+ // that overlaps the newer one, racing writes to lastTimestamp /
464+ // consecutiveErrors. start()/clear() reset isPolling on their own
465+ // generation bump, so a superseded poll leaving it set can't wedge the
466+ // new timer.
467+ defer { if generation == pollGeneration { isPolling = false } }
383
468
384 let now = Date()
469 let now = Date()
385 // The Parseable server truncates query time-ranges to minute
470 // The Parseable server truncates query time-ranges to minute
@@ -407,22 +492,27 @@ final class LiveTailViewModel {
407 // Process records off the main actor using a snapshot of seen fingerprints
492 // Process records off the main actor using a snapshot of seen fingerprints
408 let seenSnapshot = seenFingerprints
493 let seenSnapshot = seenFingerprints
409 let pollTime = now
494 let pollTime = now
410− let candidateEntries = await Task.detached(priority: .userInitiated) {
495+ let (candidateEntries, batchCounts) = await Task.detached(priority: .userInitiated) {
411− var results: [LiveTailEntry] = []
496+ var results: [(entry: LiveTailEntry, occurrence: Int)] = []
497+ var counts: [String: Int] = [:]
412 for record in records {
498 for record in records {
413 let fp = Self.fingerprint(for: record)
499 let fp = Self.fingerprint(for: record)
500+ let occurrence = (counts[fp] ?? 0) + 1
501+ counts[fp] = occurrence
502+ // The first `seen` occurrences of this content were delivered
503+ // by earlier polls; only later ones are new.
414− guard !seenSnapshot.contains(fp) else { continue }
504+ guard occurrence > (seenSnapshot[fp] ?? 0) else { continue }
415 let timestamp = Self.parseTimestamp(from: record) ?? pollTime
505 let timestamp = Self.parseTimestamp(from: record) ?? pollTime
416 let summary = Self.buildSummary(from: record)
506 let summary = Self.buildSummary(from: record)
417− results.append(LiveTailEntry(
507+ results.append((LiveTailEntry(
418 timestamp: timestamp,
508 timestamp: timestamp,
419 record: record,
509 record: record,
420 displayTimestamp: Self.displayFormatter.string(from: timestamp),
510 displayTimestamp: Self.displayFormatter.string(from: timestamp),
421 summary: summary,
511 summary: summary,
422 fingerprint: fp
512 fingerprint: fp
423− ))
513+ ), occurrence))
424 }
514 }
425− return results
515+ return (results, counts)
426 }.value
516 }.value
427
517
428 // Re-check after the detached processing hop: a stop/clear could have
518 // Re-check after the detached processing hop: a stop/clear could have
@@ -431,11 +521,13 @@ final class LiveTailViewModel {
431
521
432 // Authoritative dedup on main actor
522 // Authoritative dedup on main actor
433 var newEntries: [LiveTailEntry] = []
523 var newEntries: [LiveTailEntry] = []
434− for entry in candidateEntries {
524+ for candidate in candidateEntries
435− if seenFingerprints.insert(entry.fingerprint).inserted {
525+ where candidate.occurrence > (seenFingerprints[candidate.entry.fingerprint] ?? 0) {
526+ newEntries.append(candidate.entry)
527+ }
528+ for (fp, count) in batchCounts where count > (seenFingerprints[fp] ?? 0) {
436− seenFingerprintOrder.append(entry.fingerprint)
529+ if seenFingerprints[fp] == nil { seenFingerprintOrder.append(fp) }
437− newEntries.append(entry)
530+ seenFingerprints[fp] = count
438− }
439 }
531 }
440
532
441 if !newEntries.isEmpty {
533 if !newEntries.isEmpty {
@@ -457,23 +549,27 @@ final class LiveTailViewModel {
457 // would let it pass the dedup check and be re-appended as a
549 // would let it pass the dedup check and be re-appended as a
458 // duplicate. Keep the full seen-set so dedup stays correct;
550 // duplicate. Keep the full seen-set so dedup stays correct;
459 // it is bounded below to avoid unbounded growth.
551 // it is bounded below to avoid unbounded growth.
552+ }
553+
554+ // Bound the seen-set on every poll, not only when the buffer
555+ // overflows: clear() keeps fingerprints while emptying `entries`,
556+ // so the set can outgrow the cap without the buffer ever filling.
460− if seenFingerprints.count > maxSeenFingerprints {
557+ if seenFingerprints.count > maxSeenFingerprints {
461− // Evict the OLDEST surplus fingerprints by insertion order,
558+ // Evict the OLDEST surplus fingerprints by insertion order,
462− // not by what's currently buffered. Collapsing to only the
559+ // not by what's currently buffered. Collapsing to only the
463− // buffered set would drop recently-evicted fingerprints that
560+ // buffered set would drop recently-evicted fingerprints that
464− // still fall inside the next poll's ~90 s look-back window,
561+ // still fall inside the next poll's ~90 s look-back window,
465− // re-admitting those records as duplicates. Keeping the
562+ // re-admitting those records as duplicates. Keeping the
466− // most-recently-seen tail preserves a full window of evicted
563+ // most-recently-seen tail preserves a full window of evicted
467− // fingerprints. Buffered fingerprints are always retained so
564+ // fingerprints. Buffered fingerprints are always retained so
468− // a re-fetch of a buffered record stays deduplicated.
565+ // a re-fetch of a buffered record stays deduplicated.
469− let overflow = seenFingerprints.count - maxSeenFingerprints
566+ let overflow = seenFingerprints.count - maxSeenFingerprints
470− let buffered = Set(entries.map { $0.fingerprint })
567+ let buffered = Set(entries.map { $0.fingerprint })
471− let toDrop = Set(seenFingerprintOrder.prefix(overflow))
568+ let toDrop = Set(seenFingerprintOrder.prefix(overflow))
472− .subtracting(buffered)
569+ .subtracting(buffered)
473− if !toDrop.isEmpty {
570+ if !toDrop.isEmpty {
474− seenFingerprints.subtract(toDrop)
571+ for fp in toDrop { seenFingerprints.removeValue(forKey: fp) }
475− seenFingerprintOrder.removeAll { toDrop.contains($0) }
572+ seenFingerprintOrder.removeAll { toDrop.contains($0) }
476− }
477 }
573 }
478 }
574 }
479
575
@@ -567,9 +663,25 @@ final class LiveTailViewModel {
567 // (with NaN folded to a single bit pattern) so a field that serializes as `5`
663 // (with NaN folded to a single bit pattern) so a field that serializes as `5`
568 // in one poll and `5.0` in another doesn't bypass dedup as a phantom row.
664 // in one poll and `5.0` in another doesn't bypass dedup as a phantom row.
569 case .int(let i):
665 case .int(let i):
666+ // If the integer round-trips losslessly through Double, hash via the
667+ // Double path so `.int(5)` and `.double(5.0)` (which are ==) share a
668+ // fingerprint. Integers beyond 2^53 lose precision in Double, so two
669+ // distinct values (e.g. 2^53 and 2^53+1) would otherwise collide and
670+ // the second would be silently dropped as a phantom duplicate — the
671+ // dedup set has no `==` fallback. No Double can equal such a
672+ // non-representable integer, so hashing its exact 64-bit pattern can't
673+ // wrongly merge it with a `.double`.
674+ if let exact = Int(exactly: Double(i)), exact == i {
570− hashNumberBits(Double(i).bitPattern, h0: &h0, h1: &h1)
675+ hashNumberBits(Double(i).bitPattern, h0: &h0, h1: &h1)
676+ } else {
677+ hashNumberBits(UInt64(bitPattern: Int64(i)), h0: &h0, h1: &h1)
678+ }
571 case .double(let d):
679 case .double(let d):
680+ // Canonicalize NaN (so all NaNs fold together) AND signed zero: JSONValue
681+ // equality treats `-0.0 == 0.0`, but their raw bit patterns differ, which
682+ // would otherwise let an identical record slip past dedup as a phantom row.
683+ let canonical: Double = d.isNaN ? .nan : (d == 0 ? 0.0 : d)
572− hashNumberBits((d.isNaN ? Double.nan : d).bitPattern, h0: &h0, h1: &h1)
684+ hashNumberBits(canonical.bitPattern, h0: &h0, h1: &h1)
573 case .string(let s):
685 case .string(let s):
574 h0 = (h0 ^ 0x04) &* 1099511628211
686 h0 = (h0 ^ 0x04) &* 1099511628211
575 h1 = (h1 ^ 0x04) &* 6700417
687 h1 = (h1 ^ 0x04) &* 6700417
@@ -612,10 +724,19 @@ final class LiveTailViewModel {
612 return nil
724 return nil
613 }
725 }
614 if case .string(let str) = value {
726 if case .string(let str) = value {
615− if let date = isoFractional.date(from: str) {
727+ if let date = isoFractional.date(from: str) ?? isoBasic.date(from: str) {
616 return date
728 return date
617 }
729 }
730+ // Parseable stores `p_timestamp` as an Arrow timestamp without a
731+ // timezone, and the query API serializes it as a naive UTC string with
732+ // no offset (e.g. "2024-01-11T09:08:34.290"). `.withInternetDateTime`
733+ // requires a zone designator, so such values never parsed: every entry
734+ // fell back to the poll time, which also scrambled the tail's order
735+ // (each poll's DESC-ordered batch was appended newest-first). Retry as
736+ // UTC; a string that already carries an offset won't parse with an
737+ // extra "Z", so this can't reinterpret zoned timestamps.
738+ let utc = str + "Z"
618− return isoBasic.date(from: str)
739+ return isoFractional.date(from: utc) ?? isoBasic.date(from: utc)
619 }
740 }
620 return nil
741 return nil
621 }
742 }
@@ -632,14 +753,16 @@ final class LiveTailViewModel {
632 }
753 }
633
754
634 if parts.isEmpty {
755 if parts.isEmpty {
756+ // Filter to scalars BEFORE taking the prefix — otherwise three leading
757+ // non-scalar fields (alphabetically) would yield an empty summary even
758+ // when scalar fields exist later in the record.
635 let scalarFields = record
759 let scalarFields = record
636 .filter { $0.key != "p_timestamp" && $0.key != "p_tags" && $0.key != "p_metadata" }
760 .filter { $0.key != "p_timestamp" && $0.key != "p_tags" && $0.key != "p_metadata" }
761+ .filter { $0.value.isScalar }
637 .sorted { $0.key < $1.key }
762 .sorted { $0.key < $1.key }
638 .prefix(3)
763 .prefix(3)
639 for (key, value) in scalarFields {
764 for (key, value) in scalarFields {
640− if value.isScalar {
641− parts.append("\(key)=\(value.displayString)")
765+ parts.append("\(key)=\(value.displayString)")
642− }
643 }
766 }
644 }
767 }
645
768
ParseableViewer/ViewModels/QueryViewModel.swift+394−56
@@ -2,6 +2,7 @@ import Foundation
2import SwiftUI
2import SwiftUI
3
3
4@Observable
4@Observable
5+@MainActor
5final class QueryViewModel {
6final class QueryViewModel {
6 var sqlQuery = "" {
7 var sqlQuery = "" {
7 didSet {
8 didSet {
@@ -16,6 +17,8 @@ final class QueryViewModel {
16 // Build search texts asynchronously
17 // Build search texts asynchronously
17 searchTextTask?.cancel()
18 searchTextTask?.cancel()
18 cachedSearchTexts = []
19 cachedSearchTexts = []
20+ searchTextGeneration &+= 1
21+ let generation = searchTextGeneration
19 // Apply the current filter immediately using the inline fallback path
22 // Apply the current filter immediately using the inline fallback path
20 // (the cache was just invalidated). Calling updateFilteredResults()
23 // (the cache was just invalidated). Calling updateFilteredResults()
21 // rather than `filteredResults = results` avoids briefly showing
24 // rather than `filteredResults = results` avoids briefly showing
@@ -24,26 +27,61 @@ final class QueryViewModel {
24 // re-applies the filter.
27 // re-applies the filter.
25 updateFilteredResults()
28 updateFilteredResults()
26 let snapshot = results
29 let snapshot = results
30+ // The class is @MainActor, so this Task inherits main-actor isolation —
31+ // its mutations of cachedSearchTexts/filteredResults are safely serialized
32+ // with SwiftUI reads. Only the pure (and potentially large) displayString
33+ // mapping is pushed off-main via Task.detached so it doesn't block the UI.
27− searchTextTask = Task {
34+ searchTextTask = Task { [weak self] in
35+ let texts = await Task.detached(priority: .userInitiated) {
28− let texts = snapshot.map { record in
36+ snapshot.map { record in
29− record.values.map { $0.displayString }.joined(separator: " ")
37+ record.values.map { $0.displayString }.joined(separator: " ")
30− }
38+ }
39+ }.value
31− guard !Task.isCancelled else { return }
40+ guard let self, !Task.isCancelled else { return }
41+ // Reject a stale write: if `results` was reassigned while this task
42+ // was awaiting, a newer didSet bumped the generation. Task.isCancelled
43+ // alone isn't sufficient because two re-queries producing result sets
44+ // of identical count would otherwise let this stale, same-length
45+ // cachedSearchTexts slip past updateFilteredResults's count guard.
46+ guard generation == self.searchTextGeneration else { return }
32− cachedSearchTexts = texts
47+ self.cachedSearchTexts = texts
33− if !filterText.isEmpty {
48+ if !self.filterText.isEmpty {
34− updateFilteredResults()
49+ self.updateFilteredResults()
35 }
50 }
36 }
51 }
37 }
52 }
38 }
53 }
39 private var cachedSearchTexts: [String] = []
54 private var cachedSearchTexts: [String] = []
40 private var searchTextTask: Task<Void, Never>?
55 private var searchTextTask: Task<Void, Never>?
56+ /// Bumped on every `results` reassignment so the async search-text builder can
57+ /// detect it was superseded and drop a stale write (see results.didSet).
58+ private var searchTextGeneration = 0
41 var columns: [String] = []
59 var columns: [String] = []
42 var columnOrder: [String] = []
60 var columnOrder: [String] = []
43 var hiddenColumns: Set<String> = []
61 var hiddenColumns: Set<String> = []
62+ /// The full persisted set of hidden columns for the current stream, including
63+ /// ones absent from the current result. Hiding a column drops it from the
64+ /// SELECT list, so it's missing from the very next result and `hiddenColumns`
65+ /// (only the columns present) forgets it — the next save would then erase the
66+ /// preference. This keeps the intent, mirroring LiveTailViewModel.
67+ private(set) var savedHiddenColumns: Set<String> = []
68+ /// The persisted column order including columns absent from the current
69+ /// result (the order counterpart of `savedHiddenColumns`), so saving doesn't
70+ /// drop a hidden column's position and it returns to its place when shown.
71+ private var savedColumnOrder: [String] = []
44 var autoHiddenColumns: Set<String> = []
72 var autoHiddenColumns: Set<String> = []
45 /// Column config from a saved query, applied on next query execution.
73 /// Column config from a saved query, applied on next query execution.
46 var pendingColumnConfig: ColumnConfiguration?
74 var pendingColumnConfig: ColumnConfiguration?
75+
76+ /// The connection and stream the editor text and results belong to. The view
77+ /// model outlives QueryView (switching tabs keeps the query), so the view
78+ /// compares this on appear to catch a stream/server change made while another
79+ /// tab was showing.
80+ struct BoundContext: Equatable {
81+ let connectionID: UUID?
82+ let stream: String
83+ }
84+ var boundContext: BoundContext?
47 private var currentStream: String?
85 private var currentStream: String?
48 var isLoading = false
86 var isLoading = false
49 var errorMessage: String?
87 var errorMessage: String?
@@ -61,10 +99,23 @@ final class QueryViewModel {
61 var schemaFields: [SchemaField] = []
99 var schemaFields: [SchemaField] = []
62 /// The stream of the most recent `loadSchema` call, used to drop a stale
100 /// The stream of the most recent `loadSchema` call, used to drop a stale
63 /// in-flight response when the user rapidly switches streams.
101 /// in-flight response when the user rapidly switches streams.
64− private var latestSchemaStream: String?
102+ private(set) var latestSchemaStream: String?
103+ /// The stream of the most recent `executeQuery` call. Defense-in-depth
104+ /// alongside `queryGeneration`/task cancellation: drops a stale in-flight
105+ /// query response if a newer query for a different stream supersedes it
106+ /// without going through the normal cancel path.
107+ private var latestQueryStream: String?
65
108
66 // Query task for cancellation
109 // Query task for cancellation
67 private var queryTask: Task<Void, Never>?
110 private var queryTask: Task<Void, Never>?
111+ /// Bumped at the start of every `executeQuery`. The task captures this value
112+ /// and only clears `isLoading` on exit if it's still the active query — so a
113+ /// superseded query's late cleanup doesn't drop the loading indicator that
114+ /// the newer query set. Without this, `isLoading = false` had to live in
115+ /// every caller of `cancel()` (cancelQuery, clearResults, superseding
116+ /// executeQuery) and a future cancel path that forgot to set it would wedge
117+ /// the spinner indefinitely.
118+ private var queryGeneration = 0
68
119
69 // Time range
120 // Time range
70 var timeRangeOption: TimeRangeOption = .last1Hour
121 var timeRangeOption: TimeRangeOption = .last1Hour
@@ -73,7 +124,10 @@ final class QueryViewModel {
73
124
74 // Query history
125 // Query history
75 var queryHistory: [QueryHistoryEntry] = []
126 var queryHistory: [QueryHistoryEntry] = []
127+ // Derived from the (always-trimmed) history so it stays correct after a
128+ // restored full history is loaded at launch, not just after an in-session
129+ // overflow. `addToHistory` trims to `maxHistory`, so count never exceeds it.
76− var historyIsFull = false
130+ var historyIsFull: Bool { queryHistory.count >= Self.maxHistory }
77 private static let maxHistory = 50
131 private static let maxHistory = 50
78
132
79 struct QueryHistoryEntry: Identifiable, Codable, Sendable {
133 struct QueryHistoryEntry: Identifiable, Codable, Sendable {
@@ -158,9 +212,16 @@ final class QueryViewModel {
158 return columnOrder.filter { !allHidden.contains($0) }
212 return columnOrder.filter { !allHidden.contains($0) }
159 }
213 }
160
214
215+ /// Whether any column is hidden — including saved-hidden columns that aren't
216+ /// in the current result, which "Show All" can bring back.
217+ var hasHiddenColumns: Bool {
218+ !hiddenColumns.isEmpty || !autoHiddenColumns.isEmpty || !savedHiddenColumns.isEmpty
219+ }
220+
161 func toggleColumnVisibility(_ column: String) {
221 func toggleColumnVisibility(_ column: String) {
162 if hiddenColumns.contains(column) {
222 if hiddenColumns.contains(column) {
163 hiddenColumns.remove(column)
223 hiddenColumns.remove(column)
224+ savedHiddenColumns.remove(column)
164 } else if autoHiddenColumns.contains(column) {
225 } else if autoHiddenColumns.contains(column) {
165 autoHiddenColumns.remove(column)
226 autoHiddenColumns.remove(column)
166 } else {
227 } else {
@@ -169,6 +230,7 @@ final class QueryViewModel {
169 let visibleCount = columnOrder.count - allHidden.count
230 let visibleCount = columnOrder.count - allHidden.count
170 if visibleCount > 1 {
231 if visibleCount > 1 {
171 hiddenColumns.insert(column)
232 hiddenColumns.insert(column)
233+ savedHiddenColumns.insert(column)
172 }
234 }
173 }
235 }
174 saveColumnConfig()
236 saveColumnConfig()
@@ -177,6 +239,7 @@ final class QueryViewModel {
177
239
178 func showAllColumns() {
240 func showAllColumns() {
179 hiddenColumns.removeAll()
241 hiddenColumns.removeAll()
242+ savedHiddenColumns.removeAll()
180 autoHiddenColumns.removeAll()
243 autoHiddenColumns.removeAll()
181 saveColumnConfig()
244 saveColumnConfig()
182 updateSQLColumns()
245 updateSQLColumns()
@@ -193,39 +256,129 @@ final class QueryViewModel {
193 let toIndex = columnOrder.firstIndex(of: targetColumn),
256 let toIndex = columnOrder.firstIndex(of: targetColumn),
194 fromIndex != toIndex else { return }
257 fromIndex != toIndex else { return }
195 let item = columnOrder.remove(at: fromIndex)
258 let item = columnOrder.remove(at: fromIndex)
259+ // Match the drop-indicator semantics: a rightward move (fromIndex < toIndex)
260+ // lands the column *after* the target, a leftward move lands it *before*.
261+ // After removing an earlier element the target shifts down by one, so
262+ // inserting at `toIndex` lands after the target for rightward moves; for
263+ // leftward moves the target is unshifted and `toIndex` lands before it.
264+ let adjusted = toIndex
196− columnOrder.insert(item, at: toIndex)
265+ columnOrder.insert(item, at: adjusted)
197 saveColumnConfig()
266 saveColumnConfig()
198 updateSQLColumns()
267 updateSQLColumns()
199 }
268 }
200
269
201 func resetColumnConfig() {
270 func resetColumnConfig() {
202 columnOrder = columns
271 columnOrder = columns
272+ savedColumnOrder = []
203 hiddenColumns.removeAll()
273 hiddenColumns.removeAll()
274+ savedHiddenColumns.removeAll()
204 autoHiddenColumns.removeAll()
275 autoHiddenColumns.removeAll()
205 saveColumnConfig()
276 saveColumnConfig()
206 updateSQLColumns()
277 updateSQLColumns()
207 }
278 }
208
279
209− /// Rewrites the SELECT clause of `sqlQuery` to reflect the current `visibleColumns`.
280+ /// Rewrites the SELECT clause of `sqlQuery` to reflect the user's column
281+ /// choices (order and hidden columns; auto-hidden columns stay display-only).
210 func updateSQLColumns() {
282 func updateSQLColumns() {
211− guard let range = SQLTokenizer.selectColumnListRange(in: sqlQuery) else { return }
283+ guard let range = SQLTokenizer.selectColumnListRange(in: sqlQuery),
212−
284+ let currentList = Self.parseColumnList(sqlQuery[range]) else { return }
213− let visible = visibleColumns
285+
286+ // Project the user's choices only. `autoHiddenColumns` is a display
287+ // heuristic for the current rows (the field is empty in all of them);
288+ // baking it into the SQL would stop the field from ever being fetched
289+ // again, even for a time range where it has values.
290+ let visible = columnOrder.filter { !hiddenColumns.contains($0) }
214 guard !visible.isEmpty else { return }
291 guard !visible.isEmpty else { return }
215
292
293+ // Columns an explicit list names that aren't in the results: Parseable
294+ // omits all-null fields from query JSON, so a sparse listed field can be
295+ // missing from `columns`. Keep it (unless the user hid it) rather than
296+ // silently dropping it from the SELECT on the next rewrite.
297+ var listedButAbsent: [String] = []
298+ var excludesHiddenColumn = false
299+ if case .identifiers(let names) = currentList {
300+ let present = Set(columns)
301+ listedButAbsent = names.filter { !present.contains($0) && !savedHiddenColumns.contains($0) }
302+ // A column the user hid is absent from these results precisely
303+ // because this list was narrowed to exclude it.
304+ excludesHiddenColumn = !savedHiddenColumns.isSubset(of: present)
305+ }
306+
216 let newColumnList: String
307 let newColumnList: String
217 // Use `*` whenever every column is visible, regardless of display order —
308 // Use `*` whenever every column is visible, regardless of display order —
218 // reordering columns (a set-preserving permutation) must not pin the query
309 // reordering columns (a set-preserving permutation) must not pin the query
219− // to an explicit snapshot that hides newly-appearing fields on re-query.
310+ // to an explicit snapshot that hides newly-appearing fields on re-query —
311+ // unless the list is explicit because it leaves out a hidden column, which
312+ // `*` would fetch again.
220− if visible.count == columns.count && Set(visible) == Set(columns) {
313+ if !excludesHiddenColumn && visible.count == columns.count && Set(visible) == Set(columns) {
221 newColumnList = "*"
314 newColumnList = "*"
222 } else {
315 } else {
223− newColumnList = visible.map { Self.escapeSQLIdentifier($0) }.joined(separator: ", ")
316+ newColumnList = (visible + listedButAbsent).map { Self.escapeSQLIdentifier($0) }.joined(separator: ", ")
224 }
317 }
225
318
226 sqlQuery.replaceSubrange(range, with: newColumnList)
319 sqlQuery.replaceSubrange(range, with: newColumnList)
227 }
320 }
228
321
322+ enum ColumnListShape: Equatable {
323+ case star
324+ /// Plain column references, as the column names they resolve to.
325+ case identifiers([String])
326+ }
327+
328+ /// Parses a SELECT list the column manager can regenerate without changing
329+ /// what the query computes: `*`, or a comma-separated list of plain column
330+ /// references. Anything else — aggregates, expressions, aliases
331+ /// (`count(*) AS n`), qualified names — returns nil: rewriting it with `*` or
332+ /// bare result keys would break the query, so the column manager then only
333+ /// hides/reorders client-side.
334+ nonisolated static func parseColumnList(_ list: Substring) -> ColumnListShape? {
335+ let tokens = SQLTokenizer.tokenize(String(list)).filter { !$0.kind.isTrivia }
336+ if tokens.count == 1, tokens[0].kind == .star { return .star }
337+ guard tokens.count % 2 == 1 else { return nil }
338+ var names: [String] = []
339+ for (index, token) in tokens.enumerated() {
340+ if index % 2 == 1 {
341+ guard token.kind == .comma else { return nil }
342+ continue
343+ }
344+ switch token.kind {
345+ // DataFusion folds unquoted identifiers to lowercase.
346+ case .identifier(let name): names.append(name.lowercased())
347+ case .quotedIdentifier(let name): names.append(name)
348+ default: return nil
349+ }
350+ }
351+ return .identifiers(names)
352+ }
353+
354+ /// Inserts `newColumns` into `order`, placing each one that appears in
355+ /// `reference` (a saved order) right after its nearest saved predecessor
356+ /// that's already placed — or first if none is — and appending the rest.
357+ /// Used to keep the positions of columns that are absent for a while.
358+ nonisolated static func mergedColumnOrder(
359+ _ order: [String], inserting newColumns: [String], reference: [String]
360+ ) -> [String] {
361+ var result = order
362+ let referenceIndex = Dictionary(reference.enumerated().map { ($1, $0) }, uniquingKeysWith: { first, _ in first })
363+ let pending = newColumns.filter { referenceIndex[$0] == nil }
364+ let positioned = newColumns
365+ .filter { referenceIndex[$0] != nil }
366+ .sorted { referenceIndex[$0]! < referenceIndex[$1]! }
367+ for column in positioned where !result.contains(column) {
368+ let savedIndex = referenceIndex[column]!
369+ let predecessor = reference[..<savedIndex].reversed().first { result.contains($0) }
370+ if let predecessor, let position = result.firstIndex(of: predecessor) {
371+ result.insert(column, at: position + 1)
372+ } else {
373+ result.insert(column, at: 0)
374+ }
375+ }
376+ for column in pending where !result.contains(column) {
377+ result.append(column)
378+ }
379+ return result
380+ }
381+
229 // MARK: - Column Configuration Persistence
382 // MARK: - Column Configuration Persistence
230
383
231 struct ColumnConfiguration: Codable {
384 struct ColumnConfiguration: Codable {
@@ -239,7 +392,16 @@ final class QueryViewModel {
239
392
240 private func saveColumnConfig() {
393 private func saveColumnConfig() {
241 guard let stream = currentStream else { return }
394 guard let stream = currentStream else { return }
395+ // Persist the full intended hidden set and order, not just the currently
396+ // present columns (see `savedHiddenColumns` / `savedColumnOrder`).
397+ let present = Set(columnOrder)
398+ let order = Self.mergedColumnOrder(
399+ columnOrder,
400+ inserting: savedColumnOrder.filter { !present.contains($0) },
401+ reference: savedColumnOrder
402+ )
403+ savedColumnOrder = order
242− let config = ColumnConfiguration(order: columnOrder, hidden: hiddenColumns)
404+ let config = ColumnConfiguration(order: order, hidden: savedHiddenColumns.union(hiddenColumns))
243 if let data = try? JSONEncoder().encode(config) {
405 if let data = try? JSONEncoder().encode(config) {
244 UserDefaults.standard.set(data, forKey: Self.columnConfigKey(for: stream))
406 UserDefaults.standard.set(data, forKey: Self.columnConfigKey(for: stream))
245 }
407 }
@@ -268,7 +430,9 @@ final class QueryViewModel {
268
430
269 guard let config else {
431 guard let config else {
270 columnOrder = extractedColumns
432 columnOrder = extractedColumns
433+ savedColumnOrder = []
271 hiddenColumns = []
434 hiddenColumns = []
435+ savedHiddenColumns = []
272 return
436 return
273 }
437 }
274
438
@@ -281,7 +445,20 @@ final class QueryViewModel {
281 merged.append(col)
445 merged.append(col)
282 }
446 }
283 columnOrder = merged
447 columnOrder = merged
448+ savedColumnOrder = config.order
449+ savedHiddenColumns = config.hidden
284 hiddenColumns = config.hidden.intersection(extractedSet)
450 hiddenColumns = config.hidden.intersection(extractedSet)
451+
452+ // The `visibleCount > 1` guard in toggleColumnVisibility only covers
453+ // user toggles. A saved config can hide every currently-extracted column
454+ // (e.g. the stream's schema shrank so only previously-hidden columns
455+ // remain), which would render the results table with zero columns and
456+ // leave `updateSQLColumns` early-returning on the empty case. Keep at
457+ // least the first column visible. Mirrors LiveTailViewModel.updateColumns.
458+ if !columnOrder.isEmpty, hiddenColumns.count >= columnOrder.count,
459+ let firstColumn = columnOrder.first {
460+ hiddenColumns.remove(firstColumn)
461+ }
285 }
462 }
286
463
287 private(set) var filteredResults: [LogRecord] = []
464 private(set) var filteredResults: [LogRecord] = []
@@ -363,6 +540,13 @@ final class QueryViewModel {
363
540
364 // Cancel any in-flight query
541 // Cancel any in-flight query
365 queryTask?.cancel()
542 queryTask?.cancel()
543+ // Bump the generation so the in-flight task's cleanup can detect that
544+ // it has been superseded and skip its `isLoading = false` (which would
545+ // otherwise race with the `isLoading = true` below and briefly drop
546+ // the loading indicator during this query).
547+ queryGeneration &+= 1
548+ let generation = queryGeneration
549+ latestQueryStream = stream
366
550
367 isLoading = true
551 isLoading = true
368 errorMessage = nil
552 errorMessage = nil
@@ -371,6 +555,16 @@ final class QueryViewModel {
371 let startTime = CFAbsoluteTimeGetCurrent()
555 let startTime = CFAbsoluteTimeGetCurrent()
372
556
373 let task = Task {
557 let task = Task {
558+ // Always clear the spinner on exit when this is still the active
559+ // query. The defer runs on every path (success, error, and the
560+ // cancellation cases that previously returned before reaching the
561+ // trailing `isLoading = false`), so the spinner can never wedge
562+ // even if a new cancel path is added without resetting it.
563+ defer {
564+ if generation == queryGeneration {
565+ isLoading = false
566+ }
567+ }
374 do {
568 do {
375 try Task.checkCancellation()
569 try Task.checkCancellation()
376 let queryResults = try await client.query(sql: sql, startTime: startDate, endTime: endDate)
570 let queryResults = try await client.query(sql: sql, startTime: startDate, endTime: endDate)
@@ -380,14 +574,25 @@ final class QueryViewModel {
380 // cancellation landing just after the check above would otherwise
574 // cancellation landing just after the check above would otherwise
381 // let a stale query clobber the newer one's results/columns.
575 // let a stale query clobber the newer one's results/columns.
382 guard !Task.isCancelled else { return }
576 guard !Task.isCancelled else { return }
577+ // Drop the response if a newer query (possibly for a different
578+ // stream) has since been launched without cancelling this task.
579+ guard latestQueryStream == stream else { return }
383
580
384 results = queryResults
581 results = queryResults
582+ // Drop any prior row selection: the new result set reassigns row
583+ // identities, so a retained selectedLogEntry would either dangle or
584+ // value-match an unrelated row, leaving a stale detail inspector.
585+ selectedLogEntry = nil
385 queryDuration = CFAbsoluteTimeGetCurrent() - startTime
586 queryDuration = CFAbsoluteTimeGetCurrent() - startTime
386 resultCount = results.count
587 resultCount = results.count
387
588
388− // Detect if results were likely truncated (only meaningful
589+ // Detect if results were likely truncated. We know the cap for
389− // for auto-generated queries where we control the LIMIT)
590+ // auto-generated queries; for user-entered SQL (which includes the
591+ // default query `setDefaultQuery` writes into the editor — the common
592+ // stream-selection path), parse a trailing `LIMIT n` so the indicator
593+ // still fires when the row count lands exactly on the limit.
594+ let effectiveLimit = usedAutoLimit ? limit : Self.trailingLimit(in: sql)
390− resultsTruncated = usedAutoLimit && results.count == limit
595+ resultsTruncated = effectiveLimit.map { results.count == $0 } ?? false
391
596
392 // Extract columns in a single pass, with priority fields first,
597 // Extract columns in a single pass, with priority fields first,
393 // then apply any saved column configuration for this stream
598 // then apply any saved column configuration for this stream
@@ -414,12 +619,23 @@ final class QueryViewModel {
414 return
619 return
415 } catch {
620 } catch {
416 guard !Task.isCancelled else { return }
621 guard !Task.isCancelled else { return }
622+ // Same supersession guard as the success path: a stale failed
623+ // query must not clobber a newer query's state.
624+ guard latestQueryStream == stream else { return }
417 errorMessage = ParseableError.userFriendlyMessage(for: error)
625 errorMessage = ParseableError.userFriendlyMessage(for: error)
418 if let serverError = error as? ParseableError,
626 if let serverError = error as? ParseableError,
419 case .serverError(_, let msg) = serverError,
627 case .serverError(_, let msg) = serverError,
420− let pos = SQLErrorPosition.parse(from: msg) {
628+ let pos = SQLErrorPosition.parse(from: msg),
629+ sqlQuery == sql {
630+ // The server's line/column is relative to the *submitted* SQL.
631+ // Only map it back into the editor when the editor still holds
632+ // that same text — if the user edited the query while it was in
633+ // flight, the position no longer corresponds to what's displayed
634+ // and would underline an unrelated span. (`sqlQuery`'s didSet
635+ // already cleared errorRange on that edit, so leaving it nil here
636+ // keeps the editor unmarked.)
421 errorRange = SQLTokenizer.errorHighlightRange(
637 errorRange = SQLTokenizer.errorHighlightRange(
422− line: pos.line, column: pos.column, in: sqlQuery
638+ line: pos.line, column: pos.column, in: sql
423 )
639 )
424 }
640 }
425 results = []
641 results = []
@@ -429,10 +645,10 @@ final class QueryViewModel {
429 // report stale column names after the failure.
645 // report stale column names after the failure.
430 columnOrder = []
646 columnOrder = []
431 hiddenColumns = []
647 hiddenColumns = []
648+ savedHiddenColumns = []
649+ savedColumnOrder = []
432 autoHiddenColumns = []
650 autoHiddenColumns = []
433 }
651 }
434−
435− isLoading = false
436 }
652 }
437 queryTask = task
653 queryTask = task
438 await task.value
654 await task.value
@@ -503,25 +719,31 @@ final class QueryViewModel {
503 return empty
719 return empty
504 }
720 }
505
721
722+ /// Exports the on-screen rows as JSON. Uses `filteredResults` (not the full
723+ /// `results`) so the output matches what the user sees with an active result
724+ /// filter, mirroring the view's export path.
506 func exportAsJSON() -> String {
725 func exportAsJSON() -> String {
726+ let rows = filteredResults
507− guard !results.isEmpty else { return "[]" }
727+ guard !rows.isEmpty else { return "[]" }
508 let encoder = JSONEncoder()
728 let encoder = JSONEncoder()
509 encoder.outputFormatting = [.prettyPrinted, .sortedKeys]
729 encoder.outputFormatting = [.prettyPrinted, .sortedKeys]
510− if let data = try? encoder.encode(Self.projectRecords(results, to: visibleColumns)),
730+ if let data = try? encoder.encode(Self.projectRecords(rows, to: visibleColumns)),
511 let string = String(data: data, encoding: .utf8) {
731 let string = String(data: data, encoding: .utf8) {
512 return string
732 return string
513 }
733 }
514 return "[]"
734 return "[]"
515 }
735 }
516
736
737+ /// Exports the on-screen rows as CSV. Uses `filteredResults` for the same
738+ /// reason as `exportAsJSON`.
517 func exportAsCSV() -> String {
739 func exportAsCSV() -> String {
518− Self.buildCSV(records: results, columns: visibleColumns)
740+ Self.buildCSV(records: filteredResults, columns: visibleColumns)
519 }
741 }
520
742
521 /// Projects each record down to the given columns so JSON export, like CSV,
743 /// Projects each record down to the given columns so JSON export, like CSV,
522 /// respects hidden columns instead of leaking every field. Safe to call from
744 /// respects hidden columns instead of leaking every field. Safe to call from
523 /// any thread.
745 /// any thread.
524− static func projectRecords(_ records: [LogRecord], to columns: [String]) -> [LogRecord] {
746+ nonisolated static func projectRecords(_ records: [LogRecord], to columns: [String]) -> [LogRecord] {
525 // No column list means no restriction (columns not computed yet) — export
747 // No column list means no restriction (columns not computed yet) — export
526 // the full records rather than stripping every field to an empty object.
748 // the full records rather than stripping every field to an empty object.
527 guard !columns.isEmpty else { return records }
749 guard !columns.isEmpty else { return records }
@@ -530,7 +752,7 @@ final class QueryViewModel {
530 }
752 }
531
753
532 /// Builds a CSV string from records and columns. Safe to call from any thread.
754 /// Builds a CSV string from records and columns. Safe to call from any thread.
533− static func buildCSV(records: [LogRecord], columns: [String]) -> String {
755+ nonisolated static func buildCSV(records: [LogRecord], columns: [String]) -> String {
534 guard !records.isEmpty, !columns.isEmpty else { return "" }
756 guard !records.isEmpty, !columns.isEmpty else { return "" }
535
757
536 var csv = String()
758 var csv = String()
@@ -556,7 +778,7 @@ final class QueryViewModel {
556 return csv
778 return csv
557 }
779 }
558
780
559− private static func escapeCSV(_ value: String) -> String {
781+ nonisolated private static func escapeCSV(_ value: String) -> String {
560 // Check at the Unicode scalar level to correctly detect \r and \n
782 // Check at the Unicode scalar level to correctly detect \r and \n
561 // inside \r\n (CRLF) grapheme clusters, which Swift's String.contains
783 // inside \r\n (CRLF) grapheme clusters, which Swift's String.contains
562 // treats as a single Character that doesn't match "\r" or "\n" alone.
784 // treats as a single Character that doesn't match "\r" or "\n" alone.
@@ -581,10 +803,18 @@ final class QueryViewModel {
581 filterTask?.cancel()
803 filterTask?.cancel()
582 filterTask = nil
804 filterTask = nil
583
805
806+ // The in-flight query was cancelled above, so its task returns via the
807+ // CancellationError path and never reaches `isLoading = false`. Reset it
808+ // here, otherwise deselecting the stream / disconnecting (which calls
809+ // clearResults with no follow-up query) leaves the spinner stuck on.
810+ isLoading = false
811+
584 results = []
812 results = []
585 columns = []
813 columns = []
586 columnOrder = []
814 columnOrder = []
587 hiddenColumns = []
815 hiddenColumns = []
816+ savedHiddenColumns = []
817+ savedColumnOrder = []
588 autoHiddenColumns = []
818 autoHiddenColumns = []
589 currentStream = nil
819 currentStream = nil
590 resultCount = 0
820 resultCount = 0
@@ -594,6 +824,11 @@ final class QueryViewModel {
594 errorRange = nil
824 errorRange = nil
595 resultsTruncated = false
825 resultsTruncated = false
596 schemaFields = []
826 schemaFields = []
827+ // Keep the stream trackers consistent with the cleared schema/results so
828+ // a later reload guard never sees a non-nil stream paired with empty data.
829+ latestSchemaStream = nil
830+ latestQueryStream = nil
831+ boundContext = nil
597 }
832 }
598
833
599 /// Modifies `sqlQuery` to add a column-value filter condition.
834 /// Modifies `sqlQuery` to add a column-value filter condition.
@@ -603,51 +838,139 @@ final class QueryViewModel {
603 let condition: String
838 let condition: String
604 if let value, value != .null {
839 if let value, value != .null {
605 let literal = value.sqlLiteral
840 let literal = value.sqlLiteral
841+ // Exclude with `IS DISTINCT FROM`, not `<>`: under SQL's three-valued
842+ // logic `NULL <> 'x'` is NULL, so `<>` also dropped every row where the
843+ // column is missing/NULL — common for sparse fields — which "exclude
844+ // this value" doesn't ask for (the live-tail exclude keeps them too).
845+ condition = exclude
846+ ? "\(escapedCol) IS DISTINCT FROM \(literal)"
606− condition = exclude ? "\(escapedCol) <> \(literal)" : "\(escapedCol) = \(literal)"
847+ : "\(escapedCol) = \(literal)"
607 } else {
848 } else {
608 condition = exclude ? "\(escapedCol) IS NOT NULL" : "\(escapedCol) IS NULL"
849 condition = exclude ? "\(escapedCol) IS NOT NULL" : "\(escapedCol) IS NULL"
609 }
850 }
610
851
611− var sql = sqlQuery.trimmingCharacters(in: .whitespacesAndNewlines)
852+ let sql = sqlQuery.trimmingCharacters(in: .whitespacesAndNewlines)
612 if sql.isEmpty { return }
853 if sql.isEmpty { return }
613
854
614 // Locate the top-level WHERE and the first trailing clause using the
855 // Locate the top-level WHERE and the first trailing clause using the
615 // tokenizer rather than a raw regex, so keywords inside quoted
856 // tokenizer rather than a raw regex, so keywords inside quoted
616 // identifiers, string literals, comments, or subqueries are ignored.
857 // identifiers, string literals, comments, or subqueries are ignored.
617− let tailKeywords: Set<String> = ["ORDER", "GROUP", "HAVING", "LIMIT", "OFFSET"]
858+ let tokens = SQLTokenizer.tokenize(sql)
618 var depth = 0
859 var depth = 0
619− var whereEnd: String.Index?
860+ // WHERE and the trailing clauses only follow the top-level FROM. Keywords
620− var tailStart: String.Index?
861+ // before it belong to the SELECT list — e.g. a field named `offset` or
621− for token in SQLTokenizer.tokenize(sql) {
862+ // `limit` (which the tokenizer reports as keywords) — and must not be
863+ // mistaken for clause starts.
864+ var fromSeen = false
865+ var whereIndex: Int?
866+ var tailIndex: Int?
867+ // Track whether the existing WHERE body has a top-level OR — only then does
868+ // AND-ing a new condition need parentheses to preserve precedence.
869+ var bodyHasTopLevelOr = false
870+ // The last token that is actual SQL — not whitespace, a comment, or a
871+ // statement-terminating `;`. New text is spliced in right after such
872+ // tokens (never at the raw end of the string), so a trailing `;` or a
873+ // trailing `-- comment` stays at the end instead of swallowing the
874+ // appended condition: `... LIMIT 10;` must not become `... LIMIT 10; WHERE`,
875+ // and `... -- note` must not become `... -- note WHERE <commented out>`.
876+ func isSignificant(_ token: SQLTokenizer.Token) -> Bool {
877+ !token.kind.isTrivia && token.kind != .other(";")
878+ }
879+ func nextSignificantKind(after index: Int) -> SQLTokenizer.TokenKind? {
880+ tokens[(index + 1)...].first(where: { isSignificant($0) })?.kind
881+ }
882+ // Whether the keyword at `index` actually opens a trailing clause. ORDER and
883+ // GROUP must be followed by BY (so `WITHIN GROUP (…)` or a column named
884+ // `order` doesn't count), and LIMIT/OFFSET by their row count (so a column
885+ // named `offset` in `WHERE offset > 5` doesn't cut the WHERE body short).
886+ func startsTrailingClause(_ keyword: String, at index: Int) -> Bool {
887+ switch keyword {
888+ case "ORDER", "GROUP":
889+ return nextSignificantKind(after: index) == .keyword("BY")
890+ case "LIMIT", "OFFSET":
891+ if case .number = nextSignificantKind(after: index) { return true }
892+ return false
893+ case "HAVING":
894+ return true
895+ default:
896+ return false
897+ }
898+ }
899+ var lastSignificant: Int?
900+ for (idx, token) in tokens.enumerated() {
901+ if isSignificant(token) { lastSignificant = idx }
622 switch token.kind {
902 switch token.kind {
623 case .leftParen:
903 case .leftParen:
624 depth += 1
904 depth += 1
625 case .rightParen:
905 case .rightParen:
626 depth = max(0, depth - 1)
906 depth = max(0, depth - 1)
907+ case .keyword("FROM") where depth == 0:
908+ fromSeen = true
627− case .keyword(let kw) where depth == 0:
909+ case .keyword(let kw) where depth == 0 && fromSeen:
628− if kw == "WHERE", whereEnd == nil {
910+ if kw == "WHERE", whereIndex == nil {
629− whereEnd = token.range.upperBound
911+ whereIndex = idx
630− } else if tailKeywords.contains(kw), tailStart == nil {
912+ // A trailing clause seen *before* the WHERE (e.g. the GROUP BY of
631− tailStart = token.range.lowerBound
913+ // the first SELECT in `... GROUP BY a UNION ALL SELECT ... WHERE`)
914+ // doesn't end this WHERE's body. Keeping it would make the body
915+ // range run backwards (a trapping `Range`), so look for the
916+ // body's end afresh after the WHERE.
917+ tailIndex = nil
918+ } else if tailIndex == nil, startsTrailingClause(kw, at: idx) {
919+ tailIndex = idx
920+ } else if kw == "OR", whereIndex != nil, tailIndex == nil {
921+ bodyHasTopLevelOr = true
632 }
922 }
633 default:
923 default:
634 break
924 break
635 }
925 }
636 }
926 }
637−
927+ // Nothing but comments/semicolons — there's no statement to filter.
638− if let whereEnd {
928+ guard let lastSignificant else { return }
639− if let tailStart, tailStart > whereEnd {
929+
640− sql.insert(contentsOf: "AND \(condition) ", at: tailStart)
930+ // Each branch rebuilds the query from slices of the original text, keeping
931+ // the user's line breaks: joining pieces with single spaces would pull the
932+ // text after a `-- comment` onto the comment's line and comment it out.
933+ // Text spliced directly in front of another token (`(a = 1)ORDER BY`) needs
934+ // a space so the two don't fuse (`... IS NULLORDER BY`).
935+ func separated(_ rest: Substring) -> String {
936+ guard let next = rest.first, !next.isWhitespace, next != ";" else { return String(rest) }
937+ return " " + rest
938+ }
939+ if let whereIndex {
940+ // The WHERE body runs up to the first trailing clause, or to the end
941+ // of the statement. Only its significant tokens bound the splice, so a
942+ // comment between the body and ORDER BY stays after the new condition.
943+ let bodyEndIndex = tailIndex ?? (lastSignificant + 1)
944+ let bodyIndices = (whereIndex + 1)..<bodyEndIndex
945+ if let first = bodyIndices.first(where: { isSignificant(tokens[$0]) }),
946+ let last = bodyIndices.last(where: { isSignificant(tokens[$0]) }) {
947+ // Parenthesize the existing WHERE body before AND-ing the new
948+ // condition only when it has a top-level OR, otherwise the OR
949+ // silently changes meaning: `WHERE a = 1 OR b = 2` would become
950+ // `WHERE a = 1 OR b = 2 AND c = 3`, which SQL binds as
951+ // `a = 1 OR (b = 2 AND c = 3)` rather than the intended
952+ // `(a = 1 OR b = 2) AND c = 3`. A body without a top-level OR is
953+ // left un-parenthesized so simple filters stay readable.
954+ let bodyStart = tokens[first].range.lowerBound
955+ let bodyEnd = tokens[last].range.upperBound
956+ let body = sql[bodyStart..<bodyEnd]
957+ let wrappedBody = bodyHasTopLevelOr ? "(\(body))" : String(body)
958+ sqlQuery = "\(sql[..<bodyStart])\(wrappedBody) AND \(condition)\(separated(sql[bodyEnd...]))"
641 } else {
959 } else {
642− sql += " AND \(condition)"
960+ // An empty WHERE body (e.g. a half-written `... WHERE` with no
961+ // condition yet, or `WHERE` immediately followed by ORDER/GROUP/
962+ // LIMIT) would otherwise produce `WHERE AND <condition>` — a
963+ // dangling AND with no left operand. Drop the AND in that case.
964+ let whereEnd = tokens[whereIndex].range.upperBound
965+ sqlQuery = "\(sql[..<whereEnd]) \(condition)\(separated(sql[whereEnd...]))"
643 }
966 }
644− } else if let tailStart {
967+ } else if let tailIndex {
968+ let tailStart = tokens[tailIndex].range.lowerBound
645− sql.insert(contentsOf: "WHERE \(condition) ", at: tailStart)
969+ sqlQuery = "\(sql[..<tailStart])WHERE \(condition) \(sql[tailStart...])"
646 } else {
970 } else {
647− sql += " WHERE \(condition)"
971+ let statementEnd = tokens[lastSignificant].range.upperBound
972+ sqlQuery = "\(sql[..<statementEnd]) WHERE \(condition)\(separated(sql[statementEnd...]))"
648 }
973 }
649−
650− sqlQuery = sql
651 }
974 }
652
975
653 /// Sets the default query for the given stream, returning `true` if the
976 /// Sets the default query for the given stream, returning `true` if the
@@ -671,26 +994,41 @@ final class QueryViewModel {
671 }
994 }
672
995
673 /// Escapes a SQL identifier by doubling internal double-quotes, then wrapping in double-quotes.
996 /// Escapes a SQL identifier by doubling internal double-quotes, then wrapping in double-quotes.
674− static func escapeSQLIdentifier(_ identifier: String) -> String {
997+ nonisolated static func escapeSQLIdentifier(_ identifier: String) -> String {
675 "\"\(identifier.replacingOccurrences(of: "\"", with: "\"\""))\""
998 "\"\(identifier.replacingOccurrences(of: "\"", with: "\"\""))\""
676 }
999 }
677
1000
1001+ /// Extracts the row cap from a trailing `LIMIT n` clause (tolerating a trailing
1002+ /// semicolon/whitespace), used to detect when a result set was truncated.
1003+ /// Returns nil when there's no recognizable trailing `LIMIT` — e.g. when the
1004+ /// query uses `LIMIT n OFFSET m` — so we never report a false truncation.
1005+ nonisolated static func trailingLimit(in sql: String) -> Int? {
1006+ let pattern = #"(?i)\bLIMIT\s+(\d+)\s*;?\s*$"#
1007+ guard let regex = try? NSRegularExpression(pattern: pattern) else { return nil }
1008+ let range = NSRange(sql.startIndex..<sql.endIndex, in: sql)
1009+ guard let match = regex.firstMatch(in: sql, range: range),
1010+ let numberRange = Range(match.range(at: 1), in: sql) else { return nil }
1011+ return Int(sql[numberRange])
1012+ }
1013+
678 // MARK: - Query History
1014 // MARK: - Query History
679
1015
680 private func addToHistory(_ entry: QueryHistoryEntry) {
1016 private func addToHistory(_ entry: QueryHistoryEntry) {
1017+ // Start from what's persisted, not this instance's copy: a model created
1018+ // earlier (e.g. one whose query finished after a reconnect replaced it)
1019+ // would otherwise overwrite newer entries saved by the current one.
1020+ queryHistory = Self.loadHistory()
681 // Deduplicate consecutive identical queries
1021 // Deduplicate consecutive identical queries
682 if queryHistory.first?.sql == entry.sql { return }
1022 if queryHistory.first?.sql == entry.sql { return }
683 queryHistory.insert(entry, at: 0)
1023 queryHistory.insert(entry, at: 0)
684 if queryHistory.count > Self.maxHistory {
1024 if queryHistory.count > Self.maxHistory {
685 queryHistory = Array(queryHistory.prefix(Self.maxHistory))
1025 queryHistory = Array(queryHistory.prefix(Self.maxHistory))
686− historyIsFull = true
687 }
1026 }
688 Self.saveHistory(queryHistory)
1027 Self.saveHistory(queryHistory)
689 }
1028 }
690
1029
691 func clearHistory() {
1030 func clearHistory() {
692 queryHistory = []
1031 queryHistory = []
693− historyIsFull = false
694 Self.saveHistory([])
1032 Self.saveHistory([])
695 }
1033 }
696
1034
ParseableViewer/Views/AlertsView.swift+28−6
@@ -5,6 +5,10 @@ struct AlertsView: View {
5 @State private var alertConfig: AlertConfig?
5 @State private var alertConfig: AlertConfig?
6 @State private var isLoading = false
6 @State private var isLoading = false
7 @State private var errorMessage: String?
7 @State private var errorMessage: String?
8+ /// Bumped by every load. SwiftUI cancels the previous `.task(id:)` load on a
9+ /// stream switch, but that load still resumes (with a cancellation error) —
10+ /// and must not touch `isLoading`, which now belongs to the newer load.
11+ @State private var loadGeneration = 0
8
12
9 var body: some View {
13 var body: some View {
10 Group {
14 Group {
@@ -63,7 +67,7 @@ struct AlertsView: View {
63 .frame(maxWidth: .infinity, maxHeight: .infinity)
67 .frame(maxWidth: .infinity, maxHeight: .infinity)
64 }
68 }
65 }
69 }
66− .task(id: stream) {
70+ .task(id: "\(appState.activeConnection?.id.uuidString ?? "")|\(stream)") {
67 await loadAlerts(stream: stream)
71 await loadAlerts(stream: stream)
68 }
72 }
69 } else {
73 } else {
@@ -76,19 +80,31 @@ struct AlertsView: View {
76
80
77 private func loadAlerts(stream: String) async {
81 private func loadAlerts(stream: String) async {
78 guard let client = appState.client else { return }
82 guard let client = appState.client else { return }
83+ let connID = appState.activeConnection?.id
84+ loadGeneration &+= 1
85+ let generation = loadGeneration
79 isLoading = true
86 isLoading = true
80 errorMessage = nil
87 errorMessage = nil
81 alertConfig = nil
88 alertConfig = nil
82
89
83 do {
90 do {
84 let config = try await client.getAlerts(stream: stream)
91 let config = try await client.getAlerts(stream: stream)
92+ // Superseded by a newer load: leave its spinner and state alone.
93+ guard generation == loadGeneration else { return }
85 // The manual Refresh/Retry buttons spawn detached Tasks not tied to
94 // The manual Refresh/Retry buttons spawn detached Tasks not tied to
86 // `.task(id: stream)` cancellation, so a slow refresh for the old stream
95 // `.task(id: stream)` cancellation, so a slow refresh for the old stream
87− // can resolve after a switch. Drop stale results.
96+ // can resolve after a switch. Drop stale results. Check the connection
97+ // id too: two servers can expose a same-named stream, so a name-only
98+ // guard could write the previous server's alerts after a server switch.
99+ // Still clear isLoading on the stale path: this task set the spinner,
100+ // so leaving it true wedges the ProgressView and disables Refresh.
88− guard stream == appState.selectedStream else { return }
101+ guard stream == appState.selectedStream,
102+ connID == appState.activeConnection?.id else { isLoading = false; return }
89 alertConfig = config
103 alertConfig = config
90 } catch {
104 } catch {
105+ guard generation == loadGeneration else { return }
91− guard stream == appState.selectedStream else { return }
106+ guard stream == appState.selectedStream,
107+ connID == appState.activeConnection?.id else { isLoading = false; return }
92 errorMessage = ParseableError.userFriendlyMessage(for: error)
108 errorMessage = ParseableError.userFriendlyMessage(for: error)
93 }
109 }
94
110
@@ -196,8 +212,14 @@ struct AlertRowView: View {
196 private var stateColor: Color {
212 private var stateColor: Color {
197 switch alert.state?.lowercased() {
213 switch alert.state?.lowercased() {
198 case "triggered": return .red
214 case "triggered": return .red
199− case "disabled": return .gray
215+ case "disabled", "silenced": return .gray
200− default: return .orange
216+ // Parseable's default, healthy state (and 2.x's "resolved") — not a warning.
217+ case "not-triggered", "resolved": return .green
218+ // A reported-but-unrecognized state is a genuine "needs attention" signal,
219+ // so tint it orange; a missing state is simply unknown and stays neutral so
220+ // the bell icon isn't painted as a warning for every stateless alert.
221+ case .some: return .orange
222+ case nil: return .secondary
201 }
223 }
202 }
224 }
203
225
ParseableViewer/Views/ConnectionSheet.swift+34−6
@@ -18,6 +18,10 @@ struct ConnectionSheet: View {
18 /// retry (after a failed connect) updates it in place rather than appending a
18 /// retry (after a failed connect) updates it in place rather than appending a
19 /// duplicate.
19 /// duplicate.
20 @State private var createdConnection: ServerConnection?
20 @State private var createdConnection: ServerConnection?
21+ /// Bumped on every field edit and every test run, so an in-flight Test
22+ /// Connection whose fields have since changed doesn't post its (now stale)
23+ /// success/failure under values that were never tested.
24+ @State private var testGeneration = 0
21
25
22 enum TestResult {
26 enum TestResult {
23 case success(String)
27 case success(String)
@@ -43,6 +47,7 @@ struct ConnectionSheet: View {
43 TextField("Name:", text: $name)
47 TextField("Name:", text: $name)
44 .textFieldStyle(.roundedBorder)
48 .textFieldStyle(.roundedBorder)
45 .accessibilityLabel("Connection name")
49 .accessibilityLabel("Connection name")
50+ .onChange(of: name) { _, _ in invalidateTestResult() }
46
51
47 TextField("Server URL:", text: $url)
52 TextField("Server URL:", text: $url)
48 .textFieldStyle(.roundedBorder)
53 .textFieldStyle(.roundedBorder)
@@ -50,7 +55,7 @@ struct ConnectionSheet: View {
50 .onChange(of: url) { _, newValue in
55 .onChange(of: url) { _, newValue in
51 urlValidationError = Self.validateURL(newValue)
56 urlValidationError = Self.validateURL(newValue)
52 // A prior test result no longer reflects the edited fields.
57 // A prior test result no longer reflects the edited fields.
53− testResult = nil
58+ invalidateTestResult()
54 }
59 }
55
60
56 if let urlError = urlValidationError, !url.isEmpty {
61 if let urlError = urlValidationError, !url.isEmpty {
@@ -62,12 +67,12 @@ struct ConnectionSheet: View {
62 TextField("Username:", text: $username)
67 TextField("Username:", text: $username)
63 .textFieldStyle(.roundedBorder)
68 .textFieldStyle(.roundedBorder)
64 .accessibilityLabel("Username")
69 .accessibilityLabel("Username")
65− .onChange(of: username) { _, _ in testResult = nil }
70+ .onChange(of: username) { _, _ in invalidateTestResult() }
66
71
67 SecureField("Password:", text: $password)
72 SecureField("Password:", text: $password)
68 .textFieldStyle(.roundedBorder)
73 .textFieldStyle(.roundedBorder)
69 .accessibilityLabel("Password")
74 .accessibilityLabel("Password")
70− .onChange(of: password) { _, _ in testResult = nil }
75+ .onChange(of: password) { _, _ in invalidateTestResult() }
71 }
76 }
72 .formStyle(.grouped)
77 .formStyle(.grouped)
73 .padding()
78 .padding()
@@ -123,6 +128,13 @@ struct ConnectionSheet: View {
123 .accessibilityLabel("Test connection")
128 .accessibilityLabel("Test connection")
124
129
125 Button("Cancel") {
130 Button("Cancel") {
131+ // A new connection is persisted on the first save so a retry
132+ // updates it in place. If the user abandons it via Cancel
133+ // (e.g. after a failed connect), remove that never-connected
134+ // entry so it doesn't linger in the sidebar.
135+ if let created = createdConnection {
136+ appState.removeConnection(created)
137+ }
126 password = ""
138 password = ""
127 dismiss()
139 dismiss()
128 }
140 }
@@ -157,9 +169,16 @@ struct ConnectionSheet: View {
157 }
169 }
158 }
170 }
159
171
172+ private func invalidateTestResult() {
173+ testGeneration += 1
174+ testResult = nil
175+ }
176+
160 private func testConnection() {
177 private func testConnection() {
161 isTesting = true
178 isTesting = true
162 testResult = nil
179 testResult = nil
180+ testGeneration += 1
181+ let generation = testGeneration
163
182
164 Task {
183 Task {
165 do {
184 do {
@@ -171,14 +190,19 @@ struct ConnectionSheet: View {
171 )
190 )
172 let client = try ParseableClient(connection: conn)
191 let client = try ParseableClient(connection: conn)
173 try await client.checkHealth()
192 try await client.checkHealth()
193+ // The health endpoint doesn't check credentials; see
194+ // AppState.verifyCredentials.
195+ try await AppState.verifyCredentials(with: client)
174 await MainActor.run {
196 await MainActor.run {
175− testResult = .success("Connection successful")
176 isTesting = false
197 isTesting = false
198+ guard generation == testGeneration else { return }
199+ testResult = .success("Connection successful")
177 }
200 }
178 } catch {
201 } catch {
179 await MainActor.run {
202 await MainActor.run {
180− testResult = .failure(ParseableError.userFriendlyMessage(for: error))
181 isTesting = false
203 isTesting = false
204+ guard generation == testGeneration else { return }
205+ testResult = .failure(ParseableError.userFriendlyMessage(for: error))
182 }
206 }
183 }
207 }
184 }
208 }
@@ -213,6 +237,10 @@ struct ConnectionSheet: View {
213 dismiss()
237 dismiss()
214 } else {
238 } else {
215 testResult = .failure(appState.errorMessage ?? "Connection failed")
239 testResult = .failure(appState.errorMessage ?? "Connection failed")
240+ // The sheet reports the failure inline; drop the app-wide alert
241+ // performConnect also raised, which would otherwise surface
242+ // behind/after the sheet (or twice, from the Settings window).
243+ appState.showError = false
216 }
244 }
217 }
245 }
218 }
246 }
@@ -223,7 +251,7 @@ struct ConnectionSheet: View {
223 guard !trimmed.isEmpty else { return nil }
251 guard !trimmed.isEmpty else { return nil }
224
252
225 var normalized = trimmed
253 var normalized = trimmed
226− if !normalized.hasPrefix("http://") && !normalized.hasPrefix("https://") {
254+ if !ServerConnection.hasURLScheme(normalized) {
227 normalized = "https://\(normalized)"
255 normalized = "https://\(normalized)"
228 }
256 }
229
257
ParseableViewer/Views/ContentView.swift+30−2
@@ -19,7 +19,10 @@ struct ContentView: View {
19 ConnectionSheet(connection: appState.editingConnection)
19 ConnectionSheet(connection: appState.editingConnection)
20 }
20 }
21 .alert("Error", isPresented: $appState.showError) {
21 .alert("Error", isPresented: $appState.showError) {
22+ Button("OK") {
22− Button("OK") { appState.showError = false }
23+ appState.showError = false
24+ appState.errorMessage = nil
25+ }
23 } message: {
26 } message: {
24 Text(appState.errorMessage ?? "An unknown error occurred")
27 Text(appState.errorMessage ?? "An unknown error occurred")
25 }
28 }
@@ -28,6 +31,18 @@ struct ContentView: View {
28
31
29struct MainContentView: View {
32struct MainContentView: View {
30 @Environment(AppState.self) private var appState
33 @Environment(AppState.self) private var appState
34+ /// Kept here rather than inside QueryView so the Query tab's editor text,
35+ /// results and time range survive switching to another tab and back (only
36+ /// the active tab's view is instantiated). Dropped on disconnect, when this
37+ /// view goes away.
38+ @State private var queryViewModel = QueryViewModel()
39+
40+ /// Which server the Query tab's state belongs to. Editing the active
41+ /// connection's URL or user keeps its id (so QueryView's own stream/connection
42+ /// check can't see it), and a rename shouldn't wipe the user's query.
43+ private var serverIdentity: String? {
44+ appState.activeConnection.map { "\($0.id.uuidString)|\($0.url)|\($0.username)" }
45+ }
31
46
32 var body: some View {
47 var body: some View {
33 @Bindable var appState = appState
48 @Bindable var appState = appState
@@ -60,7 +75,7 @@ struct MainContentView: View {
60 } else {
75 } else {
61 switch appState.currentTab {
76 switch appState.currentTab {
62 case .query:
77 case .query:
63− QueryView()
78+ QueryView(viewModel: queryViewModel)
64 .id(AppState.AppTab.query)
79 .id(AppState.AppTab.query)
65 case .liveTail:
80 case .liveTail:
66 LiveTailView()
81 LiveTailView()
@@ -80,6 +95,19 @@ struct MainContentView: View {
80 }
95 }
81 }
96 }
82 }
97 }
98+ .onChange(of: serverIdentity) { _, _ in
99+ // The Query tab's model outlives stream and server changes made while
100+ // it isn't on screen. On a new server, drop the old one's results and
101+ // query (cancelling one still in flight) so the tab starts fresh.
102+ queryViewModel.clearResults()
103+ queryViewModel.sqlQuery = ""
104+ queryViewModel.filterText = ""
105+ }
106+ .onDisappear {
107+ // Disconnecting tears this view (and the model) down; don't leave an
108+ // in-flight query running against the server the user just left.
109+ queryViewModel.clearResults()
110+ }
83 }
111 }
84}
112}
85
113
ParseableViewer/Views/LiveTailView.swift+68−17
@@ -123,7 +123,10 @@ struct LiveTailView: View {
123 Image(systemName: "trash")
123 Image(systemName: "trash")
124 }
124 }
125 .help("Clear entries")
125 .help("Clear entries")
126+ // ⌘K, not ⌘⌫: command-key equivalents are dispatched before the
127+ // focused text field sees the keystroke, so ⌘⌫ (delete to start
128+ // of line) in the Filter field would wipe the whole buffer.
126− .keyboardShortcut(.delete, modifiers: .command)
129+ .keyboardShortcut("k", modifiers: .command)
127 .accessibilityLabel("Clear all entries")
130 .accessibilityLabel("Clear all entries")
128 }
131 }
129 .padding(8)
132 .padding(8)
@@ -136,10 +139,21 @@ struct LiveTailView: View {
136 Circle()
139 Circle()
137 .fill(viewModel.isPaused ? .yellow : .green)
140 .fill(viewModel.isPaused ? .yellow : .green)
138 .frame(width: 8, height: 8)
141 .frame(width: 8, height: 8)
139− .opacity(viewModel.isPaused ? 1.0 : pulseOpacity)
142+ .opacity(pulseOpacity)
140 .accessibilityLabel(viewModel.isPaused ? "Status: Paused" : "Status: Streaming")
143 .accessibilityLabel(viewModel.isPaused ? "Status: Paused" : "Status: Streaming")
141− .animation(.easeInOut(duration: 1.0).repeatForever(autoreverses: true), value: pulseOpacity)
144+ // Drive the pulse with an explicit, re-triggerable animation.
142− .onAppear { pulseOpacity = 0.3 }
145+ // The previous one-shot `pulseOpacity = 0.3` only changed the
146+ // value once, so the implicit `animation(value:)` never
147+ // restarted after a pause/resume and the dot froze. Pausing
148+ // settles the dot to solid; resuming restarts the pulse.
149+ .onAppear { startPulsing() }
150+ .onChange(of: viewModel.isPaused) { _, paused in
151+ if paused {
152+ withAnimation(.easeInOut(duration: 0.3)) { pulseOpacity = 1.0 }
153+ } else {
154+ startPulsing()
155+ }
156+ }
143 .onDisappear { pulseOpacity = 1.0 }
157 .onDisappear { pulseOpacity = 1.0 }
144 Text(viewModel.isPaused ? "Paused" : "Streaming")
158 Text(viewModel.isPaused ? "Paused" : "Streaming")
145 .font(.caption)
159 .font(.caption)
@@ -261,6 +275,7 @@ struct LiveTailView: View {
261 }
275 }
262 .onDisappear {
276 .onDisappear {
263 viewModel.stop()
277 viewModel.stop()
278+ liveTailSortTask?.cancel()
264 }
279 }
265 .onChange(of: appState.selectedStream) { _, _ in
280 .onChange(of: appState.selectedStream) { _, _ in
266 if viewModel.isRunning {
281 if viewModel.isRunning {
@@ -272,18 +287,43 @@ struct LiveTailView: View {
272 columnWidths = [:]
287 columnWidths = [:]
273 cachedSorted = []
288 cachedSorted = []
274 }
289 }
275− .onChange(of: viewModel.visibleColumns) { _, _ in
290+ .onChange(of: viewModel.visibleColumns) { _, newColumns in
291+ // Hiding the sort column leaves no header to show or change the sort,
292+ // while rows stay ordered by the invisible value (and auto-scroll stays
293+ // off). Drop it like LogTableView does; the sortColumn change rebuilds.
294+ if let sortColumn, !newColumns.contains(sortColumn) {
295+ self.sortColumn = nil
296+ return
297+ }
276 // Showing/hiding a column changes the severity column set, so rebuild
298 // Showing/hiding a column changes the severity column set, so rebuild
277 // (which also fills in widths for any newly visible columns). A
299 // (which also fills in widths for any newly visible columns). A
278 // width-only update would leave row severity tinting stale until the
300 // width-only update would leave row severity tinting stale until the
279 // next poll.
301 // next poll.
280 rebuildSortedEntries()
302 rebuildSortedEntries()
281 }
303 }
304+ .onChange(of: viewModel.filteredEntriesGeneration) { _, _ in
305+ // Kept on the always-present container (not inside liveTailTable, which
306+ // is only mounted once visibleColumns is non-empty). A generation bump
307+ // that lands while the table is unmounted — e.g. the first poll arriving
308+ // before columns exist — would otherwise be dropped, leaving the new
309+ // entries unrendered until some other change happened to trigger a rebuild.
310+ rebuildSortedEntries()
311+ }
282 .onAppear {
312 .onAppear {
283 rebuildSortedEntries()
313 rebuildSortedEntries()
284 }
314 }
285 }
315 }
286
316
317+ /// Starts (or restarts) the streaming-status pulse. Resetting `pulseOpacity`
318+ /// to 1.0 before animating to 0.3 guarantees a value delta so the repeating
319+ /// animation re-triggers even when called after a pause/resume cycle.
320+ private func startPulsing() {
321+ pulseOpacity = 1.0
322+ withAnimation(.easeInOut(duration: 1.0).repeatForever(autoreverses: true)) {
323+ pulseOpacity = 0.3
324+ }
325+ }
326+
287 /// Computes widths for any visible columns that don't have one yet. Covers
327 /// Computes widths for any visible columns that don't have one yet. Covers
288 /// the case where switching to a stream with an identical column set doesn't
328 /// the case where switching to a stream with an identical column set doesn't
289 /// fire `onChange(of: visibleColumns)`, which would otherwise leave widths
329 /// fire `onChange(of: visibleColumns)`, which would otherwise leave widths
@@ -299,6 +339,13 @@ struct LiveTailView: View {
299 private func rebuildSortedEntries() {
339 private func rebuildSortedEntries() {
300 ensureColumnWidths()
340 ensureColumnWidths()
301 let entries = viewModel.cachedFilteredEntries
341 let entries = viewModel.cachedFilteredEntries
342+ // Clear a selection whose entry no longer exists — filtered out by a new
343+ // column/text filter, or aged out of the capped buffer during streaming.
344+ // Without this the detail pane silently vanishes while the stale id lingers
345+ // (LogTableView solves the same problem with reconcileSelection()).
346+ if let id = selectedEntryID, !entries.contains(where: { $0.id == id }) {
347+ selectedEntryID = nil
348+ }
302 let cols = viewModel.visibleColumns
349 let cols = viewModel.visibleColumns
303 let sevCols = buildSeverityColumnSet(columns: cols)
350 let sevCols = buildSeverityColumnSet(columns: cols)
304 // Precompute severity per entry; sorting doesn't change the set.
351 // Precompute severity per entry; sorting doesn't change the set.
@@ -311,10 +358,15 @@ struct LiveTailView: View {
311 // wake after its 150ms delay and overwrite the unsorted result with
358 // wake after its 150ms delay and overwrite the unsorted result with
312 // stale sorted entries.
359 // stale sorted entries.
313 liveTailSortTask?.cancel()
360 liveTailSortTask?.cancel()
361+ // Severity is keyed by entry id and is independent of row order, so
362+ // publish it synchronously in both paths. Deferring it into the
363+ // debounced sort task (as the sorted path used to) left rows tinted from
364+ // the previous severity-column set for ~150ms — or indefinitely if rapid
365+ // changes kept cancelling the task before it fired.
366+ severityColumnSet = sevCols
367+ severityByID = severityMap
314 guard let col = sortColumn else {
368 guard let col = sortColumn else {
315 cachedSorted = entries
369 cachedSorted = entries
316− severityColumnSet = sevCols
317− severityByID = severityMap
318 return
370 return
319 }
371 }
320 let asc = sortAscending
372 let asc = sortAscending
@@ -322,16 +374,18 @@ struct LiveTailView: View {
322 try? await Task.sleep(for: .milliseconds(150))
374 try? await Task.sleep(for: .milliseconds(150))
323 guard !Task.isCancelled else { return }
375 guard !Task.isCancelled else { return }
324 let sorted = await Task.detached(priority: .userInitiated) {
376 let sorted = await Task.detached(priority: .userInitiated) {
377+ // `sorted(by:)` is not guaranteed stable and entry ids are random
378+ // UUIDs, so break ties on arrival order (the buffer index) to stop
379+ // equal-keyed rows from reshuffling on every poll.
325− entries.sorted { a, b in
380+ entries.enumerated().sorted { a, b in
326− let aVal = a.record[col] ?? .null
381+ let aVal = a.element.record[col] ?? .null
327− let bVal = b.record[col] ?? .null
382+ let bVal = b.element.record[col] ?? .null
328− return asc ? aVal < bVal : bVal < aVal
383+ if aVal != bVal { return asc ? aVal < bVal : bVal < aVal }
329− }
384+ return a.offset < b.offset
385+ }.map(\.element)
330 }.value
386 }.value
331 guard !Task.isCancelled else { return }
387 guard !Task.isCancelled else { return }
332 cachedSorted = sorted
388 cachedSorted = sorted
333− severityColumnSet = sevCols
334− severityByID = severityMap
335 }
389 }
336 }
390 }
337
391
@@ -379,9 +433,6 @@ struct LiveTailView: View {
379 }
433 }
380 }
434 }
381 }
435 }
382− .onChange(of: viewModel.filteredEntriesGeneration) { _, _ in
383− rebuildSortedEntries()
384− }
385 .onChange(of: sortColumn) { _, _ in
436 .onChange(of: sortColumn) { _, _ in
386 rebuildSortedEntries()
437 rebuildSortedEntries()
387 }
438 }
ParseableViewer/Views/LogDetailView.swift+5−2
@@ -157,8 +157,11 @@ struct JSONValueView: View {
157 Text(String(v))
157 Text(String(v))
158 .font(.system(.caption, design: .monospaced))
158 .font(.system(.caption, design: .monospaced))
159 .foregroundStyle(Color(nsColor: .systemPurple))
159 .foregroundStyle(Color(nsColor: .systemPurple))
160− case .double(let v):
160+ case .double:
161− Text(String(v))
161+ // Use displayString so whole-number doubles render as "3" (not "3.0"),
162+ // scientific notation is avoided, and inf/nan map to "null" — matching
163+ // the table cells, CSV/JSON export, and the rest of the app.
164+ Text(value.displayString)
162 .font(.system(.caption, design: .monospaced))
165 .font(.system(.caption, design: .monospaced))
163 .foregroundStyle(Color(nsColor: .systemPurple))
166 .foregroundStyle(Color(nsColor: .systemPurple))
164 case .string(let v):
167 case .string(let v):
ParseableViewer/Views/LogTableView.swift+70−10
@@ -315,7 +315,23 @@ struct LogTableView: View {
315 }
315 }
316 .onChange(of: sortColumn) { _, _ in debouncedRebuildSort() }
316 .onChange(of: sortColumn) { _, _ in debouncedRebuildSort() }
317 .onChange(of: sortAscending) { _, _ in debouncedRebuildSort() }
317 .onChange(of: sortAscending) { _, _ in debouncedRebuildSort() }
318+ .onChange(of: selectedRecord) { _, newValue in
319+ // The owner can drop the selection on its own (QueryViewModel
320+ // clears it after every query). When the new result set equals the
321+ // old one, `onChange(of: records)` doesn't fire, so nothing would
322+ // reconcile the row highlight: the row stayed highlighted with no
323+ // detail pane, and the next click on it merely "deselected" it.
324+ if newValue == nil { selectedIndex = nil }
325+ }
318 .onAppear {
326 .onAppear {
327+ // This branch is unmounted while results are empty (e.g. across a
328+ // stream switch, which clears them first), so `onChange(of: columns)`
329+ // never sees the switch. Drop a sort column the new columns don't
330+ // have — or hide — before sorting, or rows would be ordered by an
331+ // invisible column with no header showing the sort.
332+ if let sortColumn, !columns.contains(sortColumn) {
333+ self.sortColumn = nil
334+ }
319 recomputeColumnWidths(columns: columns, records: records)
335 recomputeColumnWidths(columns: columns, records: records)
320 rebuildSort()
336 rebuildSort()
321 }
337 }
@@ -359,7 +375,12 @@ struct LogTableView: View {
359 sorted = enumerated.sorted { a, b in
375 sorted = enumerated.sorted { a, b in
360 let aVal = a.element[col] ?? .null
376 let aVal = a.element[col] ?? .null
361 let bVal = b.element[col] ?? .null
377 let bVal = b.element[col] ?? .null
378+ // `sorted(by:)` is not guaranteed stable, so break ties on
379+ // the original index to keep equal-keyed rows in a fixed
380+ // order across re-sorts (otherwise low-cardinality columns
381+ // like `level`/`status` would reshuffle on every refresh).
362− return asc ? aVal < bVal : bVal < aVal
382+ if aVal != bVal { return asc ? aVal < bVal : bVal < aVal }
383+ return a.offset < b.offset
363 }
384 }
364 } else {
385 } else {
365 sorted = enumerated
386 sorted = enumerated
@@ -396,6 +417,9 @@ struct LogTableView: View {
396 }
417 }
397
418
398 private func rebuildSort() {
419 private func rebuildSort() {
420+ // A debounced rebuild scheduled before the table unmounted must not land
421+ // after (and overwrite) this synchronous one.
422+ sortTask?.cancel()
399 let sevCols = buildSeverityColumnSet(columns: columns)
423 let sevCols = buildSeverityColumnSet(columns: columns)
400 let enumerated = Array(records.enumerated())
424 let enumerated = Array(records.enumerated())
401 let sorted: [(offset: Int, element: LogRecord)]
425 let sorted: [(offset: Int, element: LogRecord)]
@@ -403,7 +427,9 @@ struct LogTableView: View {
403 sorted = enumerated.sorted { a, b in
427 sorted = enumerated.sorted { a, b in
404 let aVal = a.element[sortColumn] ?? .null
428 let aVal = a.element[sortColumn] ?? .null
405 let bVal = b.element[sortColumn] ?? .null
429 let bVal = b.element[sortColumn] ?? .null
430+ // Stable tiebreak on original index — see the async path above.
406− return sortAscending ? aVal < bVal : bVal < aVal
431+ if aVal != bVal { return sortAscending ? aVal < bVal : bVal < aVal }
432+ return a.offset < b.offset
407 }
433 }
408 } else {
434 } else {
409 sorted = enumerated
435 sorted = enumerated
@@ -469,12 +495,28 @@ struct LogHeaderView: View {
469
495
470 @State private var draggedColumn: String?
496 @State private var draggedColumn: String?
471 @State private var dropTargetColumn: String?
497 @State private var dropTargetColumn: String?
498+ /// `.contextMenu`'s builder runs during body evaluation, so the filter menus
499+ /// compute their values while the view is being rendered. Writing an `@State`
500+ /// dictionary from there is "modifying state during view update" (undefined
501+ /// behavior, and it re-invalidates the header). The cache is therefore a plain
502+ /// reference that isn't observed; `recordsGeneration` — bumped outside body
503+ /// evaluation — is what invalidates it and triggers the re-render.
472− @State private var uniqueValuesCache: [String: [JSONValue]] = [:]
504+ @State private var uniqueValuesCache = UniqueValuesCache()
505+ @State private var recordsGeneration = 0
506+
507+ private final class UniqueValuesCache {
508+ var generation = -1
509+ var values: [String: [JSONValue]] = [:]
510+ }
473
511
474 private static let maxFilterValues = 20
512 private static let maxFilterValues = 20
475
513
476 private func uniqueValues(for column: String) -> [JSONValue] {
514 private func uniqueValues(for column: String) -> [JSONValue] {
515+ if uniqueValuesCache.generation != recordsGeneration {
516+ uniqueValuesCache.generation = recordsGeneration
517+ uniqueValuesCache.values = [:]
518+ }
477− if let cached = uniqueValuesCache[column] {
519+ if let cached = uniqueValuesCache.values[column] {
478 return cached
520 return cached
479 }
521 }
480 var seen = Set<JSONValue>()
522 var seen = Set<JSONValue>()
@@ -486,7 +528,7 @@ struct LogHeaderView: View {
486 }
528 }
487 }
529 }
488 let sorted = result.sorted()
530 let sorted = result.sorted()
489− uniqueValuesCache[column] = sorted
531+ uniqueValuesCache.values[column] = sorted
490 return sorted
532 return sorted
491 }
533 }
492
534
@@ -629,7 +671,7 @@ struct LogHeaderView: View {
629 .background(Color(nsColor: .controlBackgroundColor))
671 .background(Color(nsColor: .controlBackgroundColor))
630 .border(Color.secondary.opacity(0.2), width: 0.5)
672 .border(Color.secondary.opacity(0.2), width: 0.5)
631 .onChange(of: records) { _, _ in
673 .onChange(of: records) { _, _ in
632− uniqueValuesCache = [:]
674+ recordsGeneration &+= 1
633 }
675 }
634 }
676 }
635}
677}
@@ -662,14 +704,26 @@ private struct ColumnDropDelegate: DropDelegate {
662 }
704 }
663
705
664 func performDrop(info: DropInfo) -> Bool {
706 func performDrop(info: DropInfo) -> Bool {
665− guard let dragged = draggedColumn, dragged != column else {
707+ guard let dragged = draggedColumn, dragged != column,
708+ let provider = info.itemProviders(for: [UTType.plainText]).first else {
666 draggedColumn = nil
709 draggedColumn = nil
667 dropTargetColumn = nil
710 dropTargetColumn = nil
668 return false
711 return false
669 }
712 }
670− onMoveColumn?(dragged, column)
671 draggedColumn = nil
713 draggedColumn = nil
672 dropTargetColumn = nil
714 dropTargetColumn = nil
715+ // `draggedColumn` is only cleared by a drop on a header, so a header drag
716+ // that was cancelled (or dropped elsewhere) leaves it set, and any later
717+ // plain-text drag onto a header would move that stale column. Only move
718+ // when the dropped payload really is the column header being dragged.
719+ let target = column
720+ let move = onMoveColumn
721+ _ = provider.loadObject(ofClass: NSString.self) { object, _ in
722+ guard let payload = object as? NSString, payload as String == dragged else { return }
723+ DispatchQueue.main.async {
724+ move?(dragged, target)
725+ }
726+ }
673 return true
727 return true
674 }
728 }
675
729
@@ -717,11 +771,17 @@ struct LogRowView: View {
717 }
771 }
718 if let onCellFilter {
772 if let onCellFilter {
719 Divider()
773 Divider()
774+ // Normalize a null cell to `nil` so this produces an
775+ // IS NULL / IS NOT NULL filter, matching the header
776+ // "Filter by Value" path. Passing `.null` straight
777+ // through would build an `= null` value filter, which
778+ // the live-tail in-memory matcher treats differently.
779+ let filterValue: JSONValue? = value == .null ? nil : value
720 Button("Filter by This Value") {
780 Button("Filter by This Value") {
721− onCellFilter(column, value, false)
781+ onCellFilter(column, filterValue, false)
722 }
782 }
723 Button("Exclude This Value") {
783 Button("Exclude This Value") {
724− onCellFilter(column, value, true)
784+ onCellFilter(column, filterValue, true)
725 }
785 }
726 }
786 }
727 }
787 }
ParseableViewer/Views/QueryView.swift+58−12
@@ -12,7 +12,11 @@ private struct QueryViewContext: Equatable {
12
12
13struct QueryView: View {
13struct QueryView: View {
14 @Environment(AppState.self) private var appState
14 @Environment(AppState.self) private var appState
15+ /// Owned by MainContentView, not by this view: only the active tab is
16+ /// mounted, so a view-local @State model was rebuilt on every tab switch —
17+ /// discarding the user's query, results, time range and filter and re-running
18+ /// the default query when they came back.
15− @State private var viewModel = QueryViewModel()
19+ let viewModel: QueryViewModel
16 @State private var showSaveFilterSheet = false
20 @State private var showSaveFilterSheet = false
17 @State private var saveFilterName = ""
21 @State private var saveFilterName = ""
18 @State private var isSavingFilter = false
22 @State private var isSavingFilter = false
@@ -89,7 +93,7 @@ struct QueryView: View {
89 } label: {
93 } label: {
90 Image(systemName: "square.and.arrow.up")
94 Image(systemName: "square.and.arrow.up")
91 }
95 }
92− .disabled(viewModel.results.isEmpty)
96+ .disabled(viewModel.filteredResults.isEmpty)
93 .accessibilityLabel("Export results")
97 .accessibilityLabel("Export results")
94 .help("Export results")
98 .help("Export results")
95
99
@@ -247,6 +251,23 @@ struct QueryView: View {
247 isLoading: viewModel.isLoading,
251 isLoading: viewModel.isLoading,
248 wrapText: wrapText,
252 wrapText: wrapText,
249 onCellFilter: { column, value, exclude in
253 onCellFilter: { column, value, exclude in
254+ // Arrays/objects are List/Struct columns on the server; a
255+ // `col = '<json>'` comparison fails to plan, and the failed
256+ // run would clear the results the user is looking at.
257+ if let value, !value.isScalar {
258+ viewModel.errorMessage = "Filtering by an array or object value isn't supported in SQL queries."
259+ return
260+ }
261+ // Results from a blank editor come from the auto-generated
262+ // default query, which `addColumnFilter` can't see (it edits
263+ // `sqlQuery`); put that query in the editor so the filter
264+ // actually applies instead of silently re-running unfiltered.
265+ if viewModel.sqlQuery.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty,
266+ let stream = appState.selectedStream {
267+ // (Passing a previous stream forces the replacement even
268+ // when the blank editor holds only whitespace.)
269+ viewModel.setDefaultQuery(stream: stream, previousStream: stream)
270+ }
250 viewModel.addColumnFilter(column: column, value: value, exclude: exclude)
271 viewModel.addColumnFilter(column: column, value: value, exclude: exclude)
251 Task {
272 Task {
252 await viewModel.executeQuery(
273 await viewModel.executeQuery(
@@ -269,6 +290,9 @@ struct QueryView: View {
269 let streamChanged = old.stream != new.stream
290 let streamChanged = old.stream != new.stream
270 if streamChanged {
291 if streamChanged {
271 viewModel.clearResults()
292 viewModel.clearResults()
293+ viewModel.boundContext = QueryViewModel.BoundContext(
294+ connectionID: appState.activeConnection?.id, stream: stream
295+ )
272 Task {
296 Task {
273 await viewModel.loadSchema(client: appState.client, stream: stream)
297 await viewModel.loadSchema(client: appState.client, stream: stream)
274 }
298 }
@@ -303,7 +327,27 @@ struct QueryView: View {
303 }
327 }
304 .onAppear {
328 .onAppear {
305 if let stream = appState.selectedStream {
329 if let stream = appState.selectedStream {
330+ // The view model outlives this view. If the stream (or server)
331+ // changed while another tab was showing, the state it kept belongs
332+ // to the old one — reset it exactly like a stream switch would.
333+ let context = QueryViewModel.BoundContext(
334+ connectionID: appState.activeConnection?.id, stream: stream
335+ )
336+ var previousStream: String?
337+ if let bound = viewModel.boundContext, bound != context {
338+ previousStream = bound.stream
339+ viewModel.clearResults()
340+ // A freshly built model (what this view used to get on every
341+ // mount) had no results filter; don't let an old one hide rows.
342+ viewModel.filterText = ""
343+ }
344+ viewModel.boundContext = context
345+ // Reload the schema when it's missing OR when the loaded schema
346+ // belongs to a different stream than the current one — otherwise a
347+ // viewModel that survives with another stream's schema would keep
348+ // autocomplete pointed at the wrong stream's fields until the next
349+ // stream switch.
306− if viewModel.schemaFields.isEmpty {
350+ if viewModel.schemaFields.isEmpty || viewModel.latestSchemaStream != stream {
307 Task {
351 Task {
308 await viewModel.loadSchema(client: appState.client, stream: stream)
352 await viewModel.loadSchema(client: appState.client, stream: stream)
309 }
353 }
@@ -323,7 +367,7 @@ struct QueryView: View {
323 )
367 )
324 }
368 }
325 } else {
369 } else {
326− let didSetDefault = viewModel.setDefaultQuery(stream: stream)
370+ let didSetDefault = viewModel.setDefaultQuery(stream: stream, previousStream: previousStream)
327 if didSetDefault && viewModel.results.isEmpty {
371 if didSetDefault && viewModel.results.isEmpty {
328 Task {
372 Task {
329 await viewModel.executeQuery(
373 await viewModel.executeQuery(
@@ -335,12 +379,11 @@ struct QueryView: View {
335 }
379 }
336 }
380 }
337 }
381 }
338− .onDisappear {
339− if viewModel.isLoading {
340− viewModel.cancelQuery()
341− }
342− }
343 .onChange(of: appState.queryRefreshToken) { _, _ in
382 .onChange(of: appState.queryRefreshToken) { _, _ in
383+ // Guard on a selected stream like every sibling query trigger in this
384+ // view. Without it, a refresh (Cmd+R) with no stream selected and an
385+ // empty editor surfaces a spurious "Select a stream..." error.
386+ guard appState.selectedStream != nil else { return }
344 Task {
387 Task {
345 await viewModel.executeQuery(
388 await viewModel.executeQuery(
346 client: appState.client,
389 client: appState.client,
@@ -404,7 +447,10 @@ struct QueryView: View {
404 panel.begin { result in
447 panel.begin { result in
405 guard result == .OK, let url = panel.url else { return }
448 guard result == .OK, let url = panel.url else { return }
406 let isCSV = url.pathExtension.lowercased() == "csv"
449 let isCSV = url.pathExtension.lowercased() == "csv"
450+ // Export what's on screen: the table renders `filteredResults`, so a
451+ // user who has typed a result filter expects the export to contain
452+ // those rows, not the full unfiltered set.
407− let results = viewModel.results
453+ let results = viewModel.filteredResults
408 let columns = viewModel.visibleColumns
454 let columns = viewModel.visibleColumns
409 Task.detached(priority: .userInitiated) {
455 Task.detached(priority: .userInitiated) {
410 let content: String?
456 let content: String?
@@ -450,7 +496,7 @@ struct ColumnManagerView: View {
450 Text("Columns")
496 Text("Columns")
451 .font(.headline)
497 .font(.headline)
452 Spacer()
498 Spacer()
453− if !viewModel.hiddenColumns.isEmpty || !viewModel.autoHiddenColumns.isEmpty || viewModel.columnOrder != viewModel.columns {
499+ if viewModel.hasHiddenColumns || viewModel.columnOrder != viewModel.columns {
454 Button("Reset") {
500 Button("Reset") {
455 viewModel.resetColumnConfig()
501 viewModel.resetColumnConfig()
456 }
502 }
@@ -504,7 +550,7 @@ struct ColumnManagerView: View {
504 }
550 }
505 .listStyle(.plain)
551 .listStyle(.plain)
506
552
507− if !viewModel.hiddenColumns.isEmpty || !viewModel.autoHiddenColumns.isEmpty {
553+ if viewModel.hasHiddenColumns {
508 Divider()
554 Divider()
509 Button("Show All Columns") {
555 Button("Show All Columns") {
510 viewModel.showAllColumns()
556 viewModel.showAllColumns()
ParseableViewer/Views/SQLAutoComplete.swift+143−9
@@ -100,13 +100,45 @@ enum SQLCompletionProvider {
100 }
100 }
101
101
102 let prefix = nsText.substring(with: NSRange(location: wordStart, length: cursorPosition - wordStart))
102 let prefix = nsText.substring(with: NSRange(location: wordStart, length: cursorPosition - wordStart))
103− let range = NSRange(location: wordStart, length: wordEnd - wordStart)
104
103
105 guard !prefix.isEmpty else {
104 guard !prefix.isEmpty else {
106− return ([], "", range)
105+ return ([], "", NSRange(location: wordStart, length: wordEnd - wordStart))
106+ }
107+
108+ // Words typed inside a comment or a string literal aren't SQL, so offer
109+ // nothing there. Otherwise the popup opens (e.g. `-- grouped by date`
110+ // matches DATE) and the Return/Tab meant to end the line or keep typing is
111+ // captured to accept a keyword, rewriting the comment or the literal value.
112+ if isInsideCommentOrStringLiteral(text, offset: cursorPosition) {
113+ return ([], prefix, NSRange(location: wordStart, length: wordEnd - wordStart))
107 }
114 }
108
115
109 let context = determineContext(text: text, position: wordStart)
116 let context = determineContext(text: text, position: wordStart)
117+
118+ // Table (and column) completions supply their own quoting. If the user has
119+ // already typed quotes directly around the word (common for stream names
120+ // with spaces, e.g. `FROM "my"`), absorb them into the replacement range so
121+ // the inserted `"name"` replaces the stray quotes instead of producing
122+ // `""name""`. Mirror `insertCompletion`, which absorbs leading and trailing
123+ // quotes independently for any `.table`/`.column` item — so apply this
124+ // wherever those completions are offered (`.tableRef`, `.columnRef` and
125+ // `.general`).
126+ var replaceStart = wordStart
127+ var replaceEnd = wordEnd
128+ let offersIdentifiers: Bool
129+ switch context {
130+ case .tableRef, .columnRef, .general: offersIdentifiers = true
131+ case .afterOrder, .afterGroup: offersIdentifiers = false
132+ }
133+ if offersIdentifiers {
134+ if wordStart > 0, nsText.character(at: wordStart - 1) == 0x22 {
135+ replaceStart -= 1
136+ }
137+ if wordEnd < nsText.length, nsText.character(at: wordEnd) == 0x22 {
138+ replaceEnd += 1
139+ }
140+ }
141+ let range = NSRange(location: replaceStart, length: replaceEnd - replaceStart)
110 let uppercasePrefix = prefix.uppercased()
142 let uppercasePrefix = prefix.uppercased()
111
143
112 var items: [SQLCompletionItem] = []
144 var items: [SQLCompletionItem] = []
@@ -124,7 +156,7 @@ enum SQLCompletionProvider {
124 case .columnRef:
156 case .columnRef:
125 for field in schemaFields.sorted(by: { $0.name < $1.name })
157 for field in schemaFields.sorted(by: { $0.name < $1.name })
126 where field.name.uppercased().hasPrefix(uppercasePrefix) {
158 where field.name.uppercased().hasPrefix(uppercasePrefix) {
127− items.append(SQLCompletionItem(text: field.name, kind: .column, detail: field.dataType))
159+ items.append(columnItem(for: field))
128 }
160 }
129 for fn in SQLSyntaxHighlighter.sortedFunctions where fn.hasPrefix(uppercasePrefix) {
161 for fn in SQLSyntaxHighlighter.sortedFunctions where fn.hasPrefix(uppercasePrefix) {
130 items.append(SQLCompletionItem(text: fn, kind: .function))
162 items.append(SQLCompletionItem(text: fn, kind: .function))
@@ -154,20 +186,87 @@ enum SQLCompletionProvider {
154 }
186 }
155 for field in schemaFields.sorted(by: { $0.name < $1.name })
187 for field in schemaFields.sorted(by: { $0.name < $1.name })
156 where field.name.uppercased().hasPrefix(uppercasePrefix) {
188 where field.name.uppercased().hasPrefix(uppercasePrefix) {
157− items.append(SQLCompletionItem(text: field.name, kind: .column, detail: field.dataType))
189+ items.append(columnItem(for: field))
158 }
190 }
159 }
191 }
160
192
161 // Don't show the popup when the only match is an exact hit. Compare the
193 // Don't show the popup when the only match is an exact hit. Compare the
162 // match text (unquoted), not the display text — a table's display text is
194 // match text (unquoted), not the display text — a table's display text is
163 // wrapped in quotes (`"name"`) and would never match the bare prefix.
195 // wrapped in quotes (`"name"`) and would never match the bare prefix.
196+ // Columns are the exception: one that needs quoting but was typed bare
197+ // (`statusCode`, which DataFusion would lowercase) must still be offered,
198+ // so a column only counts as an exact hit when accepting would leave the
199+ // text unchanged (the range already spans any quotes the user typed).
200+ if items.count == 1 {
201+ let only = items[0]
202+ let isExactHit = only.kind == .column
203+ ? nsText.substring(with: range) == only.text
164− if items.count == 1 && items[0].matchText.uppercased() == uppercasePrefix {
204+ : only.matchText.uppercased() == uppercasePrefix
165− return ([], prefix, range)
205+ if isExactHit { return ([], prefix, range) }
166 }
206 }
167
207
168 return (items, prefix, range)
208 return (items, prefix, range)
169 }
209 }
170
210
211+ /// Whether the UTF-16 `offset` falls inside a `--`/`/* */` comment or a
212+ /// `'...'` string literal. The caret at the very end of an unterminated
213+ /// literal/comment (or of a line comment) is still inside it.
214+ static func isInsideCommentOrStringLiteral(_ text: String, offset: Int) -> Bool {
215+ for token in SQLTokenizer.tokenize(text) {
216+ let range = NSRange(token.range, in: text)
217+ // Tokens are in order; one starting at or after the caret can't contain it.
218+ if range.location >= offset { break }
219+ let end = NSMaxRange(range)
220+ let isClosed: Bool
221+ switch token.kind {
222+ case .lineComment:
223+ isClosed = false
224+ case .blockComment:
225+ isClosed = range.length >= 4 && text[token.range].hasSuffix("*/")
226+ case .stringLiteral:
227+ // `''` escapes pair up, so a terminated literal has an even count.
228+ let quotes = text[token.range].filter { $0 == "'" }.count
229+ isClosed = range.length >= 2 && quotes % 2 == 0
230+ default:
231+ continue
232+ }
233+ if offset < end || (offset == end && !isClosed) { return true }
234+ }
235+ return false
236+ }
237+
238+ /// A column completion, double-quoted when the name wouldn't survive as a bare
239+ /// identifier. DataFusion (Parseable's query engine) folds unquoted identifiers
240+ /// to lowercase, so inserting a mixed-case field bare (`statusCode`) queries a
241+ /// nonexistent `statuscode`; names with dots, spaces, or other punctuation
242+ /// (e.g. OTel's `service.name`) wouldn't parse as one identifier at all. Plain
243+ /// lowercase names stay bare so the common case reads naturally.
244+ static func columnItem(for field: SchemaField) -> SQLCompletionItem {
245+ SQLCompletionItem(
246+ text: quotedIdentifierIfNeeded(field.name),
247+ kind: .column,
248+ detail: field.dataType,
249+ matchText: field.name
250+ )
251+ }
252+
253+ static func quotedIdentifierIfNeeded(_ name: String) -> String {
254+ let isPlainLowercase = name.unicodeScalars.enumerated().allSatisfy { index, scalar in
255+ switch scalar {
256+ case "a"..."z", "_": return true
257+ case "0"..."9": return index > 0
258+ default: return false
259+ }
260+ }
261+ // A bare keyword/function name (`order`, `user`, `limit`, `date`) would be
262+ // parsed as SQL syntax rather than as the column, so quote those too.
263+ let upper = name.uppercased()
264+ let isReservedWord = SQLSyntaxHighlighter.keywords.contains(upper)
265+ || SQLSyntaxHighlighter.functions.contains(upper)
266+ if !name.isEmpty && isPlainLowercase && !isReservedWord { return name }
267+ return QueryViewModel.escapeSQLIdentifier(name)
268+ }
269+
171 // MARK: - Context Detection
270 // MARK: - Context Detection
172
271
173 static func determineContext(text: String, position: Int) -> SQLCompletionContext {
272 static func determineContext(text: String, position: Int) -> SQLCompletionContext {
@@ -175,6 +274,17 @@ enum SQLCompletionProvider {
175 // NSString too. `String.prefix` counts grapheme clusters and would slice
274 // NSString too. `String.prefix` counts grapheme clusters and would slice
176 // at the wrong place when non-BMP characters (e.g. emoji) precede it.
275 // at the wrong place when non-BMP characters (e.g. emoji) precede it.
177 let before = (text as NSString).substring(to: position)
276 let before = (text as NSString).substring(to: position)
277+
278+ // A qualifier dot directly before the caret (`t.co|`, `"stream".le|`) means
279+ // we're completing the column half of a table-qualified reference. The word
280+ // scan that produced `position` stops at the dot (it's not a word character),
281+ // while the tokenizer keeps the dot attached to the preceding token — so
282+ // without this the last token is `"t."`, which matches no keyword and falls
283+ // through to `.general`, mixing keywords/tables into a column position.
284+ if before.hasSuffix(".") {
285+ return .columnRef
286+ }
287+
178 let tokens = tokenize(before)
288 let tokens = tokenize(before)
179
289
180 guard let lastToken = tokens.last?.uppercased() else {
290 guard let lastToken = tokens.last?.uppercased() else {
@@ -192,7 +302,10 @@ enum SQLCompletionProvider {
192 case "GROUP":
302 case "GROUP":
193 return .afterGroup
303 return .afterGroup
194 default:
304 default:
305+ // After a `,` or an opening `(` the caret continues an argument/column
306+ // list (e.g. `SELECT COUNT(`, `WHERE status IN (`, `SELECT a, `), so
307+ // scan back to the governing keyword to decide column vs table context.
195− if lastToken == "," {
308+ if lastToken == "," || lastToken == "(" {
196 for token in tokens.reversed() {
309 for token in tokens.reversed() {
197 let upper = token.uppercased()
310 let upper = token.uppercased()
198 if ["SELECT", "BY", "WHERE", "HAVING", "ON"].contains(upper) {
311 if ["SELECT", "BY", "WHERE", "HAVING", "ON"].contains(upper) {
@@ -251,7 +364,12 @@ enum SQLCompletionProvider {
251 (ch >= 0x41 && ch <= 0x5A) || // A-Z
364 (ch >= 0x41 && ch <= 0x5A) || // A-Z
252 (ch >= 0x61 && ch <= 0x7A) || // a-z
365 (ch >= 0x61 && ch <= 0x7A) || // a-z
253 (ch >= 0x30 && ch <= 0x39) || // 0-9
366 (ch >= 0x30 && ch <= 0x39) || // 0-9
254− ch == 0x5F // _
367+ ch == 0x5F || // _
368+ ch >= 0x80 // any non-ASCII code unit: Unicode letters
369+ // and digits in identifiers (e.g. accented or CJK schema field names), plus
370+ // both halves of a surrogate pair. SQL delimiters — whitespace, operators,
371+ // quotes, parens, commas — are all ASCII (< 0x80), so this only sweeps in
372+ // genuine identifier characters and won't run a word past a token boundary.
255 }
373 }
256}
374}
257
375
@@ -347,7 +465,12 @@ final class SQLCompletionPopup: NSObject, NSTableViewDataSource, NSTableViewDele
347
465
348 let rowCount = min(items.count, Self.maxVisibleRows)
466 let rowCount = min(items.count, Self.maxVisibleRows)
349 let height = CGFloat(rowCount) * Self.rowHeight + 4
467 let height = CGFloat(rowCount) * Self.rowHeight + 4
468+ // Anchor by the top edge (where the caret sits), matching show()/reposition().
469+ // The panel's origin is its bottom-left corner, so holding origin.y fixed
470+ // while the height changes would move the top edge — growing the list would
471+ // creep the popup upward over the caret. Pin the top edge instead.
472+ let topEdge = panel.frame.origin.y + panel.frame.height
350− let frame = NSRect(x: panel.frame.origin.x, y: panel.frame.origin.y,
473+ let frame = NSRect(x: panel.frame.origin.x, y: topEdge - height,
351 width: Self.panelWidth, height: height)
474 width: Self.panelWidth, height: height)
352 panel.setFrame(frame, display: true)
475 panel.setFrame(frame, display: true)
353 }
476 }
@@ -406,6 +529,17 @@ final class SQLCompletionPopup: NSObject, NSTableViewDataSource, NSTableViewDele
406
529
407 // MARK: - NSTableViewDelegate
530 // MARK: - NSTableViewDelegate
408
531
532+ func tableViewSelectionDidChange(_ notification: Notification) {
533+ // A single click selects a row in the table directly. Keep `selectedIndex`
534+ // — what Return/Tab accept and what the arrow keys move from — in sync,
535+ // otherwise Return inserts the previously highlighted item instead of the
536+ // one the user clicked.
537+ let row = tableView.selectedRow
538+ if row >= 0, row < items.count {
539+ selectedIndex = row
540+ }
541+ }
542+
409 func tableView(_ tableView: NSTableView, viewFor tableColumn: NSTableColumn?, row: Int) -> NSView? {
543 func tableView(_ tableView: NSTableView, viewFor tableColumn: NSTableColumn?, row: Int) -> NSView? {
410 guard row < items.count else { return nil }
544 guard row < items.count else { return nil }
411 let item = items[row]
545 let item = items[row]
ParseableViewer/Views/SQLEditorView.swift+62−5
@@ -57,14 +57,22 @@ struct SQLEditorView: NSViewRepresentable {
57 // Only update when text changed externally (not from user typing)
57 // Only update when text changed externally (not from user typing)
58 if textView.string != text && !context.coordinator.isEditing {
58 if textView.string != text && !context.coordinator.isEditing {
59 let selectedRanges = textView.selectedRanges
59 let selectedRanges = textView.selectedRanges
60− textView.string = text
60+ context.coordinator.replaceText(with: text, in: textView)
61 context.coordinator.applyHighlighting(to: textView)
61 context.coordinator.applyHighlighting(to: textView)
62 // NSRange is UTF-16 based; compare against the NSString length, not
62 // NSRange is UTF-16 based; compare against the NSString length, not
63 // String.count (grapheme count), or wide characters drop the selection.
63 // String.count (grapheme count), or wide characters drop the selection.
64+ // Clamp each range to the new length rather than discarding the ones that
65+ // overflow, so replacing the text with a shorter string keeps the caret
66+ // at the end instead of losing the selection entirely.
64 let length = (textView.string as NSString).length
67 let length = (textView.string as NSString).length
65− let validRanges = selectedRanges.filter { NSMaxRange($0.rangeValue) <= length }
68+ let clampedRanges = selectedRanges.map { value -> NSValue in
69+ let range = value.rangeValue
70+ let location = min(range.location, length)
71+ let len = min(range.length, length - location)
72+ return NSValue(range: NSRange(location: location, length: len))
73+ }
66− if !validRanges.isEmpty {
74+ if !clampedRanges.isEmpty {
67− textView.selectedRanges = validRanges
75+ textView.selectedRanges = clampedRanges
68 }
76 }
69 }
77 }
70 context.coordinator.updateErrorHighlight(in: textView, errorRange: errorRange)
78 context.coordinator.updateErrorHighlight(in: textView, errorRange: errorRange)
@@ -93,6 +101,11 @@ struct SQLEditorView: NSViewRepresentable {
93 /// Tracks whether we are inserting a completion so that `textDidChange`
101 /// Tracks whether we are inserting a completion so that `textDidChange`
94 /// doesn't re-trigger the popup for the replacement.
102 /// doesn't re-trigger the popup for the replacement.
95 private var isInsertingCompletion = false
103 private var isInsertingCompletion = false
104+ /// Set while `replaceText(with:in:)` pushes a model-side change (history
105+ /// pick, saved filter, cell filter, column toggle…) into the text view, so
106+ /// `textDidChange` doesn't echo it back into the binding or open the
107+ /// completion popup for text the user didn't type.
108+ private var isApplyingModelChange = false
96
109
97 init(text: Binding<String>, streamNames: [String], schemaFields: [SchemaField]) {
110 init(text: Binding<String>, streamNames: [String], schemaFields: [SchemaField]) {
98 self.text = text
111 self.text = text
@@ -108,7 +121,8 @@ struct SQLEditorView: NSViewRepresentable {
108 // MARK: - NSTextViewDelegate
121 // MARK: - NSTextViewDelegate
109
122
110 func textDidChange(_ notification: Notification) {
123 func textDidChange(_ notification: Notification) {
111− guard let textView = notification.object as? NSTextView else { return }
124+ guard let textView = notification.object as? NSTextView,
125+ !isApplyingModelChange else { return }
112 isEditing = true
126 isEditing = true
113 clearErrorHighlight(in: textView)
127 clearErrorHighlight(in: textView)
114 text.wrappedValue = textView.string
128 text.wrappedValue = textView.string
@@ -120,6 +134,34 @@ struct SQLEditorView: NSViewRepresentable {
120 }
134 }
121 }
135 }
122
136
137+ /// Replaces the editor's contents with text set from outside the editor.
138+ /// Assigning `textView.string` directly bypasses the text system's undo
139+ /// registration and leaves the user's earlier typing on the undo stack with
140+ /// ranges that belong to the old text — Cmd+Z would then edit an arbitrary
141+ /// span of the new query (or raise on an out-of-range undo). Routing the
142+ /// change through `shouldChangeText`/`didChangeText` records it as one
143+ /// undoable step, so Cmd+Z restores the previous query instead.
144+ func replaceText(with newText: String, in textView: NSTextView) {
145+ let fullRange = NSRange(location: 0, length: (textView.string as NSString).length)
146+ isApplyingModelChange = true
147+ defer { isApplyingModelChange = false }
148+ textView.breakUndoCoalescing()
149+ if textView.shouldChangeText(in: fullRange, replacementString: newText) {
150+ textView.replaceCharacters(in: fullRange, with: newText)
151+ textView.didChangeText()
152+ } else {
153+ textView.string = newText
154+ }
155+ textView.breakUndoCoalescing()
156+ }
157+
158+ func textDidEndEditing(_ notification: Notification) {
159+ // The popup is a separate child panel; without this it keeps floating
160+ // over the results (and accepting double-clicks) after focus moves to
161+ // another control.
162+ completionPopup.hide()
163+ }
164+
123 func textView(_ textView: NSTextView, doCommandBy commandSelector: Selector) -> Bool {
165 func textView(_ textView: NSTextView, doCommandBy commandSelector: Selector) -> Bool {
124 guard completionPopup.isVisible else { return false }
166 guard completionPopup.isVisible else { return false }
125
167
@@ -215,6 +257,21 @@ struct SQLEditorView: NSViewRepresentable {
215 }
257 }
216 }
258 }
217
259
260+ // Table and column completions carry their own quoting (a column is
261+ // quoted only when it needs it); absorb any double-quote the user typed
262+ // directly around the word so the inserted identifier replaces it
263+ // instead of leaving a stray quote. Leading and trailing are handled
264+ // independently: a closing quote can exist without an opening one (e.g.
265+ // `FROM my"`), which would otherwise produce a dangling `"name""`.
266+ if item.kind == .table || item.kind == .column {
267+ if wordStart > 0, nsText.character(at: wordStart - 1) == 0x22 {
268+ wordStart -= 1
269+ }
270+ if wordEnd < nsText.length, nsText.character(at: wordEnd) == 0x22 {
271+ wordEnd += 1
272+ }
273+ }
274+
218 let replaceRange = NSRange(location: wordStart, length: wordEnd - wordStart)
275 let replaceRange = NSRange(location: wordStart, length: wordEnd - wordStart)
219
276
220 isInsertingCompletion = true
277 isInsertingCompletion = true
ParseableViewer/Views/ServerInfoView.swift+49−11
@@ -3,9 +3,15 @@ import SwiftUI
3struct ServerInfoView: View {
3struct ServerInfoView: View {
4 @Environment(AppState.self) private var appState
4 @Environment(AppState.self) private var appState
5 @State private var about: ServerAbout?
5 @State private var about: ServerAbout?
6+ // Start in the loading state: the `.task` always runs `loadInfo()` on appear, so
7+ // initializing to `false` would paint the `else` branch's red "Unhealthy" dot for
8+ // one frame before the check begins. Begin neutral ("Checking…") instead.
6− @State private var isLoading = false
9+ @State private var isLoading = true
7 @State private var isHealthy = false
10 @State private var isHealthy = false
8 @State private var errorMessage: String?
11 @State private var errorMessage: String?
12+ /// Bumped by every load so a superseded (cancelled) load can't clear the
13+ /// newer load's spinner or overwrite its results.
14+ @State private var loadGeneration = 0
9
15
10 var body: some View {
16 var body: some View {
11 ScrollView {
17 ScrollView {
@@ -16,7 +22,7 @@ struct ServerInfoView: View {
16 .fontWeight(.semibold)
22 .fontWeight(.semibold)
17 Spacer()
23 Spacer()
18 Button {
24 Button {
19− Task { await loadInfo() }
25+ Task { await loadInfo(forceRefresh: true) }
20 } label: {
26 } label: {
21 Image(systemName: "arrow.clockwise")
27 Image(systemName: "arrow.clockwise")
22 }
28 }
@@ -41,12 +47,25 @@ struct ServerInfoView: View {
41 // Health status
47 // Health status
42 GroupBox("Health") {
48 GroupBox("Health") {
43 HStack {
49 HStack {
50+ // While the check is in flight, show a neutral state
51+ // rather than the default `isHealthy == false`, which
52+ // otherwise flashes a red "Unhealthy" dot on every
53+ // connection switch before the result resolves.
54+ if isLoading {
44− Circle()
55+ Circle()
56+ .fill(.gray)
57+ .frame(width: 12, height: 12)
58+ .accessibilityLabel("Checking health")
59+ Text("Checking…")
60+ .fontWeight(.medium)
61+ } else {
62+ Circle()
45− .fill(isHealthy ? .green : .red)
63+ .fill(isHealthy ? .green : .red)
46− .frame(width: 12, height: 12)
64+ .frame(width: 12, height: 12)
47− .accessibilityLabel(isHealthy ? "Healthy" : "Unhealthy")
65+ .accessibilityLabel(isHealthy ? "Healthy" : "Unhealthy")
48− Text(isHealthy ? "Healthy" : "Unhealthy")
66+ Text(isHealthy ? "Healthy" : "Unhealthy")
49− .fontWeight(.medium)
67+ .fontWeight(.medium)
68+ }
50 Spacer()
69 Spacer()
51 }
70 }
52 .padding(8)
71 .padding(8)
@@ -126,13 +145,29 @@ struct ServerInfoView: View {
126 // Key on the active connection so switching (or reconnecting to a
145 // Key on the active connection so switching (or reconnecting to a
127 // different) server reloads instead of showing the previous server's info.
146 // different) server reloads instead of showing the previous server's info.
128 .task(id: appState.activeConnection?.id) {
147 .task(id: appState.activeConnection?.id) {
148+ // Drop the previous server's details immediately on switch so they
149+ // aren't shown while the newly-selected server's info loads. (Local
150+ // @State persists across the .task id change, so it must be cleared
151+ // explicitly.) The manual Refresh button keeps the current values.
152+ about = nil
153+ isHealthy = false
154+ errorMessage = nil
129 await loadInfo()
155 await loadInfo()
130 }
156 }
131 }
157 }
132
158
133− private func loadInfo() async {
159+ private func loadInfo(forceRefresh: Bool = false) async {
160+ // Clear isLoading on the no-client path: since isLoading now starts `true`
161+ // (to avoid a red "Unhealthy" flash before the first check), an early return
162+ // without resetting it would wedge the spinner permanently.
134− guard let client = appState.client else { return }
163+ guard let client = appState.client else { isLoading = false; return }
164+ // `getAbout()` is cached for 60 s; the Refresh button must bypass that.
165+ if forceRefresh {
166+ await client.invalidateCache()
167+ }
135 let connID = appState.activeConnection?.id
168 let connID = appState.activeConnection?.id
169+ loadGeneration &+= 1
170+ let generation = loadGeneration
136 isLoading = true
171 isLoading = true
137 errorMessage = nil
172 errorMessage = nil
138 // Seed from the app-wide cache so first load isn't blank, but never fall
173 // Seed from the app-wide cache so first load isn't blank, but never fall
@@ -161,7 +196,10 @@ struct ServerInfoView: View {
161
196
162 // Drop results if the active connection changed while awaiting, so a slow
197 // Drop results if the active connection changed while awaiting, so a slow
163 // load for the previous server can't overwrite the current one's state.
198 // load for the previous server can't overwrite the current one's state.
199+ // Still clear isLoading: this task owns the spinner it set, and leaving it
200+ // true on the stale path wedges the ProgressView and disables Refresh.
201+ guard generation == loadGeneration else { return }
164− guard connID == appState.activeConnection?.id else { return }
202+ guard connID == appState.activeConnection?.id else { isLoading = false; return }
165 isHealthy = healthy
203 isHealthy = healthy
166 about = aboutValue
204 about = aboutValue
167 errorMessage = aboutError
205 errorMessage = aboutError
ParseableViewer/Views/SettingsView.swift+21−2
@@ -6,6 +6,13 @@ struct SettingsView: View {
6 @AppStorage("maxQueryResults") private var maxQueryResults = 1000
6 @AppStorage("maxQueryResults") private var maxQueryResults = 1000
7 @AppStorage("liveTailPollInterval") private var liveTailPollInterval = 2.0
7 @AppStorage("liveTailPollInterval") private var liveTailPollInterval = 2.0
8 @AppStorage("liveTailMaxEntries") private var liveTailMaxEntries = 5000
8 @AppStorage("liveTailMaxEntries") private var liveTailMaxEntries = 5000
9+ // The Settings scene is a separate window from ContentView. A .sheet presents
10+ // over the window hosting the modifier, and binding both windows to the shared
11+ // appState.showConnectionSheet would present two sheets at once. So the Settings
12+ // window drives its own connection sheet with local state, fully independent of
13+ // the main window's.
14+ @State private var showConnectionSheet = false
15+ @State private var sheetConnection: ServerConnection?
9 var body: some View {
16 var body: some View {
10 TabView {
17 TabView {
11 // General settings
18 // General settings
@@ -76,11 +83,20 @@ struct SettingsView: View {
76 .clipShape(RoundedRectangle(cornerRadius: 4))
83 .clipShape(RoundedRectangle(cornerRadius: 4))
77 }
84 }
78
85
86+ Button {
87+ sheetConnection = connection
88+ showConnectionSheet = true
89+ } label: {
90+ Image(systemName: "pencil")
91+ }
92+ .help("Edit connection")
93+
79 Button(role: .destructive) {
94 Button(role: .destructive) {
80 appState.removeConnection(connection)
95 appState.removeConnection(connection)
81 } label: {
96 } label: {
82 Image(systemName: "trash")
97 Image(systemName: "trash")
83 }
98 }
99+ .help("Delete connection")
84 }
100 }
85 .padding(.vertical, 4)
101 .padding(.vertical, 4)
86 }
102 }
@@ -89,8 +105,8 @@ struct SettingsView: View {
89 HStack {
105 HStack {
90 Spacer()
106 Spacer()
91 Button("Add Connection...") {
107 Button("Add Connection...") {
92− appState.editingConnection = nil
108+ sheetConnection = nil
93− appState.showConnectionSheet = true
109+ showConnectionSheet = true
94 }
110 }
95 }
111 }
96 .padding()
112 .padding()
@@ -100,5 +116,8 @@ struct SettingsView: View {
100 }
116 }
101 }
117 }
102 .frame(width: 500, height: 400)
118 .frame(width: 500, height: 400)
119+ .sheet(isPresented: $showConnectionSheet) {
120+ ConnectionSheet(connection: sheetConnection)
121+ }
103 }
122 }
104}
123}
ParseableViewer/Views/SidebarView.swift+39−7
@@ -6,11 +6,19 @@ struct SidebarView: View {
6 @State private var newStreamName = ""
6 @State private var newStreamName = ""
7 @State private var showDeleteConfirm = false
7 @State private var showDeleteConfirm = false
8 @State private var streamToDelete: String?
8 @State private var streamToDelete: String?
9+ /// The connection `streamToDelete` was picked on. Stream names aren't unique
10+ /// across servers, and the delete runs against whatever client is current
11+ /// when the user confirms — so a server switch while the stats lookup was in
12+ /// flight could otherwise delete a same-named stream on the *new* server.
13+ @State private var streamToDeleteConnectionID: UUID?
14+ /// The client the stream was picked with. Editing the active connection's URL
15+ /// keeps its id but installs a new client for a possibly different server.
16+ @State private var streamToDeleteClient: ParseableClient?
9 @State private var deleteStreamDetail: String?
17 @State private var deleteStreamDetail: String?
10
18
11 /// Validates a stream name, returning an error message or nil if valid.
19 /// Validates a stream name, returning an error message or nil if valid.
12 static func validateStreamName(_ name: String) -> String? {
20 static func validateStreamName(_ name: String) -> String? {
13− if name.trimmingCharacters(in: .whitespaces).isEmpty {
21+ if name.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty {
14 return "Stream name cannot be empty."
22 return "Stream name cannot be empty."
15 }
23 }
16 if name.count > 255 {
24 if name.count > 255 {
@@ -58,11 +66,21 @@ struct SidebarView: View {
58 }
66 }
59 Divider()
67 Divider()
60 Button("Delete...", role: .destructive) {
68 Button("Delete...", role: .destructive) {
69+ // Capture the target so a slower stats fetch
70+ // for a *previously* clicked stream can't write
71+ // its detail (or raise the confirm) against the
72+ // stream the user has since selected — which
73+ // would show the wrong stats on a destructive
74+ // confirmation.
61− streamToDelete = stream.name
75+ let target = stream.name
76+ let targetConnectionID = appState.activeConnection?.id
77+ streamToDelete = target
78+ streamToDeleteConnectionID = targetConnectionID
79+ streamToDeleteClient = appState.client
62 deleteStreamDetail = nil
80 deleteStreamDetail = nil
63 Task {
81 Task {
64 if let client = appState.client,
82 if let client = appState.client,
65− let stats = try? await client.getStreamStats(stream: stream.name) {
83+ let stats = try? await client.getStreamStats(stream: target) {
66 var parts: [String] = []
84 var parts: [String] = []
67 if let count = stats.ingestion?.lifetime_count {
85 if let count = stats.ingestion?.lifetime_count {
68 parts.append("\(count) total records ingested")
86 parts.append("\(count) total records ingested")
@@ -70,11 +88,15 @@ struct SidebarView: View {
70 if let size = stats.storage?.lifetime_size ?? stats.storage?.size {
88 if let size = stats.storage?.lifetime_size ?? stats.storage?.size {
71 parts.append("\(size) storage used")
89 parts.append("\(size) storage used")
72 }
90 }
73− if !parts.isEmpty {
91+ if !parts.isEmpty, streamToDelete == target {
74 deleteStreamDetail = parts.joined(separator: ", ")
92 deleteStreamDetail = parts.joined(separator: ", ")
75 }
93 }
76 }
94 }
95+ if streamToDelete == target,
96+ appState.activeConnection?.id == targetConnectionID,
97+ appState.client === streamToDeleteClient {
77− showDeleteConfirm = true
98+ showDeleteConfirm = true
99+ }
78 }
100 }
79 }
101 }
80 }
102 }
@@ -155,7 +177,7 @@ struct SidebarView: View {
155 .alert("Create Log Stream", isPresented: $showCreateStream) {
177 .alert("Create Log Stream", isPresented: $showCreateStream) {
156 TextField("Stream name", text: $newStreamName)
178 TextField("Stream name", text: $newStreamName)
157 Button("Create") {
179 Button("Create") {
158− let name = newStreamName.trimmingCharacters(in: .whitespaces)
180+ let name = newStreamName.trimmingCharacters(in: .whitespacesAndNewlines)
159 guard !name.isEmpty else { newStreamName = ""; return }
181 guard !name.isEmpty else { newStreamName = ""; return }
160 if let error = Self.validateStreamName(name) {
182 if let error = Self.validateStreamName(name) {
161 appState.showErrorMessage(error)
183 appState.showErrorMessage(error)
@@ -178,6 +200,11 @@ struct SidebarView: View {
178 .alert("Delete Stream", isPresented: $showDeleteConfirm) {
200 .alert("Delete Stream", isPresented: $showDeleteConfirm) {
179 Button("Delete", role: .destructive) {
201 Button("Delete", role: .destructive) {
180 if let name = streamToDelete {
202 if let name = streamToDelete {
203+ guard appState.activeConnection?.id == streamToDeleteConnectionID,
204+ appState.client === streamToDeleteClient else {
205+ appState.showErrorMessage("The server changed since \"\(name)\" was selected, so it was not deleted.")
206+ return
207+ }
181 Task {
208 Task {
182 do {
209 do {
183 try await appState.deleteStream(name: name)
210 try await appState.deleteStream(name: name)
@@ -216,7 +243,12 @@ struct ConnectionStatusView: View {
216 if newID == ConnectionStatusView.addConnectionID {
243 if newID == ConnectionStatusView.addConnectionID {
217 appState.editingConnection = nil
244 appState.editingConnection = nil
218 appState.showConnectionSheet = true
245 appState.showConnectionSheet = true
246+ } else if newID != connection.id,
219− } else if let conn = appState.connections.first(where: { $0.id == newID }) {
247+ let conn = appState.connections.first(where: { $0.id == newID }) {
248+ // Only reconnect when a *different* connection is
249+ // picked. Re-selecting the active one would tear
250+ // down and rebuild the client for no reason (a full
251+ // health-check + stream reload, with a visible flicker).
220 Task { await appState.connect(to: conn) }
252 Task { await appState.connect(to: conn) }
221 }
253 }
222 }
254 }
ParseableViewer/Views/StreamDetailView.swift+34−6
@@ -8,6 +8,9 @@ struct StreamDetailView: View {
8 @State private var retention: [RetentionConfig] = []
8 @State private var retention: [RetentionConfig] = []
9 @State private var isLoading = false
9 @State private var isLoading = false
10 @State private var errorMessage: String?
10 @State private var errorMessage: String?
11+ /// Bumped by every load. A superseded load (its `.task(id:)` cancelled by a
12+ /// stream switch) still resumes, and must not clear the newer load's spinner.
13+ @State private var loadGeneration = 0
11
14
12 var body: some View {
15 var body: some View {
13 Group {
16 Group {
@@ -25,7 +28,7 @@ struct StreamDetailView: View {
25 Spacer()
28 Spacer()
26
29
27 Button {
30 Button {
28− Task { await loadData(stream: stream) }
31+ Task { await loadData(stream: stream, forceRefresh: true) }
29 } label: {
32 } label: {
30 Image(systemName: "arrow.clockwise")
33 Image(systemName: "arrow.clockwise")
31 }
34 }
@@ -44,7 +47,7 @@ struct StreamDetailView: View {
44 Text(error)
47 Text(error)
45 Spacer()
48 Spacer()
46 Button("Retry") {
49 Button("Retry") {
47− Task { await loadData(stream: stream) }
50+ Task { await loadData(stream: stream, forceRefresh: true) }
48 }
51 }
49 .buttonStyle(.bordered)
52 .buttonStyle(.bordered)
50 .controlSize(.small)
53 .controlSize(.small)
@@ -157,7 +160,11 @@ struct StreamDetailView: View {
157 }
160 }
158 .padding()
161 .padding()
159 }
162 }
163+ // Key on the connection too: switching to a different server that
164+ // exposes a same-named stream leaves `stream` unchanged, so without
165+ // the connection id the task wouldn't refire and the old server's
166+ // data would linger.
160− .task(id: stream) {
167+ .task(id: "\(appState.activeConnection?.id.uuidString ?? "")|\(stream)") {
161 await loadData(stream: stream)
168 await loadData(stream: stream)
162 }
169 }
163 } else {
170 } else {
@@ -170,8 +177,22 @@ struct StreamDetailView: View {
170 }
177 }
171 }
178 }
172
179
173− private func loadData(stream: String) async {
180+ private func loadData(stream: String, forceRefresh: Bool = false) async {
174 guard let client = appState.client else { return }
181 guard let client = appState.client else { return }
182+ // The client caches schema/stats/info for 60 s. That's right for the
183+ // automatic load on appear, but an explicit Refresh/Retry must hit the
184+ // server — otherwise it repaints the same cached numbers and looks like a
185+ // refresh that found nothing new.
186+ if forceRefresh {
187+ await client.invalidateCache()
188+ }
189+ // Capture the connection this fetch belongs to. Two servers can expose a
190+ // same-named stream, so guarding on the stream name alone would let a
191+ // slow fetch write the previous server's data under the new server's
192+ // stream after a server switch.
193+ let connID = appState.activeConnection?.id
194+ loadGeneration &+= 1
195+ let generation = loadGeneration
175 isLoading = true
196 isLoading = true
176 errorMessage = nil
197 errorMessage = nil
177 // Clear previous stream's data so it isn't shown under the new header
198 // Clear previous stream's data so it isn't shown under the new header
@@ -205,8 +226,15 @@ struct StreamDetailView: View {
205 // The manual Refresh/Retry buttons spawn detached Tasks that aren't tied to
226 // 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
227 // `.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
228 // resolve after the user switched. Drop stale results instead of writing
208− // them under the new stream's header.
229+ // them under the new stream's header. Also check the connection so a
230+ // same-named stream on a different server doesn't accept the old server's
231+ // data. Reset `isLoading` on the stale path or the spinner wedges forever —
232+ // unless a newer load has started, which owns the spinner now.
233+ guard generation == loadGeneration else { return }
209− guard stream == appState.selectedStream else { return }
234+ guard stream == appState.selectedStream, connID == appState.activeConnection?.id else {
235+ isLoading = false
236+ return
237+ }
210
238
211 schema = newSchema
239 schema = newSchema
212 stats = newStats
240 stats = newStats
ParseableViewer/Views/SyntaxHighlighting.swift+32−58
@@ -49,10 +49,23 @@ enum SQLSyntaxHighlighter {
49 static let sortedFunctions: [String] = functions.sorted()
49 static let sortedFunctions: [String] = functions.sorted()
50
50
51 // Pre-compiled regexes (created once, reused every keystroke)
51 // Pre-compiled regexes (created once, reused every keystroke)
52+ //
53+ // String literals, quoted identifiers, and comments in ONE alternation, so a
54+ // single left-to-right scan claims each span for whichever construct starts
55+ // first — exactly how a SQL lexer reads it. Separate passes can't agree: an
56+ // apostrophe inside a comment (`-- don't`) opened a bogus string that painted
57+ // every following line red, and a `"` inside a string (or any quote inside a
58+ // block comment) did the same. Capture groups: 1 = 'string', 2 = "identifier",
59+ // 3 = `--` line comment or `/* */` block comment (unterminated runs to the end).
52− private static let commentRegex = try! NSRegularExpression(pattern: "--[^\n]*", options: [.caseInsensitive])
60+ private static let literalOrCommentRegex = try! NSRegularExpression(
53− private static let singleQuoteRegex = try! NSRegularExpression(pattern: "'[^']*(?:''[^']*)*'", options: [.caseInsensitive])
61+ pattern: "('[^']*(?:''[^']*)*')|(\"[^\"]*(?:\"\"[^\"]*)*\")|(--[^\\n]*|/\\*[\\s\\S]*?(?:\\*/|\\z))",
54− private static let doubleQuoteRegex = try! NSRegularExpression(pattern: "\"[^\"]*(?:\"\"[^\"]*)*\"", options: [.caseInsensitive])
62+ options: []
63+ )
64+ // Matches the same numeric forms as `SQLTokenizer.consumeNumber`: integers,
65+ // decimals, leading-dot floats (`.5`), and exponents (`1e5`, `1.5e-3`). The
66+ // lookbehind keeps digits that belong to an identifier (`col1`, `t.2`) from
67+ // being highlighted as numbers.
55− private static let numberRegex = try! NSRegularExpression(pattern: "\\b\\d+(?:\\.\\d+)?\\b", options: [])
68+ private static let numberRegex = try! NSRegularExpression(pattern: "(?<![\\w.])(?:\\d+\\.?\\d*|\\.\\d+)(?:[eE][+-]?\\d+)?", options: [])
56 private static let keywordRegex: NSRegularExpression = {
69 private static let keywordRegex: NSRegularExpression = {
57 let pattern = "\\b(?:" + sortedKeywords.joined(separator: "|") + ")\\b"
70 let pattern = "\\b(?:" + sortedKeywords.joined(separator: "|") + ")\\b"
58 return try! NSRegularExpression(pattern: pattern, options: [.caseInsensitive])
71 return try! NSRegularExpression(pattern: pattern, options: [.caseInsensitive])
@@ -76,20 +89,21 @@ enum SQLSyntaxHighlighter {
76 let nsText = text as NSString
89 let nsText = text as NSString
77 var protectedRanges: [NSRange] = []
90 var protectedRanges: [NSRange] = []
78
91
79− // 1. Single-quoted strings
92+ // 1–3. Single-quoted strings, double-quoted identifiers, and comments, in
80− applyRegex(singleQuoteRegex, to: textStorage, text: nsText,
93+ // one pass so none of them can start inside another (`'a--b'` stays a
81− color: .systemRed, protectedRanges: &protectedRanges)
94+ // string, `-- don't` stays a comment).
82−
95+ let literalMatches = literalOrCommentRegex.matches(
83− // 2. Double-quoted identifiers
96+ in: text, range: NSRange(location: 0, length: nsText.length)
84− applyRegex(doubleQuoteRegex, to: textStorage, text: nsText,
97+ )
85− color: .systemRed, protectedRanges: &protectedRanges)
98+ for match in literalMatches {
86−
99+ let isComment = match.range(at: 3).location != NSNotFound
87− // 3. Comments (-- to end of line). Highlighted AFTER strings, and a `--`
100+ textStorage.addAttribute(
88− // that begins inside a string literal (protectedRanges currently holds
101+ .foregroundColor,
89− // only string ranges) is skipped — otherwise `'a--b' AND y = 5` would
102+ value: isComment ? NSColor.secondaryLabelColor : NSColor.systemRed,
90− // bleed a gray comment over the trailing real SQL.
103+ range: match.range
91− applyCommentRegex(commentRegex, to: textStorage, text: nsText,
104+ )
92− color: .secondaryLabelColor, protectedRanges: &protectedRanges)
105+ protectedRanges.append(match.range)
106+ }
93
107
94 // 4. Numbers (outside protected ranges)
108 // 4. Numbers (outside protected ranges)
95 applyUnprotectedRegex(numberRegex, to: textStorage, text: nsText,
109 applyUnprotectedRegex(numberRegex, to: textStorage, text: nsText,
@@ -106,46 +120,6 @@ enum SQLSyntaxHighlighter {
106 protectedRanges: protectedRanges)
120 protectedRanges: protectedRanges)
107 }
121 }
108
122
109− private static func applyRegex(
110− _ regex: NSRegularExpression,
111− to textStorage: NSTextStorage,
112− text: NSString,
113− color: NSColor,
114− protectedRanges: inout [NSRange]
115− ) {
116− let matches = regex.matches(in: text as String, range: NSRange(location: 0, length: text.length))
117− for match in matches {
118− textStorage.addAttribute(.foregroundColor, value: color, range: match.range)
119− protectedRanges.append(match.range)
120− }
121− }
122−
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−
149 private static func applyUnprotectedRegex(
123 private static func applyUnprotectedRegex(
150 _ regex: NSRegularExpression,
124 _ regex: NSRegularExpression,
151 to textStorage: NSTextStorage,
125 to textStorage: NSTextStorage,
ParseableViewer/Views/TimeRangePicker.swift+25−3
@@ -31,6 +31,27 @@ struct TimeRangePicker: View {
31 .font(.caption)
31 .font(.caption)
32 }
32 }
33 }
33 }
34+ .onChange(of: showCustomPicker) { _, isShown in
35+ // Dismissing the popover by clicking outside bypasses the "Done"
36+ // button's `customEnd > customStart` validation, which could leave an
37+ // inverted or zero-width range selected (the menu already set
38+ // `option == .custom`). Sanitize on close so a backwards range can
39+ // never reach a query.
40+ guard !isShown else { return }
41+ if customEnd <= customStart {
42+ customEnd = customStart.addingTimeInterval(3600)
43+ }
44+ // Commit on *every* close path, not just the "Done" button. Closing
45+ // the popover by clicking outside is the common dismissal gesture;
46+ // without this the picker reads "Custom" while results still reflect
47+ // the previously selected preset, since QueryView only re-queries on
48+ // `timeRangeOption` changes (which it skips for `.custom`) — never on
49+ // the custom dates. Done sets `showCustomPicker = false` and lets this
50+ // handler do the single commit, so there's no double-query.
51+ if option == .custom {
52+ onCommit?()
53+ }
54+ }
34 .popover(isPresented: $showCustomPicker) {
55 .popover(isPresented: $showCustomPicker) {
35 VStack(spacing: 12) {
56 VStack(spacing: 12) {
36 Text("Custom Time Range")
57 Text("Custom Time Range")
@@ -41,7 +62,7 @@ struct TimeRangePicker: View {
41 DatePicker("End:", selection: $customEnd)
62 DatePicker("End:", selection: $customEnd)
42 .datePickerStyle(.field)
63 .datePickerStyle(.field)
43
64
44− if customEnd < customStart {
65+ if customEnd <= customStart {
45 HStack(spacing: 4) {
66 HStack(spacing: 4) {
46 Image(systemName: "exclamationmark.triangle.fill")
67 Image(systemName: "exclamationmark.triangle.fill")
47 Text("End date must be after start date")
68 Text("End date must be after start date")
@@ -78,11 +99,12 @@ struct TimeRangePicker: View {
78 Spacer()
99 Spacer()
79
100
80 Button("Done") {
101 Button("Done") {
102+ // Just close — the `showCustomPicker` change handler is the
103+ // single commit point for all close paths (Done + click-out).
81 showCustomPicker = false
104 showCustomPicker = false
82− onCommit?()
83 }
105 }
84 .buttonStyle(.borderedProminent)
106 .buttonStyle(.borderedProminent)
85− .disabled(customEnd < customStart)
107+ .disabled(customEnd <= customStart)
86 }
108 }
87 }
109 }
88 .padding()
110 .padding()
ParseableViewer/Views/UsersView.swift+14−2
@@ -5,6 +5,9 @@ struct UsersView: View {
5 @State private var users: [UserInfo] = []
5 @State private var users: [UserInfo] = []
6 @State private var isLoading = false
6 @State private var isLoading = false
7 @State private var errorMessage: String?
7 @State private var errorMessage: String?
8+ /// Bumped by every load so a superseded (cancelled) load can't clear the
9+ /// newer load's spinner.
10+ @State private var loadGeneration = 0
8
11
9 var body: some View {
12 var body: some View {
10 VStack(spacing: 0) {
13 VStack(spacing: 0) {
@@ -82,8 +85,14 @@ struct UsersView: View {
82 private func loadUsers() async {
85 private func loadUsers() async {
83 guard let client = appState.client else { return }
86 guard let client = appState.client else { return }
84 let connID = appState.activeConnection?.id
87 let connID = appState.activeConnection?.id
88+ loadGeneration &+= 1
89+ let generation = loadGeneration
85 isLoading = true
90 isLoading = true
86 errorMessage = nil
91 errorMessage = nil
92+ // Clear the previous server's list up front so it can't briefly show through
93+ // after a switch (e.g. on the stale-guard early-return below). Matches the
94+ // sibling views (AlertsView clears alertConfig, ServerInfoView clears about).
95+ users = []
87
96
88 let loaded: [UserInfo]?
97 let loaded: [UserInfo]?
89 let loadError: String?
98 let loadError: String?
@@ -95,8 +104,11 @@ struct UsersView: View {
95 loadError = ParseableError.userFriendlyMessage(for: error)
104 loadError = ParseableError.userFriendlyMessage(for: error)
96 }
105 }
97
106
98− // Drop results if the active connection changed while awaiting.
107+ // Drop results if the active connection changed while awaiting. Still clear
108+ // isLoading: this task set the spinner, so leaving it true on the stale path
109+ // wedges the ProgressView and disables Refresh.
110+ guard generation == loadGeneration else { return }
99− guard connID == appState.activeConnection?.id else { return }
111+ guard connID == appState.activeConnection?.id else { isLoading = false; return }
100 if let loaded { users = loaded }
112 if let loaded { users = loaded }
101 errorMessage = loadError
113 errorMessage = loadError
102 isLoading = false
114 isLoading = false
ParseableViewerTests/JSONValueTests.swift+18
@@ -75,6 +75,24 @@ final class JSONValueTests: XCTestCase {
75 XCTAssertEqual(original, decoded)
75 XCTAssertEqual(original, decoded)
76 }
76 }
77
77
78+ func testEncodeNonFiniteDoubleAsNull() throws {
79+ // A non-finite double must encode as JSON null rather than throwing — a
80+ // single infinity/NaN otherwise aborts the whole JSONEncoder pass and
81+ // collapses exportAsJSON() to "[]" (total silent data loss).
82+ let encoder = JSONEncoder()
83+ for value: Double in [.infinity, -.infinity, .nan] {
84+ let data = try encoder.encode(JSONValue.double(value))
85+ XCTAssertEqual(String(data: data, encoding: .utf8), "null")
86+ }
87+ }
88+
89+ func testEncodeArrayWithNonFiniteDoubleDoesNotThrow() throws {
90+ // Mirrors the export path: one non-finite value must not poison the rest.
91+ let original: JSONValue = .array([.int(1), .double(.infinity), .string("ok")])
92+ let data = try JSONEncoder().encode(original)
93+ XCTAssertEqual(String(data: data, encoding: .utf8), "[1,null,\"ok\"]")
94+ }
95+
78 // MARK: - displayString
96 // MARK: - displayString
79
97
80 func testDisplayStringNull() {
98 func testDisplayStringNull() {
ParseableViewerTests/LiveTailViewModelTests.swift+39−1
@@ -404,10 +404,23 @@ final class LiveTailViewModelTests: XCTestCase {
404 XCTAssertEqual(vm.columnFilters.count, 1)
404 XCTAssertEqual(vm.columnFilters.count, 1)
405 }
405 }
406
406
407− func testAddFilterDifferentValueIsAdditive() {
407+ func testAddFilterSameColumnIncludeReplaces() {
408 let vm = LiveTailViewModel()
408 let vm = LiveTailViewModel()
409 vm.addColumnFilter(column: "level", value: .string("error"), exclude: false)
409 vm.addColumnFilter(column: "level", value: .string("error"), exclude: false)
410+ // A second include on the same column replaces the first: includes are
411+ // AND-combined and a column can only equal one value, so stacking two
412+ // includes would match zero rows and silently empty the tail.
410 vm.addColumnFilter(column: "level", value: .string("warn"), exclude: false)
413 vm.addColumnFilter(column: "level", value: .string("warn"), exclude: false)
414+ XCTAssertEqual(vm.columnFilters.count, 1)
415+ XCTAssertEqual(vm.columnFilters[0].value, .string("warn"))
416+ }
417+
418+ func testAddFilterSameColumnExcludesStack() {
419+ let vm = LiveTailViewModel()
420+ vm.addColumnFilter(column: "level", value: .string("debug"), exclude: true)
421+ // Excludes on the same column legitimately stack: level ≠ debug AND
422+ // level ≠ trace is meaningful, so both filters are retained.
423+ vm.addColumnFilter(column: "level", value: .string("trace"), exclude: true)
411 XCTAssertEqual(vm.columnFilters.count, 2)
424 XCTAssertEqual(vm.columnFilters.count, 2)
412 }
425 }
413
426
@@ -448,4 +461,29 @@ final class LiveTailViewModelTests: XCTestCase {
448 let filter = LiveTailViewModel.ColumnFilter(column: "field", value: nil, exclude: false)
461 let filter = LiveTailViewModel.ColumnFilter(column: "field", value: nil, exclude: false)
449 XCTAssertEqual(filter.displayLabel, "field = null")
462 XCTAssertEqual(filter.displayLabel, "field = null")
450 }
463 }
464+
465+ // MARK: - Zone-less timestamps / saved column order
466+
467+ func testParseTimestampTreatsZonelessAsUTC() {
468+ // Parseable serializes p_timestamp without a zone designator.
469+ let naive = LiveTailViewModel.parseTimestamp(from: ["p_timestamp": .string("2024-06-15T10:30:45.123")])
470+ let zoned = LiveTailViewModel.parseTimestamp(from: ["p_timestamp": .string("2024-06-15T10:30:45.123Z")])
471+ XCTAssertNotNil(naive)
472+ XCTAssertEqual(naive, zoned)
473+ let naiveWhole = LiveTailViewModel.parseTimestamp(from: ["p_timestamp": .string("2024-06-15T10:30:45")])
474+ XCTAssertEqual(naiveWhole, LiveTailViewModel.parseTimestamp(from: ["p_timestamp": .string("2024-06-15T10:30:45Z")]))
475+ }
476+
477+ func testMergedColumnOrderRestoresSavedPosition() {
478+ let merged = QueryViewModel.mergedColumnOrder(
479+ ["p_timestamp", "level", "message"],
480+ inserting: ["trace_id"],
481+ reference: ["p_timestamp", "trace_id", "level", "message"]
482+ )
483+ XCTAssertEqual(merged, ["p_timestamp", "trace_id", "level", "message"])
484+ }
485+
486+ func testMergedColumnOrderAppendsColumnsWithoutSavedPosition() {
487+ XCTAssertEqual(QueryViewModel.mergedColumnOrder(["a"], inserting: ["b"], reference: []), ["a", "b"])
488+ }
451}
489}
ParseableViewerTests/ParseableClientTests.swift+58
@@ -646,4 +646,62 @@ final class ParseableClientTests: XCTestCase {
646 // Carriage returns in values should be wrapped in quotes per RFC 4180
646 // Carriage returns in values should be wrapped in quotes per RFC 4180
647 XCTAssertTrue(csv.contains("\"line1\r\nline2\""))
647 XCTAssertTrue(csv.contains("\"line1\r\nline2\""))
648 }
648 }
649+
650+ // MARK: - Server response key spellings
651+
652+ func testServerAboutDecodesCamelCaseKeys() throws {
653+ let json = #"{"uiVersion": "v1.2", "deploymentId": "01H", "grpcPort": 8001}"#.data(using: .utf8)!
654+ let about = try JSONDecoder().decode(ServerAbout.self, from: json)
655+ XCTAssertEqual(about.uiVersion, "v1.2")
656+ XCTAssertEqual(about.deploymentId, "01H")
657+ XCTAssertEqual(about.grpcPort, 8001)
658+ }
659+
660+ func testStreamInfoDecodesSnakeCaseKeys() throws {
661+ let json = #"{"created-at": "c", "first-event-at": "f", "time_partition": "ts", "static_schema_flag": "true", "cache_enabled": true}"#
662+ .data(using: .utf8)!
663+ let info = try JSONDecoder().decode(StreamInfo.self, from: json)
664+ XCTAssertEqual(info.createdAt, "c")
665+ XCTAssertEqual(info.firstEventAt, "f")
666+ XCTAssertEqual(info.timePartition, "ts")
667+ XCTAssertEqual(info.staticSchemaFlag, true)
668+ XCTAssertEqual(info.cacheEnabled, true)
669+ }
670+
671+ func testStreamInfoDecodesCamelCaseKeys() throws {
672+ let json = #"{"createdAt": "c", "firstEventAt": "f", "timePartition": "ts", "staticSchemaFlag": "true"}"#
673+ .data(using: .utf8)!
674+ let info = try JSONDecoder().decode(StreamInfo.self, from: json)
675+ XCTAssertEqual(info.createdAt, "c")
676+ XCTAssertEqual(info.firstEventAt, "f")
677+ XCTAssertEqual(info.timePartition, "ts")
678+ XCTAssertEqual(info.staticSchemaFlag, true)
679+ }
680+
681+ func testStorageStatsReadsFormatAndUnitSuffixedSizes() throws {
682+ let json = #"{"storage": {"size": "2048 Bytes", "format": "parquet"}}"#.data(using: .utf8)!
683+ let stats = try JSONDecoder().decode(StreamStats.self, from: json)
684+ XCTAssertEqual(stats.storage?.type, "parquet")
685+ XCTAssertEqual(stats.storage?.size, ByteCountFormatter.string(fromByteCount: 2048, countStyle: .file))
686+ }
687+
688+ func testSchemaFieldDescribesParameterizedArrowTypes() throws {
689+ let json = #"{"name": "p_timestamp", "data_type": {"Timestamp": ["Millisecond", null]}}"#.data(using: .utf8)!
690+ let field = try JSONDecoder().decode(SchemaField.self, from: json)
691+ XCTAssertEqual(field.dataType, "Timestamp(Millisecond, None)")
692+ }
693+
694+ func testLegacyAlertRuleWithObjectConfigKeepsRuleType() throws {
695+ let json = #"{"name": "a", "rule": {"type": "column", "config": {"column": "status", "operator": "=", "value": 500}}}"#
696+ .data(using: .utf8)!
697+ let rule = try JSONDecoder().decode(AlertRule.self, from: json)
698+ XCTAssertEqual(rule.rule?.type, "column")
699+ XCTAssertNotNil(rule.rule?.config)
700+ }
701+
702+ func testServerAboutDecodesObjectLicense() throws {
703+ let json = #"{"license": {"name": "AGPL-3.0-only", "plan": "OSS"}}"#.data(using: .utf8)!
704+ let about = try JSONDecoder().decode(ServerAbout.self, from: json)
705+ XCTAssertEqual(about.license, "AGPL-3.0-only (OSS)")
706+ }
649}
707}
ParseableViewerTests/QueryViewModelTests.swift+152−2
@@ -1,6 +1,8 @@
1import XCTest
1import XCTest
2@testable import ParseableViewer
2@testable import ParseableViewer
3
3
4+// QueryViewModel is @MainActor, so its tests must run on the main actor too.
5+@MainActor
4final class QueryViewModelTests: XCTestCase {
6final class QueryViewModelTests: XCTestCase {
5 // MARK: - Column extraction
7 // MARK: - Column extraction
6
8
@@ -233,7 +235,7 @@ final class QueryViewModelTests: XCTestCase {
233 let vm = QueryViewModel()
235 let vm = QueryViewModel()
234 vm.sqlQuery = "SELECT * FROM \"logs\" ORDER BY p_timestamp DESC LIMIT 1000"
236 vm.sqlQuery = "SELECT * FROM \"logs\" ORDER BY p_timestamp DESC LIMIT 1000"
235 vm.addColumnFilter(column: "level", value: .string("debug"), exclude: true)
237 vm.addColumnFilter(column: "level", value: .string("debug"), exclude: true)
236− XCTAssertTrue(vm.sqlQuery.contains("WHERE \"level\" <> 'debug'"))
238+ XCTAssertTrue(vm.sqlQuery.contains("WHERE \"level\" IS DISTINCT FROM 'debug'"))
237 XCTAssertTrue(vm.sqlQuery.contains("ORDER BY"))
239 XCTAssertTrue(vm.sqlQuery.contains("ORDER BY"))
238 }
240 }
239
241
@@ -259,6 +261,15 @@ final class QueryViewModelTests: XCTestCase {
259 XCTAssertTrue(vm.sqlQuery.contains("WHERE \"level\" = 'error'"))
261 XCTAssertTrue(vm.sqlQuery.contains("WHERE \"level\" = 'error'"))
260 }
262 }
261
263
264+ func testAddColumnFilterParenthesizesExistingOr() {
265+ // An existing top-level OR must be parenthesized before AND-ing the new
266+ // condition so precedence is preserved.
267+ let vm = QueryViewModel()
268+ vm.sqlQuery = "SELECT * FROM \"logs\" WHERE \"a\" = 1 OR \"b\" = 2"
269+ vm.addColumnFilter(column: "host", value: .string("web-1"), exclude: false)
270+ XCTAssertTrue(vm.sqlQuery.contains("(\"a\" = 1 OR \"b\" = 2) AND \"host\" = 'web-1'"))
271+ }
272+
262 func testAddColumnFilterIntValue() {
273 func testAddColumnFilterIntValue() {
263 let vm = QueryViewModel()
274 let vm = QueryViewModel()
264 vm.sqlQuery = "SELECT * FROM \"logs\""
275 vm.sqlQuery = "SELECT * FROM \"logs\""
@@ -277,7 +288,7 @@ final class QueryViewModelTests: XCTestCase {
277 let vm = QueryViewModel()
288 let vm = QueryViewModel()
278 vm.sqlQuery = "SELECT * FROM \"logs\""
289 vm.sqlQuery = "SELECT * FROM \"logs\""
279 vm.addColumnFilter(column: "level", value: .string("warn"), exclude: true)
290 vm.addColumnFilter(column: "level", value: .string("warn"), exclude: true)
280− XCTAssertTrue(vm.sqlQuery.contains("WHERE \"level\" <> 'warn'"))
291+ XCTAssertTrue(vm.sqlQuery.contains("WHERE \"level\" IS DISTINCT FROM 'warn'"))
281 }
292 }
282
293
283 func testAddColumnFilterEscapesSingleQuotesInValue() {
294 func testAddColumnFilterEscapesSingleQuotesInValue() {
@@ -348,6 +359,25 @@ final class QueryViewModelTests: XCTestCase {
348 XCTAssertEqual(vm.columnOrder, ["c", "a", "b"])
359 XCTAssertEqual(vm.columnOrder, ["c", "a", "b"])
349 }
360 }
350
361
362+ func testMoveColumnRightwardLandsAfterTarget() {
363+ // Rightward move (e.g. the "Move Right" context action, which passes the
364+ // adjacent column as the target) must land the column *after* the target,
365+ // not produce a no-op.
366+ let vm = QueryViewModel()
367+ vm.columns = ["a", "b", "c"]
368+ vm.columnOrder = ["a", "b", "c"]
369+ vm.moveColumn("b", to: "c")
370+ XCTAssertEqual(vm.columnOrder, ["a", "c", "b"])
371+ }
372+
373+ func testMoveColumnLeftwardLandsBeforeTarget() {
374+ let vm = QueryViewModel()
375+ vm.columns = ["a", "b", "c"]
376+ vm.columnOrder = ["a", "b", "c"]
377+ vm.moveColumn("b", to: "a")
378+ XCTAssertEqual(vm.columnOrder, ["b", "a", "c"])
379+ }
380+
351 func testMoveColumnByIndexSet() {
381 func testMoveColumnByIndexSet() {
352 let vm = QueryViewModel()
382 let vm = QueryViewModel()
353 vm.columns = ["a", "b", "c"]
383 vm.columns = ["a", "b", "c"]
@@ -641,5 +671,125 @@ final class QueryViewModelTests: XCTestCase {
641 let empty = vm.emptyColumns(in: records, columns: ["a"])
671 let empty = vm.emptyColumns(in: records, columns: ["a"])
642 XCTAssertTrue(empty.isEmpty)
672 XCTAssertTrue(empty.isEmpty)
643 }
673 }
674+
675+ // MARK: - Column filter edge cases
676+
677+ func testAddColumnFilterKeepsTrailingSemicolonLast() {
678+ let vm = QueryViewModel()
679+ vm.sqlQuery = "SELECT * FROM \"logs\";"
680+ vm.addColumnFilter(column: "level", value: .string("error"), exclude: false)
681+ XCTAssertEqual(vm.sqlQuery, "SELECT * FROM \"logs\" WHERE \"level\" = 'error';")
682+ }
683+
684+ func testAddColumnFilterDoesNotLandInTrailingComment() {
685+ let vm = QueryViewModel()
686+ vm.sqlQuery = "SELECT * FROM \"logs\" -- recent"
687+ vm.addColumnFilter(column: "level", value: .string("error"), exclude: false)
688+ XCTAssertEqual(vm.sqlQuery, "SELECT * FROM \"logs\" WHERE \"level\" = 'error' -- recent")
689+ }
690+
691+ func testAddColumnFilterKeepsLineBreakAfterWhereBodyComment() {
692+ let vm = QueryViewModel()
693+ vm.sqlQuery = "SELECT * FROM \"logs\"\nWHERE a = 1 -- note\nORDER BY p_timestamp DESC"
694+ vm.addColumnFilter(column: "host", value: .string("web-1"), exclude: false)
695+ XCTAssertEqual(
696+ vm.sqlQuery,
697+ "SELECT * FROM \"logs\"\nWHERE a = 1 AND \"host\" = 'web-1' -- note\nORDER BY p_timestamp DESC"
698+ )
699+ }
700+
701+ func testAddColumnFilterWithTrailingClauseBeforeWhereDoesNotCrash() {
702+ let vm = QueryViewModel()
703+ vm.sqlQuery = "SELECT a FROM t1 GROUP BY a UNION ALL SELECT a FROM t2 WHERE b = 1"
704+ vm.addColumnFilter(column: "host", value: .string("web-1"), exclude: false)
705+ XCTAssertEqual(
706+ vm.sqlQuery,
707+ "SELECT a FROM t1 GROUP BY a UNION ALL SELECT a FROM t2 WHERE b = 1 AND \"host\" = 'web-1'"
708+ )
709+ }
710+
711+ func testAddColumnFilterIgnoresKeywordNamedColumnsInSelectList() {
712+ let vm = QueryViewModel()
713+ vm.sqlQuery = "SELECT offset, message FROM \"kafka\" WHERE partition = 3"
714+ vm.addColumnFilter(column: "host", value: .string("web-1"), exclude: false)
715+ XCTAssertEqual(
716+ vm.sqlQuery,
717+ "SELECT offset, message FROM \"kafka\" WHERE partition = 3 AND \"host\" = 'web-1'"
718+ )
719+ }
720+
721+ // MARK: - SELECT list rewriting
722+
723+ func testUpdateSQLColumnsLeavesExpressionListsAlone() {
724+ let vm = QueryViewModel()
725+ let sql = "SELECT count(*) AS n, level FROM \"logs\" GROUP BY level"
726+ vm.sqlQuery = sql
727+ vm.columns = ["n", "level"]
728+ vm.columnOrder = ["n", "level"]
729+ vm.toggleColumnVisibility("n")
730+ XCTAssertEqual(vm.sqlQuery, sql)
731+ XCTAssertEqual(vm.visibleColumns, ["level"])
732+ }
733+
734+ func testUpdateSQLColumnsDoesNotBakeInAutoHiddenColumns() {
735+ let vm = QueryViewModel()
736+ vm.sqlQuery = "SELECT * FROM \"logs\""
737+ vm.columns = ["a", "b", "c"]
738+ vm.columnOrder = ["a", "b", "c"]
739+ vm.autoHiddenColumns = ["c"]
740+ vm.moveColumn("b", to: "a")
741+ XCTAssertEqual(vm.sqlQuery, "SELECT * FROM \"logs\"")
742+ }
743+
744+ func testUpdateSQLColumnsKeepsHiddenColumnOutAfterNarrowedRerun() {
745+ // Hiding `c` narrowed the SELECT list, so the re-run's results lack it; a
746+ // later reorder must not switch back to `*` and fetch it again.
747+ let vm = QueryViewModel()
748+ vm.sqlQuery = "SELECT \"a\", \"b\" FROM \"logs\""
749+ vm.pendingColumnConfig = QueryViewModel.ColumnConfiguration(order: ["a", "b", "c"], hidden: ["c"])
750+ vm.applyColumnConfig(extractedColumns: ["a", "b"], stream: nil)
751+ vm.moveColumn("b", to: "a")
752+ XCTAssertEqual(vm.sqlQuery, "SELECT \"b\", \"a\" FROM \"logs\"")
753+ XCTAssertTrue(vm.hasHiddenColumns)
754+ vm.showAllColumns()
755+ XCTAssertEqual(vm.sqlQuery, "SELECT * FROM \"logs\"")
756+ }
757+
758+ func testExcludeFilterKeepsRowsWhereColumnIsNull() {
759+ let vm = QueryViewModel()
760+ vm.sqlQuery = "SELECT * FROM \"logs\""
761+ vm.addColumnFilter(column: "level", value: .string("debug"), exclude: true)
762+ XCTAssertFalse(vm.sqlQuery.contains("<>"), "`<>` would also drop rows where level is NULL")
763+ XCTAssertTrue(vm.sqlQuery.contains("\"level\" IS DISTINCT FROM 'debug'"))
764+ }
765+
766+ func testUpdateSQLColumnsKeepsListedColumnsMissingFromResults() {
767+ // Parseable omits all-null fields from query JSON, so a listed column can
768+ // be absent from `columns`; rewriting must neither refuse nor drop it.
769+ let vm = QueryViewModel()
770+ vm.sqlQuery = "SELECT \"a\", \"b\", \"c\", \"sparse\" FROM \"logs\""
771+ vm.columns = ["a", "b", "c"]
772+ vm.columnOrder = ["a", "b", "c"]
773+ vm.toggleColumnVisibility("c")
774+ XCTAssertEqual(vm.sqlQuery, "SELECT \"a\", \"b\", \"sparse\" FROM \"logs\"")
775+ }
776+
777+ func testReorderKeepsStarWhenSavedHiddenColumnIsAbsentAnyway() {
778+ // Under `SELECT *` a saved-hidden column that's missing is missing for
779+ // other reasons (sparse/removed); a plain reorder must keep `*`.
780+ let vm = QueryViewModel()
781+ vm.sqlQuery = "SELECT * FROM \"logs\""
782+ vm.pendingColumnConfig = QueryViewModel.ColumnConfiguration(order: ["a", "b", "gone"], hidden: ["gone"])
783+ vm.applyColumnConfig(extractedColumns: ["a", "b"], stream: nil)
784+ vm.moveColumn("b", to: "a")
785+ XCTAssertEqual(vm.sqlQuery, "SELECT * FROM \"logs\"")
786+ }
787+
788+ func testMergedColumnOrderKeepsSavedPositionForAbsentColumn() {
789+ XCTAssertEqual(
790+ QueryViewModel.mergedColumnOrder(["b", "a"], inserting: ["x"], reference: ["a", "x", "b"]),
791+ ["b", "a", "x"]
792+ )
793+ }
644}
794}
645
795
ParseableViewerTests/SQLAutoCompleteTests.swift+56
@@ -275,6 +275,62 @@ final class SQLAutoCompleteTests: XCTestCase {
275 let ctx = SQLCompletionProvider.determineContext(text: "SELECT * FROM a LEFT JOIN ", position: 26)
275 let ctx = SQLCompletionProvider.determineContext(text: "SELECT * FROM a LEFT JOIN ", position: 26)
276 XCTAssertEqual(ctx, .tableRef)
276 XCTAssertEqual(ctx, .tableRef)
277 }
277 }
278+
279+ // MARK: - Identifier quoting / non-SQL regions
280+
281+ func testColumnCompletionQuotesNamesThatNeedIt() {
282+ let fields = [SchemaField(name: "statusCode", dataType: "Int64"), SchemaField(name: "status", dataType: "Utf8")]
283+ let result = SQLCompletionProvider.completions(
284+ for: "SELECT * FROM t WHERE stat", cursorPosition: 26,
285+ streamNames: [], schemaFields: fields
286+ )
287+ let columns = result.items.filter { $0.kind == .column }.map(\.text)
288+ XCTAssertEqual(columns, ["status", "\"statusCode\""])
289+ }
290+
291+ func testNoCompletionsInsideLineComment() {
292+ let text = "-- grouped by date"
293+ let result = SQLCompletionProvider.completions(
294+ for: text, cursorPosition: (text as NSString).length,
295+ streamNames: streams, schemaFields: fields
296+ )
297+ XCTAssertTrue(result.items.isEmpty)
298+ }
299+
300+ func testNoCompletionsInsideStringLiteral() {
301+ let text = "SELECT * FROM t WHERE msg LIKE '%sel"
302+ let result = SQLCompletionProvider.completions(
303+ for: text, cursorPosition: (text as NSString).length,
304+ streamNames: streams, schemaFields: fields
305+ )
306+ XCTAssertTrue(result.items.isEmpty)
307+ }
308+
309+ func testFullyTypedMixedCaseColumnStillOffersQuotedForm() {
310+ let fields = [SchemaField(name: "statusCode", dataType: "Int64")]
311+ let bare = "SELECT * FROM t WHERE statusCode"
312+ let result = SQLCompletionProvider.completions(
313+ for: bare, cursorPosition: (bare as NSString).length,
314+ streamNames: [], schemaFields: fields
315+ )
316+ XCTAssertEqual(result.items.map(\.text), ["\"statusCode\""])
317+
318+ // Already quoted: accepting would change nothing, so no popup.
319+ let quoted = "SELECT * FROM t WHERE \"statusCode\""
320+ let quotedResult = SQLCompletionProvider.completions(
321+ for: quoted, cursorPosition: (quoted as NSString).length - 1,
322+ streamNames: [], schemaFields: fields
323+ )
324+ XCTAssertTrue(quotedResult.items.isEmpty)
325+ }
326+
327+ func testReservedWordColumnIsQuoted() {
328+ let fields = [SchemaField(name: "order", dataType: "Utf8")]
329+ let result = SQLCompletionProvider.completions(
330+ for: "SELECT ord", cursorPosition: 10, streamNames: [], schemaFields: fields
331+ )
332+ XCTAssertEqual(result.items.filter { $0.kind == .column }.map(\.text), ["\"order\""])
333+ }
278}
334}
279
335
280// Make SQLCompletionContext Equatable for assertions
336// Make SQLCompletionContext Equatable for assertions
ParseableViewerTests/SQLTokenizerTests.swift+15
@@ -348,4 +348,19 @@ final class SQLTokenizerTests: XCTestCase {
348 let sql = "SELECT * FROM t"
348 let sql = "SELECT * FROM t"
349 XCTAssertNil(SQLTokenizer.errorHighlightRange(line: 99, column: 1, in: sql))
349 XCTAssertNil(SQLTokenizer.errorHighlightRange(line: 99, column: 1, in: sql))
350 }
350 }
351+
352+ // MARK: - CRLF line endings
353+
354+ func testCRLFIsWhitespaceAndEndsLineComment() {
355+ // Swift treats "\r\n" as a single Character, equal to neither "\r" nor "\n".
356+ let sql = "SELECT * -- all\r\nFROM t"
357+ let kinds = SQLTokenizer.tokenize(sql).map(\.kind).filter { !$0.isTrivia }
358+ XCTAssertEqual(kinds, [.keyword("SELECT"), .star, .keyword("FROM"), .identifier("t")])
359+ }
360+
361+ func testSelectColumnListRangeExcludesCRLF() {
362+ let sql = "SELECT *\r\nFROM t"
363+ let range = SQLTokenizer.selectColumnListRange(in: sql)
364+ XCTAssertEqual(range.map { String(sql[$0]) }, "*")
365+ }
351}
366}
ParseableViewerTests/ServerConnectionTests.swift+10
@@ -77,4 +77,14 @@ final class ServerConnectionTests: XCTestCase {
77 let conn = ServerConnection(name: "test", url: "https://", username: "admin", password: "pass")
77 let conn = ServerConnection(name: "test", url: "https://", username: "admin", password: "pass")
78 XCTAssertNil(conn.baseURL)
78 XCTAssertNil(conn.baseURL)
79 }
79 }
80+
81+ func testBaseURLAcceptsUppercaseScheme() {
82+ let conn = ServerConnection(name: "t", url: "HTTP://10.0.0.5:8000", username: "u", password: "p")
83+ XCTAssertEqual(conn.baseURL?.absoluteString, "http://10.0.0.5:8000")
84+ }
85+
86+ func testBaseURLRejectsNonHTTPScheme() {
87+ let conn = ServerConnection(name: "t", url: "ftp://example.com", username: "u", password: "p")
88+ XCTAssertNil(conn.baseURL)
89+ }
80}
90}
ParseableViewerTests/SyntaxHighlightingTests.swift+10
@@ -140,6 +140,16 @@ final class SyntaxHighlightingTests: XCTestCase {
140 "Double-quoted identifiers should differ from keywords")
140 "Double-quoted identifiers should differ from keywords")
141 }
141 }
142
142
143+ func testSQLApostropheInCommentDoesNotStartString() {
144+ // The later quoted literal gives the stray apostrophe a closing partner, so
145+ // separate string/comment passes painted everything in between as a string.
146+ let sql = "-- don't\nSELECT * FROM t WHERE a = 'b'"
147+ let ts = sqlHighlight(sql)
148+ let selectIdx = (sql as NSString).range(of: "SELECT").location
149+ XCTAssertEqual(colorAt(ts, selectIdx), colorAt(sqlHighlight("SELECT x"), 0),
150+ "An apostrophe inside a comment must not open a string literal")
151+ }
152+
143 // MARK: - JSON Highlighting
153 // MARK: - JSON Highlighting
144
154
145 func testJSONHighlightProducesOutput() {
155 func testJSONHighlightProducesOutput() {
README.md+1−1
@@ -143,7 +143,7 @@ ParseableViewer/
143| `Cmd+R` | Refresh streams |
143| `Cmd+R` | Refresh streams |
144| `Cmd+Enter` | Run query |
144| `Cmd+Enter` | Run query |
145| `Cmd+P` | Pause/resume live tail |
145| `Cmd+P` | Pause/resume live tail |
146−| `Cmd+Delete` | Clear live tail entries |
146+| `Cmd+K` | Clear live tail entries |
147| `Cmd+Ctrl+S` | Toggle sidebar |
147| `Cmd+Ctrl+S` | Toggle sidebar |
148
148
149## License
149## License