git.iko.soy/parseable-macos
on 96828ac help sign in
37 files changed+321−1959
since splitsnapshots
unifiedsplit
ParseableViewer/App/AppState.swift+18−140 ParseableViewer/App/ParseableViewerApp.swift+1−13 ParseableViewer/Info.plist−5 ParseableViewer/Models/AlertModels.swift−21 ParseableViewer/Models/JSONValue.swift+2−32 ParseableViewer/Models/LogStream.swift+12−109 ParseableViewer/Models/SQLTokenizer.swift+5−33 ParseableViewer/Models/ServerConnection.swift+2−19 ParseableViewer/Models/ServerInfo.swift+10−47 ParseableViewer/Services/ParseableClient.swift+8−118 ParseableViewer/ViewModels/LiveTailViewModel.swift+48−133 ParseableViewer/ViewModels/QueryViewModel.swift+72−362 ParseableViewer/Views/AlertsView.swift+5−21 ParseableViewer/Views/ConnectionSheet.swift+6−27 ParseableViewer/Views/ContentView.swift+2−30 ParseableViewer/Views/LiveTailView.swift+12−59 ParseableViewer/Views/LogDetailView.swift+2−5 ParseableViewer/Views/LogTableView.swift+6−53 ParseableViewer/Views/QueryView.swift+10−53 ParseableViewer/Views/SQLAutoComplete.swift+12−129 ParseableViewer/Views/SQLEditorView.swift+6−41 ParseableViewer/Views/ServerInfoView.swift+10−39 ParseableViewer/Views/SettingsView.swift+2−21 ParseableViewer/Views/SidebarView.swift+1−19 ParseableViewer/Views/StreamDetailView.swift+6−34 ParseableViewer/Views/SyntaxHighlighting.swift+58−32 ParseableViewer/Views/TimeRangePicker.swift+1−23 ParseableViewer/Views/UsersView.swift−10 ParseableViewerTests/JSONValueTests.swift−18 ParseableViewerTests/LiveTailViewModelTests.swift+1−39 ParseableViewerTests/ParseableClientTests.swift−58 ParseableViewerTests/QueryViewModelTests.swift+2−124 ParseableViewerTests/SQLAutoCompleteTests.swift−56 ParseableViewerTests/SQLTokenizerTests.swift−15 ParseableViewerTests/ServerConnectionTests.swift−10 ParseableViewerTests/SyntaxHighlightingTests.swift−10 README.md+1−1
ParseableViewer/App/AppState.swift+18−140
@@ -15,10 +15,6 @@ final class AppState {
1515 private let networkMonitor = NWPathMonitor()
1616 private let networkMonitorQueue = DispatchQueue(label: "com.parseableviewer.network-monitor")
1717 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?
2218
2319 // MARK: - Stream State
2420 var streams: [LogStream] = []
@@ -100,7 +96,6 @@ final class AppState {
10096
10197 if let activeID = ConnectionStore.loadActiveConnectionID(),
10298 let connection = connections.first(where: { $0.id == activeID }) {
103− connectingConnectionID = connection.id
10499 connectTask = Task { @MainActor [weak self] in
105100 await self?.performConnect(to: connection) ?? false
106101 }
@@ -120,7 +115,6 @@ final class AppState {
120115 existing.cancel()
121116 _ = await existing.value
122117 }
123− connectingConnectionID = connection.id
124118 let task = Task { @MainActor [weak self] in
125119 await self?.performConnect(to: connection) ?? false
126120 }
@@ -130,16 +124,9 @@ final class AppState {
130124
131125 @MainActor
132126 private func performConnect(to connection: ServerConnection) async -> Bool {
133− defer {
134− if connectingConnectionID == connection.id { connectingConnectionID = nil }
135− }
136127 guard !isConnecting else { return false }
137128
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.
142− if !isNetworkAvailable && !Self.isLoopback(connection.baseURL) {
129+ if !isNetworkAvailable {
143130 self.errorMessage = "No internet connection. Check your network and try again."
144131 self.showError = true
145132 return false
@@ -147,25 +134,13 @@ final class AppState {
147134
148135 isConnecting = true
149136 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".
154137 errorMessage = nil
155− showError = false
156138
157139 // When switching to a different server, clear stream-specific state
158140 // so stale selections don't reference streams that don't exist.
159141 if activeConnection?.id != connection.id {
160142 selectedStream = nil
161143 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 = []
169144 // Drop the previous server's cached `about` so views seeding from it
170145 // (ServerInfoView) don't paint the old server's details while the new
171146 // one loads. It's repopulated below once the new server responds.
@@ -182,7 +157,6 @@ final class AppState {
182157
183158 // Test connection — throws on non-200 or network failure
184159 try await newClient.checkHealth()
185− try await Self.verifyCredentials(with: newClient)
186160
187161 // If the task was cancelled while awaiting (e.g. user clicked
188162 // Disconnect during auto-reconnect), bail before installing the
@@ -219,12 +193,6 @@ final class AppState {
219193 case .success(let list):
220194 self.streams = list
221195 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− }
228196 case .failure(let error):
229197 self.streams = []
230198 self.streamLoadError = ParseableError.userFriendlyMessage(for: error)
@@ -276,28 +244,10 @@ final class AppState {
276244 @MainActor
277245 func refreshStreams() async {
278246 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
287247 isLoadingStreams = true
288248 streamLoadError = nil
289249 do {
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
250+ streams = try await client.listStreams()
301251 // Drop a selection that no longer exists server-side (e.g. the stream
302252 // was deleted from another client); otherwise the detail tabs keep
303253 // querying a missing stream and surface 404s.
@@ -305,10 +255,6 @@ final class AppState {
305255 selectedStream = nil
306256 }
307257 } catch {
308− guard connID == activeConnection?.id, client === self.client else {
309− isLoadingStreams = false
310− return
311− }
312258 let message = ParseableError.userFriendlyMessage(for: error)
313259 streamLoadError = message
314260 self.errorMessage = "Failed to load streams: \(message)"
@@ -327,7 +273,6 @@ final class AppState {
327273
328274 @MainActor
329275 func updateConnection(_ connection: ServerConnection) {
330− let previous = connections.first(where: { $0.id == connection.id })
331276 if let index = connections.firstIndex(where: { $0.id == connection.id }) {
332277 connections[index] = connection
333278 ConnectionStore.saveConnections(connections)
@@ -335,36 +280,23 @@ final class AppState {
335280 // If the active connection was edited, its URL or credentials may now point
336281 // at a different server, so swapping the client alone isn't enough — the
337282 // displayed streams/selection/server-info would be stale and queries would
338− // 404. The caller is responsible for the actual reconnect (re-validate
339− // health, reload streams/filters/about); we only clear the now-possibly-
340− // stale UI state here, since `performConnect` skips its own cleanup when the
341− // connection id is unchanged. We must NOT also spawn a connect here: the
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.
283+ // 404. Reconnect end to end (re-validate health, reload streams/filters/
284+ // about). Clear the stream selection first so a now-missing stream doesn't
285+ // keep driving detail-tab requests during the reconnect.
286+ if activeConnection?.id == connection.id {
287+ // `performConnect` skips its stale-state cleanup because the id is
288+ // unchanged, but the edited URL/credentials may point at a different
289+ // server. Clear the stream-specific UI state here so a leftover sidebar
290+ // search filter doesn't hide the new server's streams and a
291+ // stream-specific tab doesn't linger with no valid selection.
358292 selectedStream = nil
359293 streamSearchText = ""
360294 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.
295+ // Drop the previous server's cached `about` too — performConnect skips
296+ // its own clear (id unchanged), so without this ServerInfoView keeps
297+ // painting the old server's version/storage until the new /about lands.
365298 serverAbout = nil
366− streams = []
367− filters = []
299+ Task { await connect(to: connection) }
368300 }
369301 }
370302
@@ -375,49 +307,9 @@ final class AppState {
375307 ConnectionStore.saveConnections(connections)
376308 if activeConnection?.id == connection.id {
377309 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.
412310 }
413311 }
414312
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−
421313 // MARK: - Stream Management
422314
423315 @MainActor
@@ -443,18 +335,8 @@ final class AppState {
443335 @MainActor
444336 func refreshFilters() async {
445337 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
451338 isLoadingFilters = true
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
339+ filters = (try? await client.listFilters()) ?? []
458340 isLoadingFilters = false
459341 }
460342
@@ -473,12 +355,8 @@ final class AppState {
473355 @MainActor
474356 func deleteFilter(_ filter: ParseableFilter) async throws {
475357 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.
479− if let filterId = filter.filterId {
480− try await client.deleteFilter(id: filterId)
481− }
358+ guard let filterId = filter.filterId else { return }
359+ try await client.deleteFilter(id: filterId)
482360 filters.removeAll { $0.id == filter.id }
483361 }
484362
ParseableViewer/App/ParseableViewerApp.swift+1−13
@@ -4,13 +4,6 @@ import SwiftUI
44struct ParseableViewerApp: App {
55 @State private var appState = AppState()
66
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−
147 var body: some Scene {
158 WindowGroup {
169 ContentView()
@@ -24,12 +17,7 @@ struct ParseableViewerApp: App {
2417 AboutCommand()
2518 }
2619
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.
32− CommandGroup(replacing: .newItem) {
20+ CommandGroup(after: .newItem) {
3321 Button("New Connection...") {
3422 appState.editingConnection = nil
3523 appState.showConnectionSheet = true
ParseableViewer/Info.plist−5
@@ -24,11 +24,6 @@
2424 <string>public.app-category.developer-tools</string>
2525 <key>LSMinimumSystemVersion</key>
2626 <string>$(MACOSX_DEPLOYMENT_TARGET)</string>
27− <key>NSAppTransportSecurity</key>
28− <dict>
29− <key>NSAllowsArbitraryLoads</key>
30− <true/>
31− </dict>
3227 <key>NSMainNibFile</key>
3328 <string></string>
3429 <key>NSPrincipalClass</key>
ParseableViewer/Models/AlertModels.swift−21
@@ -85,27 +85,6 @@ struct AlertRule: Identifiable, Codable, Sendable {
8585struct AlertRuleSpec: Codable, Sendable {
8686 let type: String?
8787 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− }
10988}
11089
11190struct AlertTarget: Codable, Sendable {
ParseableViewer/Models/JSONValue.swift+2−32
@@ -37,14 +37,7 @@ enum JSONValue: Codable, Hashable, Sendable, Comparable {
3737 case .null: try container.encodeNil()
3838 case .bool(let v): try container.encode(v)
3939 case .int(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() }
40+ case .double(let v): try container.encode(v)
4841 case .string(let v): try container.encode(v)
4942 case .array(let v): try container.encode(v)
5043 case .object(let v): try container.encode(v)
@@ -318,16 +311,7 @@ enum JSONValue: Codable, Hashable, Sendable, Comparable {
318311 /// non-finite values serialize as `null` rather than the invalid `nan`/`inf`
319312 /// that `String(_:)` would emit.
320313 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.
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)
314+ v.isFinite ? String(v) : "null"
331315 }
332316
333317 // MARK: - Equatable / Hashable
@@ -379,18 +363,4 @@ typealias LogRecord = [String: JSONValue]
379363struct QueryResponse: Codable, Sendable {
380364 let records: [LogRecord]
381365 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− }
396366}
ParseableViewer/Models/LogStream.swift+12−109
@@ -80,39 +80,11 @@ struct SchemaField: Identifiable, Codable, Hashable, Sendable {
8080 if let typeStr = try? container.decode(String.self, forKey: .dataType) {
8181 self.dataType = typeStr
8282 } else if let typeObj = try? container.decode(JSONValue.self, forKey: .dataType) {
83− self.dataType = Self.describeArrowType(typeObj)
83+ self.dataType = typeObj.displayString
8484 } else {
8585 self.dataType = "Unknown"
8686 }
8787 }
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− }
11688}
11789
11890/// Formats a byte count that may arrive from the server as a fractional or
@@ -131,62 +103,6 @@ private func formatByteSize(_ value: Int) -> String? {
131103 return ByteCountFormatter.string(fromByteCount: Int64(value), countStyle: .file)
132104}
133105
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− }
188−}
189−
190106struct StreamStats: Codable, Sendable {
191107 let ingestion: IngestionStats?
192108 let storage: StorageStats?
@@ -207,7 +123,7 @@ struct StreamStats: Codable, Sendable {
207123 self.lifetime_count = try? container.decode(Int.self, forKey: .lifetime_count)
208124 // Handle size as string or number
209125 if let s = try? container.decode(String.self, forKey: .size) {
210− self.size = formatByteSize(s)
126+ self.size = s
211127 } else if let n = try? container.decode(Int.self, forKey: .size) {
212128 self.size = formatByteSize(n)
213129 } else if let d = try? container.decode(Double.self, forKey: .size) {
@@ -216,7 +132,7 @@ struct StreamStats: Codable, Sendable {
216132 self.size = nil
217133 }
218134 if let s = try? container.decode(String.self, forKey: .lifetime_size) {
219− self.lifetime_size = formatByteSize(s)
135+ self.lifetime_size = s
220136 } else if let n = try? container.decode(Int.self, forKey: .lifetime_size) {
221137 self.lifetime_size = formatByteSize(n)
222138 } else if let d = try? container.decode(Double.self, forKey: .lifetime_size) {
@@ -238,13 +154,9 @@ struct StreamStats: Codable, Sendable {
238154
239155 init(from decoder: Decoder) throws {
240156 let container = try decoder.container(keyedBy: CodingKeys.self)
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")
157+ self.type = try? container.decode(String.self, forKey: .type)
246158 if let s = try? container.decode(String.self, forKey: .size) {
247− self.size = formatByteSize(s)
159+ self.size = s
248160 } else if let n = try? container.decode(Int.self, forKey: .size) {
249161 self.size = formatByteSize(n)
250162 } else if let d = try? container.decode(Double.self, forKey: .size) {
@@ -253,7 +165,7 @@ struct StreamStats: Codable, Sendable {
253165 self.size = nil
254166 }
255167 if let s = try? container.decode(String.self, forKey: .lifetime_size) {
256− self.lifetime_size = formatByteSize(s)
168+ self.lifetime_size = s
257169 } else if let n = try? container.decode(Int.self, forKey: .lifetime_size) {
258170 self.lifetime_size = formatByteSize(n)
259171 } else if let d = try? container.decode(Double.self, forKey: .lifetime_size) {
@@ -285,20 +197,11 @@ struct StreamInfo: Codable, Sendable {
285197 }
286198
287199 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".
295− let container = try decoder.container(keyedBy: FlexibleCodingKey.self)
296− self.createdAt = container.firstString("created-at", "createdAt", "created_at")
297− self.firstEventAt = container.firstString("first-event-at", "firstEventAt", "first_event_at")
298− self.cacheEnabled = container.firstBool("cache_enabled", "cacheEnabled", "cache-enabled")
299− self.timePartition = container.firstString("time_partition", "timePartition", "time-partition")
300− self.staticSchemaFlag = container.firstBool(
301− "static_schema_flag", "staticSchemaFlag", "static-schema-flag"
302− )
200+ let container = try decoder.container(keyedBy: CodingKeys.self)
201+ self.createdAt = try? container.decode(String.self, forKey: .createdAt)
202+ self.firstEventAt = try? container.decode(String.self, forKey: .firstEventAt)
203+ self.cacheEnabled = try? container.decode(Bool.self, forKey: .cacheEnabled)
204+ self.timePartition = try? container.decode(String.self, forKey: .timePartition)
205+ self.staticSchemaFlag = try? container.decode(Bool.self, forKey: .staticSchemaFlag)
303206 }
304207}
ParseableViewer/Models/SQLTokenizer.swift+5−33
@@ -80,20 +80,13 @@ struct SQLTokenizer: Sendable {
8080
8181 switch c {
8282 // Whitespace
83− case _ where isWhitespace(c):
84− advance(&i, in: sql, while: isWhitespace)
83+ case " ", "\t", "\n", "\r":
84+ advance(&i, in: sql, while: { $0 == " " || $0 == "\t" || $0 == "\n" || $0 == "\r" })
8585 tokens.append(Token(kind: .whitespace, range: start..<i))
8686
8787 // Line comment
8888 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.
96− advance(&i, in: sql, while: { !isLineBreak($0) })
89+ advance(&i, in: sql, while: { $0 != "\n" })
9790 tokens.append(Token(kind: .lineComment, range: start..<i))
9891
9992 // Block comment
@@ -138,8 +131,8 @@ struct SQLTokenizer: Sendable {
138131 tokens.append(Token(kind: .number(String(sql[start..<i])), range: start..<i))
139132
140133 // Identifier or keyword
141− case _ where isASCIILetter(c) || c == "_":
142− advance(&i, in: sql, while: { isASCIILetter($0) || isASCIIDigit($0) || $0 == "_" })
134+ case _ where c.isLetter || c == "_":
135+ advance(&i, in: sql, while: { $0.isLetter || $0.isNumber || $0 == "_" })
143136 let word = String(sql[start..<i])
144137 if keywords.contains(word.uppercased()) {
145138 tokens.append(Token(kind: .keyword(word.uppercased()), range: start..<i))
@@ -262,17 +255,6 @@ struct SQLTokenizer: Sendable {
262255 return value // unterminated
263256 }
264257
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−
276258 /// SQL number literals use only ASCII digits. `Character.isNumber` also
277259 /// matches Unicode numerics (`²`, `½`, Arabic-Indic digits, etc.), which
278260 /// DataFusion's lexer rejects — accepting them here would tokenize those
@@ -282,16 +264,6 @@ struct SQLTokenizer: Sendable {
282264 c.isASCII && c.isNumber
283265 }
284266
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−
295267 private static func consumeNumber(_ i: inout String.Index, in sql: String) {
296268 // Digits with at most one decimal point, so malformed input like
297269 // `1.2.3` doesn't get swallowed into a single number token.
ParseableViewer/Models/ServerConnection.swift+2−19
@@ -56,32 +56,15 @@ struct ServerConnection: Identifiable, Codable, Hashable, Sendable {
5656 var baseURL: URL? {
5757 var urlString = url.trimmingCharacters(in: .whitespacesAndNewlines)
5858 if urlString.isEmpty { return nil }
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 {
59+ if !urlString.hasPrefix("http://") && !urlString.hasPrefix("https://") {
6460 urlString = "https://\(urlString)"
6561 }
6662 while urlString.hasSuffix("/") {
6763 urlString.removeLast()
6864 }
69− guard let parsed = URL(string: urlString), parsed.host != nil,
70− parsed.scheme == "http" || parsed.scheme == "https" else {
65+ guard let parsed = URL(string: urlString), parsed.host != nil else {
7166 return nil
7267 }
7368 return parsed
7469 }
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− }
8770}
ParseableViewer/Models/ServerInfo.swift+10−47
@@ -15,59 +15,33 @@ struct ServerAbout: Codable, Sendable {
1515 let llmActive: Bool?
1616 let oidcActive: Bool?
1717
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.
2318 private enum CodingKeys: String, CodingKey {
2419 case version, commit, mode, staging, store, license
25− case uiVersion
26− case deploymentId
27− case grpcPort
20+ case uiVersion = "ui_version"
21+ case deploymentId = "deployment_id"
22+ case grpcPort = "grpc_port"
2823 case updateAvailable
2924 case latestVersion
3025 case llmActive
3126 case oidcActive
3227 }
3328
34− private enum SnakeCaseKeys: String, CodingKey {
35− case uiVersion = "ui_version"
36− case deploymentId = "deployment_id"
37− case grpcPort = "grpc_port"
38− }
39−
4029 init(from decoder: Decoder) throws {
4130 let container = try decoder.container(keyedBy: CodingKeys.self)
42− let snakeCase = try? decoder.container(keyedBy: SnakeCaseKeys.self)
4331 self.version = try? container.decode(String.self, forKey: .version)
44− self.uiVersion = (try? container.decode(String.self, forKey: .uiVersion))
45− ?? (try? snakeCase?.decode(String.self, forKey: .uiVersion))
32+ self.uiVersion = try? container.decode(String.self, forKey: .uiVersion)
4633 self.commit = try? container.decode(String.self, forKey: .commit)
47− self.deploymentId = (try? container.decode(String.self, forKey: .deploymentId))
48− ?? (try? snakeCase?.decode(String.self, forKey: .deploymentId))
34+ self.deploymentId = try? container.decode(String.self, forKey: .deploymentId)
4935 self.mode = try? container.decode(String.self, forKey: .mode)
5036 self.staging = try? container.decode(String.self, forKey: .staging)
5137 self.store = try? container.decode(StoreInfo.self, forKey: .store)
52− self.license = (try? container.decode(String.self, forKey: .license))
53− ?? (try? container.decode(JSONValue.self, forKey: .license)).flatMap(Self.describeLicense)
54− self.grpcPort = (try? container.decode(Int.self, forKey: .grpcPort))
55− ?? (try? snakeCase?.decode(Int.self, forKey: .grpcPort))
38+ self.license = try? container.decode(String.self, forKey: .license)
39+ self.grpcPort = try? container.decode(Int.self, forKey: .grpcPort)
5640 self.updateAvailable = try? container.decode(Bool.self, forKey: .updateAvailable)
5741 self.latestVersion = try? container.decode(String.self, forKey: .latestVersion)
5842 self.llmActive = try? container.decode(Bool.self, forKey: .llmActive)
5943 self.oidcActive = try? container.decode(Bool.self, forKey: .oidcActive)
6044 }
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− }
7145}
7246
7347struct StoreInfo: Codable, Sendable {
@@ -111,20 +85,9 @@ struct UserInfo: Identifiable, Codable, Sendable {
11185 }
11286
11387 init(from decoder: Decoder) throws {
114− // Handle both {"id": "x", ...} and a bare "x" string element.
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).
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− }
88+ // Handle both {"id": "x", ...} and variations
89+ if let container = try? decoder.container(keyedBy: CodingKeys.self) {
90+ self.id = try container.decode(String.self, forKey: .id)
12891 self.method = try? container.decode(String.self, forKey: .method)
12992 } else {
13093 let container = try decoder.singleValueContainer()
ParseableViewer/Services/ParseableClient.swift+8−118
@@ -88,58 +88,6 @@ private actor ResponseCache {
8888 }
8989}
9090
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−
14391final class ParseableClient: Sendable {
14492 let baseURL: URL
14593 let username: String
@@ -148,20 +96,13 @@ final class ParseableClient: Sendable {
14896 private let authHeader: String
14997 private let cache = ResponseCache()
15098
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.
154− private static var jsonDecoder: JSONDecoder { JSONDecoder() }
99+ private static nonisolated(unsafe) let jsonDecoder = JSONDecoder()
155100
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.
160− private static var isoFormatter: ISO8601DateFormatter {
101+ private static let isoFormatter: ISO8601DateFormatter = {
161102 let f = ISO8601DateFormatter()
162103 f.formatOptions = [.withInternetDateTime, .withFractionalSeconds]
163104 return f
164− }
105+ }()
165106
166107 init(url: URL, username: String, password: String) {
167108 self.baseURL = url
@@ -178,15 +119,7 @@ final class ParseableClient: Sendable {
178119 let config = URLSessionConfiguration.default
179120 config.timeoutIntervalForRequest = 30
180121 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.
185− self.session = URLSession(
186− configuration: config,
187− delegate: RedirectSanitizer(baseURL: url),
188− delegateQueue: nil
189− )
122+ self.session = URLSession(configuration: config)
190123 }
191124
192125 convenience init(connection: ServerConnection) throws {
@@ -352,11 +285,10 @@ final class ParseableClient: Sendable {
352285 // MARK: - Query
353286
354287 func query(sql: String, startTime: Date, endTime: Date) async throws -> [LogRecord] {
355− let formatter = Self.isoFormatter
356288 let body: [String: Any] = [
357289 "query": sql,
358− "startTime": formatter.string(from: startTime),
359− "endTime": formatter.string(from: endTime)
290+ "startTime": Self.isoFormatter.string(from: startTime),
291+ "endTime": Self.isoFormatter.string(from: endTime)
360292 ]
361293
362294 let bodyData = try JSONSerialization.data(withJSONObject: body)
@@ -409,51 +341,9 @@ final class ParseableClient: Sendable {
409341 return try Self.jsonDecoder.decode(AlertConfig.self, from: data)
410342 }
411343
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−
455344 do {
456− let (data, config) = try await modernAlerts()
345+ let data = try await performRequest(method: "GET", path: "/api/v1/alerts")
346+ let config = try Self.jsonDecoder.decode(AlertConfig.self, from: data)
457347 // AlertConfig decodes leniently (every field via try?), so a 200 with
458348 // an unexpected body yields an empty, non-throwing config rather than
459349 // a DecodingError — indistinguishable from a server that genuinely has
ParseableViewer/ViewModels/LiveTailViewModel.swift+48−133
@@ -27,26 +27,14 @@ final class LiveTailViewModel {
2727 /// column hidden last session would reappear visible when it shows up in a
2828 /// later poll. This retains the intent so it stays hidden when it appears.
2929 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] = []
3630 private var currentStream: String?
3731
3832 // nonisolated(unsafe) so deinit can invalidate the timer.
3933 // Only mutated from @MainActor methods (start/stop).
4034 nonisolated(unsafe) private var timer: Timer?
4135 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.
48− private var seenFingerprints: [String: Int] = [:]
49− /// Insertion-ordered mirror of `seenFingerprints` keys (oldest first), used to
36+ private var seenFingerprints: Set<String> = []
37+ /// Insertion-ordered mirror of `seenFingerprints` (oldest first), used to
5038 /// evict the least-recently-seen fingerprints when the set is trimmed.
5139 private var seenFingerprintOrder: [String] = []
5240 /// Upper bound on the dedup fingerprint set. Kept well above `maxEntries` so
@@ -69,15 +57,7 @@ final class LiveTailViewModel {
6957 private var pollGeneration = 0
7058
7159 deinit {
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− }
60+ timer?.invalidate()
8161 }
8262
8363 // Cached formatters — nonisolated(unsafe) so static methods can use them from Task.detached
@@ -170,21 +150,8 @@ final class LiveTailViewModel {
170150 }
171151
172152 func addColumnFilter(column: String, value: JSONValue?, exclude: Bool) {
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.)
186− columnFilters.removeAll { $0.column == column && !$0.exclude }
187− }
153+ // Remove any existing filter on the same column with the same value
154+ columnFilters.removeAll { $0.column == column && $0.value == value && $0.exclude == exclude }
188155 columnFilters.append(ColumnFilter(column: column, value: value, exclude: exclude))
189156 rebuildFilteredEntries()
190157 }
@@ -214,14 +181,9 @@ final class LiveTailViewModel {
214181 pollGeneration += 1
215182 isRunning = true
216183 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
222184 errorMessage = nil
223185 entries = []
224− seenFingerprints = [:]
186+ seenFingerprints = []
225187 seenFingerprintOrder = []
226188 allKnownKeys = []
227189 droppedCount = 0
@@ -232,7 +194,6 @@ final class LiveTailViewModel {
232194 columnOrder = []
233195 hiddenColumns = []
234196 savedHiddenColumns = []
235− savedColumnOrder = []
236197 columnFilters = []
237198 // Clear the text filter too. Leaving it set while clearing the column-filter
238199 // chips is inconsistent and would let a stale search from a previous stream
@@ -270,17 +231,13 @@ final class LiveTailViewModel {
270231
271232 func clear() {
272233 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
277234 entries = []
278− // Keep the dedup fingerprints (unlike start(), which begins a new session):
279− // every poll looks back at least 90 s regardless of `lastTimestamp` (see
280− // `safeStart` in poll()), so the records just cleared are re-fetched by the
281− // next poll. With an emptied fingerprint set they all passed dedup again and
282− // the tail refilled with exactly what the user cleared. The set stays
283− // bounded by the eviction in poll().
235+ seenFingerprints = []
236+ seenFingerprintOrder = []
237+ // Advance the look-back floor to now: without this, the next poll queries
238+ // from the old `lastTimestamp` with an empty fingerprint set and re-inserts
239+ // every just-cleared record still inside the ~90 s window, repopulating the
240+ // view the user just emptied. Matches start()'s reset.
284241 lastTimestamp = Date()
285242 allKnownKeys = []
286243 droppedCount = 0
@@ -288,14 +245,10 @@ final class LiveTailViewModel {
288245 // leaving accumulated errors in place could trip the auto-stop threshold
289246 // sooner than the intended N consecutive failures. Matches start()'s reset.
290247 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
294248 columns = []
295249 columnOrder = []
296250 hiddenColumns = []
297251 savedHiddenColumns = []
298− savedColumnOrder = []
299252 columnFilters = []
300253 // Match start(): clear the text filter alongside the column-filter chips so
301254 // a clear()ed tail isn't immediately re-hidden by a lingering search string.
@@ -351,7 +304,6 @@ final class LiveTailViewModel {
351304
352305 func resetColumnConfig() {
353306 columnOrder = columns
354− savedColumnOrder = []
355307 hiddenColumns.removeAll()
356308 savedHiddenColumns.removeAll()
357309 saveColumnConfig()
@@ -365,16 +317,9 @@ final class LiveTailViewModel {
365317
366318 private func saveColumnConfig() {
367319 guard let stream = currentStream else { return }
368− // Persist the full intended hidden set and order (including columns not
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
377− let config = QueryViewModel.ColumnConfiguration(order: order, hidden: savedHiddenColumns)
320+ // Persist the full intended hidden set (including columns not yet seen
321+ // this session) so it survives across restarts and incremental discovery.
322+ let config = QueryViewModel.ColumnConfiguration(order: columnOrder, hidden: savedHiddenColumns)
378323 if let data = try? JSONEncoder().encode(config) {
379324 UserDefaults.standard.set(data, forKey: Self.columnConfigKey(for: stream))
380325 }
@@ -416,12 +361,10 @@ final class LiveTailViewModel {
416361 merged.append(col)
417362 }
418363 columnOrder = merged
419− savedColumnOrder = config.order
420364 savedHiddenColumns = config.hidden
421365 hiddenColumns = config.hidden.intersection(extractedSet)
422366 } else {
423367 columnOrder = extracted
424− savedColumnOrder = []
425368 savedHiddenColumns = []
426369 hiddenColumns = []
427370 }
@@ -434,7 +377,7 @@ final class LiveTailViewModel {
434377 }
435378 if !newCols.isEmpty {
436379 columns.append(contentsOf: newCols)
437− columnOrder = QueryViewModel.mergedColumnOrder(columnOrder, inserting: newCols, reference: savedColumnOrder)
380+ columnOrder.append(contentsOf: newCols)
438381 // Re-hide any newly appearing column that was hidden in a prior
439382 // session but hadn't been seen yet this run.
440383 hiddenColumns.formUnion(newCols.filter { savedHiddenColumns.contains($0) })
@@ -453,18 +396,10 @@ final class LiveTailViewModel {
453396 }
454397
455398 private func poll(client: ParseableClient, stream: String) async {
456− let generation = pollGeneration
457399 guard !isPolling else { return }
458400 isPolling = true
459− // Only release the guard if this poll is still the current generation. A
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 } }
401+ defer { isPolling = false }
402+ let generation = pollGeneration
468403
469404 let now = Date()
470405 // The Parseable server truncates query time-ranges to minute
@@ -492,27 +427,22 @@ final class LiveTailViewModel {
492427 // Process records off the main actor using a snapshot of seen fingerprints
493428 let seenSnapshot = seenFingerprints
494429 let pollTime = now
495− let (candidateEntries, batchCounts) = await Task.detached(priority: .userInitiated) {
496− var results: [(entry: LiveTailEntry, occurrence: Int)] = []
497− var counts: [String: Int] = [:]
430+ let candidateEntries = await Task.detached(priority: .userInitiated) {
431+ var results: [LiveTailEntry] = []
498432 for record in records {
499433 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.
504− guard occurrence > (seenSnapshot[fp] ?? 0) else { continue }
434+ guard !seenSnapshot.contains(fp) else { continue }
505435 let timestamp = Self.parseTimestamp(from: record) ?? pollTime
506436 let summary = Self.buildSummary(from: record)
507− results.append((LiveTailEntry(
437+ results.append(LiveTailEntry(
508438 timestamp: timestamp,
509439 record: record,
510440 displayTimestamp: Self.displayFormatter.string(from: timestamp),
511441 summary: summary,
512442 fingerprint: fp
513− ), occurrence))
443+ ))
514444 }
515− return (results, counts)
445+ return results
516446 }.value
517447
518448 // Re-check after the detached processing hop: a stop/clear could have
@@ -521,13 +451,11 @@ final class LiveTailViewModel {
521451
522452 // Authoritative dedup on main actor
523453 var newEntries: [LiveTailEntry] = []
524− for candidate in candidateEntries
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) {
529− if seenFingerprints[fp] == nil { seenFingerprintOrder.append(fp) }
530− seenFingerprints[fp] = count
454+ for entry in candidateEntries {
455+ if seenFingerprints.insert(entry.fingerprint).inserted {
456+ seenFingerprintOrder.append(entry.fingerprint)
457+ newEntries.append(entry)
458+ }
531459 }
532460
533461 if !newEntries.isEmpty {
@@ -549,27 +477,23 @@ final class LiveTailViewModel {
549477 // would let it pass the dedup check and be re-appended as a
550478 // duplicate. Keep the full seen-set so dedup stays correct;
551479 // 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.
557− if seenFingerprints.count > maxSeenFingerprints {
558− // Evict the OLDEST surplus fingerprints by insertion order,
559− // not by what's currently buffered. Collapsing to only the
560− // buffered set would drop recently-evicted fingerprints that
561− // still fall inside the next poll's ~90 s look-back window,
562− // re-admitting those records as duplicates. Keeping the
563− // most-recently-seen tail preserves a full window of evicted
564− // fingerprints. Buffered fingerprints are always retained so
565− // a re-fetch of a buffered record stays deduplicated.
566− let overflow = seenFingerprints.count - maxSeenFingerprints
567− let buffered = Set(entries.map { $0.fingerprint })
568− let toDrop = Set(seenFingerprintOrder.prefix(overflow))
569− .subtracting(buffered)
570− if !toDrop.isEmpty {
571− for fp in toDrop { seenFingerprints.removeValue(forKey: fp) }
572− seenFingerprintOrder.removeAll { toDrop.contains($0) }
480+ if seenFingerprints.count > maxSeenFingerprints {
481+ // Evict the OLDEST surplus fingerprints by insertion order,
482+ // not by what's currently buffered. Collapsing to only the
483+ // buffered set would drop recently-evicted fingerprints that
484+ // still fall inside the next poll's ~90 s look-back window,
485+ // re-admitting those records as duplicates. Keeping the
486+ // most-recently-seen tail preserves a full window of evicted
487+ // fingerprints. Buffered fingerprints are always retained so
488+ // a re-fetch of a buffered record stays deduplicated.
489+ let overflow = seenFingerprints.count - maxSeenFingerprints
490+ let buffered = Set(entries.map { $0.fingerprint })
491+ let toDrop = Set(seenFingerprintOrder.prefix(overflow))
492+ .subtracting(buffered)
493+ if !toDrop.isEmpty {
494+ seenFingerprints.subtract(toDrop)
495+ seenFingerprintOrder.removeAll { toDrop.contains($0) }
496+ }
573497 }
574498 }
575499
@@ -724,19 +648,10 @@ final class LiveTailViewModel {
724648 return nil
725649 }
726650 if case .string(let str) = value {
727− if let date = isoFractional.date(from: str) ?? isoBasic.date(from: str) {
651+ if let date = isoFractional.date(from: str) {
728652 return date
729653 }
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"
739− return isoFractional.date(from: utc) ?? isoBasic.date(from: utc)
654+ return isoBasic.date(from: str)
740655 }
741656 return nil
742657 }
ParseableViewer/ViewModels/QueryViewModel.swift+72−362
@@ -2,7 +2,6 @@ import Foundation
22import SwiftUI
33
44@Observable
5−@MainActor
65final class QueryViewModel {
76 var sqlQuery = "" {
87 didSet {
@@ -17,8 +16,6 @@ final class QueryViewModel {
1716 // Build search texts asynchronously
1817 searchTextTask?.cancel()
1918 cachedSearchTexts = []
20− searchTextGeneration &+= 1
21− let generation = searchTextGeneration
2219 // Apply the current filter immediately using the inline fallback path
2320 // (the cache was just invalidated). Calling updateFilteredResults()
2421 // rather than `filteredResults = results` avoids briefly showing
@@ -27,61 +24,26 @@ final class QueryViewModel {
2724 // re-applies the filter.
2825 updateFilteredResults()
2926 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.
34− searchTextTask = Task { [weak self] in
35− let texts = await Task.detached(priority: .userInitiated) {
36− snapshot.map { record in
37− record.values.map { $0.displayString }.joined(separator: " ")
38− }
39− }.value
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 }
47− self.cachedSearchTexts = texts
48− if !self.filterText.isEmpty {
49− self.updateFilteredResults()
27+ searchTextTask = Task {
28+ let texts = snapshot.map { record in
29+ record.values.map { $0.displayString }.joined(separator: " ")
30+ }
31+ guard !Task.isCancelled else { return }
32+ cachedSearchTexts = texts
33+ if !filterText.isEmpty {
34+ updateFilteredResults()
5035 }
5136 }
5237 }
5338 }
5439 private var cachedSearchTexts: [String] = []
5540 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
5941 var columns: [String] = []
6042 var columnOrder: [String] = []
6143 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] = []
7244 var autoHiddenColumns: Set<String> = []
7345 /// Column config from a saved query, applied on next query execution.
7446 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?
8547 private var currentStream: String?
8648 var isLoading = false
8749 var errorMessage: String?
@@ -99,23 +61,10 @@ final class QueryViewModel {
9961 var schemaFields: [SchemaField] = []
10062 /// The stream of the most recent `loadSchema` call, used to drop a stale
10163 /// in-flight response when the user rapidly switches streams.
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?
64+ private var latestSchemaStream: String?
10865
10966 // Query task for cancellation
11067 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
11968
12069 // Time range
12170 var timeRangeOption: TimeRangeOption = .last1Hour
@@ -212,16 +161,9 @@ final class QueryViewModel {
212161 return columnOrder.filter { !allHidden.contains($0) }
213162 }
214163
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−
221164 func toggleColumnVisibility(_ column: String) {
222165 if hiddenColumns.contains(column) {
223166 hiddenColumns.remove(column)
224− savedHiddenColumns.remove(column)
225167 } else if autoHiddenColumns.contains(column) {
226168 autoHiddenColumns.remove(column)
227169 } else {
@@ -230,7 +172,6 @@ final class QueryViewModel {
230172 let visibleCount = columnOrder.count - allHidden.count
231173 if visibleCount > 1 {
232174 hiddenColumns.insert(column)
233− savedHiddenColumns.insert(column)
234175 }
235176 }
236177 saveColumnConfig()
@@ -239,7 +180,6 @@ final class QueryViewModel {
239180
240181 func showAllColumns() {
241182 hiddenColumns.removeAll()
242− savedHiddenColumns.removeAll()
243183 autoHiddenColumns.removeAll()
244184 saveColumnConfig()
245185 updateSQLColumns()
@@ -269,116 +209,32 @@ final class QueryViewModel {
269209
270210 func resetColumnConfig() {
271211 columnOrder = columns
272− savedColumnOrder = []
273212 hiddenColumns.removeAll()
274− savedHiddenColumns.removeAll()
275213 autoHiddenColumns.removeAll()
276214 saveColumnConfig()
277215 updateSQLColumns()
278216 }
279217
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).
218+ /// Rewrites the SELECT clause of `sqlQuery` to reflect the current `visibleColumns`.
282219 func updateSQLColumns() {
283− guard let range = SQLTokenizer.selectColumnListRange(in: sqlQuery),
284− let currentList = Self.parseColumnList(sqlQuery[range]) else { return }
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) }
291− guard !visible.isEmpty else { return }
220+ guard let range = SQLTokenizer.selectColumnListRange(in: sqlQuery) else { return }
292221
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− }
222+ let visible = visibleColumns
223+ guard !visible.isEmpty else { return }
306224
307225 let newColumnList: String
308226 // Use `*` whenever every column is visible, regardless of display order —
309227 // reordering columns (a set-preserving permutation) must not pin the 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.
313− if !excludesHiddenColumn && visible.count == columns.count && Set(visible) == Set(columns) {
228+ // to an explicit snapshot that hides newly-appearing fields on re-query.
229+ if visible.count == columns.count && Set(visible) == Set(columns) {
314230 newColumnList = "*"
315231 } else {
316− newColumnList = (visible + listedButAbsent).map { Self.escapeSQLIdentifier($0) }.joined(separator: ", ")
232+ newColumnList = visible.map { Self.escapeSQLIdentifier($0) }.joined(separator: ", ")
317233 }
318234
319235 sqlQuery.replaceSubrange(range, with: newColumnList)
320236 }
321237
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−
382238 // MARK: - Column Configuration Persistence
383239
384240 struct ColumnConfiguration: Codable {
@@ -392,16 +248,7 @@ final class QueryViewModel {
392248
393249 private func saveColumnConfig() {
394250 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
404− let config = ColumnConfiguration(order: order, hidden: savedHiddenColumns.union(hiddenColumns))
251+ let config = ColumnConfiguration(order: columnOrder, hidden: hiddenColumns)
405252 if let data = try? JSONEncoder().encode(config) {
406253 UserDefaults.standard.set(data, forKey: Self.columnConfigKey(for: stream))
407254 }
@@ -430,9 +277,7 @@ final class QueryViewModel {
430277
431278 guard let config else {
432279 columnOrder = extractedColumns
433− savedColumnOrder = []
434280 hiddenColumns = []
435− savedHiddenColumns = []
436281 return
437282 }
438283
@@ -445,8 +290,6 @@ final class QueryViewModel {
445290 merged.append(col)
446291 }
447292 columnOrder = merged
448− savedColumnOrder = config.order
449− savedHiddenColumns = config.hidden
450293 hiddenColumns = config.hidden.intersection(extractedSet)
451294
452295 // The `visibleCount > 1` guard in toggleColumnVisibility only covers
@@ -540,13 +383,6 @@ final class QueryViewModel {
540383
541384 // Cancel any in-flight query
542385 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
550386
551387 isLoading = true
552388 errorMessage = nil
@@ -555,16 +391,6 @@ final class QueryViewModel {
555391 let startTime = CFAbsoluteTimeGetCurrent()
556392
557393 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− }
568394 do {
569395 try Task.checkCancellation()
570396 let queryResults = try await client.query(sql: sql, startTime: startDate, endTime: endDate)
@@ -574,25 +400,14 @@ final class QueryViewModel {
574400 // cancellation landing just after the check above would otherwise
575401 // let a stale query clobber the newer one's results/columns.
576402 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 }
580403
581404 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
586405 queryDuration = CFAbsoluteTimeGetCurrent() - startTime
587406 resultCount = results.count
588407
589− // Detect if results were likely truncated. We know the cap for
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)
595− resultsTruncated = effectiveLimit.map { results.count == $0 } ?? false
408+ // Detect if results were likely truncated (only meaningful
409+ // for auto-generated queries where we control the LIMIT)
410+ resultsTruncated = usedAutoLimit && results.count == limit
596411
597412 // Extract columns in a single pass, with priority fields first,
598413 // then apply any saved column configuration for this stream
@@ -619,23 +434,12 @@ final class QueryViewModel {
619434 return
620435 } catch {
621436 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 }
625437 errorMessage = ParseableError.userFriendlyMessage(for: error)
626438 if let serverError = error as? ParseableError,
627439 case .serverError(_, let msg) = serverError,
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.)
440+ let pos = SQLErrorPosition.parse(from: msg) {
637441 errorRange = SQLTokenizer.errorHighlightRange(
638− line: pos.line, column: pos.column, in: sql
442+ line: pos.line, column: pos.column, in: sqlQuery
639443 )
640444 }
641445 results = []
@@ -645,10 +449,10 @@ final class QueryViewModel {
645449 // report stale column names after the failure.
646450 columnOrder = []
647451 hiddenColumns = []
648− savedHiddenColumns = []
649− savedColumnOrder = []
650452 autoHiddenColumns = []
651453 }
454+
455+ isLoading = false
652456 }
653457 queryTask = task
654458 await task.value
@@ -719,31 +523,25 @@ final class QueryViewModel {
719523 return empty
720524 }
721525
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.
725526 func exportAsJSON() -> String {
726− let rows = filteredResults
727− guard !rows.isEmpty else { return "[]" }
527+ guard !results.isEmpty else { return "[]" }
728528 let encoder = JSONEncoder()
729529 encoder.outputFormatting = [.prettyPrinted, .sortedKeys]
730− if let data = try? encoder.encode(Self.projectRecords(rows, to: visibleColumns)),
530+ if let data = try? encoder.encode(Self.projectRecords(results, to: visibleColumns)),
731531 let string = String(data: data, encoding: .utf8) {
732532 return string
733533 }
734534 return "[]"
735535 }
736536
737− /// Exports the on-screen rows as CSV. Uses `filteredResults` for the same
738− /// reason as `exportAsJSON`.
739537 func exportAsCSV() -> String {
740− Self.buildCSV(records: filteredResults, columns: visibleColumns)
538+ Self.buildCSV(records: results, columns: visibleColumns)
741539 }
742540
743541 /// Projects each record down to the given columns so JSON export, like CSV,
744542 /// respects hidden columns instead of leaking every field. Safe to call from
745543 /// any thread.
746− nonisolated static func projectRecords(_ records: [LogRecord], to columns: [String]) -> [LogRecord] {
544+ static func projectRecords(_ records: [LogRecord], to columns: [String]) -> [LogRecord] {
747545 // No column list means no restriction (columns not computed yet) — export
748546 // the full records rather than stripping every field to an empty object.
749547 guard !columns.isEmpty else { return records }
@@ -752,7 +550,7 @@ final class QueryViewModel {
752550 }
753551
754552 /// Builds a CSV string from records and columns. Safe to call from any thread.
755− nonisolated static func buildCSV(records: [LogRecord], columns: [String]) -> String {
553+ static func buildCSV(records: [LogRecord], columns: [String]) -> String {
756554 guard !records.isEmpty, !columns.isEmpty else { return "" }
757555
758556 var csv = String()
@@ -778,7 +576,7 @@ final class QueryViewModel {
778576 return csv
779577 }
780578
781− nonisolated private static func escapeCSV(_ value: String) -> String {
579+ private static func escapeCSV(_ value: String) -> String {
782580 // Check at the Unicode scalar level to correctly detect \r and \n
783581 // inside \r\n (CRLF) grapheme clusters, which Swift's String.contains
784582 // treats as a single Character that doesn't match "\r" or "\n" alone.
@@ -813,8 +611,6 @@ final class QueryViewModel {
813611 columns = []
814612 columnOrder = []
815613 hiddenColumns = []
816− savedHiddenColumns = []
817− savedColumnOrder = []
818614 autoHiddenColumns = []
819615 currentStream = nil
820616 resultCount = 0
@@ -824,11 +620,6 @@ final class QueryViewModel {
824620 errorRange = nil
825621 resultsTruncated = false
826622 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
832623 }
833624
834625 /// Modifies `sqlQuery` to add a column-value filter condition.
@@ -838,139 +629,75 @@ final class QueryViewModel {
838629 let condition: String
839630 if let value, value != .null {
840631 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)"
847− : "\(escapedCol) = \(literal)"
632+ condition = exclude ? "\(escapedCol) <> \(literal)" : "\(escapedCol) = \(literal)"
848633 } else {
849634 condition = exclude ? "\(escapedCol) IS NOT NULL" : "\(escapedCol) IS NULL"
850635 }
851636
852− let sql = sqlQuery.trimmingCharacters(in: .whitespacesAndNewlines)
637+ var sql = sqlQuery.trimmingCharacters(in: .whitespacesAndNewlines)
853638 if sql.isEmpty { return }
854639
855640 // Locate the top-level WHERE and the first trailing clause using the
856641 // tokenizer rather than a raw regex, so keywords inside quoted
857642 // identifiers, string literals, comments, or subqueries are ignored.
858− let tokens = SQLTokenizer.tokenize(sql)
643+ let tailKeywords: Set<String> = ["ORDER", "GROUP", "HAVING", "LIMIT", "OFFSET"]
859644 var depth = 0
860− // WHERE and the trailing clauses only follow the top-level FROM. Keywords
861− // before it belong to the SELECT list — e.g. a field named `offset` or
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?
645+ var whereEnd: String.Index?
646+ var tailStart: String.Index?
867647 // Track whether the existing WHERE body has a top-level OR — only then does
868648 // AND-ing a new condition need parentheses to preserve precedence.
869649 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 }
650+ for token in SQLTokenizer.tokenize(sql) {
902651 switch token.kind {
903652 case .leftParen:
904653 depth += 1
905654 case .rightParen:
906655 depth = max(0, depth - 1)
907− case .keyword("FROM") where depth == 0:
908− fromSeen = true
909− case .keyword(let kw) where depth == 0 && fromSeen:
910− if kw == "WHERE", whereIndex == nil {
911− whereIndex = idx
912− // A trailing clause seen *before* the WHERE (e.g. the GROUP BY of
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 {
656+ case .keyword(let kw) where depth == 0:
657+ if kw == "WHERE", whereEnd == nil {
658+ whereEnd = token.range.upperBound
659+ } else if tailKeywords.contains(kw), tailStart == nil {
660+ tailStart = token.range.lowerBound
661+ } else if kw == "OR", whereEnd != nil, tailStart == nil {
921662 bodyHasTopLevelOr = true
922663 }
923664 default:
924665 break
925666 }
926667 }
927− // Nothing but comments/semicolons — there's no statement to filter.
928− guard let lastSignificant else { return }
929−
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...]))"
668+
669+ if let whereEnd {
670+ // Parenthesize the existing WHERE body before AND-ing the new
671+ // condition only when it has a top-level OR, otherwise the OR
672+ // silently changes meaning: `WHERE a = 1 OR b = 2` would become
673+ // `WHERE a = 1 OR b = 2 AND c = 3`, which SQL binds as
674+ // `a = 1 OR (b = 2 AND c = 3)` rather than the intended
675+ // `(a = 1 OR b = 2) AND c = 3`. A body without a top-level OR is
676+ // left un-parenthesized so simple filters stay readable.
677+ let bodyEnd = tailStart ?? sql.endIndex
678+ let head = sql[..<whereEnd]
679+ let body = sql[whereEnd..<bodyEnd].trimmingCharacters(in: .whitespacesAndNewlines)
680+ let tail = sql[bodyEnd...].trimmingCharacters(in: .whitespacesAndNewlines)
681+ // An empty WHERE body (e.g. a half-written `... WHERE` with no
682+ // condition yet, or `WHERE` immediately followed by ORDER/GROUP/
683+ // LIMIT) would otherwise produce `WHERE AND <condition>` — a
684+ // dangling AND with no left operand. Drop the AND in that case.
685+ if body.isEmpty {
686+ sql = "\(head) \(condition)"
959687 } else {
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...]))"
688+ let wrappedBody = bodyHasTopLevelOr ? "(\(body))" : body
689+ sql = "\(head) \(wrappedBody) AND \(condition)"
690+ }
691+ if !tail.isEmpty {
692+ sql += " \(tail)"
966693 }
967− } else if let tailIndex {
968− let tailStart = tokens[tailIndex].range.lowerBound
969− sqlQuery = "\(sql[..<tailStart])WHERE \(condition) \(sql[tailStart...])"
694+ } else if let tailStart {
695+ sql.insert(contentsOf: "WHERE \(condition) ", at: tailStart)
970696 } else {
971− let statementEnd = tokens[lastSignificant].range.upperBound
972− sqlQuery = "\(sql[..<statementEnd]) WHERE \(condition)\(separated(sql[statementEnd...]))"
697+ sql += " WHERE \(condition)"
973698 }
699+
700+ sqlQuery = sql
974701 }
975702
976703 /// Sets the default query for the given stream, returning `true` if the
@@ -994,30 +721,13 @@ final class QueryViewModel {
994721 }
995722
996723 /// Escapes a SQL identifier by doubling internal double-quotes, then wrapping in double-quotes.
997− nonisolated static func escapeSQLIdentifier(_ identifier: String) -> String {
724+ static func escapeSQLIdentifier(_ identifier: String) -> String {
998725 "\"\(identifier.replacingOccurrences(of: "\"", with: "\"\""))\""
999726 }
1000727
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−
1014728 // MARK: - Query History
1015729
1016730 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()
1021731 // Deduplicate consecutive identical queries
1022732 if queryHistory.first?.sql == entry.sql { return }
1023733 queryHistory.insert(entry, at: 0)
ParseableViewer/Views/AlertsView.swift+5−21
@@ -5,10 +5,6 @@ struct AlertsView: View {
55 @State private var alertConfig: AlertConfig?
66 @State private var isLoading = false
77 @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
128
139 var body: some View {
1410 Group {
@@ -67,7 +63,7 @@ struct AlertsView: View {
6763 .frame(maxWidth: .infinity, maxHeight: .infinity)
6864 }
6965 }
70− .task(id: "\(appState.activeConnection?.id.uuidString ?? "")|\(stream)") {
66+ .task(id: stream) {
7167 await loadAlerts(stream: stream)
7268 }
7369 } else {
@@ -80,31 +76,21 @@ struct AlertsView: View {
8076
8177 private func loadAlerts(stream: String) async {
8278 guard let client = appState.client else { return }
83− let connID = appState.activeConnection?.id
84− loadGeneration &+= 1
85− let generation = loadGeneration
8679 isLoading = true
8780 errorMessage = nil
8881 alertConfig = nil
8982
9083 do {
9184 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 }
9485 // The manual Refresh/Retry buttons spawn detached Tasks not tied to
9586 // `.task(id: stream)` cancellation, so a slow refresh for the old stream
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.
87+ // can resolve after a switch. Drop stale results.
9988 // Still clear isLoading on the stale path: this task set the spinner,
10089 // so leaving it true wedges the ProgressView and disables Refresh.
101− guard stream == appState.selectedStream,
102− connID == appState.activeConnection?.id else { isLoading = false; return }
90+ guard stream == appState.selectedStream else { isLoading = false; return }
10391 alertConfig = config
10492 } catch {
105− guard generation == loadGeneration else { return }
106− guard stream == appState.selectedStream,
107− connID == appState.activeConnection?.id else { isLoading = false; return }
93+ guard stream == appState.selectedStream else { isLoading = false; return }
10894 errorMessage = ParseableError.userFriendlyMessage(for: error)
10995 }
11096
@@ -212,9 +198,7 @@ struct AlertRowView: View {
212198 private var stateColor: Color {
213199 switch alert.state?.lowercased() {
214200 case "triggered": return .red
215− case "disabled", "silenced": return .gray
216− // Parseable's default, healthy state (and 2.x's "resolved") — not a warning.
217− case "not-triggered", "resolved": return .green
201+ case "disabled": return .gray
218202 // A reported-but-unrecognized state is a genuine "needs attention" signal,
219203 // so tint it orange; a missing state is simply unknown and stays neutral so
220204 // the bell icon isn't painted as a warning for every stateless alert.
ParseableViewer/Views/ConnectionSheet.swift+6−27
@@ -18,10 +18,6 @@ struct ConnectionSheet: View {
1818 /// retry (after a failed connect) updates it in place rather than appending a
1919 /// duplicate.
2020 @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
2521
2622 enum TestResult {
2723 case success(String)
@@ -47,7 +43,6 @@ struct ConnectionSheet: View {
4743 TextField("Name:", text: $name)
4844 .textFieldStyle(.roundedBorder)
4945 .accessibilityLabel("Connection name")
50− .onChange(of: name) { _, _ in invalidateTestResult() }
5146
5247 TextField("Server URL:", text: $url)
5348 .textFieldStyle(.roundedBorder)
@@ -55,7 +50,7 @@ struct ConnectionSheet: View {
5550 .onChange(of: url) { _, newValue in
5651 urlValidationError = Self.validateURL(newValue)
5752 // A prior test result no longer reflects the edited fields.
58− invalidateTestResult()
53+ testResult = nil
5954 }
6055
6156 if let urlError = urlValidationError, !url.isEmpty {
@@ -67,12 +62,12 @@ struct ConnectionSheet: View {
6762 TextField("Username:", text: $username)
6863 .textFieldStyle(.roundedBorder)
6964 .accessibilityLabel("Username")
70− .onChange(of: username) { _, _ in invalidateTestResult() }
65+ .onChange(of: username) { _, _ in testResult = nil }
7166
7267 SecureField("Password:", text: $password)
7368 .textFieldStyle(.roundedBorder)
7469 .accessibilityLabel("Password")
75− .onChange(of: password) { _, _ in invalidateTestResult() }
70+ .onChange(of: password) { _, _ in testResult = nil }
7671 }
7772 .formStyle(.grouped)
7873 .padding()
@@ -169,16 +164,9 @@ struct ConnectionSheet: View {
169164 }
170165 }
171166
172− private func invalidateTestResult() {
173− testGeneration += 1
174− testResult = nil
175− }
176−
177167 private func testConnection() {
178168 isTesting = true
179169 testResult = nil
180− testGeneration += 1
181− let generation = testGeneration
182170
183171 Task {
184172 do {
@@ -190,19 +178,14 @@ struct ConnectionSheet: View {
190178 )
191179 let client = try ParseableClient(connection: conn)
192180 try await client.checkHealth()
193− // The health endpoint doesn't check credentials; see
194− // AppState.verifyCredentials.
195− try await AppState.verifyCredentials(with: client)
196181 await MainActor.run {
197− isTesting = false
198− guard generation == testGeneration else { return }
199182 testResult = .success("Connection successful")
183+ isTesting = false
200184 }
201185 } catch {
202186 await MainActor.run {
203− isTesting = false
204− guard generation == testGeneration else { return }
205187 testResult = .failure(ParseableError.userFriendlyMessage(for: error))
188+ isTesting = false
206189 }
207190 }
208191 }
@@ -237,10 +220,6 @@ struct ConnectionSheet: View {
237220 dismiss()
238221 } else {
239222 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
244223 }
245224 }
246225 }
@@ -251,7 +230,7 @@ struct ConnectionSheet: View {
251230 guard !trimmed.isEmpty else { return nil }
252231
253232 var normalized = trimmed
254− if !ServerConnection.hasURLScheme(normalized) {
233+ if !normalized.hasPrefix("http://") && !normalized.hasPrefix("https://") {
255234 normalized = "https://\(normalized)"
256235 }
257236
ParseableViewer/Views/ContentView.swift+2−30
@@ -19,10 +19,7 @@ struct ContentView: View {
1919 ConnectionSheet(connection: appState.editingConnection)
2020 }
2121 .alert("Error", isPresented: $appState.showError) {
22− Button("OK") {
23− appState.showError = false
24− appState.errorMessage = nil
25− }
22+ Button("OK") { appState.showError = false }
2623 } message: {
2724 Text(appState.errorMessage ?? "An unknown error occurred")
2825 }
@@ -31,18 +28,6 @@ struct ContentView: View {
3128
3229struct MainContentView: View {
3330 @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− }
4631
4732 var body: some View {
4833 @Bindable var appState = appState
@@ -75,7 +60,7 @@ struct MainContentView: View {
7560 } else {
7661 switch appState.currentTab {
7762 case .query:
78− QueryView(viewModel: queryViewModel)
63+ QueryView()
7964 .id(AppState.AppTab.query)
8065 case .liveTail:
8166 LiveTailView()
@@ -95,19 +80,6 @@ struct MainContentView: View {
9580 }
9681 }
9782 }
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− }
11183 }
11284}
11385
ParseableViewer/Views/LiveTailView.swift+12−59
@@ -123,10 +123,7 @@ struct LiveTailView: View {
123123 Image(systemName: "trash")
124124 }
125125 .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.
129− .keyboardShortcut("k", modifiers: .command)
126+ .keyboardShortcut(.delete, modifiers: .command)
130127 .accessibilityLabel("Clear all entries")
131128 }
132129 .padding(8)
@@ -139,21 +136,10 @@ struct LiveTailView: View {
139136 Circle()
140137 .fill(viewModel.isPaused ? .yellow : .green)
141138 .frame(width: 8, height: 8)
142− .opacity(pulseOpacity)
139+ .opacity(viewModel.isPaused ? 1.0 : pulseOpacity)
143140 .accessibilityLabel(viewModel.isPaused ? "Status: Paused" : "Status: Streaming")
144− // Drive the pulse with an explicit, re-triggerable animation.
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− }
141+ .animation(.easeInOut(duration: 1.0).repeatForever(autoreverses: true), value: pulseOpacity)
142+ .onAppear { pulseOpacity = 0.3 }
157143 .onDisappear { pulseOpacity = 1.0 }
158144 Text(viewModel.isPaused ? "Paused" : "Streaming")
159145 .font(.caption)
@@ -275,7 +261,6 @@ struct LiveTailView: View {
275261 }
276262 .onDisappear {
277263 viewModel.stop()
278− liveTailSortTask?.cancel()
279264 }
280265 .onChange(of: appState.selectedStream) { _, _ in
281266 if viewModel.isRunning {
@@ -287,43 +272,18 @@ struct LiveTailView: View {
287272 columnWidths = [:]
288273 cachedSorted = []
289274 }
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− }
275+ .onChange(of: viewModel.visibleColumns) { _, _ in
298276 // Showing/hiding a column changes the severity column set, so rebuild
299277 // (which also fills in widths for any newly visible columns). A
300278 // width-only update would leave row severity tinting stale until the
301279 // next poll.
302280 rebuildSortedEntries()
303281 }
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− }
312282 .onAppear {
313283 rebuildSortedEntries()
314284 }
315285 }
316286
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−
327287 /// Computes widths for any visible columns that don't have one yet. Covers
328288 /// the case where switching to a stream with an identical column set doesn't
329289 /// fire `onChange(of: visibleColumns)`, which would otherwise leave widths
@@ -339,13 +299,6 @@ struct LiveTailView: View {
339299 private func rebuildSortedEntries() {
340300 ensureColumnWidths()
341301 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− }
349302 let cols = viewModel.visibleColumns
350303 let sevCols = buildSeverityColumnSet(columns: cols)
351304 // Precompute severity per entry; sorting doesn't change the set.
@@ -358,15 +311,10 @@ struct LiveTailView: View {
358311 // wake after its 150ms delay and overwrite the unsorted result with
359312 // stale sorted entries.
360313 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
368314 guard let col = sortColumn else {
369315 cachedSorted = entries
316+ severityColumnSet = sevCols
317+ severityByID = severityMap
370318 return
371319 }
372320 let asc = sortAscending
@@ -386,6 +334,8 @@ struct LiveTailView: View {
386334 }.value
387335 guard !Task.isCancelled else { return }
388336 cachedSorted = sorted
337+ severityColumnSet = sevCols
338+ severityByID = severityMap
389339 }
390340 }
391341
@@ -433,6 +383,9 @@ struct LiveTailView: View {
433383 }
434384 }
435385 }
386+ .onChange(of: viewModel.filteredEntriesGeneration) { _, _ in
387+ rebuildSortedEntries()
388+ }
436389 .onChange(of: sortColumn) { _, _ in
437390 rebuildSortedEntries()
438391 }
ParseableViewer/Views/LogDetailView.swift+2−5
@@ -157,11 +157,8 @@ struct JSONValueView: View {
157157 Text(String(v))
158158 .font(.system(.caption, design: .monospaced))
159159 .foregroundStyle(Color(nsColor: .systemPurple))
160− case .double:
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)
160+ case .double(let v):
161+ Text(String(v))
165162 .font(.system(.caption, design: .monospaced))
166163 .foregroundStyle(Color(nsColor: .systemPurple))
167164 case .string(let v):
ParseableViewer/Views/LogTableView.swift+6−53
@@ -315,23 +315,7 @@ struct LogTableView: View {
315315 }
316316 .onChange(of: sortColumn) { _, _ in debouncedRebuildSort() }
317317 .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− }
326318 .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− }
335319 recomputeColumnWidths(columns: columns, records: records)
336320 rebuildSort()
337321 }
@@ -417,9 +401,6 @@ struct LogTableView: View {
417401 }
418402
419403 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()
423404 let sevCols = buildSeverityColumnSet(columns: columns)
424405 let enumerated = Array(records.enumerated())
425406 let sorted: [(offset: Int, element: LogRecord)]
@@ -495,28 +476,12 @@ struct LogHeaderView: View {
495476
496477 @State private var draggedColumn: String?
497478 @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.
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− }
479+ @State private var uniqueValuesCache: [String: [JSONValue]] = [:]
511480
512481 private static let maxFilterValues = 20
513482
514483 private func uniqueValues(for column: String) -> [JSONValue] {
515− if uniqueValuesCache.generation != recordsGeneration {
516− uniqueValuesCache.generation = recordsGeneration
517− uniqueValuesCache.values = [:]
518− }
519− if let cached = uniqueValuesCache.values[column] {
484+ if let cached = uniqueValuesCache[column] {
520485 return cached
521486 }
522487 var seen = Set<JSONValue>()
@@ -528,7 +493,7 @@ struct LogHeaderView: View {
528493 }
529494 }
530495 let sorted = result.sorted()
531− uniqueValuesCache.values[column] = sorted
496+ uniqueValuesCache[column] = sorted
532497 return sorted
533498 }
534499
@@ -671,7 +636,7 @@ struct LogHeaderView: View {
671636 .background(Color(nsColor: .controlBackgroundColor))
672637 .border(Color.secondary.opacity(0.2), width: 0.5)
673638 .onChange(of: records) { _, _ in
674− recordsGeneration &+= 1
639+ uniqueValuesCache = [:]
675640 }
676641 }
677642}
@@ -704,26 +669,14 @@ private struct ColumnDropDelegate: DropDelegate {
704669 }
705670
706671 func performDrop(info: DropInfo) -> Bool {
707− guard let dragged = draggedColumn, dragged != column,
708− let provider = info.itemProviders(for: [UTType.plainText]).first else {
672+ guard let dragged = draggedColumn, dragged != column else {
709673 draggedColumn = nil
710674 dropTargetColumn = nil
711675 return false
712676 }
677+ onMoveColumn?(dragged, column)
713678 draggedColumn = nil
714679 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− }
727680 return true
728681 }
729682
ParseableViewer/Views/QueryView.swift+10−53
@@ -12,11 +12,7 @@ private struct QueryViewContext: Equatable {
1212
1313struct QueryView: View {
1414 @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.
19− let viewModel: QueryViewModel
15+ @State private var viewModel = QueryViewModel()
2016 @State private var showSaveFilterSheet = false
2117 @State private var saveFilterName = ""
2218 @State private var isSavingFilter = false
@@ -251,23 +247,6 @@ struct QueryView: View {
251247 isLoading: viewModel.isLoading,
252248 wrapText: wrapText,
253249 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− }
271250 viewModel.addColumnFilter(column: column, value: value, exclude: exclude)
272251 Task {
273252 await viewModel.executeQuery(
@@ -290,9 +269,6 @@ struct QueryView: View {
290269 let streamChanged = old.stream != new.stream
291270 if streamChanged {
292271 viewModel.clearResults()
293− viewModel.boundContext = QueryViewModel.BoundContext(
294− connectionID: appState.activeConnection?.id, stream: stream
295− )
296272 Task {
297273 await viewModel.loadSchema(client: appState.client, stream: stream)
298274 }
@@ -327,27 +303,7 @@ struct QueryView: View {
327303 }
328304 .onAppear {
329305 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.
350− if viewModel.schemaFields.isEmpty || viewModel.latestSchemaStream != stream {
306+ if viewModel.schemaFields.isEmpty {
351307 Task {
352308 await viewModel.loadSchema(client: appState.client, stream: stream)
353309 }
@@ -367,7 +323,7 @@ struct QueryView: View {
367323 )
368324 }
369325 } else {
370− let didSetDefault = viewModel.setDefaultQuery(stream: stream, previousStream: previousStream)
326+ let didSetDefault = viewModel.setDefaultQuery(stream: stream)
371327 if didSetDefault && viewModel.results.isEmpty {
372328 Task {
373329 await viewModel.executeQuery(
@@ -379,11 +335,12 @@ struct QueryView: View {
379335 }
380336 }
381337 }
338+ .onDisappear {
339+ if viewModel.isLoading {
340+ viewModel.cancelQuery()
341+ }
342+ }
382343 .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 }
387344 Task {
388345 await viewModel.executeQuery(
389346 client: appState.client,
@@ -496,7 +453,7 @@ struct ColumnManagerView: View {
496453 Text("Columns")
497454 .font(.headline)
498455 Spacer()
499− if viewModel.hasHiddenColumns || viewModel.columnOrder != viewModel.columns {
456+ if !viewModel.hiddenColumns.isEmpty || !viewModel.autoHiddenColumns.isEmpty || viewModel.columnOrder != viewModel.columns {
500457 Button("Reset") {
501458 viewModel.resetColumnConfig()
502459 }
@@ -550,7 +507,7 @@ struct ColumnManagerView: View {
550507 }
551508 .listStyle(.plain)
552509
553− if viewModel.hasHiddenColumns {
510+ if !viewModel.hiddenColumns.isEmpty || !viewModel.autoHiddenColumns.isEmpty {
554511 Divider()
555512 Button("Show All Columns") {
556513 viewModel.showAllColumns()
ParseableViewer/Views/SQLAutoComplete.swift+12−129
@@ -105,40 +105,17 @@ enum SQLCompletionProvider {
105105 return ([], "", NSRange(location: wordStart, length: wordEnd - wordStart))
106106 }
107107
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))
114− }
115−
116108 let context = determineContext(text: text, position: wordStart)
117109
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`).
110+ // A table completion supplies its own surrounding quotes. If the user has
111+ // already typed an opening double-quote (common for stream names with
112+ // spaces, e.g. `FROM "my`), absorb it into the replacement range so the
113+ // inserted `"name"` replaces the stray quote instead of producing `""name"`.
126114 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− }
115+ if case .tableRef = context, wordStart > 0, nsText.character(at: wordStart - 1) == 0x22 {
116+ replaceStart -= 1
140117 }
141− let range = NSRange(location: replaceStart, length: replaceEnd - replaceStart)
118+ let range = NSRange(location: replaceStart, length: wordEnd - replaceStart)
142119 let uppercasePrefix = prefix.uppercased()
143120
144121 var items: [SQLCompletionItem] = []
@@ -156,7 +133,7 @@ enum SQLCompletionProvider {
156133 case .columnRef:
157134 for field in schemaFields.sorted(by: { $0.name < $1.name })
158135 where field.name.uppercased().hasPrefix(uppercasePrefix) {
159− items.append(columnItem(for: field))
136+ items.append(SQLCompletionItem(text: field.name, kind: .column, detail: field.dataType))
160137 }
161138 for fn in SQLSyntaxHighlighter.sortedFunctions where fn.hasPrefix(uppercasePrefix) {
162139 items.append(SQLCompletionItem(text: fn, kind: .function))
@@ -186,87 +163,20 @@ enum SQLCompletionProvider {
186163 }
187164 for field in schemaFields.sorted(by: { $0.name < $1.name })
188165 where field.name.uppercased().hasPrefix(uppercasePrefix) {
189− items.append(columnItem(for: field))
166+ items.append(SQLCompletionItem(text: field.name, kind: .column, detail: field.dataType))
190167 }
191168 }
192169
193170 // Don't show the popup when the only match is an exact hit. Compare the
194171 // match text (unquoted), not the display text — a table's display text is
195172 // 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
204− : only.matchText.uppercased() == uppercasePrefix
205− if isExactHit { return ([], prefix, range) }
173+ if items.count == 1 && items[0].matchText.uppercased() == uppercasePrefix {
174+ return ([], prefix, range)
206175 }
207176
208177 return (items, prefix, range)
209178 }
210179
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−
270180 // MARK: - Context Detection
271181
272182 static func determineContext(text: String, position: Int) -> SQLCompletionContext {
@@ -274,17 +184,6 @@ enum SQLCompletionProvider {
274184 // NSString too. `String.prefix` counts grapheme clusters and would slice
275185 // at the wrong place when non-BMP characters (e.g. emoji) precede it.
276186 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−
288187 let tokens = tokenize(before)
289188
290189 guard let lastToken = tokens.last?.uppercased() else {
@@ -465,12 +364,7 @@ final class SQLCompletionPopup: NSObject, NSTableViewDataSource, NSTableViewDele
465364
466365 let rowCount = min(items.count, Self.maxVisibleRows)
467366 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
473− let frame = NSRect(x: panel.frame.origin.x, y: topEdge - height,
367+ let frame = NSRect(x: panel.frame.origin.x, y: panel.frame.origin.y,
474368 width: Self.panelWidth, height: height)
475369 panel.setFrame(frame, display: true)
476370 }
@@ -529,17 +423,6 @@ final class SQLCompletionPopup: NSObject, NSTableViewDataSource, NSTableViewDele
529423
530424 // MARK: - NSTableViewDelegate
531425
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−
543426 func tableView(_ tableView: NSTableView, viewFor tableColumn: NSTableColumn?, row: Int) -> NSView? {
544427 guard row < items.count else { return nil }
545428 let item = items[row]
ParseableViewer/Views/SQLEditorView.swift+6−41
@@ -57,7 +57,7 @@ struct SQLEditorView: NSViewRepresentable {
5757 // Only update when text changed externally (not from user typing)
5858 if textView.string != text && !context.coordinator.isEditing {
5959 let selectedRanges = textView.selectedRanges
60− context.coordinator.replaceText(with: text, in: textView)
60+ textView.string = text
6161 context.coordinator.applyHighlighting(to: textView)
6262 // NSRange is UTF-16 based; compare against the NSString length, not
6363 // String.count (grapheme count), or wide characters drop the selection.
@@ -101,11 +101,6 @@ struct SQLEditorView: NSViewRepresentable {
101101 /// Tracks whether we are inserting a completion so that `textDidChange`
102102 /// doesn't re-trigger the popup for the replacement.
103103 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
109104
110105 init(text: Binding<String>, streamNames: [String], schemaFields: [SchemaField]) {
111106 self.text = text
@@ -121,8 +116,7 @@ struct SQLEditorView: NSViewRepresentable {
121116 // MARK: - NSTextViewDelegate
122117
123118 func textDidChange(_ notification: Notification) {
124− guard let textView = notification.object as? NSTextView,
125− !isApplyingModelChange else { return }
119+ guard let textView = notification.object as? NSTextView else { return }
126120 isEditing = true
127121 clearErrorHighlight(in: textView)
128122 text.wrappedValue = textView.string
@@ -134,34 +128,6 @@ struct SQLEditorView: NSViewRepresentable {
134128 }
135129 }
136130
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−
165131 func textView(_ textView: NSTextView, doCommandBy commandSelector: Selector) -> Bool {
166132 guard completionPopup.isVisible else { return false }
167133
@@ -257,13 +223,12 @@ struct SQLEditorView: NSViewRepresentable {
257223 }
258224 }
259225
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
226+ // A table completion carries its own quotes; absorb any double-quote the
227+ // user typed directly around the word so the inserted `"name"` replaces
228+ // it instead of leaving a stray quote. Leading and trailing are handled
264229 // independently: a closing quote can exist without an opening one (e.g.
265230 // `FROM my"`), which would otherwise produce a dangling `"name""`.
266− if item.kind == .table || item.kind == .column {
231+ if item.kind == .table {
267232 if wordStart > 0, nsText.character(at: wordStart - 1) == 0x22 {
268233 wordStart -= 1
269234 }
ParseableViewer/Views/ServerInfoView.swift+10−39
@@ -3,15 +3,9 @@ import SwiftUI
33struct ServerInfoView: View {
44 @Environment(AppState.self) private var appState
55 @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.
9− @State private var isLoading = true
6+ @State private var isLoading = false
107 @State private var isHealthy = false
118 @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
159
1610 var body: some View {
1711 ScrollView {
@@ -22,7 +16,7 @@ struct ServerInfoView: View {
2216 .fontWeight(.semibold)
2317 Spacer()
2418 Button {
25− Task { await loadInfo(forceRefresh: true) }
19+ Task { await loadInfo() }
2620 } label: {
2721 Image(systemName: "arrow.clockwise")
2822 }
@@ -47,25 +41,12 @@ struct ServerInfoView: View {
4741 // Health status
4842 GroupBox("Health") {
4943 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 {
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()
63− .fill(isHealthy ? .green : .red)
64− .frame(width: 12, height: 12)
65− .accessibilityLabel(isHealthy ? "Healthy" : "Unhealthy")
66− Text(isHealthy ? "Healthy" : "Unhealthy")
67− .fontWeight(.medium)
68− }
44+ Circle()
45+ .fill(isHealthy ? .green : .red)
46+ .frame(width: 12, height: 12)
47+ .accessibilityLabel(isHealthy ? "Healthy" : "Unhealthy")
48+ Text(isHealthy ? "Healthy" : "Unhealthy")
49+ .fontWeight(.medium)
6950 Spacer()
7051 }
7152 .padding(8)
@@ -156,18 +137,9 @@ struct ServerInfoView: View {
156137 }
157138 }
158139
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.
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− }
140+ private func loadInfo() async {
141+ guard let client = appState.client else { return }
168142 let connID = appState.activeConnection?.id
169− loadGeneration &+= 1
170− let generation = loadGeneration
171143 isLoading = true
172144 errorMessage = nil
173145 // Seed from the app-wide cache so first load isn't blank, but never fall
@@ -198,7 +170,6 @@ struct ServerInfoView: View {
198170 // load for the previous server can't overwrite the current one's state.
199171 // Still clear isLoading: this task owns the spinner it set, and leaving it
200172 // true on the stale path wedges the ProgressView and disables Refresh.
201− guard generation == loadGeneration else { return }
202173 guard connID == appState.activeConnection?.id else { isLoading = false; return }
203174 isHealthy = healthy
204175 about = aboutValue
ParseableViewer/Views/SettingsView.swift+2−21
@@ -6,13 +6,6 @@ struct SettingsView: View {
66 @AppStorage("maxQueryResults") private var maxQueryResults = 1000
77 @AppStorage("liveTailPollInterval") private var liveTailPollInterval = 2.0
88 @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?
169 var body: some View {
1710 TabView {
1811 // General settings
@@ -83,20 +76,11 @@ struct SettingsView: View {
8376 .clipShape(RoundedRectangle(cornerRadius: 4))
8477 }
8578
86− Button {
87− sheetConnection = connection
88− showConnectionSheet = true
89− } label: {
90− Image(systemName: "pencil")
91− }
92− .help("Edit connection")
93−
9479 Button(role: .destructive) {
9580 appState.removeConnection(connection)
9681 } label: {
9782 Image(systemName: "trash")
9883 }
99− .help("Delete connection")
10084 }
10185 .padding(.vertical, 4)
10286 }
@@ -105,8 +89,8 @@ struct SettingsView: View {
10589 HStack {
10690 Spacer()
10791 Button("Add Connection...") {
108− sheetConnection = nil
109− showConnectionSheet = true
92+ appState.editingConnection = nil
93+ appState.showConnectionSheet = true
11094 }
11195 }
11296 .padding()
@@ -116,8 +100,5 @@ struct SettingsView: View {
116100 }
117101 }
118102 .frame(width: 500, height: 400)
119− .sheet(isPresented: $showConnectionSheet) {
120− ConnectionSheet(connection: sheetConnection)
121− }
122103 }
123104}
ParseableViewer/Views/SidebarView.swift+1−19
@@ -6,14 +6,6 @@ struct SidebarView: View {
66 @State private var newStreamName = ""
77 @State private var showDeleteConfirm = false
88 @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?
179 @State private var deleteStreamDetail: String?
1810
1911 /// Validates a stream name, returning an error message or nil if valid.
@@ -73,10 +65,7 @@ struct SidebarView: View {
7365 // would show the wrong stats on a destructive
7466 // confirmation.
7567 let target = stream.name
76− let targetConnectionID = appState.activeConnection?.id
7768 streamToDelete = target
78− streamToDeleteConnectionID = targetConnectionID
79− streamToDeleteClient = appState.client
8069 deleteStreamDetail = nil
8170 Task {
8271 if let client = appState.client,
@@ -92,9 +81,7 @@ struct SidebarView: View {
9281 deleteStreamDetail = parts.joined(separator: ", ")
9382 }
9483 }
95− if streamToDelete == target,
96− appState.activeConnection?.id == targetConnectionID,
97− appState.client === streamToDeleteClient {
84+ if streamToDelete == target {
9885 showDeleteConfirm = true
9986 }
10087 }
@@ -200,11 +187,6 @@ struct SidebarView: View {
200187 .alert("Delete Stream", isPresented: $showDeleteConfirm) {
201188 Button("Delete", role: .destructive) {
202189 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− }
208190 Task {
209191 do {
210192 try await appState.deleteStream(name: name)
ParseableViewer/Views/StreamDetailView.swift+6−34
@@ -8,9 +8,6 @@ struct StreamDetailView: View {
88 @State private var retention: [RetentionConfig] = []
99 @State private var isLoading = false
1010 @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
1411
1512 var body: some View {
1613 Group {
@@ -28,7 +25,7 @@ struct StreamDetailView: View {
2825 Spacer()
2926
3027 Button {
31− Task { await loadData(stream: stream, forceRefresh: true) }
28+ Task { await loadData(stream: stream) }
3229 } label: {
3330 Image(systemName: "arrow.clockwise")
3431 }
@@ -47,7 +44,7 @@ struct StreamDetailView: View {
4744 Text(error)
4845 Spacer()
4946 Button("Retry") {
50− Task { await loadData(stream: stream, forceRefresh: true) }
47+ Task { await loadData(stream: stream) }
5148 }
5249 .buttonStyle(.bordered)
5350 .controlSize(.small)
@@ -160,11 +157,7 @@ struct StreamDetailView: View {
160157 }
161158 .padding()
162159 }
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.
167− .task(id: "\(appState.activeConnection?.id.uuidString ?? "")|\(stream)") {
160+ .task(id: stream) {
168161 await loadData(stream: stream)
169162 }
170163 } else {
@@ -177,22 +170,8 @@ struct StreamDetailView: View {
177170 }
178171 }
179172
180− private func loadData(stream: String, forceRefresh: Bool = false) async {
173+ private func loadData(stream: String) async {
181174 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
196175 isLoading = true
197176 errorMessage = nil
198177 // Clear previous stream's data so it isn't shown under the new header
@@ -226,15 +205,8 @@ struct StreamDetailView: View {
226205 // The manual Refresh/Retry buttons spawn detached Tasks that aren't tied to
227206 // `.task(id: stream)` cancellation, so a slow refresh for the old stream can
228207 // resolve after the user switched. Drop stale results instead of writing
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 }
234− guard stream == appState.selectedStream, connID == appState.activeConnection?.id else {
235− isLoading = false
236− return
237− }
208+ // them under the new stream's header.
209+ guard stream == appState.selectedStream else { return }
238210
239211 schema = newSchema
240212 stats = newStats
ParseableViewer/Views/SyntaxHighlighting.swift+58−32
@@ -49,23 +49,10 @@ enum SQLSyntaxHighlighter {
4949 static let sortedFunctions: [String] = functions.sorted()
5050
5151 // 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).
60− private static let literalOrCommentRegex = try! NSRegularExpression(
61− pattern: "('[^']*(?:''[^']*)*')|(\"[^\"]*(?:\"\"[^\"]*)*\")|(--[^\\n]*|/\\*[\\s\\S]*?(?:\\*/|\\z))",
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.
68− private static let numberRegex = try! NSRegularExpression(pattern: "(?<![\\w.])(?:\\d+\\.?\\d*|\\.\\d+)(?:[eE][+-]?\\d+)?", options: [])
52+ private static let commentRegex = try! NSRegularExpression(pattern: "--[^\n]*", options: [.caseInsensitive])
53+ private static let singleQuoteRegex = try! NSRegularExpression(pattern: "'[^']*(?:''[^']*)*'", options: [.caseInsensitive])
54+ private static let doubleQuoteRegex = try! NSRegularExpression(pattern: "\"[^\"]*(?:\"\"[^\"]*)*\"", options: [.caseInsensitive])
55+ private static let numberRegex = try! NSRegularExpression(pattern: "\\b\\d+(?:\\.\\d+)?\\b", options: [])
6956 private static let keywordRegex: NSRegularExpression = {
7057 let pattern = "\\b(?:" + sortedKeywords.joined(separator: "|") + ")\\b"
7158 return try! NSRegularExpression(pattern: pattern, options: [.caseInsensitive])
@@ -89,21 +76,20 @@ enum SQLSyntaxHighlighter {
8976 let nsText = text as NSString
9077 var protectedRanges: [NSRange] = []
9178
92− // 1–3. Single-quoted strings, double-quoted identifiers, and comments, in
93− // one pass so none of them can start inside another (`'a--b'` stays a
94− // string, `-- don't` stays a comment).
95− let literalMatches = literalOrCommentRegex.matches(
96− in: text, range: NSRange(location: 0, length: nsText.length)
97− )
98− for match in literalMatches {
99− let isComment = match.range(at: 3).location != NSNotFound
100− textStorage.addAttribute(
101− .foregroundColor,
102− value: isComment ? NSColor.secondaryLabelColor : NSColor.systemRed,
103− range: match.range
104− )
105− protectedRanges.append(match.range)
106− }
79+ // 1. Single-quoted strings
80+ applyRegex(singleQuoteRegex, to: textStorage, text: nsText,
81+ color: .systemRed, protectedRanges: &protectedRanges)
82+
83+ // 2. Double-quoted identifiers
84+ applyRegex(doubleQuoteRegex, to: textStorage, text: nsText,
85+ color: .systemRed, protectedRanges: &protectedRanges)
86+
87+ // 3. Comments (-- to end of line). Highlighted AFTER strings, and a `--`
88+ // that begins inside a string literal (protectedRanges currently holds
89+ // only string ranges) is skipped — otherwise `'a--b' AND y = 5` would
90+ // bleed a gray comment over the trailing real SQL.
91+ applyCommentRegex(commentRegex, to: textStorage, text: nsText,
92+ color: .secondaryLabelColor, protectedRanges: &protectedRanges)
10793
10894 // 4. Numbers (outside protected ranges)
10995 applyUnprotectedRegex(numberRegex, to: textStorage, text: nsText,
@@ -120,6 +106,46 @@ enum SQLSyntaxHighlighter {
120106 protectedRanges: protectedRanges)
121107 }
122108
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+
123149 private static func applyUnprotectedRegex(
124150 _ regex: NSRegularExpression,
125151 to textStorage: NSTextStorage,
ParseableViewer/Views/TimeRangePicker.swift+1−23
@@ -31,27 +31,6 @@ struct TimeRangePicker: View {
3131 .font(.caption)
3232 }
3333 }
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− }
5534 .popover(isPresented: $showCustomPicker) {
5635 VStack(spacing: 12) {
5736 Text("Custom Time Range")
@@ -99,9 +78,8 @@ struct TimeRangePicker: View {
9978 Spacer()
10079
10180 Button("Done") {
102− // Just close — the `showCustomPicker` change handler is the
103− // single commit point for all close paths (Done + click-out).
10481 showCustomPicker = false
82+ onCommit?()
10583 }
10684 .buttonStyle(.borderedProminent)
10785 .disabled(customEnd <= customStart)
ParseableViewer/Views/UsersView.swift−10
@@ -5,9 +5,6 @@ struct UsersView: View {
55 @State private var users: [UserInfo] = []
66 @State private var isLoading = false
77 @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
118
129 var body: some View {
1310 VStack(spacing: 0) {
@@ -85,14 +82,8 @@ struct UsersView: View {
8582 private func loadUsers() async {
8683 guard let client = appState.client else { return }
8784 let connID = appState.activeConnection?.id
88− loadGeneration &+= 1
89− let generation = loadGeneration
9085 isLoading = true
9186 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 = []
9687
9788 let loaded: [UserInfo]?
9889 let loadError: String?
@@ -107,7 +98,6 @@ struct UsersView: View {
10798 // Drop results if the active connection changed while awaiting. Still clear
10899 // isLoading: this task set the spinner, so leaving it true on the stale path
109100 // wedges the ProgressView and disables Refresh.
110− guard generation == loadGeneration else { return }
111101 guard connID == appState.activeConnection?.id else { isLoading = false; return }
112102 if let loaded { users = loaded }
113103 errorMessage = loadError
ParseableViewerTests/JSONValueTests.swift−18
@@ -75,24 +75,6 @@ final class JSONValueTests: XCTestCase {
7575 XCTAssertEqual(original, decoded)
7676 }
7777
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−
9678 // MARK: - displayString
9779
9880 func testDisplayStringNull() {
ParseableViewerTests/LiveTailViewModelTests.swift+1−39
@@ -404,23 +404,10 @@ final class LiveTailViewModelTests: XCTestCase {
404404 XCTAssertEqual(vm.columnFilters.count, 1)
405405 }
406406
407− func testAddFilterSameColumnIncludeReplaces() {
407+ func testAddFilterDifferentValueIsAdditive() {
408408 let vm = LiveTailViewModel()
409409 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.
413410 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)
424411 XCTAssertEqual(vm.columnFilters.count, 2)
425412 }
426413
@@ -461,29 +448,4 @@ final class LiveTailViewModelTests: XCTestCase {
461448 let filter = LiveTailViewModel.ColumnFilter(column: "field", value: nil, exclude: false)
462449 XCTAssertEqual(filter.displayLabel, "field = null")
463450 }
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− }
489451}
ParseableViewerTests/ParseableClientTests.swift−58
@@ -646,62 +646,4 @@ final class ParseableClientTests: XCTestCase {
646646 // Carriage returns in values should be wrapped in quotes per RFC 4180
647647 XCTAssertTrue(csv.contains("\"line1\r\nline2\""))
648648 }
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− }
707649}
ParseableViewerTests/QueryViewModelTests.swift+2−124
@@ -1,8 +1,6 @@
11import XCTest
22@testable import ParseableViewer
33
4−// QueryViewModel is @MainActor, so its tests must run on the main actor too.
5−@MainActor
64final class QueryViewModelTests: XCTestCase {
75 // MARK: - Column extraction
86
@@ -235,7 +233,7 @@ final class QueryViewModelTests: XCTestCase {
235233 let vm = QueryViewModel()
236234 vm.sqlQuery = "SELECT * FROM \"logs\" ORDER BY p_timestamp DESC LIMIT 1000"
237235 vm.addColumnFilter(column: "level", value: .string("debug"), exclude: true)
238− XCTAssertTrue(vm.sqlQuery.contains("WHERE \"level\" IS DISTINCT FROM 'debug'"))
236+ XCTAssertTrue(vm.sqlQuery.contains("WHERE \"level\" <> 'debug'"))
239237 XCTAssertTrue(vm.sqlQuery.contains("ORDER BY"))
240238 }
241239
@@ -288,7 +286,7 @@ final class QueryViewModelTests: XCTestCase {
288286 let vm = QueryViewModel()
289287 vm.sqlQuery = "SELECT * FROM \"logs\""
290288 vm.addColumnFilter(column: "level", value: .string("warn"), exclude: true)
291− XCTAssertTrue(vm.sqlQuery.contains("WHERE \"level\" IS DISTINCT FROM 'warn'"))
289+ XCTAssertTrue(vm.sqlQuery.contains("WHERE \"level\" <> 'warn'"))
292290 }
293291
294292 func testAddColumnFilterEscapesSingleQuotesInValue() {
@@ -671,125 +669,5 @@ final class QueryViewModelTests: XCTestCase {
671669 let empty = vm.emptyColumns(in: records, columns: ["a"])
672670 XCTAssertTrue(empty.isEmpty)
673671 }
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− }
794672}
795673
ParseableViewerTests/SQLAutoCompleteTests.swift−56
@@ -275,62 +275,6 @@ final class SQLAutoCompleteTests: XCTestCase {
275275 let ctx = SQLCompletionProvider.determineContext(text: "SELECT * FROM a LEFT JOIN ", position: 26)
276276 XCTAssertEqual(ctx, .tableRef)
277277 }
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− }
334278}
335279
336280// Make SQLCompletionContext Equatable for assertions
ParseableViewerTests/SQLTokenizerTests.swift−15
@@ -348,19 +348,4 @@ final class SQLTokenizerTests: XCTestCase {
348348 let sql = "SELECT * FROM t"
349349 XCTAssertNil(SQLTokenizer.errorHighlightRange(line: 99, column: 1, in: sql))
350350 }
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− }
366351}
ParseableViewerTests/ServerConnectionTests.swift−10
@@ -77,14 +77,4 @@ final class ServerConnectionTests: XCTestCase {
7777 let conn = ServerConnection(name: "test", url: "https://", username: "admin", password: "pass")
7878 XCTAssertNil(conn.baseURL)
7979 }
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− }
9080}
ParseableViewerTests/SyntaxHighlightingTests.swift−10
@@ -140,16 +140,6 @@ final class SyntaxHighlightingTests: XCTestCase {
140140 "Double-quoted identifiers should differ from keywords")
141141 }
142142
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−
153143 // MARK: - JSON Highlighting
154144
155145 func testJSONHighlightProducesOutput() {
README.md+1−1
@@ -143,7 +143,7 @@ ParseableViewer/
143143| `Cmd+R` | Refresh streams |
144144| `Cmd+Enter` | Run query |
145145| `Cmd+P` | Pause/resume live tail |
146−| `Cmd+K` | Clear live tail entries |
146+| `Cmd+Delete` | Clear live tail entries |
147147| `Cmd+Ctrl+S` | Toggle sidebar |
148148
149149## License