git.iko.soy/parseable-macos
on 0ec0378 help sign in
.github/workflows/build.yml−28
since splitsnapshots
unifiedsplit
@@ -9,10 +9,6 @@ on:
9jobs:
9jobs:
10 build:
10 build:
11 runs-on: macos-15
11 runs-on: macos-15
12− permissions:
13− id-token: write
14− attestations: write
15− contents: read
16 strategy:
12 strategy:
17 matrix:
13 matrix:
18 include:
14 include:
@@ -73,11 +69,6 @@ jobs:
73 exit 1
69 exit 1
74 fi
70 fi
75
71
76− - name: Attest artifact
77− uses: actions/attest-build-provenance@v2
78− with:
79− subject-path: ParseableViewer-${{ matrix.arch }}.zip
80−
81 - name: Upload artifact
72 - name: Upload artifact
82 uses: actions/upload-artifact@v4
73 uses: actions/upload-artifact@v4
83 with:
74 with:
@@ -87,10 +78,6 @@ jobs:
87 create-universal:
78 create-universal:
88 needs: build
79 needs: build
89 runs-on: macos-15
80 runs-on: macos-15
90− permissions:
91− id-token: write
92− attestations: write
93− contents: read
94 steps:
81 steps:
95 - name: Checkout
82 - name: Checkout
96 uses: actions/checkout@v4
83 uses: actions/checkout@v4
@@ -138,11 +125,6 @@ jobs:
138 cd universal_app
125 cd universal_app
139 zip -r -y "../ParseableViewer-universal.zip" "Parseable Viewer.app"
126 zip -r -y "../ParseableViewer-universal.zip" "Parseable Viewer.app"
140
127
141− - name: Attest universal artifact
142− uses: actions/attest-build-provenance@v2
143− with:
144− subject-path: ParseableViewer-universal.zip
145−
146 - name: Upload universal artifact
128 - name: Upload universal artifact
147 uses: actions/upload-artifact@v4
129 uses: actions/upload-artifact@v4
148 with:
130 with:
@@ -158,8 +140,6 @@ jobs:
158 if: github.event_name == 'push'
140 if: github.event_name == 'push'
159 permissions:
141 permissions:
160 contents: write
142 contents: write
161− id-token: write
162− attestations: write
163 steps:
143 steps:
164 - name: Checkout
144 - name: Checkout
165 uses: actions/checkout@v4
145 uses: actions/checkout@v4
@@ -175,14 +155,6 @@ jobs:
175 with:
155 with:
176 path: artifacts
156 path: artifacts
177
157
178− - name: Attest release artifacts
179− uses: actions/attest-build-provenance@v2
180− with:
181− subject-path: |
182− artifacts/ParseableViewer-arm64/ParseableViewer-arm64.zip
183− artifacts/ParseableViewer-x86_64/ParseableViewer-x86_64.zip
184− artifacts/ParseableViewer-universal/ParseableViewer-universal.zip
185−
186 - name: Create release
158 - name: Create release
187 uses: softprops/action-gh-release@v2
159 uses: softprops/action-gh-release@v2
188 with:
160 with:
CLAUDE.md+3−3
@@ -31,7 +31,7 @@ CI builds on `macos-15` runners via `.github/workflows/build.yml`, producing arm
31- **Concurrency**: Swift structured concurrency (`async/await`, `@MainActor`, strict `Sendable` conformance)
31- **Concurrency**: Swift structured concurrency (`async/await`, `@MainActor`, strict `Sendable` conformance)
32- **Networking**: `URLSession` async/await, no external dependencies
32- **Networking**: `URLSession` async/await, no external dependencies
33- **Project generation**: XcodeGen from `project.yml`
33- **Project generation**: XcodeGen from `project.yml`
34−- **Persistence**: Keychain (file-based login keychain) for connections and passwords; `UserDefaults` for saved queries. Do not use `kSecUseDataProtectionKeychain: true` — it requires `keychain-access-groups` entitlements that are incompatible with ad-hoc code signing and causes writes to silently fail.
34+- **Persistence**: `UserDefaults` for connections and saved queries; Keychain for passwords
35
35
36### Source layout
36### Source layout
37
37
@@ -83,10 +83,10 @@ Live tail in Parseable uses gRPC Arrow Flight streaming. This app approximates i
83- Models use defensive `try?` decoding for optional fields to tolerate API version differences
83- Models use defensive `try?` decoding for optional fields to tolerate API version differences
84- Views follow the macOS pattern: `NavigationSplitView` sidebar + detail, with tab switching in the detail pane
84- Views follow the macOS pattern: `NavigationSplitView` sidebar + detail, with tab switching in the detail pane
85- All API calls are `async` and dispatched from views via `Task { }`
85- All API calls are `async` and dispatched from views via `Task { }`
86−- Connection metadata and passwords are stored in the Keychain (file-based login keychain); `ServerConnection.password` is excluded from `Codable`. Do not add `kSecUseDataProtectionKeychain: true` — it requires `keychain-access-groups` entitlements that conflict with ad-hoc code signing and causes writes to silently fail.
86+- Passwords are stored in the Keychain, never in UserDefaults; `ServerConnection.password` is excluded from `Codable`
87- Stream names are SQL-escaped via `escapeSQLIdentifier` (double-quote wrapping) and URL-encoded via `encodePathComponent` for API paths
87- Stream names are SQL-escaped via `escapeSQLIdentifier` (double-quote wrapping) and URL-encoded via `encodePathComponent` for API paths
88- Views clear stale data before loading new content (e.g., switching streams clears previous schema/stats)
88- Views clear stale data before loading new content (e.g., switching streams clears previous schema/stats)
89−- Entitlements: none (App Sandbox is not enabled)
89+- Entitlements: App Sandbox enabled with network.client for outbound connections
90
90
91## Common patterns
91## Common patterns
92
92
ParseableViewer.xcodeproj/project.pbxproj+1−1
@@ -293,6 +293,7 @@
293 };
293 };
294 };
294 };
295 buildConfigurationList = 91629F84D63DA522312E65D6 /* Build configuration list for PBXProject "ParseableViewer" */;
295 buildConfigurationList = 91629F84D63DA522312E65D6 /* Build configuration list for PBXProject "ParseableViewer" */;
296+ compatibilityVersion = "Xcode 14.0";
296 developmentRegion = en;
297 developmentRegion = en;
297 hasScannedForEncodings = 0;
298 hasScannedForEncodings = 0;
298 knownRegions = (
299 knownRegions = (
@@ -302,7 +303,6 @@
302 mainGroup = D06ED124AC4DFCB31F6EF0C1;
303 mainGroup = D06ED124AC4DFCB31F6EF0C1;
303 minimizedProjectReferenceProxies = 1;
304 minimizedProjectReferenceProxies = 1;
304 preferredProjectObjectVersion = 77;
305 preferredProjectObjectVersion = 77;
305− productRefGroup = D37830AB19DD594BCD2B5499 /* Products */;
306 projectDirPath = "";
306 projectDirPath = "";
307 projectRoot = "";
307 projectRoot = "";
308 targets = (
308 targets = (
ParseableViewer/App/AppState.swift+17−236
@@ -14,11 +14,7 @@ final class AppState {
14 private(set) var isNetworkAvailable = true
14 private(set) var isNetworkAvailable = true
15 private let networkMonitor = NWPathMonitor()
15 private let networkMonitor = NWPathMonitor()
16 private let networkMonitorQueue = DispatchQueue(label: "com.parseableviewer.network-monitor")
16 private let networkMonitorQueue = DispatchQueue(label: "com.parseableviewer.network-monitor")
17− private var connectTask: Task<Bool, Never>?
17+ private var connectTask: Task<Void, 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?
22
18
23 // MARK: - Stream State
19 // MARK: - Stream State
24 var streams: [LogStream] = []
20 var streams: [LogStream] = []
@@ -66,15 +62,6 @@ final class AppState {
66 case .serverInfo: return "server.rack"
62 case .serverInfo: return "server.rack"
67 }
63 }
68 }
64 }
69−
70− /// Whether the tab's content is scoped to a selected stream. Server-level
71− /// tabs (Users, Server Info) stay reachable even with no stream selected.
72− var requiresStream: Bool {
73− switch self {
74− case .query, .liveTail, .streamInfo, .alerts: return true
75− case .users, .serverInfo: return false
76− }
77− }
78 }
65 }
79
66
80 var filteredStreams: [LogStream] {
67 var filteredStreams: [LogStream] {
@@ -100,9 +87,8 @@ final class AppState {
100
87
101 if let activeID = ConnectionStore.loadActiveConnectionID(),
88 if let activeID = ConnectionStore.loadActiveConnectionID(),
102 let connection = connections.first(where: { $0.id == activeID }) {
89 let connection = connections.first(where: { $0.id == activeID }) {
103− connectingConnectionID = connection.id
104− connectTask = Task { @MainActor [weak self] in
90+ connectTask = Task { @MainActor in
105− await self?.performConnect(to: connection) ?? false
91+ await connect(to: connection)
106 }
92 }
107 }
93 }
108 }
94 }
@@ -110,71 +96,23 @@ final class AppState {
110 // MARK: - Connection Management
96 // MARK: - Connection Management
111
97
112 @MainActor
98 @MainActor
113− @discardableResult
114− func connect(to connection: ServerConnection) async -> Bool {
99+ func connect(to connection: ServerConnection) async {
115− // Supersede any connect already in flight (e.g. the launch auto-reconnect
116− // or a prior request) instead of silently dropping this one — which would
117− // surface as a spurious "Connection failed". Cancel it and wait for it to
118− // unwind so two connects never run concurrently.
119− if let existing = connectTask {
120− existing.cancel()
121− _ = await existing.value
122− }
123− connectingConnectionID = connection.id
124− let task = Task { @MainActor [weak self] in
125− await self?.performConnect(to: connection) ?? false
126− }
127− connectTask = task
128− return await task.value
129− }
130−
131− @MainActor
132− private func performConnect(to connection: ServerConnection) async -> Bool {
133− defer {
134− if connectingConnectionID == connection.id { connectingConnectionID = nil }
135− }
136− guard !isConnecting else { return false }
100+ guard !isConnecting else { return }
137
101
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) {
102+ if !isNetworkAvailable {
143 self.errorMessage = "No internet connection. Check your network and try again."
103 self.errorMessage = "No internet connection. Check your network and try again."
144 self.showError = true
104 self.showError = true
145− return false
105+ return
146 }
106 }
147
107
148 isConnecting = true
108 isConnecting = true
149− defer { isConnecting = false }
150− // Reset the flag with the message: a previous failed connect left
151− // `showError` set, and if its alert couldn't present (e.g. behind the
152− // connection sheet), it would pop up later — after a successful retry —
153− // reading "An unknown error occurred".
154 errorMessage = nil
109 errorMessage = nil
155− showError = false
156
110
157 // When switching to a different server, clear stream-specific state
111 // When switching to a different server, clear stream-specific state
158 // so stale selections don't reference streams that don't exist.
112 // so stale selections don't reference streams that don't exist.
159 if activeConnection?.id != connection.id {
113 if activeConnection?.id != connection.id {
160 selectedStream = nil
114 selectedStream = nil
161 streamLoadError = nil
115 streamLoadError = nil
162− // Drop the previous server's stream list and filters too — otherwise the
163− // sidebar keeps rendering server A's streams/filters (with selection
164− // already nilled) throughout the reconnect window. Same rationale as
165− // `serverAbout` below; matches the failure path and `disconnect()`. Both
166− // are repopulated once the new server's listStreams/listFilters respond.
167− streams = []
168− filters = []
169− // Drop the previous server's cached `about` so views seeding from it
170− // (ServerInfoView) don't paint the old server's details while the new
171− // one loads. It's repopulated below once the new server responds.
172− serverAbout = nil
173− // Reset the sidebar search filter and tab too: a stale filter would
174− // silently hide the new server's streams, and a stream-specific tab
175− // would have no valid selection behind it.
176− streamSearchText = ""
177− currentTab = .query
178 }
116 }
179
117
180 do {
118 do {
@@ -182,12 +120,6 @@ final class AppState {
182
120
183 // Test connection — throws on non-200 or network failure
121 // Test connection — throws on non-200 or network failure
184 try await newClient.checkHealth()
122 try await newClient.checkHealth()
185− try await Self.verifyCredentials(with: newClient)
186−
187− // If the task was cancelled while awaiting (e.g. user clicked
188− // Disconnect during auto-reconnect), bail before installing the
189− // client so we don't resurrect a connection disconnect() just cleared.
190− if Task.isCancelled { return false }
191
123
192 self.client = newClient
124 self.client = newClient
193 self.activeConnection = connection
125 self.activeConnection = connection
@@ -200,36 +132,15 @@ final class AppState {
200 async let streamList = newClient.listStreams()
132 async let streamList = newClient.listStreams()
201 async let filterList = newClient.listFilters()
133 async let filterList = newClient.listFilters()
202
134
203− let aboutResult = try? await about
135+ self.serverAbout = try? await about
204− let filterResult = (try? await filterList) ?? []
136+ self.filters = (try? await filterList) ?? []
205− let streamResult: Result<[LogStream], Error>
206 do {
137 do {
207− streamResult = .success(try await streamList)
138+ self.streams = try await streamList
208− } catch {
209− streamResult = .failure(error)
210− }
211−
212− // Re-check after the loads above: a disconnect could have landed
213− // while awaiting, in which case committing these would show stale data.
214− if Task.isCancelled { return false }
215−
216− self.serverAbout = aboutResult
217− self.filters = filterResult
218− switch streamResult {
219− case .success(let list):
220− self.streams = list
221 self.streamLoadError = nil
139 self.streamLoadError = nil
222− // Like refreshStreams: a selection that doesn't exist on this server
140+ } catch {
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− }
228− case .failure(let error):
229 self.streams = []
141 self.streams = []
230 self.streamLoadError = ParseableError.userFriendlyMessage(for: error)
142 self.streamLoadError = ParseableError.userFriendlyMessage(for: error)
231 }
143 }
232− return true
233 } catch {
144 } catch {
234 // If the task was cancelled (e.g. user clicked Disconnect during
145 // If the task was cancelled (e.g. user clicked Disconnect during
235 // auto-reconnect), skip the error alert — disconnect() already
146 // auto-reconnect), skip the error alert — disconnect() already
@@ -240,16 +151,10 @@ final class AppState {
240 self.isConnected = false
151 self.isConnected = false
241 self.client = nil
152 self.client = nil
242 self.activeConnection = nil
153 self.activeConnection = nil
243− // Clear stream-specific state so the sidebar doesn't keep showing
244− // the previous server's streams/selection after a failed connect.
245− self.streams = []
246− self.selectedStream = nil
247− self.streamLoadError = nil
248− self.serverAbout = nil
249− self.filters = []
250 }
154 }
251− return false
252 }
155 }
156+
157+ isConnecting = false
253 }
158 }
254
159
255 @MainActor
160 @MainActor
@@ -263,52 +168,17 @@ final class AppState {
263 selectedStream = nil
168 selectedStream = nil
264 serverAbout = nil
169 serverAbout = nil
265 filters = []
170 filters = []
266− // Clear transient stream/UI state too, otherwise a leftover stream-load
267− // error keeps rendering in the sidebar after disconnect and a stale search
268− // filter / tab carries into the next connection.
269− streamLoadError = nil
270− isLoadingStreams = false
271− streamSearchText = ""
272− currentTab = .query
273 ConnectionStore.saveActiveConnectionID(nil)
171 ConnectionStore.saveActiveConnectionID(nil)
274 }
172 }
275
173
276 @MainActor
174 @MainActor
277 func refreshStreams() async {
175 func refreshStreams() async {
278 guard let client else { return }
176 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
287 isLoadingStreams = true
177 isLoadingStreams = true
288 streamLoadError = nil
178 streamLoadError = nil
289 do {
179 do {
290− let loaded = try await client.listStreams()
180+ streams = 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
301− // Drop a selection that no longer exists server-side (e.g. the stream
302− // was deleted from another client); otherwise the detail tabs keep
303− // querying a missing stream and surface 404s.
304− if let selected = selectedStream, !streams.contains(where: { $0.name == selected }) {
305− selectedStream = nil
306− }
307 } catch {
181 } catch {
308− guard connID == activeConnection?.id, client === self.client else {
309− isLoadingStreams = false
310− return
311− }
312 let message = ParseableError.userFriendlyMessage(for: error)
182 let message = ParseableError.userFriendlyMessage(for: error)
313 streamLoadError = message
183 streamLoadError = message
314 self.errorMessage = "Failed to load streams: \(message)"
184 self.errorMessage = "Failed to load streams: \(message)"
@@ -327,45 +197,10 @@ final class AppState {
327
197
328 @MainActor
198 @MainActor
329 func updateConnection(_ connection: ServerConnection) {
199 func updateConnection(_ connection: ServerConnection) {
330− let previous = connections.first(where: { $0.id == connection.id })
331 if let index = connections.firstIndex(where: { $0.id == connection.id }) {
200 if let index = connections.firstIndex(where: { $0.id == connection.id }) {
332 connections[index] = connection
201 connections[index] = connection
333 ConnectionStore.saveConnections(connections)
202 ConnectionStore.saveConnections(connections)
334 }
203 }
335− // If the active connection was edited, its URL or credentials may now point
336− // at a different server, so swapping the client alone isn't enough — the
337− // 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.
358− selectedStream = nil
359− streamSearchText = ""
360− currentTab = .query
361− // Drop the previous server's cached `about`, stream list, and filters —
362− // performConnect skips its own clear (id unchanged), so without this the
363− // sidebar keeps rendering server A's streams/filters and ServerInfoView
364− // paints the old version/storage until the new server's responses land.
365− serverAbout = nil
366− streams = []
367− filters = []
368− }
369 }
204 }
370
205
371 @MainActor
206 @MainActor
@@ -375,49 +210,9 @@ final class AppState {
375 ConnectionStore.saveConnections(connections)
210 ConnectionStore.saveConnections(connections)
376 if activeConnection?.id == connection.id {
211 if activeConnection?.id == connection.id {
377 disconnect()
212 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 }
213 }
397 }
214 }
398
215
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.
412− }
413− }
414−
415− private static func isLoopback(_ url: URL?) -> Bool {
416− guard let host = url?.host?.lowercased() else { return false }
417− return host == "localhost" || host.hasSuffix(".localhost")
418− || host == "::1" || host.hasPrefix("127.")
419− }
420−
421 // MARK: - Stream Management
216 // MARK: - Stream Management
422
217
423 @MainActor
218 @MainActor
@@ -443,18 +238,8 @@ final class AppState {
443 @MainActor
238 @MainActor
444 func refreshFilters() async {
239 func refreshFilters() async {
445 guard let client else { return }
240 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
451 isLoadingFilters = true
241 isLoadingFilters = true
452− let loaded = (try? await client.listFilters()) ?? []
242+ filters = (try? await client.listFilters()) ?? []
453− guard connID == activeConnection?.id, client === self.client else {
454− isLoadingFilters = false
455− return
456− }
457− filters = loaded
458 isLoadingFilters = false
243 isLoadingFilters = false
459 }
244 }
460
245
@@ -473,12 +258,8 @@ final class AppState {
473 @MainActor
258 @MainActor
474 func deleteFilter(_ filter: ParseableFilter) async throws {
259 func deleteFilter(_ filter: ParseableFilter) async throws {
475 guard let client else { throw ParseableError.notConnected }
260 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 {
261+ guard let filterId = filter.filterId else { return }
480− try await client.deleteFilter(id: filterId)
262+ try await client.deleteFilter(id: filterId)
481− }
482 filters.removeAll { $0.id == filter.id }
263 filters.removeAll { $0.id == filter.id }
483 }
264 }
484
265
ParseableViewer/App/ParseableViewerApp.swift+1−13
@@ -4,13 +4,6 @@ import SwiftUI
4struct ParseableViewerApp: App {
4struct ParseableViewerApp: App {
5 @State private var appState = AppState()
5 @State private var appState = AppState()
6
6
7− init() {
8− // All window content shares one AppState (see the File > New group below),
9− // so don't offer window tabs either: the tab bar's "+" would open a second
10− // window just like File > New Window.
11− NSWindow.allowsAutomaticWindowTabbing = false
12− }
13−
14 var body: some Scene {
7 var body: some Scene {
15 WindowGroup {
8 WindowGroup {
16 ContentView()
9 ContentView()
@@ -24,12 +17,7 @@ struct ParseableViewerApp: App {
24 AboutCommand()
17 AboutCommand()
25 }
18 }
26
19
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) {
33 Button("New Connection...") {
21 Button("New Connection...") {
34 appState.editingConnection = nil
22 appState.editingConnection = nil
35 appState.showConnectionSheet = true
23 appState.showConnectionSheet = true
ParseableViewer/Info.plist−5
@@ -24,11 +24,6 @@
24 <string>public.app-category.developer-tools</string>
24 <string>public.app-category.developer-tools</string>
25 <key>LSMinimumSystemVersion</key>
25 <key>LSMinimumSystemVersion</key>
26 <string>$(MACOSX_DEPLOYMENT_TARGET)</string>
26 <string>$(MACOSX_DEPLOYMENT_TARGET)</string>
27− <key>NSAppTransportSecurity</key>
28− <dict>
29− <key>NSAllowsArbitraryLoads</key>
30− <true/>
31− </dict>
32 <key>NSMainNibFile</key>
27 <key>NSMainNibFile</key>
33 <string></string>
28 <string></string>
34 <key>NSPrincipalClass</key>
29 <key>NSPrincipalClass</key>
ParseableViewer/Models/AlertModels.swift+1−27
@@ -45,15 +45,10 @@ struct AlertRule: Identifiable, Codable, Sendable {
45 let rule: AlertRuleSpec?
45 let rule: AlertRuleSpec?
46 let targets: [AlertTarget]?
46 let targets: [AlertTarget]?
47
47
48− /// Stable fallback identity for alerts that carry no id/name/title.
49− /// Generated once at init so `id` doesn't change on every access (which
50− /// would make SwiftUI tear down and rebuild list rows continuously).
51− private let localID = UUID()
52−
53 /// Display name, preferring the modern `title` field over legacy `name`.
48 /// Display name, preferring the modern `title` field over legacy `name`.
54 var displayName: String { title ?? name ?? "Unnamed Alert" }
49 var displayName: String { title ?? name ?? "Unnamed Alert" }
55
50
56− var id: String { alertId ?? name ?? title ?? localID.uuidString }
51+ var id: String { alertId ?? name ?? title ?? UUID().uuidString }
57
52
58 private enum CodingKeys: String, CodingKey {
53 private enum CodingKeys: String, CodingKey {
59 case alertId = "id"
54 case alertId = "id"
@@ -85,27 +80,6 @@ struct AlertRule: Identifiable, Codable, Sendable {
85struct AlertRuleSpec: Codable, Sendable {
80struct AlertRuleSpec: Codable, Sendable {
86 let type: String?
81 let type: String?
87 let config: String?
82 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− }
109}
83}
110
84
111struct AlertTarget: Codable, Sendable {
85struct AlertTarget: Codable, Sendable {
ParseableViewer/Models/JSONValue.swift+23−224
@@ -37,14 +37,7 @@ enum JSONValue: Codable, Hashable, Sendable, Comparable {
37 case .null: try container.encodeNil()
37 case .null: try container.encodeNil()
38 case .bool(let v): try container.encode(v)
38 case .bool(let v): try container.encode(v)
39 case .int(let v): try container.encode(v)
39 case .int(let v): try container.encode(v)
40− case .double(let v):
40+ case .double(let v): try container.encode(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() }
48 case .string(let v): try container.encode(v)
41 case .string(let v): try container.encode(v)
49 case .array(let v): try container.encode(v)
42 case .array(let v): try container.encode(v)
50 case .object(let v): try container.encode(v)
43 case .object(let v): try container.encode(v)
@@ -57,14 +50,7 @@ enum JSONValue: Codable, Hashable, Sendable, Comparable {
57 case .bool(let v): return v ? "true" : "false"
50 case .bool(let v): return v ? "true" : "false"
58 case .int(let v): return String(v)
51 case .int(let v): return String(v)
59 case .double(let v):
52 case .double(let v):
60− // Non-finite doubles have no display (and no JSON) representation;
61− // render them as "null" to match jsonNumber/exportString rather than
62− // letting String(_:) emit "inf"/"-inf"/"nan" into the UI and CSV cells.
63− if !v.isFinite { return "null" }
64 if v == v.rounded() && abs(v) < 1e15 {
53 if v == v.rounded() && abs(v) < 1e15 {
65− // Normalize negative zero so it renders as "0", not "-0"
66− // (`%.0f` preserves the sign bit of -0.0).
67− if v == 0 { return "0" }
68 return String(format: "%.0f", v)
54 return String(format: "%.0f", v)
69 }
55 }
70 return String(v)
56 return String(v)
@@ -74,31 +60,16 @@ enum JSONValue: Codable, Hashable, Sendable, Comparable {
74 }
60 }
75 }
61 }
76
62
77− /// Compact JSON serialization without allocating a JSONEncoder.
78− var compactJSON: String {
79− switch self {
80− case .null: return "null"
81− case .bool(let v): return v ? "true" : "false"
82− case .int(let v): return String(v)
83− case .double(let v): return Self.jsonNumber(v)
84− case .string(let v): return "\"\(Self.escapeJSONString(v))\""
85− case .array(let items):
86− return "[" + items.map { $0.compactJSON }.joined(separator: ",") + "]"
87− case .object(let dict):
88− let pairs = dict.keys.sorted().compactMap { key -> String? in
89− guard let value = dict[key] else { return nil }
90− return "\"\(Self.escapeJSONString(key))\":\(value.compactJSON)"
91− }
92− return "{" + pairs.joined(separator: ",") + "}"
93− }
94− }
95−
96 /// Like `displayString` but serializes arrays/objects as compact JSON
63 /// Like `displayString` but serializes arrays/objects as compact JSON
97 /// instead of placeholders like "[3 items]". Used for CSV export.
64 /// instead of placeholders like "[3 items]". Used for CSV export.
98 var exportString: String {
65 var exportString: String {
99 switch self {
66 switch self {
100 case .array, .object:
67 case .array, .object:
101− return compactJSON
68+ if let data = try? JSONEncoder().encode(self),
69+ let json = String(data: data, encoding: .utf8) {
70+ return json
71+ }
72+ return displayString
102 default:
73 default:
103 return displayString
74 return displayString
104 }
75 }
@@ -110,7 +81,7 @@ enum JSONValue: Codable, Hashable, Sendable, Comparable {
110 case .null: return "NULL"
81 case .null: return "NULL"
111 case .bool(let v): return v ? "true" : "false"
82 case .bool(let v): return v ? "true" : "false"
112 case .int(let v): return String(v)
83 case .int(let v): return String(v)
113− case .double(let v): return v.isFinite ? String(v) : "NULL"
84+ case .double(let v): return String(v)
114 case .string(let v): return "'\(v.replacingOccurrences(of: "'", with: "''"))'"
85 case .string(let v): return "'\(v.replacingOccurrences(of: "'", with: "''"))'"
115 case .array, .object:
86 case .array, .object:
116 return "'\(exportString.replacingOccurrences(of: "'", with: "''"))'"
87 return "'\(exportString.replacingOccurrences(of: "'", with: "''"))'"
@@ -151,7 +122,7 @@ enum JSONValue: Codable, Hashable, Sendable, Comparable {
151 case .null: return "null"
122 case .null: return "null"
152 case .bool(let v): return v ? "true" : "false"
123 case .bool(let v): return v ? "true" : "false"
153 case .int(let v): return String(v)
124 case .int(let v): return String(v)
154− case .double(let v): return Self.jsonNumber(v)
125+ case .double(let v): return String(v)
155 case .string(let v): return "\"\(Self.escapeJSONString(v))\""
126 case .string(let v): return "\"\(Self.escapeJSONString(v))\""
156 case .array(let items):
127 case .array(let items):
157 if items.isEmpty { return "[]" }
128 if items.isEmpty { return "[]" }
@@ -174,201 +145,43 @@ enum JSONValue: Codable, Hashable, Sendable, Comparable {
174 /// Escapes special characters for valid JSON string output.
145 /// Escapes special characters for valid JSON string output.
175 static func escapeJSONString(_ s: String) -> String {
146 static func escapeJSONString(_ s: String) -> String {
176 var result = ""
147 var result = ""
177− result.unicodeScalars.reserveCapacity(s.unicodeScalars.count)
148+ result.reserveCapacity(s.count)
178− for scalar in s.unicodeScalars {
149+ for char in s {
179− switch scalar {
150+ switch char {
180 case "\"": result += "\\\""
151 case "\"": result += "\\\""
181 case "\\": result += "\\\\"
152 case "\\": result += "\\\\"
182 case "\n": result += "\\n"
153 case "\n": result += "\\n"
183 case "\r": result += "\\r"
154 case "\r": result += "\\r"
184 case "\t": result += "\\t"
155 case "\t": result += "\\t"
185 default:
156 default:
186− // Any control character (U+0000–U+001F), including those that
157+ if char.asciiValue.map({ $0 < 0x20 }) == true {
187− // would otherwise combine into a grapheme cluster, must be
158+ let code = char.asciiValue!
188− // escaped to produce valid JSON.
189− if scalar.value < 0x20 {
190− result += String(format: "\\u%04x", scalar.value)
159+ result += String(format: "\\u%04x", code)
191 } else {
160 } else {
192− result.unicodeScalars.append(scalar)
161+ result.append(char)
193 }
162 }
194 }
163 }
195 }
164 }
196 return result
165 return result
197 }
166 }
198
167
199− /// Stable ordering bucket per case so cross-type comparisons form a total
200− /// order. Integers and doubles share a bucket so they compare numerically.
201− private var typeRank: Int {
202− switch self {
203− case .null: return 0
204− case .bool: return 1
205− case .int, .double: return 2
206− case .string: return 3
207− case .array: return 4
208− case .object: return 5
209− }
210− }
211−
212 /// Type-aware comparison: numbers compare numerically, strings
168 /// Type-aware comparison: numbers compare numerically, strings
213 /// lexicographically, nulls sort first, bools sort false < true.
169 /// lexicographically, nulls sort first, bools sort false < true.
214− /// Different types are ordered by a stable type rank so that `<` is a strict
170+ /// Cross-type comparisons fall back to displayString.
215− /// weak ordering consistent with `==` (the `Comparable` contract requires
216− /// that order-equivalent values are equal — a `displayString` fallback
217− /// violated this, e.g. `.int(5)` vs `.string("5")` both render "5").
218 static func < (lhs: JSONValue, rhs: JSONValue) -> Bool {
171 static func < (lhs: JSONValue, rhs: JSONValue) -> Bool {
219− if lhs.typeRank != rhs.typeRank { return lhs.typeRank < rhs.typeRank }
220 switch (lhs, rhs) {
172 switch (lhs, rhs) {
221 case (.null, .null): return false
173 case (.null, .null): return false
174+ case (.null, _): return true
175+ case (_, .null): return false
222 case (.bool(let a), .bool(let b)): return !a && b
176 case (.bool(let a), .bool(let b)): return !a && b
223 case (.int(let a), .int(let b)): return a < b
177 case (.int(let a), .int(let b)): return a < b
224− case (.double(let a), .double(let b)): return doubleLessThan(a, b)
178+ case (.double(let a), .double(let b)): return a < b
225− case (.int(let a), .double(let b)): return intLessThanDouble(a, b)
179+ case (.int(let a), .double(let b)): return Double(a) < b
226− case (.double(let a), .int(let b)): return doubleLessThanInt(a, b)
180+ case (.double(let a), .int(let b)): return a < Double(b)
227 case (.string(let a), .string(let b)):
181 case (.string(let a), .string(let b)):
228− // Two distinct strings can fold to `.orderedSame` under
229− // `localizedStandardCompare` (e.g. full-width vs half-width forms that
230− // Swift's `==` still treats as different). Break that tie with the raw
231− // `<` so order-equivalent values stay `==`, as `Comparable` requires —
232− // the same fallback `LogStream.<` already applies.
233− let order = a.localizedStandardCompare(b)
182+ return a.localizedStandardCompare(b) == .orderedAscending
234− return order == .orderedAscending || (order == .orderedSame && a < b)
235− case (.array(let a), .array(let b)):
236− return arrayLessThan(a, b)
237− case (.object(let a), .object(let b)):
238− return objectLessThan(a, b)
239 default:
183 default:
240− return false
184+ return lhs.displayString < rhs.displayString
241− }
242− }
243−
244− /// Lexicographic order over array elements using the element-level `<`/`==`.
245− /// A serialized-string tiebreaker (`compactJSON < compactJSON`) was wrong:
246− /// `.int(1) == .double(1.0)` are equal yet serialize as `1` vs `1.0`, so two
247− /// non-equal values could share a serialization (or two equal ones differ),
248− /// breaking the strict-weak-ordering `Comparable` requires. Comparing
249− /// elements directly stays consistent with `==` (arrays are equal iff same
250− /// length and equal elements).
251− private static func arrayLessThan(_ a: [JSONValue], _ b: [JSONValue]) -> Bool {
252− for (x, y) in zip(a, b) where x != y {
253− return x < y
254− }
255− return a.count < b.count
256− }
257−
258− /// Deterministic order over objects: compare key/value pairs in sorted-key
259− /// order. Independent of dictionary iteration order and consistent with `==`
260− /// (objects are equal iff the same keys map to equal values).
261− private static func objectLessThan(_ a: [String: JSONValue], _ b: [String: JSONValue]) -> Bool {
262− let aKeys = a.keys.sorted()
263− let bKeys = b.keys.sorted()
264− for (ak, bk) in zip(aKeys, bKeys) {
265− if ak != bk { return ak < bk }
266− // Force-unwrap is safe: ak/bk come from each dictionary's own keys.
267− let av = a[ak]!, bv = b[bk]!
268− if av != bv { return av < bv }
269− }
270− return aKeys.count < bKeys.count
271− }
272−
273− /// Total ordering for doubles that keeps `sort()` from trapping on NaN
274− /// (IEEE comparisons with NaN are always false, which violates the strict
275− /// weak ordering `Comparable` requires). NaN is ordered after all numbers.
276− private static func doubleLessThan(_ a: Double, _ b: Double) -> Bool {
277− if a.isNaN { return false }
278− if b.isNaN { return true }
279− return a < b
280− }
281−
282− /// Exact three-way comparison of an `Int` against a `Double` with no
283− /// precision loss. Returns a negative value if `a < b`, zero if they are
284− /// numerically equal, and a positive value if `a > b`. `NaN` sorts after all
285− /// numbers (so any int compares as less than `NaN`).
286− ///
287− /// Comparing via `Double(a)` loses precision for `|a| > 2^53`, and
288− /// `Int(exactly: b)` is `nil` once `b` falls outside the `Int` range — the
289− /// previous helpers fell back to the lossy path there, which made `<`, `>`,
290− /// and `==` mutually inconsistent at the 2^63 boundary (e.g. `Int.max` vs
291− /// `Double(Int.max)`, which rounds up to 2^63). This range-checks `b` first
292− /// so the integer parts are always compared exactly.
293− private static func compareIntDouble(_ a: Int, _ b: Double) -> Int {
294− if b.isNaN { return -1 } // NaN sorts after numbers
295− if b >= 9223372036854775808.0 { return -1 } // b > Int.max (incl. +inf)
296− if b < -9223372036854775808.0 { return 1 } // b < Int.min (incl. -inf)
297− // b is finite and its floor fits in Int.
298− let bFloor = Int(b.rounded(.down))
299− if a != bFloor { return a < bFloor ? -1 : 1 }
300− // Integer parts are equal: a fractional b is the larger of the two.
301− return b > Double(bFloor) ? -1 : 0
302− }
303−
304− private static func intLessThanDouble(_ a: Int, _ b: Double) -> Bool {
305− compareIntDouble(a, b) < 0
306− }
307−
308− private static func doubleLessThanInt(_ a: Double, _ b: Int) -> Bool {
309− // a < b ⟺ b > a ⟺ compareIntDouble(b, a) is positive.
310− compareIntDouble(b, a) > 0
311− }
312−
313− private static func intEqualsDouble(_ a: Int, _ b: Double) -> Bool {
314− compareIntDouble(a, b) == 0
315− }
316−
317− /// JSON-safe rendering of a double: JSON has no NaN/Infinity literals, so
318− /// non-finite values serialize as `null` rather than the invalid `nan`/`inf`
319− /// that `String(_:)` would emit.
320− static func jsonNumber(_ v: Double) -> String {
321− // Match `displayString`'s whole-number normalization so a value like
322− // 100.0 serializes identically whether it's a top-level cell (which goes
323− // through displayString) or nested in an array/object (which goes through
324− // compactJSON). Emitting "100" instead of "100.0" is still valid JSON.
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)
331− }
332−
333− // MARK: - Equatable / Hashable
334−
335− /// Equality treats numerically-equal integers and doubles as equal
336− /// (e.g. `.int(1) == .double(1.0)`) to stay consistent with `<`, which
337− /// compares the two numerically. Synthesized conformance would instead
338− /// distinguish them by enum case, violating the `Comparable` contract.
339− static func == (lhs: JSONValue, rhs: JSONValue) -> Bool {
340− switch (lhs, rhs) {
341− case (.null, .null): return true
342− case (.bool(let a), .bool(let b)): return a == b
343− case (.int(let a), .int(let b)): return a == b
344− // NaN must compare equal to itself here: `<` (via doubleLessThan) orders
345− // two NaNs as equivalent, and Comparable requires order-equivalent values
346− // to be ==. Raw `a == b` (NaN != NaN) would break reflexivity, so e.g.
347− // `.array([.double(.nan)])` would not equal itself.
348− case (.double(let a), .double(let b)): return a == b || (a.isNaN && b.isNaN)
349− case (.int(let a), .double(let b)): return Self.intEqualsDouble(a, b)
350− case (.double(let a), .int(let b)): return Self.intEqualsDouble(b, a)
351− case (.string(let a), .string(let b)): return a == b
352− case (.array(let a), .array(let b)): return a == b
353− case (.object(let a), .object(let b)): return a == b
354− default: return false
355− }
356− }
357−
358− func hash(into hasher: inout Hasher) {
359− switch self {
360− case .null: hasher.combine(0)
361− case .bool(let v): hasher.combine(v)
362− // Hash integers via their Double value so `.int(1)` and `.double(1.0)`
363− // — which are `==` — produce the same hash.
364− case .int(let v): hasher.combine(Double(v))
365− // Canonicalize NaN: `==` treats all NaNs as equal, so every NaN must hash
366− // identically to satisfy the Hashable contract (differing NaN bit patterns
367− // would otherwise hash differently).
368− case .double(let v): hasher.combine(v.isNaN ? Double.nan : v)
369− case .string(let v): hasher.combine(v)
370− case .array(let v): hasher.combine(v)
371− case .object(let v): hasher.combine(v)
372 }
185 }
373 }
186 }
374}
187}
@@ -379,18 +192,4 @@ typealias LogRecord = [String: JSONValue]
379struct QueryResponse: Codable, Sendable {
192struct QueryResponse: Codable, Sendable {
380 let records: [LogRecord]
193 let records: [LogRecord]
381 let fields: [String]?
194 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− }
396}
195}
ParseableViewer/Models/LogStream.swift+17−145
@@ -10,14 +10,7 @@ struct LogStream: Identifiable, Codable, Hashable, Sendable, Comparable {
10 }
10 }
11
11
12 static func < (lhs: LogStream, rhs: LogStream) -> Bool {
12 static func < (lhs: LogStream, rhs: LogStream) -> Bool {
13− // Fall back to a raw comparison when the localized comparison reports
13+ lhs.name.localizedStandardCompare(rhs.name) == .orderedAscending
14− // `.orderedSame` for two distinct names (e.g. Unicode-equivalent forms),
15− // so `<` stays a strict total order consistent with synthesized `==`.
16− switch lhs.name.localizedStandardCompare(rhs.name) {
17− case .orderedAscending: return true
18− case .orderedDescending: return false
19− case .orderedSame: return lhs.name < rhs.name
20− }
21 }
14 }
22
15
23 init(from decoder: Decoder) throws {
16 init(from decoder: Decoder) throws {
@@ -80,111 +73,11 @@ struct SchemaField: Identifiable, Codable, Hashable, Sendable {
80 if let typeStr = try? container.decode(String.self, forKey: .dataType) {
73 if let typeStr = try? container.decode(String.self, forKey: .dataType) {
81 self.dataType = typeStr
74 self.dataType = typeStr
82 } else if let typeObj = try? container.decode(JSONValue.self, forKey: .dataType) {
75 } else if let typeObj = try? container.decode(JSONValue.self, forKey: .dataType) {
83− self.dataType = Self.describeArrowType(typeObj)
76+ self.dataType = typeObj.displayString
84 } else {
77 } else {
85 self.dataType = "Unknown"
78 self.dataType = "Unknown"
86 }
79 }
87 }
80 }
88−
89− /// Renders a serde-serialized Arrow `DataType`. Parameterized types are
90− /// externally tagged objects — `p_timestamp`, present in every Parseable
91− /// stream, arrives as `{"Timestamp": ["Millisecond", null]}` and a list as
92− /// `{"List": {<field>}}` — which `displayString` collapsed to the placeholder
93− /// "{1 fields}". Produce Arrow's own notation instead, e.g.
94− /// `Timestamp(Millisecond, None)` / `List(Utf8)`.
95− static func describeArrowType(_ value: JSONValue) -> String {
96− switch value {
97− case .null:
98− return "None"
99− case .string(let s):
100− return s
101− case .array(let items):
102− return items.map { describeArrowType($0) }.joined(separator: ", ")
103− case .object(let dict):
104− // A nested `Field` (list/map element, struct member): show its type.
105− if let fieldType = dict["data_type"] {
106− return describeArrowType(fieldType)
107− }
108− if dict.count == 1, let entry = dict.first {
109− return "\(entry.key)(\(describeArrowType(entry.value)))"
110− }
111− return value.compactJSON
112− default:
113− return value.displayString
114− }
115− }
116−}
117−
118−/// Formats a byte count that may arrive from the server as a fractional or
119−/// out-of-range Double. `Int64(_:)` from a `Double` is a *trapping* conversion —
120−/// it crashes on NaN, infinity, or values outside `Int64`'s range — so a valid
121−/// but huge JSON number like `1e30` would crash the whole decode. Guard first.
122−private func formatByteSize(_ value: Double) -> String? {
123− guard value.isFinite, value >= 0, value < 9.223372036854776e18 else { return nil }
124− return ByteCountFormatter.string(fromByteCount: Int64(value), countStyle: .file)
125−}
126−
127−private func formatByteSize(_ value: Int) -> String? {
128− // A negative byte count isn't meaningful; `ByteCountFormatter` would render it
129− // as a bogus "-1 KB"/"Zero KB" string, so drop it like the `Double` path does.
130− guard value >= 0 else { return nil }
131− return ByteCountFormatter.string(fromByteCount: Int64(value), countStyle: .file)
132−}
133−
134−/// Normalizes a byte count that arrived as a JSON *string*. A bare numeric string
135−/// (e.g. "1048576") is a raw byte count and is formatted like the numeric branches
136−/// so identical sizes render consistently ("1 MB") regardless of whether the server
137−/// quoted the value. Parseable 1.x — the versions that quote sizes — sends them as
138−/// "<n> Bytes" (e.g. "1048576 Bytes"), so that unit suffix is accepted too. Any
139−/// other already-formatted string (e.g. "1.2 GB") is passed through unchanged.
140−private func formatByteSize(_ value: String) -> String {
141− var trimmed = value.trimmingCharacters(in: .whitespacesAndNewlines)
142− if trimmed.lowercased().hasSuffix(" bytes") {
143− trimmed = String(trimmed.dropLast(" bytes".count))
144− }
145− if let n = Int(trimmed) { return formatByteSize(n) ?? value }
146− return value
147−}
148−
149−/// A string-backed coding key for fields whose name differs across Parseable
150−/// versions (kebab-case, snake_case, and camelCase spellings of the same field).
151−private struct FlexibleCodingKey: CodingKey {
152− let stringValue: String
153− var intValue: Int? { nil }
154− init(stringValue: String) { self.stringValue = stringValue }
155− init?(intValue: Int) { nil }
156−}
157−
158−private extension KeyedDecodingContainer where Key == FlexibleCodingKey {
159− /// The first of `keys` that decodes as a string.
160− func firstString(_ keys: String...) -> String? {
161− for key in keys {
162− if let value = try? decode(String.self, forKey: FlexibleCodingKey(stringValue: key)) {
163− return value
164− }
165− }
166− return nil
167− }
168−
169− /// The first of `keys` that decodes as a boolean — either a JSON bool or the
170− /// strings "true"/"false" (Parseable serializes `static_schema_flag` as the
171− /// string "true").
172− func firstBool(_ keys: String...) -> Bool? {
173− for key in keys {
174− let codingKey = FlexibleCodingKey(stringValue: key)
175− if let value = try? decode(Bool.self, forKey: codingKey) {
176− return value
177− }
178− if let string = try? decode(String.self, forKey: codingKey) {
179− switch string.lowercased() {
180− case "true": return true
181− case "false": return false
182− default: continue
183− }
184− }
185− }
186− return nil
187− }
188}
81}
189
82
190struct StreamStats: Codable, Sendable {
83struct StreamStats: Codable, Sendable {
@@ -207,20 +100,16 @@ struct StreamStats: Codable, Sendable {
207 self.lifetime_count = try? container.decode(Int.self, forKey: .lifetime_count)
100 self.lifetime_count = try? container.decode(Int.self, forKey: .lifetime_count)
208 // Handle size as string or number
101 // Handle size as string or number
209 if let s = try? container.decode(String.self, forKey: .size) {
102 if let s = try? container.decode(String.self, forKey: .size) {
210− self.size = formatByteSize(s)
103+ self.size = s
211 } else if let n = try? container.decode(Int.self, forKey: .size) {
104 } else if let n = try? container.decode(Int.self, forKey: .size) {
212− self.size = formatByteSize(n)
105+ self.size = ByteCountFormatter.string(fromByteCount: Int64(n), countStyle: .file)
213− } else if let d = try? container.decode(Double.self, forKey: .size) {
214− self.size = formatByteSize(d)
215 } else {
106 } else {
216 self.size = nil
107 self.size = nil
217 }
108 }
218 if let s = try? container.decode(String.self, forKey: .lifetime_size) {
109 if let s = try? container.decode(String.self, forKey: .lifetime_size) {
219− self.lifetime_size = formatByteSize(s)
110+ self.lifetime_size = s
220 } else if let n = try? container.decode(Int.self, forKey: .lifetime_size) {
111 } else if let n = try? container.decode(Int.self, forKey: .lifetime_size) {
221− self.lifetime_size = formatByteSize(n)
112+ self.lifetime_size = ByteCountFormatter.string(fromByteCount: Int64(n), countStyle: .file)
222− } else if let d = try? container.decode(Double.self, forKey: .lifetime_size) {
223− self.lifetime_size = formatByteSize(d)
224 } else {
113 } else {
225 self.lifetime_size = nil
114 self.lifetime_size = nil
226 }
115 }
@@ -238,26 +127,18 @@ struct StreamStats: Codable, Sendable {
238
127
239 init(from decoder: Decoder) throws {
128 init(from decoder: Decoder) throws {
240 let container = try decoder.container(keyedBy: CodingKeys.self)
129 let container = try decoder.container(keyedBy: CodingKeys.self)
241− // Parseable names this field `format` (e.g. "parquet") in every
130+ self.type = try? container.decode(String.self, forKey: .type)
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")
246 if let s = try? container.decode(String.self, forKey: .size) {
131 if let s = try? container.decode(String.self, forKey: .size) {
247− self.size = formatByteSize(s)
132+ self.size = s
248 } else if let n = try? container.decode(Int.self, forKey: .size) {
133 } else if let n = try? container.decode(Int.self, forKey: .size) {
249− self.size = formatByteSize(n)
134+ self.size = ByteCountFormatter.string(fromByteCount: Int64(n), countStyle: .file)
250− } else if let d = try? container.decode(Double.self, forKey: .size) {
251− self.size = formatByteSize(d)
252 } else {
135 } else {
253 self.size = nil
136 self.size = nil
254 }
137 }
255 if let s = try? container.decode(String.self, forKey: .lifetime_size) {
138 if let s = try? container.decode(String.self, forKey: .lifetime_size) {
256− self.lifetime_size = formatByteSize(s)
139+ self.lifetime_size = s
257 } else if let n = try? container.decode(Int.self, forKey: .lifetime_size) {
140 } else if let n = try? container.decode(Int.self, forKey: .lifetime_size) {
258− self.lifetime_size = formatByteSize(n)
141+ self.lifetime_size = ByteCountFormatter.string(fromByteCount: Int64(n), countStyle: .file)
259− } else if let d = try? container.decode(Double.self, forKey: .lifetime_size) {
260− self.lifetime_size = formatByteSize(d)
261 } else {
142 } else {
262 self.lifetime_size = nil
143 self.lifetime_size = nil
263 }
144 }
@@ -285,20 +166,11 @@ struct StreamInfo: Codable, Sendable {
285 }
166 }
286
167
287 init(from decoder: Decoder) throws {
168 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)
169+ let container = try decoder.container(keyedBy: CodingKeys.self)
296− self.createdAt = container.firstString("created-at", "createdAt", "created_at")
170+ self.createdAt = try? container.decode(String.self, forKey: .createdAt)
297− self.firstEventAt = container.firstString("first-event-at", "firstEventAt", "first_event_at")
171+ self.firstEventAt = try? container.decode(String.self, forKey: .firstEventAt)
298− self.cacheEnabled = container.firstBool("cache_enabled", "cacheEnabled", "cache-enabled")
172+ self.cacheEnabled = try? container.decode(Bool.self, forKey: .cacheEnabled)
299− self.timePartition = container.firstString("time_partition", "timePartition", "time-partition")
173+ self.timePartition = try? container.decode(String.self, forKey: .timePartition)
300− self.staticSchemaFlag = container.firstBool(
174+ self.staticSchemaFlag = try? container.decode(Bool.self, forKey: .staticSchemaFlag)
301− "static_schema_flag", "staticSchemaFlag", "static-schema-flag"
302− )
303 }
175 }
304}
176}
ParseableViewer/Models/ParseableFilter.swift+1−1
@@ -19,7 +19,7 @@ struct FilterQuery: Codable, Hashable, Sendable {
19
19
20 init(from decoder: Decoder) throws {
20 init(from decoder: Decoder) throws {
21 let container = try decoder.container(keyedBy: CodingKeys.self)
21 let container = try decoder.container(keyedBy: CodingKeys.self)
22− filterType = (try? container.decode(String.self, forKey: .filterType)) ?? "sql"
22+ filterType = try container.decode(String.self, forKey: .filterType)
23 filterQuery = try? container.decode(String.self, forKey: .filterQuery)
23 filterQuery = try? container.decode(String.self, forKey: .filterQuery)
24 filterBuilder = try? container.decode(JSONValue.self, forKey: .filterBuilder)
24 filterBuilder = try? container.decode(JSONValue.self, forKey: .filterBuilder)
25 }
25 }
ParseableViewer/Models/SQLTokenizer.swift+13−92
@@ -80,20 +80,13 @@ struct SQLTokenizer: Sendable {
80
80
81 switch c {
81 switch c {
82 // Whitespace
82 // Whitespace
83− case _ where isWhitespace(c):
83+ case " ", "\t", "\n", "\r":
84− advance(&i, in: sql, while: isWhitespace)
84+ advance(&i, in: sql, while: { $0 == " " || $0 == "\t" || $0 == "\n" || $0 == "\r" })
85 tokens.append(Token(kind: .whitespace, range: start..<i))
85 tokens.append(Token(kind: .whitespace, range: start..<i))
86
86
87 // Line comment
87 // Line comment
88 case "-" where peek(sql, at: i, offset: 1) == "-":
88 case "-" where peek(sql, at: i, offset: 1) == "-":
89− // Stop at CR as well as LF: with CRLF (`\r\n`) line endings, consuming
90− // the trailing `\r` into the comment token would push its boundary past
91− // where `characterOffset`/DataFusion treat the line as ending, shifting
92− // the server-error highlight range. The line break is then tokenized as
93− // whitespace. `isLineBreak` also matches the single `"\r\n"` Character
94− // Swift forms from a CRLF pair — comparing against "\n"/"\r" alone never
95− // matches it, so the comment would swallow every following line.
96− advance(&i, in: sql, while: { !isLineBreak($0) })
89+ advance(&i, in: sql, while: { $0 != "\n" })
97 tokens.append(Token(kind: .lineComment, range: start..<i))
90 tokens.append(Token(kind: .lineComment, range: start..<i))
98
91
99 // Block comment
92 // Block comment
@@ -133,13 +126,13 @@ struct SQLTokenizer: Sendable {
133 tokens.append(Token(kind: .star, range: start..<i))
126 tokens.append(Token(kind: .star, range: start..<i))
134
127
135 // Number (digit or leading dot followed by digit)
128 // Number (digit or leading dot followed by digit)
136− case _ where isASCIIDigit(c) || (c == "." && peek(sql, at: i, offset: 1).map(isASCIIDigit) == true):
129+ case _ where c.isNumber || (c == "." && peek(sql, at: i, offset: 1)?.isNumber == true):
137 consumeNumber(&i, in: sql)
130 consumeNumber(&i, in: sql)
138 tokens.append(Token(kind: .number(String(sql[start..<i])), range: start..<i))
131 tokens.append(Token(kind: .number(String(sql[start..<i])), range: start..<i))
139
132
140 // Identifier or keyword
133 // Identifier or keyword
141− case _ where isASCIILetter(c) || c == "_":
134+ case _ where c.isLetter || c == "_":
142− advance(&i, in: sql, while: { isASCIILetter($0) || isASCIIDigit($0) || $0 == "_" })
135+ advance(&i, in: sql, while: { $0.isLetter || $0.isNumber || $0 == "_" })
143 let word = String(sql[start..<i])
136 let word = String(sql[start..<i])
144 if keywords.contains(word.uppercased()) {
137 if keywords.contains(word.uppercased()) {
145 tokens.append(Token(kind: .keyword(word.uppercased()), range: start..<i))
138 tokens.append(Token(kind: .keyword(word.uppercased()), range: start..<i))
@@ -262,60 +255,15 @@ struct SQLTokenizer: Sendable {
262 return value // unterminated
255 return value // unterminated
263 }
256 }
264
257
265− /// Swift iterates grapheme clusters, and a CRLF pair is the single Character
266− /// `"\r\n"`, which is equal to neither `"\r"` nor `"\n"`. Match it explicitly
267− /// so Windows line endings (e.g. pasted text) tokenize like LF/CR ones.
268− private static func isLineBreak(_ c: Character) -> Bool {
269− c == "\n" || c == "\r" || c == "\r\n"
270− }
271−
272− private static func isWhitespace(_ c: Character) -> Bool {
273− c == " " || c == "\t" || isLineBreak(c)
274− }
275−
276− /// SQL number literals use only ASCII digits. `Character.isNumber` also
277− /// matches Unicode numerics (`²`, `½`, Arabic-Indic digits, etc.), which
278− /// DataFusion's lexer rejects — accepting them here would tokenize those
279− /// characters as numbers and diverge token offsets from the server, throwing
280− /// off `errorHighlightRange` mapping.
281− private static func isASCIIDigit(_ c: Character) -> Bool {
282− c.isASCII && c.isNumber
283− }
284−
285− /// Bare (unquoted) SQL identifiers use only ASCII letters. `Character.isLetter`
286− /// matches the full Unicode letter range (`café`, `naïve`, etc.), which
287− /// DataFusion's lexer would require to be double-quoted — accepting them bare
288− /// here would tokenize them as identifiers and diverge token offsets from the
289− /// server, throwing off `errorHighlightRange` mapping (same rationale as
290− /// `isASCIIDigit`).
291− private static func isASCIILetter(_ c: Character) -> Bool {
292− c.isASCII && c.isLetter
293− }
294−
295 private static func consumeNumber(_ i: inout String.Index, in sql: String) {
258 private static func consumeNumber(_ i: inout String.Index, in sql: String) {
296− // Digits with at most one decimal point, so malformed input like
297− // `1.2.3` doesn't get swallowed into a single number token.
298− var seenDot = false
299− advance(&i, in: sql, while: { c in
259+ advance(&i, in: sql, while: { $0.isNumber || $0 == "." })
300− if c == "." {
260+ // Exponent
301− if seenDot { return false }
302− seenDot = true
303− return true
304− }
305− return isASCIIDigit(c)
306− })
307− // Exponent — only consume `e`/`E` (and an optional sign) when actual
308− // exponent digits follow, so `1e` or `1e+` aren't swallowed whole and
309− // instead tokenize as a number followed by an identifier/operator.
310 if i < sql.endIndex, sql[i] == "e" || sql[i] == "E" {
261 if i < sql.endIndex, sql[i] == "e" || sql[i] == "E" {
311− var lookahead = sql.index(after: i)
262+ sql.formIndex(after: &i)
312− if lookahead < sql.endIndex, sql[lookahead] == "+" || sql[lookahead] == "-" {
263+ if i < sql.endIndex, sql[i] == "+" || sql[i] == "-" {
313− sql.formIndex(after: &lookahead)
264+ sql.formIndex(after: &i)
314− }
315− if lookahead < sql.endIndex, isASCIIDigit(sql[lookahead]) {
316− i = lookahead
317− advance(&i, in: sql, while: { isASCIIDigit($0) })
318 }
265 }
266+ advance(&i, in: sql, while: { $0.isNumber })
319 }
267 }
320 }
268 }
321
269
@@ -339,26 +287,7 @@ struct SQLTokenizer: Sendable {
339 i += 1
287 i += 1
340 }
288 }
341
289
342− // Advance (column - 1) characters from the line start. `column` is a
290+ let offset = i + (column - 1)
343− // 1-based character position, so a surrogate pair (e.g. an emoji) counts
344− // as one character but two UTF-16 units; stepping char-by-char keeps the
345− // offset correct for non-BMP text. For all-ASCII/BMP lines this is
346− // identical to `i + (column - 1)`.
347− var offset = i
348− var remaining = column - 1
349− while remaining > 0 {
350− // Running out of characters means the column points past the end of
351− // the input — out of bounds, matching the original contract.
352− guard offset < length else { return nil }
353− let unit = nsString.character(at: offset)
354− if unit == 0x0A { break } // a column can't extend past the line's newline
355− if unit >= 0xD800 && unit <= 0xDBFF && offset + 1 < length {
356− offset += 2 // high surrogate: skip the trailing low surrogate too
357− } else {
358− offset += 1
359− }
360− remaining -= 1
361− }
362 guard offset <= length else { return nil }
291 guard offset <= length else { return nil }
363 return offset
292 return offset
364 }
293 }
@@ -380,14 +309,6 @@ struct SQLTokenizer: Sendable {
380 return NSRange(next.range, in: sql)
309 return NSRange(next.range, in: sql)
381 }
310 }
382 }
311 }
383− // No following real token (e.g. error reported at trailing
384− // whitespace): snap back to the previous non-trivia token
385− // instead of returning no highlight at all.
386− for prev in tokens[..<idx].reversed() {
387− if !prev.kind.isTrivia {
388− return NSRange(prev.range, in: sql)
389− }
390− }
391 return nil
312 return nil
392 }
313 }
393 return nsRange
314 return nsRange
ParseableViewer/Models/ServerConnection.swift+2−35
@@ -37,51 +37,18 @@ struct ServerConnection: Identifiable, Codable, Hashable, Sendable {
37 try container.encode(username, forKey: .username)
37 try container.encode(username, forKey: .username)
38 }
38 }
39
39
40− // `password` is deliberately excluded from equality and hashing: it isn't
41− // serialized (see CodingKeys) and is repopulated from the Keychain on
42− // decode, so including it would make a decoded connection compare unequal
43− // to the in-memory original whenever the Keychain read returns "" — e.g. in
44− // Set membership or "has this connection changed?" checks.
45− static func == (lhs: ServerConnection, rhs: ServerConnection) -> Bool {
46− lhs.id == rhs.id
47− && lhs.name == rhs.name
48− && lhs.url == rhs.url
49− && lhs.username == rhs.username
50− }
51−
52− func hash(into hasher: inout Hasher) {
53− hasher.combine(id)
54− }
55−
56 var baseURL: URL? {
40 var baseURL: URL? {
57 var urlString = url.trimmingCharacters(in: .whitespacesAndNewlines)
41 var urlString = url.trimmingCharacters(in: .whitespacesAndNewlines)
58 if urlString.isEmpty { return nil }
42 if urlString.isEmpty { return nil }
59− if let schemeRange = Self.urlSchemeRange(in: urlString) {
43+ if !urlString.hasPrefix("http://") && !urlString.hasPrefix("https://") {
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 {
64 urlString = "https://\(urlString)"
44 urlString = "https://\(urlString)"
65 }
45 }
66 while urlString.hasSuffix("/") {
46 while urlString.hasSuffix("/") {
67 urlString.removeLast()
47 urlString.removeLast()
68 }
48 }
69− guard let parsed = URL(string: urlString), parsed.host != nil,
49+ guard let parsed = URL(string: urlString), parsed.host != nil else {
70− parsed.scheme == "http" || parsed.scheme == "https" else {
71 return nil
50 return nil
72 }
51 }
73 return parsed
52 return parsed
74 }
53 }
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− }
87}
54}
ParseableViewer/Models/ServerInfo.swift+10−47
@@ -15,59 +15,33 @@ struct ServerAbout: Codable, Sendable {
15 let llmActive: Bool?
15 let llmActive: Bool?
16 let oidcActive: Bool?
16 let oidcActive: Bool?
17
17
18− // Parseable's /about handler has always sent camelCase keys (`uiVersion`,
19− // `deploymentId`, `grpcPort`), like the other fields here. Decoding only the
20− // snake_case spellings — which the server never sends — left UI Version and
21− // Deployment ID permanently "N/A" and hid the gRPC Port row. The snake_case
22− // forms are kept as a fallback.
23 private enum CodingKeys: String, CodingKey {
18 private enum CodingKeys: String, CodingKey {
24 case version, commit, mode, staging, store, license
19 case version, commit, mode, staging, store, license
25− case uiVersion
20+ case uiVersion = "ui_version"
26− case deploymentId
21+ case deploymentId = "deployment_id"
27− case grpcPort
22+ case grpcPort = "grpc_port"
28 case updateAvailable
23 case updateAvailable
29 case latestVersion
24 case latestVersion
30 case llmActive
25 case llmActive
31 case oidcActive
26 case oidcActive
32 }
27 }
33
28
34− private enum SnakeCaseKeys: String, CodingKey {
35− case uiVersion = "ui_version"
36− case deploymentId = "deployment_id"
37− case grpcPort = "grpc_port"
38− }
39−
40 init(from decoder: Decoder) throws {
29 init(from decoder: Decoder) throws {
41 let container = try decoder.container(keyedBy: CodingKeys.self)
30 let container = try decoder.container(keyedBy: CodingKeys.self)
42− let snakeCase = try? decoder.container(keyedBy: SnakeCaseKeys.self)
43 self.version = try? container.decode(String.self, forKey: .version)
31 self.version = try? container.decode(String.self, forKey: .version)
44− self.uiVersion = (try? container.decode(String.self, forKey: .uiVersion))
32+ self.uiVersion = try? container.decode(String.self, forKey: .uiVersion)
45− ?? (try? snakeCase?.decode(String.self, forKey: .uiVersion))
46 self.commit = try? container.decode(String.self, forKey: .commit)
33 self.commit = try? container.decode(String.self, forKey: .commit)
47− self.deploymentId = (try? container.decode(String.self, forKey: .deploymentId))
34+ self.deploymentId = try? container.decode(String.self, forKey: .deploymentId)
48− ?? (try? snakeCase?.decode(String.self, forKey: .deploymentId))
49 self.mode = try? container.decode(String.self, forKey: .mode)
35 self.mode = try? container.decode(String.self, forKey: .mode)
50 self.staging = try? container.decode(String.self, forKey: .staging)
36 self.staging = try? container.decode(String.self, forKey: .staging)
51 self.store = try? container.decode(StoreInfo.self, forKey: .store)
37 self.store = try? container.decode(StoreInfo.self, forKey: .store)
52− self.license = (try? container.decode(String.self, forKey: .license))
38+ 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))
39+ self.grpcPort = try? container.decode(Int.self, forKey: .grpcPort)
55− ?? (try? snakeCase?.decode(Int.self, forKey: .grpcPort))
56 self.updateAvailable = try? container.decode(Bool.self, forKey: .updateAvailable)
40 self.updateAvailable = try? container.decode(Bool.self, forKey: .updateAvailable)
57 self.latestVersion = try? container.decode(String.self, forKey: .latestVersion)
41 self.latestVersion = try? container.decode(String.self, forKey: .latestVersion)
58 self.llmActive = try? container.decode(Bool.self, forKey: .llmActive)
42 self.llmActive = try? container.decode(Bool.self, forKey: .llmActive)
59 self.oidcActive = try? container.decode(Bool.self, forKey: .oidcActive)
43 self.oidcActive = try? container.decode(Bool.self, forKey: .oidcActive)
60 }
44 }
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− }
71}
45}
72
46
73struct StoreInfo: Codable, Sendable {
47struct StoreInfo: Codable, Sendable {
@@ -111,20 +85,9 @@ struct UserInfo: Identifiable, Codable, Sendable {
111 }
85 }
112
86
113 init(from decoder: Decoder) throws {
87 init(from decoder: Decoder) throws {
114− // Handle both {"id": "x", ...} and a bare "x" string element.
88+ // Handle both {"id": "x", ...} and variations
115− if let container = try? decoder.container(keyedBy: CodingKeys.self),
89+ 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) {
90+ self.id = 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− }
128 self.method = try? container.decode(String.self, forKey: .method)
91 self.method = try? container.decode(String.self, forKey: .method)
129 } else {
92 } else {
130 let container = try decoder.singleValueContainer()
93 let container = try decoder.singleValueContainer()
ParseableViewer/Services/ConnectionStore.swift+12−20
@@ -1,29 +1,26 @@
1import Foundation
1import Foundation
2
2
3final class ConnectionStore {
3final class ConnectionStore {
4− private static let keychainConnectionsKey = "parseable_connections_list"
4+ private static let storageKey = "parseable_connections"
5− private static let keychainActiveIDKey = "parseable_active_connection_id"
5+ private static let activeConnectionKey = "parseable_active_connection_id"
6
6
7 static func loadConnections() -> [ServerConnection] {
7 static func loadConnections() -> [ServerConnection] {
8− guard let data = KeychainService.loadData(for: keychainConnectionsKey) else {
8+ guard let data = UserDefaults.standard.data(forKey: storageKey) else {
9 return []
9 return []
10 }
10 }
11 return (try? JSONDecoder().decode([ServerConnection].self, from: data)) ?? []
11 return (try? JSONDecoder().decode([ServerConnection].self, from: data)) ?? []
12 }
12 }
13
13
14 static func saveConnections(_ connections: [ServerConnection]) {
14 static func saveConnections(_ connections: [ServerConnection]) {
15− for connection in connections where !connection.password.isEmpty {
15+ for connection in connections {
16− // Only upsert non-empty passwords. We must NOT delete on an empty
16+ if !connection.password.isEmpty {
17− // password here: `ServerConnection.init(from:)` decodes the password
18− // as "" whenever the Keychain read transiently fails (locked
19− // Keychain, errSecInteractionNotAllowed, etc.), so deleting on empty
20− // would promote a transient read failure into permanent credential
21− // loss on the next save. Credentials are removed only when the
22− // connection itself is removed, via `deleteConnection`.
23− KeychainService.savePassword(connection.password, for: connection.id)
17+ KeychainService.savePassword(connection.password, for: connection.id)
18+ } else {
19+ KeychainService.deletePassword(for: connection.id)
20+ }
24 }
21 }
25 if let data = try? JSONEncoder().encode(connections) {
22 if let data = try? JSONEncoder().encode(connections) {
26− KeychainService.saveData(data, for: keychainConnectionsKey)
23+ UserDefaults.standard.set(data, forKey: storageKey)
27 }
24 }
28 }
25 }
29
26
@@ -32,18 +29,13 @@ final class ConnectionStore {
32 }
29 }
33
30
34 static func loadActiveConnectionID() -> UUID? {
31 static func loadActiveConnectionID() -> UUID? {
35− guard let data = KeychainService.loadData(for: keychainActiveIDKey),
32+ guard let string = UserDefaults.standard.string(forKey: activeConnectionKey) else {
36− let string = String(data: data, encoding: .utf8) else {
37 return nil
33 return nil
38 }
34 }
39 return UUID(uuidString: string)
35 return UUID(uuidString: string)
40 }
36 }
41
37
42 static func saveActiveConnectionID(_ id: UUID?) {
38 static func saveActiveConnectionID(_ id: UUID?) {
43− if let id, let data = id.uuidString.data(using: .utf8) {
39+ UserDefaults.standard.set(id?.uuidString, forKey: activeConnectionKey)
44− KeychainService.saveData(data, for: keychainActiveIDKey)
45− } else {
46− KeychainService.deleteData(for: keychainActiveIDKey)
47− }
48 }
40 }
49}
41}
ParseableViewer/Services/KeychainService.swift+15−65
@@ -8,8 +8,17 @@ enum KeychainService {
8
8
9 @discardableResult
9 @discardableResult
10 static func savePassword(_ password: String, for connectionID: UUID) -> Bool {
10 static func savePassword(_ password: String, for connectionID: UUID) -> Bool {
11+ let account = connectionID.uuidString
12+ deletePassword(for: connectionID)
13+
11 guard let data = password.data(using: .utf8) else { return false }
14 guard let data = password.data(using: .utf8) else { return false }
12− return upsert(data, query: baseQuery(for: connectionID), label: connectionID.uuidString)
15+ var query = baseQuery(for: connectionID)
16+ query[kSecValueData as String] = data
17+ let status = SecItemAdd(query as CFDictionary, nil)
18+ if status != errSecSuccess {
19+ logger.error("Failed to save password for \(account): OSStatus \(status)")
20+ }
21+ return status == errSecSuccess
13 }
22 }
14
23
15 static func loadPassword(for connectionID: UUID) -> String? {
24 static func loadPassword(for connectionID: UUID) -> String? {
@@ -38,74 +47,15 @@ enum KeychainService {
38 return status == errSecSuccess || status == errSecItemNotFound
47 return status == errSecSuccess || status == errSecItemNotFound
39 }
48 }
40
49
41− // MARK: - Generic data storage (string-keyed)
50+ /// Base query targeting the data-protection keychain.
42−
51+ /// Items stored here are scoped by bundle identifier rather than by
43− @discardableResult
52+ /// code-signing identity, so they survive ad-hoc re-signing across builds.
44− static func saveData(_ data: Data, for key: String) -> Bool {
45− upsert(data, query: baseQuery(for: key), label: key)
46− }
47−
48− static func loadData(for key: String) -> Data? {
49− var query = baseQuery(for: key)
50− query[kSecReturnData as String] = true
51− query[kSecMatchLimit as String] = kSecMatchLimitOne
52−
53− var result: AnyObject?
54− let status = SecItemCopyMatching(query as CFDictionary, &result)
55− if status != errSecSuccess && status != errSecItemNotFound {
56− logger.error("Failed to load data for \(key): OSStatus \(status)")
57− }
58− guard status == errSecSuccess, let data = result as? Data else { return nil }
59− return data
60− }
61−
62− @discardableResult
63− static func deleteData(for key: String) -> Bool {
64− let query = baseQuery(for: key)
65− let status = SecItemDelete(query as CFDictionary)
66− if status != errSecSuccess && status != errSecItemNotFound {
67− logger.error("Failed to delete data for \(key): OSStatus \(status)")
68− }
69− return status == errSecSuccess || status == errSecItemNotFound
70− }
71−
72− // MARK: - Upsert
73−
74− /// Atomically stores `data` for the given query: tries `SecItemAdd` and,
75− /// if the item already exists, falls back to `SecItemUpdate`. This avoids
76− /// the delete-then-add race that could drop a write (and silently return
77− /// `errSecDuplicateItem`) when two saves overlap.
78− @discardableResult
79− private static func upsert(_ data: Data, query: [String: Any], label: String) -> Bool {
80− var addQuery = query
81− addQuery[kSecValueData as String] = data
82− let addStatus = SecItemAdd(addQuery as CFDictionary, nil)
83− if addStatus == errSecSuccess {
84− return true
85− }
86− if addStatus == errSecDuplicateItem {
87− let attributes = [kSecValueData as String: data]
88− let updateStatus = SecItemUpdate(query as CFDictionary, attributes as CFDictionary)
89− if updateStatus != errSecSuccess {
90− logger.error("Failed to update item for \(label): OSStatus \(updateStatus)")
91− }
92− return updateStatus == errSecSuccess
93− }
94− logger.error("Failed to save item for \(label): OSStatus \(addStatus)")
95− return false
96− }
97−
98− // MARK: - Base queries
99−
100 private static func baseQuery(for connectionID: UUID) -> [String: Any] {
53 private static func baseQuery(for connectionID: UUID) -> [String: Any] {
101− baseQuery(for: connectionID.uuidString)
102− }
103−
104− private static func baseQuery(for account: String) -> [String: Any] {
105 [
54 [
106 kSecClass as String: kSecClassGenericPassword,
55 kSecClass as String: kSecClassGenericPassword,
107 kSecAttrService as String: service,
56 kSecAttrService as String: service,
108− kSecAttrAccount as String: account
57+ kSecAttrAccount as String: connectionID.uuidString,
58+ kSecUseDataProtectionKeychain as String: true
109 ]
59 ]
110 }
60 }
111}
61}
ParseableViewer/Services/ParseableClient.swift+37−283
@@ -61,107 +61,18 @@ enum ParseableError: LocalizedError {
61 }
61 }
62}
62}
63
63
64−private actor ResponseCache {
65− private struct Entry: Sendable {
66− let data: any Sendable
67− let timestamp: Date
68− }
69−
70− private var cache: [String: Entry] = [:]
71− private let ttl: TimeInterval = 60
72−
73− func get<T: Sendable>(_ key: String, as type: T.Type) -> T? {
74− guard let entry = cache[key],
75− Date().timeIntervalSince(entry.timestamp) < ttl else {
76− cache.removeValue(forKey: key)
77− return nil
78− }
79− return entry.data as? T
80− }
81−
82− func set(_ key: String, value: some Sendable) {
83− cache[key] = Entry(data: value, timestamp: Date())
84− }
85−
86− func invalidate() {
87− cache.removeAll()
88− }
89−}
90−
91−/// Strips the `Authorization` header on redirects that cross to a different
92−/// origin. By default `URLSession` re-sends the original request's headers —
93−/// including the `Basic` credentials this client attaches to every request —
94−/// to the redirect target. A server (or a malicious/misconfigured proxy)
95−/// answering with a 3xx to an unrelated host would otherwise harvest the user's
96−/// credentials. Same-origin redirects keep the header so normal auth still works.
97−///
98−/// Holds only an immutable `URL`, so it is safely `Sendable` despite the
99−/// NSObject base (which isn't formally `Sendable`).
100−private final class RedirectSanitizer: NSObject, URLSessionTaskDelegate, @unchecked Sendable {
101− private let baseURL: URL
102−
103− init(baseURL: URL) {
104− self.baseURL = baseURL
105− }
106−
107− func urlSession(
108− _ session: URLSession,
109− task: URLSessionTask,
110− willPerformHTTPRedirection response: HTTPURLResponse,
111− newRequest request: URLRequest,
112− completionHandler: @escaping (URLRequest?) -> Void
113− ) {
114− guard !RedirectSanitizer.sameOrigin(baseURL, request.url) else {
115− completionHandler(request)
116− return
117− }
118− var sanitized = request
119− sanitized.setValue(nil, forHTTPHeaderField: "Authorization")
120− completionHandler(sanitized)
121− }
122−
123− /// Origin equality per scheme/host/port, normalizing the implicit default
124− /// port so `https://host` and `https://host:443` are treated as the same
125− /// origin (and aren't stripped on a legitimate same-host redirect).
126− static func sameOrigin(_ a: URL, _ b: URL?) -> Bool {
127− guard let b else { return false }
128− return a.scheme?.lowercased() == b.scheme?.lowercased()
129− && a.host?.lowercased() == b.host?.lowercased()
130− && effectivePort(a) == effectivePort(b)
131− }
132−
133− private static func effectivePort(_ url: URL) -> Int? {
134− if let port = url.port { return port }
135− switch url.scheme?.lowercased() {
136− case "https": return 443
137− case "http": return 80
138− default: return nil
139− }
140− }
141−}
142−
143final class ParseableClient: Sendable {
64final class ParseableClient: Sendable {
144 let baseURL: URL
65 let baseURL: URL
145 let username: String
66 let username: String
146 let password: String
67 let password: String
147 private let session: URLSession
68 private let session: URLSession
148 private let authHeader: String
69 private let authHeader: String
149− private let cache = ResponseCache()
70+
150−
71+ private static let isoFormatter: ISO8601DateFormatter = {
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() }
155−
156− // A fresh formatter per access, for the same reason as `jsonDecoder` above:
157− // `query` can run concurrently and `ISO8601DateFormatter.string(from:)` isn't
158− // documented as thread-safe, so a single shared instance would contradict the
159− // file's per-call concurrency discipline. Constructing one is cheap.
160− private static var isoFormatter: ISO8601DateFormatter {
161 let f = ISO8601DateFormatter()
72 let f = ISO8601DateFormatter()
162 f.formatOptions = [.withInternetDateTime, .withFractionalSeconds]
73 f.formatOptions = [.withInternetDateTime, .withFractionalSeconds]
163 return f
74 return f
164− }
75+ }()
165
76
166 init(url: URL, username: String, password: String) {
77 init(url: URL, username: String, password: String) {
167 self.baseURL = url
78 self.baseURL = url
@@ -178,15 +89,7 @@ final class ParseableClient: Sendable {
178 let config = URLSessionConfiguration.default
89 let config = URLSessionConfiguration.default
179 config.timeoutIntervalForRequest = 30
90 config.timeoutIntervalForRequest = 30
180 config.timeoutIntervalForResource = 120
91 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(
92+ self.session = URLSession(configuration: config)
186− configuration: config,
187− delegate: RedirectSanitizer(baseURL: url),
188− delegateQueue: nil
189− )
190 }
93 }
191
94
192 convenience init(connection: ServerConnection) throws {
95 convenience init(connection: ServerConnection) throws {
@@ -203,18 +106,11 @@ final class ParseableClient: Sendable {
203 }
106 }
204
107
205 private func buildRequest(method: String, path: String, body: Data? = nil, queryItems: [URLQueryItem]? = nil) throws -> URLRequest {
108 private func buildRequest(method: String, path: String, body: Data? = nil, queryItems: [URLQueryItem]? = nil) throws -> URLRequest {
206− // `path` already contains percent-encoded dynamic segments (see
207− // `encodePathComponent`). Append it via `percentEncodedPath` rather than
208− // `URL.appendingPathComponent`, which would re-encode the `%` characters
209− // and corrupt names containing spaces or other reserved characters.
210− guard var components = URLComponents(url: baseURL, resolvingAgainstBaseURL: false) else {
109+ var components = URLComponents(url: baseURL.appendingPathComponent(path), resolvingAgainstBaseURL: false)
211− throw ParseableError.invalidURL
212− }
213− components.percentEncodedPath += path
214 if let queryItems {
110 if let queryItems {
215− components.queryItems = queryItems
111+ components?.queryItems = queryItems
216 }
112 }
217− guard let url = components.url else {
113+ guard let url = components?.url else {
218 throw ParseableError.invalidURL
114 throw ParseableError.invalidURL
219 }
115 }
220
116
@@ -242,11 +138,7 @@ final class ParseableClient: Sendable {
242 }
138 }
243
139
244 guard (200...299).contains(httpResponse.statusCode) else {
140 guard (200...299).contains(httpResponse.statusCode) else {
245− // A proxy/load balancer can answer with a multi-KB HTML body; surfacing
246− // it verbatim floods the error alert. Trim and cap to a readable prefix.
247− let raw = (String(data: data, encoding: .utf8) ?? "")
141+ let message = String(data: data, encoding: .utf8) ?? "Unknown error"
248− .trimmingCharacters(in: .whitespacesAndNewlines)
249− let message = raw.isEmpty ? "Unknown error" : String(raw.prefix(500))
250 throw ParseableError.serverError(httpResponse.statusCode, message)
142 throw ParseableError.serverError(httpResponse.statusCode, message)
251 }
143 }
252
144
@@ -264,28 +156,21 @@ final class ParseableClient: Sendable {
264 if httpResponse.statusCode == 401 {
156 if httpResponse.statusCode == 401 {
265 throw ParseableError.unauthorized
157 throw ParseableError.unauthorized
266 }
158 }
267− // Accept any 2xx — a HEAD /liveness can legitimately return 204, etc.
268− guard (200...299).contains(httpResponse.statusCode) else {
159+ guard httpResponse.statusCode == 200 else {
269 throw ParseableError.serverError(httpResponse.statusCode, "Health check failed")
160 throw ParseableError.serverError(httpResponse.statusCode, "Health check failed")
270 }
161 }
271 }
162 }
272
163
273 func getAbout() async throws -> ServerAbout {
164 func getAbout() async throws -> ServerAbout {
274− let key = "about"
275− if let cached: ServerAbout = await cache.get(key, as: ServerAbout.self) {
276− return cached
277− }
278 let data = try await performRequest(method: "GET", path: "/api/v1/about")
165 let data = try await performRequest(method: "GET", path: "/api/v1/about")
279− let result = try Self.jsonDecoder.decode(ServerAbout.self, from: data)
166+ return try JSONDecoder().decode(ServerAbout.self, from: data)
280− await cache.set(key, value: result)
281− return result
282 }
167 }
283
168
284 // MARK: - Log Streams
169 // MARK: - Log Streams
285
170
286 func listStreams() async throws -> [LogStream] {
171 func listStreams() async throws -> [LogStream] {
287 let data = try await performRequest(method: "GET", path: "/api/v1/logstream")
172 let data = try await performRequest(method: "GET", path: "/api/v1/logstream")
288− return try Self.jsonDecoder.decode([LogStream].self, from: data)
173+ return try JSONDecoder().decode([LogStream].self, from: data)
289 }
174 }
290
175
291 private static func encodePathComponent(_ name: String) throws -> String {
176 private static func encodePathComponent(_ name: String) throws -> String {
@@ -301,62 +186,38 @@ final class ParseableClient: Sendable {
301 func createStream(name: String) async throws {
186 func createStream(name: String) async throws {
302 let encoded = try Self.encodePathComponent(name)
187 let encoded = try Self.encodePathComponent(name)
303 _ = try await performRequest(method: "PUT", path: "/api/v1/logstream/\(encoded)")
188 _ = try await performRequest(method: "PUT", path: "/api/v1/logstream/\(encoded)")
304− // Recreating a same-named stream must not serve a previous stream's
305− // cached schema/stats/info (cached for 60s), so drop the cache.
306− await cache.invalidate()
307 }
189 }
308
190
309 func deleteStream(name: String) async throws {
191 func deleteStream(name: String) async throws {
310 let encoded = try Self.encodePathComponent(name)
192 let encoded = try Self.encodePathComponent(name)
311 _ = try await performRequest(method: "DELETE", path: "/api/v1/logstream/\(encoded)")
193 _ = try await performRequest(method: "DELETE", path: "/api/v1/logstream/\(encoded)")
312− // Invalidate so a later same-named stream doesn't see stale schema/stats/info.
313− await cache.invalidate()
314 }
194 }
315
195
316 func getStreamSchema(stream: String) async throws -> StreamSchema {
196 func getStreamSchema(stream: String) async throws -> StreamSchema {
317− let key = "schema:\(stream)"
318− if let cached: StreamSchema = await cache.get(key, as: StreamSchema.self) {
319− return cached
320− }
321 let encoded = try Self.encodePathComponent(stream)
197 let encoded = try Self.encodePathComponent(stream)
322 let data = try await performRequest(method: "GET", path: "/api/v1/logstream/\(encoded)/schema")
198 let data = try await performRequest(method: "GET", path: "/api/v1/logstream/\(encoded)/schema")
323− let result = try Self.jsonDecoder.decode(StreamSchema.self, from: data)
199+ return try JSONDecoder().decode(StreamSchema.self, from: data)
324− await cache.set(key, value: result)
325− return result
326 }
200 }
327
201
328 func getStreamStats(stream: String) async throws -> StreamStats {
202 func getStreamStats(stream: String) async throws -> StreamStats {
329− let key = "stats:\(stream)"
330− if let cached: StreamStats = await cache.get(key, as: StreamStats.self) {
331− return cached
332− }
333 let encoded = try Self.encodePathComponent(stream)
203 let encoded = try Self.encodePathComponent(stream)
334 let data = try await performRequest(method: "GET", path: "/api/v1/logstream/\(encoded)/stats")
204 let data = try await performRequest(method: "GET", path: "/api/v1/logstream/\(encoded)/stats")
335− let result = try Self.jsonDecoder.decode(StreamStats.self, from: data)
205+ return try JSONDecoder().decode(StreamStats.self, from: data)
336− await cache.set(key, value: result)
337− return result
338 }
206 }
339
207
340 func getStreamInfo(stream: String) async throws -> StreamInfo {
208 func getStreamInfo(stream: String) async throws -> StreamInfo {
341− let key = "info:\(stream)"
342− if let cached: StreamInfo = await cache.get(key, as: StreamInfo.self) {
343− return cached
344− }
345 let encoded = try Self.encodePathComponent(stream)
209 let encoded = try Self.encodePathComponent(stream)
346 let data = try await performRequest(method: "GET", path: "/api/v1/logstream/\(encoded)/info")
210 let data = try await performRequest(method: "GET", path: "/api/v1/logstream/\(encoded)/info")
347− let result = try Self.jsonDecoder.decode(StreamInfo.self, from: data)
211+ return try JSONDecoder().decode(StreamInfo.self, from: data)
348− await cache.set(key, value: result)
349− return result
350 }
212 }
351
213
352 // MARK: - Query
214 // MARK: - Query
353
215
354 func query(sql: String, startTime: Date, endTime: Date) async throws -> [LogRecord] {
216 func query(sql: String, startTime: Date, endTime: Date) async throws -> [LogRecord] {
355− let formatter = Self.isoFormatter
356 let body: [String: Any] = [
217 let body: [String: Any] = [
357 "query": sql,
218 "query": sql,
358− "startTime": formatter.string(from: startTime),
219+ "startTime": Self.isoFormatter.string(from: startTime),
359− "endTime": formatter.string(from: endTime)
220+ "endTime": Self.isoFormatter.string(from: endTime)
360 ]
221 ]
361
222
362 let bodyData = try JSONSerialization.data(withJSONObject: body)
223 let bodyData = try JSONSerialization.data(withJSONObject: body)
@@ -366,29 +227,18 @@ final class ParseableClient: Sendable {
366 return []
227 return []
367 }
228 }
368
229
369− // Peek at the first meaningful byte to choose the decoder path:
230+ // Peek at the first non-whitespace byte to choose the decoder path:
370− // '{' → wrapped response, '[' → direct array. Skip a leading UTF-8 BOM
231+ // '{' → wrapped response, '[' → direct array.
371− // (0xEF 0xBB 0xBF) and ASCII whitespace so a BOM-prefixed wrapped
372− // response isn't misrouted to the array decoder. (JSONDecoder itself
373− // tolerates the BOM.)
374− var sniff = responseData
375− if sniff.starts(with: [0xEF, 0xBB, 0xBF]) {
376− sniff = sniff.dropFirst(3)
377− }
378− let firstByte = sniff.first(where: { $0 != 0x20 && $0 != 0x0A && $0 != 0x0D && $0 != 0x09 })
232+ let firstByte = responseData.first(where: { $0 != 0x20 && $0 != 0x0A && $0 != 0x0D && $0 != 0x09 })
379− // A body of only whitespace/BOM carries no records — treat it like an
380− // empty response rather than feeding it to the array decoder, which would
381− // throw a confusing "Unexpected query response format".
382− guard let firstByte else { return [] }
383 if firstByte == 0x7B { // '{'
233 if firstByte == 0x7B { // '{'
384 do {
234 do {
385− return try Self.jsonDecoder.decode(QueryResponse.self, from: responseData).records
235+ return try JSONDecoder().decode(QueryResponse.self, from: responseData).records
386 } catch {
236 } catch {
387 throw ParseableError.decodingError("Unexpected query response format")
237 throw ParseableError.decodingError("Unexpected query response format")
388 }
238 }
389 } else {
239 } else {
390 do {
240 do {
391− return try Self.jsonDecoder.decode([LogRecord].self, from: responseData)
241+ return try JSONDecoder().decode([LogRecord].self, from: responseData)
392 } catch {
242 } catch {
393 throw ParseableError.decodingError("Unexpected query response format")
243 throw ParseableError.decodingError("Unexpected query response format")
394 }
244 }
@@ -398,106 +248,20 @@ final class ParseableClient: Sendable {
398 // MARK: - Alerts
248 // MARK: - Alerts
399
249
400 func getAlerts(stream: String) async throws -> AlertConfig {
250 func getAlerts(stream: String) async throws -> AlertConfig {
401− // Try the new API first. Fall back to the legacy per-stream endpoint on
251+ // Try new API first; only fall back to legacy per-stream endpoint on 404
402− // ANY failure — a server without the new endpoint may answer with a 404,
403− // a 405, or a 200 carrying a differently-shaped body (a DecodingError,
404− // which is not a ParseableError and would otherwise escape this method).
405− let encoded = try Self.encodePathComponent(stream)
406−
407− func legacyAlerts() async throws -> AlertConfig {
408− let data = try await performRequest(method: "GET", path: "/api/v1/logstream/\(encoded)/alert")
409− return try Self.jsonDecoder.decode(AlertConfig.self, from: data)
410− }
411−
412− // `/api/v1/alerts` paginates (default page size 100, max 1000) over ALL
413− // alerts server-wide, ordered by state/severity/title. Filtering only the
414− // first page by stream (below) silently missed any stream whose alerts sort
415− // past it. Request full pages until a short one. Servers that predate
416− // pagination ignore the parameters and return everything each time, so a
417− // page that adds nothing new also ends the loop.
418− func modernAlerts() async throws -> (data: Data, config: AlertConfig) {
419− let pageSize = 1000
420− var firstData: Data?
421− var version: String?
422− var collected: [AlertRule] = []
423− var seenIDs = Set<String>()
424− var offset = 0
425− for _ in 0..<50 {
426− let data: Data
427− do {
428− data = try await performRequest(
429− method: "GET",
430− path: "/api/v1/alerts",
431− queryItems: [
432− URLQueryItem(name: "limit", value: String(pageSize)),
433− URLQueryItem(name: "offset", value: String(offset)),
434− ]
435− )
436− } catch ParseableError.serverError(let code, _) where code == 400 && offset == 0 {
437− // A server that rejects the paging parameters: one plain request.
438− let data = try await performRequest(method: "GET", path: "/api/v1/alerts")
439− return (data, try Self.jsonDecoder.decode(AlertConfig.self, from: data))
440− }
441− let page = try Self.jsonDecoder.decode(AlertConfig.self, from: data)
442− if firstData == nil {
443− firstData = data
444− version = page.version
445− }
446− let alerts = page.alerts ?? []
447− let fresh = alerts.filter { seenIDs.insert($0.id).inserted }
448− collected.append(contentsOf: fresh)
449− if alerts.count < pageSize || fresh.isEmpty { break }
450− offset += alerts.count
451− }
452− return (firstData ?? Data(), AlertConfig(alerts: collected, version: version))
453− }
454−
455 do {
252 do {
456− let (data, config) = try await modernAlerts()
253+ let data = try await performRequest(method: "GET", path: "/api/v1/alerts")
457− // AlertConfig decodes leniently (every field via try?), so a 200 with
254+ return try JSONDecoder().decode(AlertConfig.self, from: data)
458− // an unexpected body yields an empty, non-throwing config rather than
255+ } catch let error as ParseableError {
459− // a DecodingError — indistinguishable from a server that genuinely has
256+ if case .serverError(let code, _) = error, code == 404 {
460− // no alerts. Only fall back to the legacy per-stream endpoint when the
257+ // New endpoint not available, try legacy below
461− // body ISN'T a recognizable alerts container. Any top-level JSON array
258+ } else {
462− // OR object is the modern endpoint answering — including an empty object
259+ throw error
463− // `{}` or `{"alerts": []}` meaning "no alerts" — and is authoritative, so
464− // it must not be replaced with unrelated legacy per-stream alerts. Only a
465− // non-JSON / unparseable body counts as unrecognized.
466− if config.alerts?.isEmpty ?? true {
467− let json = try? JSONSerialization.jsonObject(with: data)
468− let isRecognizedShape = json is [Any] || json is [String: Any]
469− if !isRecognizedShape,
470− let legacy = try? await legacyAlerts(), !(legacy.alerts?.isEmpty ?? true) {
471− return legacy
472− }
473− }
474− // `/api/v1/alerts` is server-global; the legacy endpoint above is already
475− // per-stream. Scope the modern result to the requested stream via each
476− // alert's `datasets` membership so every stream doesn't show the same
477− // unfiltered global list.
478− let scoped = config.alerts?.filter { rule in
479− // A missing or empty `datasets` means the alert isn't scoped to
480− // specific streams (the summary endpoint frequently omits it), so
481− // treat it as global and surface it for every stream. Only exclude
482− // an alert when it explicitly lists datasets, none of which match.
483− guard let datasets = rule.datasets, !datasets.isEmpty else { return true }
484− return datasets.contains(stream)
485− }
486− return AlertConfig(alerts: scoped, version: config.version)
487− } catch let newEndpointError {
488− // An auth failure won't be fixed by hitting the legacy endpoint (it
489− // would just 401 again), so surface it immediately rather than
490− // delaying the re-auth prompt behind a second failing round-trip.
491− if case ParseableError.unauthorized = newEndpointError {
492− throw newEndpointError
493− }
494− do {
495− return try await legacyAlerts()
496− } catch {
497− // Legacy also failed — surface the original (more informative) error.
498− throw newEndpointError
499 }
260 }
500 }
261 }
262+ let encoded = try Self.encodePathComponent(stream)
263+ let data = try await performRequest(method: "GET", path: "/api/v1/logstream/\(encoded)/alert")
264+ return try JSONDecoder().decode(AlertConfig.self, from: data)
501 }
265 }
502
266
503 // MARK: - Retention
267 // MARK: - Retention
@@ -507,17 +271,11 @@ final class ParseableClient: Sendable {
507 let data = try await performRequest(method: "GET", path: "/api/v1/logstream/\(encoded)/retention")
271 let data = try await performRequest(method: "GET", path: "/api/v1/logstream/\(encoded)/retention")
508 if data.isEmpty { return [] }
272 if data.isEmpty { return [] }
509 // Handle both array and single object response
273 // Handle both array and single object response
510− if let array = try? Self.jsonDecoder.decode([RetentionConfig].self, from: data) {
274+ if let array = try? JSONDecoder().decode([RetentionConfig].self, from: data) {
511 return array
275 return array
512 }
276 }
513 do {
277 do {
514− let single = try Self.jsonDecoder.decode(RetentionConfig.self, from: data)
278+ let single = try JSONDecoder().decode(RetentionConfig.self, from: data)
515− // RetentionConfig decodes every field with `try?`, so any JSON object
516− // (including `{}` or an error body) decodes to an all-nil config. Treat
517− // an empty config as "no retention" rather than a phantom policy entry.
518− guard single.description != nil || single.duration != nil || single.action != nil else {
519− return []
520− }
521 return [single]
279 return [single]
522 } catch {
280 } catch {
523 throw ParseableError.decodingError("Unexpected retention response format")
281 throw ParseableError.decodingError("Unexpected retention response format")
@@ -528,7 +286,7 @@ final class ParseableClient: Sendable {
528
286
529 func listUsers() async throws -> [UserInfo] {
287 func listUsers() async throws -> [UserInfo] {
530 let data = try await performRequest(method: "GET", path: "/api/v1/user")
288 let data = try await performRequest(method: "GET", path: "/api/v1/user")
531− return try Self.jsonDecoder.decode([UserInfo].self, from: data)
289+ return try JSONDecoder().decode([UserInfo].self, from: data)
532 }
290 }
533
291
534 // MARK: - Filters
292 // MARK: - Filters
@@ -536,21 +294,17 @@ final class ParseableClient: Sendable {
536 func listFilters() async throws -> [ParseableFilter] {
294 func listFilters() async throws -> [ParseableFilter] {
537 let data = try await performRequest(method: "GET", path: "/api/v1/filters")
295 let data = try await performRequest(method: "GET", path: "/api/v1/filters")
538 if data.isEmpty { return [] }
296 if data.isEmpty { return [] }
539− return try Self.jsonDecoder.decode([ParseableFilter].self, from: data)
297+ return try JSONDecoder().decode([ParseableFilter].self, from: data)
540 }
298 }
541
299
542 func createFilter(_ filter: ParseableFilter) async throws -> ParseableFilter {
300 func createFilter(_ filter: ParseableFilter) async throws -> ParseableFilter {
543 let body = try JSONEncoder().encode(filter)
301 let body = try JSONEncoder().encode(filter)
544 let data = try await performRequest(method: "POST", path: "/api/v1/filters", body: body)
302 let data = try await performRequest(method: "POST", path: "/api/v1/filters", body: body)
545− return try Self.jsonDecoder.decode(ParseableFilter.self, from: data)
303+ return try JSONDecoder().decode(ParseableFilter.self, from: data)
546 }
304 }
547
305
548 func deleteFilter(id: String) async throws {
306 func deleteFilter(id: String) async throws {
549 let encoded = try Self.encodePathComponent(id)
307 let encoded = try Self.encodePathComponent(id)
550 _ = try await performRequest(method: "DELETE", path: "/api/v1/filters/\(encoded)")
308 _ = try await performRequest(method: "DELETE", path: "/api/v1/filters/\(encoded)")
551 }
309 }
552−
553− func invalidateCache() async {
554− await cache.invalidate()
555− }
556}
310}
ParseableViewer/ViewModels/LiveTailViewModel.swift+89−347
@@ -21,82 +21,35 @@ final class LiveTailViewModel {
21 var columns: [String] = []
21 var columns: [String] = []
22 var columnOrder: [String] = []
22 var columnOrder: [String] = []
23 var hiddenColumns: Set<String> = []
23 var hiddenColumns: Set<String> = []
24− /// The full persisted set of hidden columns, including ones not yet seen in
25− /// this session. Live-tail columns are discovered incrementally across polls,
26− /// so `hiddenColumns` (only the seen ones) can't be the source of truth — a
27− /// column hidden last session would reappear visible when it shows up in a
28− /// later poll. This retains the intent so it stays hidden when it appears.
29− private var savedHiddenColumns: Set<String> = []
30− /// The full persisted column order, including columns not yet seen this
31− /// session — the order counterpart of `savedHiddenColumns`. A column that
32− /// first shows up in a later poll is slotted back into its saved position
33− /// (instead of being appended), and saving keeps unseen columns' positions
34− /// rather than dropping them from the stored order.
35− private var savedColumnOrder: [String] = []
36 private var currentStream: String?
24 private var currentStream: String?
37
25
38 // nonisolated(unsafe) so deinit can invalidate the timer.
26 // nonisolated(unsafe) so deinit can invalidate the timer.
39 // Only mutated from @MainActor methods (start/stop).
27 // Only mutated from @MainActor methods (start/stop).
40 nonisolated(unsafe) private var timer: Timer?
28 nonisolated(unsafe) private var timer: Timer?
41 private var lastTimestamp: Date?
29 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] = [:]
30+ private var seenFingerprints: Set<String> = []
49− /// Insertion-ordered mirror of `seenFingerprints` keys (oldest first), used to
50− /// evict the least-recently-seen fingerprints when the set is trimmed.
51− private var seenFingerprintOrder: [String] = []
52− /// Upper bound on the dedup fingerprint set. Kept well above `maxEntries` so
53− /// fingerprints of recently-evicted entries survive long enough to suppress
54− /// duplicates that reappear inside the next poll's look-back window, while
55− /// still bounding memory. Only when this is exceeded do we fall back to the
56− /// retained-entry fingerprints.
57− private var maxSeenFingerprints: Int { maxEntries * 4 }
58 private var allKnownKeys: Set<String> = []
31 private var allKnownKeys: Set<String> = []
59 private var consecutiveErrors = 0
32 private var consecutiveErrors = 0
60 private static let maxConsecutiveErrors = 5
33 private static let maxConsecutiveErrors = 5
61− /// Guards against overlapping polls: if a poll's network round-trip outlasts
62− /// the timer interval, the next tick must not start a second concurrent poll
63− /// (which would let the two interleave and write `lastTimestamp` /
64− /// `consecutiveErrors` out of order).
65− private var isPolling = false
66− /// Bumped by start()/stop()/clear() so an in-flight poll whose network
67− /// round-trip outlives one of those transitions can detect it was superseded
68− /// and drop its results instead of repopulating a torn-down/cleared tail.
69− private var pollGeneration = 0
70
34
71 deinit {
35 deinit {
72− // Timer.invalidate() must run on the run loop the timer was scheduled on
36+ timer?.invalidate()
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− }
81 }
37 }
82
38
83− // Cached formatters — nonisolated(unsafe) so static methods can use them from Task.detached
39+ // Cached formatters to avoid per-poll allocation
84− nonisolated(unsafe) private static let displayFormatter: DateFormatter = {
40+ private static let displayFormatter: DateFormatter = {
85 let f = DateFormatter()
41 let f = DateFormatter()
86− // Fixed-format formatters must use en_US_POSIX so a non-Gregorian or
87− // non-Latin-digit system locale doesn't render wrong numerals/era.
88− f.locale = Locale(identifier: "en_US_POSIX")
89 f.dateFormat = "HH:mm:ss.SSS"
42 f.dateFormat = "HH:mm:ss.SSS"
90 return f
43 return f
91 }()
44 }()
92
45
93− nonisolated(unsafe) private static let isoFractional: ISO8601DateFormatter = {
46+ private static let isoFractional: ISO8601DateFormatter = {
94 let f = ISO8601DateFormatter()
47 let f = ISO8601DateFormatter()
95 f.formatOptions = [.withInternetDateTime, .withFractionalSeconds]
48 f.formatOptions = [.withInternetDateTime, .withFractionalSeconds]
96 return f
49 return f
97 }()
50 }()
98
51
99− nonisolated(unsafe) private static let isoBasic: ISO8601DateFormatter = {
52+ private static let isoBasic: ISO8601DateFormatter = {
100 let f = ISO8601DateFormatter()
53 let f = ISO8601DateFormatter()
101 f.formatOptions = [.withInternetDateTime]
54 f.formatOptions = [.withInternetDateTime]
102 return f
55 return f
@@ -125,9 +78,7 @@ final class LiveTailViewModel {
125 }
78 }
126
79
127 private(set) var cachedFilteredEntries: [LiveTailEntry] = []
80 private(set) var cachedFilteredEntries: [LiveTailEntry] = []
128− var cachedFilteredRecords: [LogRecord] {
81+ private(set) var cachedFilteredRecords: [LogRecord] = []
129− cachedFilteredEntries.map { $0.record }
130− }
131 private(set) var filteredEntriesGeneration: Int = 0
82 private(set) var filteredEntriesGeneration: Int = 0
132
83
133 private func rebuildFilteredEntries() {
84 private func rebuildFilteredEntries() {
@@ -166,25 +117,13 @@ final class LiveTailViewModel {
166 }
117 }
167
118
168 cachedFilteredEntries = result
119 cachedFilteredEntries = result
120+ cachedFilteredRecords = result.map { $0.record }
169 filteredEntriesGeneration += 1
121 filteredEntriesGeneration += 1
170 }
122 }
171
123
172 func addColumnFilter(column: String, value: JSONValue?, exclude: Bool) {
124 func addColumnFilter(column: String, value: JSONValue?, exclude: Bool) {
173− // Remove any existing filter on the same column+value regardless of its
125+ // Remove any existing filter on the same column with the same value
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 }
126+ columnFilters.removeAll { $0.column == column && $0.value == value && $0.exclude == exclude }
187− }
188 columnFilters.append(ColumnFilter(column: column, value: value, exclude: exclude))
127 columnFilters.append(ColumnFilter(column: column, value: value, exclude: exclude))
189 rebuildFilteredEntries()
128 rebuildFilteredEntries()
190 }
129 }
@@ -211,18 +150,11 @@ final class LiveTailViewModel {
211 let storedMax = UserDefaults.standard.integer(forKey: "liveTailMaxEntries")
150 let storedMax = UserDefaults.standard.integer(forKey: "liveTailMaxEntries")
212 if storedMax > 0 { maxEntries = storedMax }
151 if storedMax > 0 { maxEntries = storedMax }
213
152
214− pollGeneration += 1
215 isRunning = true
153 isRunning = true
216 isPaused = false
154 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
222 errorMessage = nil
155 errorMessage = nil
223 entries = []
156 entries = []
224− seenFingerprints = [:]
157+ seenFingerprints = []
225− seenFingerprintOrder = []
226 allKnownKeys = []
158 allKnownKeys = []
227 droppedCount = 0
159 droppedCount = 0
228 consecutiveErrors = 0
160 consecutiveErrors = 0
@@ -231,33 +163,19 @@ final class LiveTailViewModel {
231 columns = []
163 columns = []
232 columnOrder = []
164 columnOrder = []
233 hiddenColumns = []
165 hiddenColumns = []
234− savedHiddenColumns = []
235− savedColumnOrder = []
236 columnFilters = []
166 columnFilters = []
237− // Clear the text filter too. Leaving it set while clearing the column-filter
238− // chips is inconsistent and would let a stale search from a previous stream
239− // make the fresh tail come up appearing empty or partially filtered.
240− filterText = ""
241 currentStream = stream
167 currentStream = stream
242 rebuildFilteredEntries()
168 rebuildFilteredEntries()
243
169
244− // Install the timer in `.common` modes so polling keeps firing while the
245− // main run loop is in event-tracking mode (scrolling, dragging a column,
246− // an open menu/popover). A `.default`-only timer — what
247− // `scheduledTimer(withTimeInterval:)` installs — silently pauses during
248− // those interactions, freezing the "live" tail until the user lets go.
249− let pollTimer = Timer(timeInterval: pollInterval, repeats: true) { [weak self] _ in
170+ timer = Timer.scheduledTimer(withTimeInterval: pollInterval, repeats: true) { [weak self] _ in
250 Task { @MainActor [weak self] in
171 Task { @MainActor [weak self] in
251 guard let self, !self.isPaused else { return }
172 guard let self, !self.isPaused else { return }
252 await self.poll(client: client, stream: stream)
173 await self.poll(client: client, stream: stream)
253 }
174 }
254 }
175 }
255− RunLoop.main.add(pollTimer, forMode: .common)
256− timer = pollTimer
257 }
176 }
258
177
259 func stop() {
178 func stop() {
260− pollGeneration += 1
261 timer?.invalidate()
179 timer?.invalidate()
262 timer = nil
180 timer = nil
263 isRunning = false
181 isRunning = false
@@ -269,37 +187,14 @@ final class LiveTailViewModel {
269 }
187 }
270
188
271 func clear() {
189 func clear() {
272− pollGeneration += 1
273− // Match start(): drop the in-flight guard so a poll still awaiting its
274− // response (whose results this generation bump will discard) can't make the
275− // next timer tick skip itself via `guard !isPolling`.
276− isPolling = false
277 entries = []
190 entries = []
278− // Keep the dedup fingerprints (unlike start(), which begins a new session):
191+ seenFingerprints = []
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().
284− lastTimestamp = Date()
285 allKnownKeys = []
192 allKnownKeys = []
286 droppedCount = 0
193 droppedCount = 0
287− // Reset the failure counter too: the timer keeps polling after a clear, so
288− // leaving accumulated errors in place could trip the auto-stop threshold
289− // sooner than the intended N consecutive failures. Matches start()'s reset.
290− consecutiveErrors = 0
291− // Match start(): a freshly emptied tail has no last-poll timestamp, so any
292− // "last updated at…" indicator shouldn't keep showing the pre-clear time.
293− lastPollTime = nil
294 columns = []
194 columns = []
295 columnOrder = []
195 columnOrder = []
296 hiddenColumns = []
196 hiddenColumns = []
297− savedHiddenColumns = []
298− savedColumnOrder = []
299 columnFilters = []
197 columnFilters = []
300− // Match start(): clear the text filter alongside the column-filter chips so
301− // a clear()ed tail isn't immediately re-hidden by a lingering search string.
302− filterText = ""
303 rebuildFilteredEntries()
198 rebuildFilteredEntries()
304 }
199 }
305
200
@@ -312,12 +207,10 @@ final class LiveTailViewModel {
312 func toggleColumnVisibility(_ column: String) {
207 func toggleColumnVisibility(_ column: String) {
313 if hiddenColumns.contains(column) {
208 if hiddenColumns.contains(column) {
314 hiddenColumns.remove(column)
209 hiddenColumns.remove(column)
315− savedHiddenColumns.remove(column)
316 } else {
210 } else {
317 let visibleCount = columnOrder.count - hiddenColumns.count
211 let visibleCount = columnOrder.count - hiddenColumns.count
318 if visibleCount > 1 {
212 if visibleCount > 1 {
319 hiddenColumns.insert(column)
213 hiddenColumns.insert(column)
320− savedHiddenColumns.insert(column)
321 }
214 }
322 }
215 }
323 saveColumnConfig()
216 saveColumnConfig()
@@ -325,7 +218,6 @@ final class LiveTailViewModel {
325
218
326 func showAllColumns() {
219 func showAllColumns() {
327 hiddenColumns.removeAll()
220 hiddenColumns.removeAll()
328− savedHiddenColumns.removeAll()
329 saveColumnConfig()
221 saveColumnConfig()
330 }
222 }
331
223
@@ -339,21 +231,13 @@ final class LiveTailViewModel {
339 let toIndex = columnOrder.firstIndex(of: targetColumn),
231 let toIndex = columnOrder.firstIndex(of: targetColumn),
340 fromIndex != toIndex else { return }
232 fromIndex != toIndex else { return }
341 let item = columnOrder.remove(at: fromIndex)
233 let item = columnOrder.remove(at: fromIndex)
342− // Match the drop-indicator semantics: a rightward move (fromIndex < toIndex)
343− // lands the column *after* the target, a leftward move lands it *before*.
344− // After removing an earlier element the target shifts down by one, so
345− // inserting at `toIndex` lands after the target for rightward moves; for
346− // leftward moves the target is unshifted and `toIndex` lands before it.
347− let adjusted = toIndex
348− columnOrder.insert(item, at: adjusted)
234+ columnOrder.insert(item, at: toIndex)
349 saveColumnConfig()
235 saveColumnConfig()
350 }
236 }
351
237
352 func resetColumnConfig() {
238 func resetColumnConfig() {
353 columnOrder = columns
239 columnOrder = columns
354− savedColumnOrder = []
355 hiddenColumns.removeAll()
240 hiddenColumns.removeAll()
356− savedHiddenColumns.removeAll()
357 saveColumnConfig()
241 saveColumnConfig()
358 }
242 }
359
243
@@ -365,16 +249,7 @@ final class LiveTailViewModel {
365
249
366 private func saveColumnConfig() {
250 private func saveColumnConfig() {
367 guard let stream = currentStream else { return }
251 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)
252+ let config = QueryViewModel.ColumnConfiguration(order: columnOrder, hidden: hiddenColumns)
378 if let data = try? JSONEncoder().encode(config) {
253 if let data = try? JSONEncoder().encode(config) {
379 UserDefaults.standard.set(data, forKey: Self.columnConfigKey(for: stream))
254 UserDefaults.standard.set(data, forKey: Self.columnConfigKey(for: stream))
380 }
255 }
@@ -387,21 +262,26 @@ final class LiveTailViewModel {
387
262
388 // MARK: - Column Extraction
263 // MARK: - Column Extraction
389
264
390− private func extractColumnsFromKeys(_ keys: Set<String>) -> [String] {
265+ private func extractColumns(from records: [LogRecord]) -> [String] {
391 let priorityFields = ["p_timestamp", "p_tags", "p_metadata", "level", "severity", "message", "msg"]
266 let priorityFields = ["p_timestamp", "p_tags", "p_metadata", "level", "severity", "message", "msg"]
392− var remaining = keys
267+ var allKeys = Set<String>()
268+ for record in records {
269+ allKeys.formUnion(record.keys)
270+ }
271+
393 var ordered: [String] = []
272 var ordered: [String] = []
394 for field in priorityFields {
273 for field in priorityFields {
395− if remaining.remove(field) != nil {
274+ if allKeys.remove(field) != nil {
396 ordered.append(field)
275 ordered.append(field)
397 }
276 }
398 }
277 }
399− ordered.append(contentsOf: remaining.sorted())
278+ ordered.append(contentsOf: allKeys.sorted())
400 return ordered
279 return ordered
401 }
280 }
402
281
403 private func updateColumns(stream: String) {
282 private func updateColumns(stream: String) {
404− let extracted = extractColumnsFromKeys(allKnownKeys)
283+ let allRecords = entries.map { $0.record }
284+ let extracted = extractColumns(from: allRecords)
405
285
406 if columns.isEmpty {
286 if columns.isEmpty {
407 // First time — apply saved config
287 // First time — apply saved config
@@ -416,13 +296,9 @@ final class LiveTailViewModel {
416 merged.append(col)
296 merged.append(col)
417 }
297 }
418 columnOrder = merged
298 columnOrder = merged
419− savedColumnOrder = config.order
420− savedHiddenColumns = config.hidden
421 hiddenColumns = config.hidden.intersection(extractedSet)
299 hiddenColumns = config.hidden.intersection(extractedSet)
422 } else {
300 } else {
423 columnOrder = extracted
301 columnOrder = extracted
424− savedColumnOrder = []
425− savedHiddenColumns = []
426 hiddenColumns = []
302 hiddenColumns = []
427 }
303 }
428 } else {
304 } else {
@@ -434,38 +310,12 @@ final class LiveTailViewModel {
434 }
310 }
435 if !newCols.isEmpty {
311 if !newCols.isEmpty {
436 columns.append(contentsOf: newCols)
312 columns.append(contentsOf: newCols)
437− columnOrder = QueryViewModel.mergedColumnOrder(columnOrder, inserting: newCols, reference: savedColumnOrder)
313+ columnOrder.append(contentsOf: newCols)
438− // Re-hide any newly appearing column that was hidden in a prior
439− // session but hadn't been seen yet this run.
440− hiddenColumns.formUnion(newCols.filter { savedHiddenColumns.contains($0) })
441 }
314 }
442 }
315 }
443−
444− // The `visibleCount > 1` guard in toggleColumnVisibility only covers
445− // user toggles. The config-load and incremental-merge paths above can
446− // hide every known column (e.g. a saved-hidden set that happens to cover
447− // all columns seen so far), which would render the live-tail table with
448− // zero columns. Keep at least the first column visible.
449− if !columnOrder.isEmpty, hiddenColumns.count >= columnOrder.count,
450− let firstColumn = columnOrder.first {
451− hiddenColumns.remove(firstColumn)
452− }
453 }
316 }
454
317
455 private func poll(client: ParseableClient, stream: String) async {
318 private func poll(client: ParseableClient, stream: String) async {
456− let generation = pollGeneration
457− guard !isPolling else { return }
458− 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 } }
468−
469 let now = Date()
319 let now = Date()
470 // The Parseable server truncates query time-ranges to minute
320 // The Parseable server truncates query time-ranges to minute
471 // boundaries, so a narrow window (< 60 s) can collapse to a
321 // boundaries, so a narrow window (< 60 s) can collapse to a
@@ -475,59 +325,28 @@ final class LiveTailViewModel {
475 let idealStart = lastTimestamp ?? now.addingTimeInterval(-30)
325 let idealStart = lastTimestamp ?? now.addingTimeInterval(-30)
476 let safeStart = now.addingTimeInterval(-90)
326 let safeStart = now.addingTimeInterval(-90)
477 let queryStart = min(idealStart, safeStart)
327 let queryStart = min(idealStart, safeStart)
478− // ORDER BY DESC keeps the newest rows when the page is capped, so a busy
479− // stream that exceeds the limit within one ~90 s window silently loses the
480− // oldest rows in that window. Keep a generous page to make that rare; this
481− // HTTP-polling tail can't match true gRPC streaming throughput regardless.
482− let sql = "SELECT * FROM \(QueryViewModel.escapeSQLIdentifier(stream)) ORDER BY p_timestamp DESC LIMIT 1000"
328+ let sql = "SELECT * FROM \(QueryViewModel.escapeSQLIdentifier(stream)) ORDER BY p_timestamp DESC LIMIT 200"
483
329
484 do {
330 do {
485 let records = try await client.query(sql: sql, startTime: queryStart, endTime: now)
331 let records = try await client.query(sql: sql, startTime: queryStart, endTime: now)
486
332
487− // A stop()/clear()/start during the network round-trip supersedes this
488− // poll; don't write stale results to a torn-down or cleared tail. Also
489− // bail if the user paused mid-flight.
490− guard isRunning, !isPaused, generation == pollGeneration else { return }
491−
492− // Process records off the main actor using a snapshot of seen fingerprints
493− let seenSnapshot = seenFingerprints
494− let pollTime = now
495− let (candidateEntries, batchCounts) = await Task.detached(priority: .userInitiated) {
496− var results: [(entry: LiveTailEntry, occurrence: Int)] = []
497− var counts: [String: Int] = [:]
498− for record in records {
499− let fp = Self.fingerprint(for: record)
500− let occurrence = (counts[fp] ?? 0) + 1
501− counts[fp] = occurrence
502− // The first `seen` occurrences of this content were delivered
503− // by earlier polls; only later ones are new.
504− guard occurrence > (seenSnapshot[fp] ?? 0) else { continue }
505− let timestamp = Self.parseTimestamp(from: record) ?? pollTime
506− let summary = Self.buildSummary(from: record)
507− results.append((LiveTailEntry(
508− timestamp: timestamp,
509− record: record,
510− displayTimestamp: Self.displayFormatter.string(from: timestamp),
511− summary: summary,
512− fingerprint: fp
513− ), occurrence))
514− }
515− return (results, counts)
516− }.value
517−
518− // Re-check after the detached processing hop: a stop/clear could have
519− // landed while we were off the main actor.
520− guard isRunning, !isPaused, generation == pollGeneration else { return }
521−
522− // Authoritative dedup on main actor
523 var newEntries: [LiveTailEntry] = []
333 var newEntries: [LiveTailEntry] = []
524− for candidate in candidateEntries
334+
525− where candidate.occurrence > (seenFingerprints[candidate.entry.fingerprint] ?? 0) {
335+ for record in records {
336+ let fp = Self.fingerprint(for: record)
337+ guard !seenFingerprints.contains(fp) else { continue }
338+ seenFingerprints.insert(fp)
339+
340+ let timestamp = parseTimestamp(from: record) ?? now
341+ let summary = buildSummary(from: record)
342+
526− newEntries.append(candidate.entry)
343+ newEntries.append(LiveTailEntry(
527− }
344+ timestamp: timestamp,
528− for (fp, count) in batchCounts where count > (seenFingerprints[fp] ?? 0) {
345+ record: record,
529− if seenFingerprints[fp] == nil { seenFingerprintOrder.append(fp) }
346+ displayTimestamp: Self.displayFormatter.string(from: timestamp),
530− seenFingerprints[fp] = count
347+ summary: summary,
348+ fingerprint: fp
349+ ))
531 }
350 }
532
351
533 if !newEntries.isEmpty {
352 if !newEntries.isEmpty {
@@ -536,41 +355,10 @@ final class LiveTailViewModel {
536 if entries.count > maxEntries {
355 if entries.count > maxEntries {
537 let excess = entries.count - maxEntries
356 let excess = entries.count - maxEntries
538 droppedCount += excess
357 droppedCount += excess
539− // Entries are not globally time-sorted across polls (a late or
358+ entries.removeFirst(excess)
540− // backfilled record appends after newer ones), so trimming by
541− // array position could evict newer entries. Sort by timestamp
542− // first so the suffix keeps the chronologically newest.
543− entries.sort { $0.timestamp < $1.timestamp }
544− entries = Array(entries.suffix(maxEntries))
545−
546− // Do NOT rebuild the fingerprint set from the retained
547− // entries: an evicted record can still fall inside the next
548− // poll's look-back window, and dropping its fingerprint here
549− // would let it pass the dedup check and be re-appended as a
550− // duplicate. Keep the full seen-set so dedup stays correct;
551− // it is bounded below to avoid unbounded growth.
552− }
553
359
554− // Bound the seen-set on every poll, not only when the buffer
360+ // Rebuild fingerprint set from stored values (no re-hashing)
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 })
361+ seenFingerprints = 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) }
573− }
574 }
362 }
575
363
576 // Only scan new records for new keys
364 // Only scan new records for new keys
@@ -594,10 +382,7 @@ final class LiveTailViewModel {
594 errorMessage = nil
382 errorMessage = nil
595 consecutiveErrors = 0
383 consecutiveErrors = 0
596 } catch {
384 } catch {
597− // Drop the error if this poll was cancelled or superseded by a
598− // stop/clear/start — don't bump the failure counter or surface an
599− // error on a tail the user already tore down.
600− guard !Task.isCancelled, isRunning, generation == pollGeneration else { return }
385+ guard !Task.isCancelled else { return }
601 consecutiveErrors += 1
386 consecutiveErrors += 1
602 if consecutiveErrors >= Self.maxConsecutiveErrors {
387 if consecutiveErrors >= Self.maxConsecutiveErrors {
603 errorMessage = "\(ParseableError.userFriendlyMessage(for: error)) — stopped after \(consecutiveErrors) consecutive failures"
388 errorMessage = "\(ParseableError.userFriendlyMessage(for: error)) — stopped after \(consecutiveErrors) consecutive failures"
@@ -608,45 +393,27 @@ final class LiveTailViewModel {
608 }
393 }
609 }
394 }
610
395
611− /// Deterministic content-based fingerprint using XOR with golden-ratio mixing.
396+ /// Deterministic content-based fingerprint using sorted key-value pairs and FNV-1a.
612− /// Order-independent: O(k) instead of O(k log k) per record.
613− nonisolated static func fingerprint(for record: LogRecord) -> String {
397+ static func fingerprint(for record: LogRecord) -> String {
614− var xorAccum0: UInt64 = 0
398+ var h0: UInt64 = 14695981039346656037
615− var xorAccum1: UInt64 = 0
616− for (key, value) in record {
617− var kh0: UInt64 = 14695981039346656037
618− var kh1: UInt64 = 14695981039346656037 &* 31
399+ var h1: UInt64 = 14695981039346656037 &* 31
400+ for key in record.keys.sorted() {
619 for byte in key.utf8 {
401 for byte in key.utf8 {
620− kh0 = (kh0 ^ UInt64(byte)) &* 1099511628211
402+ h0 = (h0 ^ UInt64(byte)) &* 1099511628211
621− kh1 = (kh1 ^ UInt64(byte)) &* 6700417
403+ h1 = (h1 ^ UInt64(byte)) &* 6700417
404+ }
405+ h0 = (h0 ^ 0) &* 1099511628211 // separator
406+ if let val = record[key] {
407+ hashJSONValue(val, h0: &h0, h1: &h1)
622 }
408 }
623− kh0 = (kh0 ^ 0) &* 1099511628211 // separator
624− hashJSONValue(value, h0: &kh0, h1: &kh1)
625− kh1 = (kh1 ^ 0xFF) &* 6700417 // field separator
409+ h1 = (h1 ^ 0xFF) &* 6700417 // field separator
626− xorAccum0 ^= kh0 &* 0x9E3779B97F4A7C15
627− xorAccum1 ^= kh1 &* 0x9E3779B97F4A7C15
628 }
410 }
629− let h0 = 14695981039346656037 &+ xorAccum0
630− let h1 = (14695981039346656037 &* 31) &+ xorAccum1
631 return String(h0, radix: 36) + String(h1, radix: 36)
411 return String(h0, radix: 36) + String(h1, radix: 36)
632 }
412 }
633
413
634− /// Mixes the 8 bytes of a number's canonical 64-bit representation, prefixed by
635− /// a shared numeric type byte so ints and equal-valued doubles fingerprint alike.
636− nonisolated private static func hashNumberBits(_ bitPattern: UInt64, h0: inout UInt64, h1: inout UInt64) {
637− h0 = (h0 ^ 0x02) &* 1099511628211
638− h1 = (h1 ^ 0x02) &* 6700417
639− var bits = bitPattern
640− for _ in 0..<8 {
641− h0 = (h0 ^ (bits & 0xFF)) &* 1099511628211
642− h1 = (h1 ^ (bits & 0xFF)) &* 6700417
643− bits >>= 8
644− }
645− }
646−
647 /// Recursively hashes a JSONValue tree without allocating intermediate strings.
414 /// Recursively hashes a JSONValue tree without allocating intermediate strings.
648 /// Uses type-discriminator bytes to prevent cross-type collisions.
415 /// Uses type-discriminator bytes to prevent cross-type collisions.
649− nonisolated private static func hashJSONValue(_ value: JSONValue, h0: inout UInt64, h1: inout UInt64) {
416+ private static func hashJSONValue(_ value: JSONValue, h0: inout UInt64, h1: inout UInt64) {
650 switch value {
417 switch value {
651 case .null:
418 case .null:
652 h0 = (h0 ^ 0x00) &* 1099511628211
419 h0 = (h0 ^ 0x00) &* 1099511628211
@@ -657,31 +424,24 @@ final class LiveTailViewModel {
657 let byte: UInt64 = b ? 1 : 0
424 let byte: UInt64 = b ? 1 : 0
658 h0 = (h0 ^ byte) &* 1099511628211
425 h0 = (h0 ^ byte) &* 1099511628211
659 h1 = (h1 ^ byte) &* 6700417
426 h1 = (h1 ^ byte) &* 6700417
660− // `.int` and `.double` share one numeric encoding so the fingerprint stays
661− // consistent with JSONValue equality, where `.int(5) == .double(5.0)`.
662− // JSONValue.hash(into:) canonicalizes both through `Double`; mirror that here
663− // (with NaN folded to a single bit pattern) so a field that serializes as `5`
664− // in one poll and `5.0` in another doesn't bypass dedup as a phantom row.
665 case .int(let i):
427 case .int(let i):
666− // If the integer round-trips losslessly through Double, hash via the
428+ h0 = (h0 ^ 0x02) &* 1099511628211
667− // Double path so `.int(5)` and `.double(5.0)` (which are ==) share a
429+ h1 = (h1 ^ 0x02) &* 6700417
668− // fingerprint. Integers beyond 2^53 lose precision in Double, so two
669− // distinct values (e.g. 2^53 and 2^53+1) would otherwise collide and
670− // the second would be silently dropped as a phantom duplicate — the
671− // dedup set has no `==` fallback. No Double can equal such a
672− // non-representable integer, so hashing its exact 64-bit pattern can't
673− // wrongly merge it with a `.double`.
674− if let exact = Int(exactly: Double(i)), exact == i {
675− hashNumberBits(Double(i).bitPattern, h0: &h0, h1: &h1)
676− } else {
677− hashNumberBits(UInt64(bitPattern: Int64(i)), h0: &h0, h1: &h1)
430+ var bits = UInt64(bitPattern: Int64(i))
431+ for _ in 0..<8 {
432+ h0 = (h0 ^ (bits & 0xFF)) &* 1099511628211
433+ h1 = (h1 ^ (bits & 0xFF)) &* 6700417
434+ bits >>= 8
678 }
435 }
679 case .double(let d):
436 case .double(let d):
680− // Canonicalize NaN (so all NaNs fold together) AND signed zero: JSONValue
437+ h0 = (h0 ^ 0x03) &* 1099511628211
681− // equality treats `-0.0 == 0.0`, but their raw bit patterns differ, which
438+ h1 = (h1 ^ 0x03) &* 6700417
682− // would otherwise let an identical record slip past dedup as a phantom row.
439+ var bits = d.bitPattern
683− let canonical: Double = d.isNaN ? .nan : (d == 0 ? 0.0 : d)
440+ for _ in 0..<8 {
684− hashNumberBits(canonical.bitPattern, h0: &h0, h1: &h1)
441+ h0 = (h0 ^ UInt64(bits & 0xFF)) &* 1099511628211
442+ h1 = (h1 ^ UInt64(bits & 0xFF)) &* 6700417
443+ bits >>= 8
444+ }
685 case .string(let s):
445 case .string(let s):
686 h0 = (h0 ^ 0x04) &* 1099511628211
446 h0 = (h0 ^ 0x04) &* 1099511628211
687 h1 = (h1 ^ 0x04) &* 6700417
447 h1 = (h1 ^ 0x04) &* 6700417
@@ -695,53 +455,37 @@ final class LiveTailViewModel {
695 for element in arr {
455 for element in arr {
696 hashJSONValue(element, h0: &h0, h1: &h1)
456 hashJSONValue(element, h0: &h0, h1: &h1)
697 h0 = (h0 ^ 0xFE) &* 1099511628211 // element separator
457 h0 = (h0 ^ 0xFE) &* 1099511628211 // element separator
698− h1 = (h1 ^ 0xFE) &* 6700417
699 }
458 }
700 case .object(let dict):
459 case .object(let dict):
701 h0 = (h0 ^ 0x06) &* 1099511628211
460 h0 = (h0 ^ 0x06) &* 1099511628211
702 h1 = (h1 ^ 0x06) &* 6700417
461 h1 = (h1 ^ 0x06) &* 6700417
703− var xor0: UInt64 = 0
704− var xor1: UInt64 = 0
705− for (key, value) in dict {
462+ for key in dict.keys.sorted() {
706− var kh0: UInt64 = 14695981039346656037
707− var kh1: UInt64 = 14695981039346656037 &* 31
708 for byte in key.utf8 {
463 for byte in key.utf8 {
709− kh0 = (kh0 ^ UInt64(byte)) &* 1099511628211
464+ h0 = (h0 ^ UInt64(byte)) &* 1099511628211
710− kh1 = (kh1 ^ UInt64(byte)) &* 6700417
465+ h1 = (h1 ^ UInt64(byte)) &* 6700417
466+ }
467+ h0 = (h0 ^ 0xFD) &* 1099511628211 // key-value separator
468+ if let val = dict[key] {
469+ hashJSONValue(val, h0: &h0, h1: &h1)
711 }
470 }
712− kh0 = (kh0 ^ 0xFD) &* 1099511628211 // key-value separator
713− hashJSONValue(value, h0: &kh0, h1: &kh1)
714− xor0 ^= kh0 &* 0x9E3779B97F4A7C15
715− xor1 ^= kh1 &* 0x9E3779B97F4A7C15
716 }
471 }
717− h0 = h0 &+ xor0
718− h1 = h1 &+ xor1
719 }
472 }
720 }
473 }
721
474
722− nonisolated static func parseTimestamp(from record: LogRecord) -> Date? {
475+ func parseTimestamp(from record: LogRecord) -> Date? {
723 guard let value = record["p_timestamp"] ?? record["timestamp"] ?? record["time"] ?? record["@timestamp"] else {
476 guard let value = record["p_timestamp"] ?? record["timestamp"] ?? record["time"] ?? record["@timestamp"] else {
724 return nil
477 return nil
725 }
478 }
726 if case .string(let str) = value {
479 if case .string(let str) = value {
727− if let date = isoFractional.date(from: str) ?? isoBasic.date(from: str) {
480+ if let date = Self.isoFractional.date(from: str) {
728 return date
481 return date
729 }
482 }
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)
483+ return Self.isoBasic.date(from: str)
740 }
484 }
741 return nil
485 return nil
742 }
486 }
743
487
744− nonisolated static func buildSummary(from record: LogRecord) -> String {
488+ func buildSummary(from record: LogRecord) -> String {
745 var parts: [String] = []
489 var parts: [String] = []
746
490
747 if let level = record["level"] ?? record["severity"] ?? record["log_level"] {
491 if let level = record["level"] ?? record["severity"] ?? record["log_level"] {
@@ -753,16 +497,14 @@ final class LiveTailViewModel {
753 }
497 }
754
498
755 if parts.isEmpty {
499 if parts.isEmpty {
756− // Filter to scalars BEFORE taking the prefix — otherwise three leading
757− // non-scalar fields (alphabetically) would yield an empty summary even
758− // when scalar fields exist later in the record.
759 let scalarFields = record
500 let scalarFields = record
760 .filter { $0.key != "p_timestamp" && $0.key != "p_tags" && $0.key != "p_metadata" }
501 .filter { $0.key != "p_timestamp" && $0.key != "p_tags" && $0.key != "p_metadata" }
761− .filter { $0.value.isScalar }
762 .sorted { $0.key < $1.key }
502 .sorted { $0.key < $1.key }
763 .prefix(3)
503 .prefix(3)
764 for (key, value) in scalarFields {
504 for (key, value) in scalarFields {
505+ if value.isScalar {
765− parts.append("\(key)=\(value.displayString)")
506+ parts.append("\(key)=\(value.displayString)")
507+ }
766 }
508 }
767 }
509 }
768
510
ParseableViewer/ViewModels/QueryViewModel.swift+55−505
@@ -2,86 +2,23 @@ import Foundation
2import SwiftUI
2import SwiftUI
3
3
4@Observable
4@Observable
5−@MainActor
6final class QueryViewModel {
5final class QueryViewModel {
7− var sqlQuery = "" {
6+ var sqlQuery = ""
8− didSet {
9− // Editing the query invalidates any error underline, whose offsets
10− // were computed against the previous text. Clearing here keeps a
11− // late-arriving highlight from landing on the wrong position.
12− if sqlQuery != oldValue { errorRange = nil }
13− }
14− }
15 var results: [LogRecord] = [] {
7 var results: [LogRecord] = [] {
16 didSet {
8 didSet {
17− // Build search texts asynchronously
18− searchTextTask?.cancel()
19− cachedSearchTexts = []
9+ cachedSearchTexts = results.map { record in
20− searchTextGeneration &+= 1
21− let generation = searchTextGeneration
22− // Apply the current filter immediately using the inline fallback path
23− // (the cache was just invalidated). Calling updateFilteredResults()
24− // rather than `filteredResults = results` avoids briefly showing
25− // unfiltered rows while a filter is active, and avoids leaving a stale
26− // unfiltered set if the async rebuild below is cancelled before it
27− // re-applies the filter.
28− updateFilteredResults()
29− let snapshot = results
30− // The class is @MainActor, so this Task inherits main-actor isolation —
31− // its mutations of cachedSearchTexts/filteredResults are safely serialized
32− // with SwiftUI reads. Only the pure (and potentially large) displayString
33− // mapping is pushed off-main via Task.detached so it doesn't block the UI.
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: " ")
10+ 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()
50− }
51 }
11 }
12+ updateFilteredResults()
52 }
13 }
53 }
14 }
54 private var cachedSearchTexts: [String] = []
15 private var cachedSearchTexts: [String] = []
55− private var searchTextTask: Task<Void, Never>?
56− /// Bumped on every `results` reassignment so the async search-text builder can
57− /// detect it was superseded and drop a stale write (see results.didSet).
58− private var searchTextGeneration = 0
59 var columns: [String] = []
16 var columns: [String] = []
60 var columnOrder: [String] = []
17 var columnOrder: [String] = []
61 var hiddenColumns: Set<String> = []
18 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] = []
72 var autoHiddenColumns: Set<String> = []
19 var autoHiddenColumns: Set<String> = []
73 /// Column config from a saved query, applied on next query execution.
20 /// Column config from a saved query, applied on next query execution.
74 var pendingColumnConfig: ColumnConfiguration?
21 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?
85 private var currentStream: String?
22 private var currentStream: String?
86 var isLoading = false
23 var isLoading = false
87 var errorMessage: String?
24 var errorMessage: String?
@@ -97,25 +34,9 @@ final class QueryViewModel {
97
34
98 // Schema fields for autocomplete
35 // Schema fields for autocomplete
99 var schemaFields: [SchemaField] = []
36 var schemaFields: [SchemaField] = []
100− /// The stream of the most recent `loadSchema` call, used to drop a stale
101− /// 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?
108
37
109 // Query task for cancellation
38 // Query task for cancellation
110 private var queryTask: Task<Void, Never>?
39 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
119
40
120 // Time range
41 // Time range
121 var timeRangeOption: TimeRangeOption = .last1Hour
42 var timeRangeOption: TimeRangeOption = .last1Hour
@@ -124,10 +45,7 @@ final class QueryViewModel {
124
45
125 // Query history
46 // Query history
126 var queryHistory: [QueryHistoryEntry] = []
47 var queryHistory: [QueryHistoryEntry] = []
127− // Derived from the (always-trimmed) history so it stays correct after a
128− // restored full history is loaded at launch, not just after an in-session
129− // overflow. `addToHistory` trims to `maxHistory`, so count never exceeds it.
130− var historyIsFull: Bool { queryHistory.count >= Self.maxHistory }
48+ var historyIsFull = false
131 private static let maxHistory = 50
49 private static let maxHistory = 50
132
50
133 struct QueryHistoryEntry: Identifiable, Codable, Sendable {
51 struct QueryHistoryEntry: Identifiable, Codable, Sendable {
@@ -212,16 +130,9 @@ final class QueryViewModel {
212 return columnOrder.filter { !allHidden.contains($0) }
130 return columnOrder.filter { !allHidden.contains($0) }
213 }
131 }
214
132
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−
221 func toggleColumnVisibility(_ column: String) {
133 func toggleColumnVisibility(_ column: String) {
222 if hiddenColumns.contains(column) {
134 if hiddenColumns.contains(column) {
223 hiddenColumns.remove(column)
135 hiddenColumns.remove(column)
224− savedHiddenColumns.remove(column)
225 } else if autoHiddenColumns.contains(column) {
136 } else if autoHiddenColumns.contains(column) {
226 autoHiddenColumns.remove(column)
137 autoHiddenColumns.remove(column)
227 } else {
138 } else {
@@ -230,7 +141,6 @@ final class QueryViewModel {
230 let visibleCount = columnOrder.count - allHidden.count
141 let visibleCount = columnOrder.count - allHidden.count
231 if visibleCount > 1 {
142 if visibleCount > 1 {
232 hiddenColumns.insert(column)
143 hiddenColumns.insert(column)
233− savedHiddenColumns.insert(column)
234 }
144 }
235 }
145 }
236 saveColumnConfig()
146 saveColumnConfig()
@@ -239,7 +149,6 @@ final class QueryViewModel {
239
149
240 func showAllColumns() {
150 func showAllColumns() {
241 hiddenColumns.removeAll()
151 hiddenColumns.removeAll()
242− savedHiddenColumns.removeAll()
243 autoHiddenColumns.removeAll()
152 autoHiddenColumns.removeAll()
244 saveColumnConfig()
153 saveColumnConfig()
245 updateSQLColumns()
154 updateSQLColumns()
@@ -256,129 +165,36 @@ final class QueryViewModel {
256 let toIndex = columnOrder.firstIndex(of: targetColumn),
165 let toIndex = columnOrder.firstIndex(of: targetColumn),
257 fromIndex != toIndex else { return }
166 fromIndex != toIndex else { return }
258 let item = columnOrder.remove(at: fromIndex)
167 let item = columnOrder.remove(at: fromIndex)
259− // Match the drop-indicator semantics: a rightward move (fromIndex < toIndex)
260− // lands the column *after* the target, a leftward move lands it *before*.
261− // After removing an earlier element the target shifts down by one, so
262− // inserting at `toIndex` lands after the target for rightward moves; for
263− // leftward moves the target is unshifted and `toIndex` lands before it.
264− let adjusted = toIndex
265− columnOrder.insert(item, at: adjusted)
168+ columnOrder.insert(item, at: toIndex)
266 saveColumnConfig()
169 saveColumnConfig()
267 updateSQLColumns()
170 updateSQLColumns()
268 }
171 }
269
172
270 func resetColumnConfig() {
173 func resetColumnConfig() {
271 columnOrder = columns
174 columnOrder = columns
272− savedColumnOrder = []
273 hiddenColumns.removeAll()
175 hiddenColumns.removeAll()
274− savedHiddenColumns.removeAll()
275 autoHiddenColumns.removeAll()
176 autoHiddenColumns.removeAll()
276 saveColumnConfig()
177 saveColumnConfig()
277 updateSQLColumns()
178 updateSQLColumns()
278 }
179 }
279
180
280− /// Rewrites the SELECT clause of `sqlQuery` to reflect the user's column
181+ /// Rewrites the SELECT clause of `sqlQuery` to reflect the current `visibleColumns`.
281− /// choices (order and hidden columns; auto-hidden columns stay display-only).
282 func updateSQLColumns() {
182 func updateSQLColumns() {
283− guard let range = SQLTokenizer.selectColumnListRange(in: sqlQuery),
183+ guard let range = SQLTokenizer.selectColumnListRange(in: sqlQuery) else { return }
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 }
292
184
293− // Columns an explicit list names that aren't in the results: Parseable
185+ let visible = visibleColumns
294− // omits all-null fields from query JSON, so a sparse listed field can be
186+ guard !visible.isEmpty else { return }
295− // missing from `columns`. Keep it (unless the user hid it) rather than
296− // silently dropping it from the SELECT on the next rewrite.
297− var listedButAbsent: [String] = []
298− var excludesHiddenColumn = false
299− if case .identifiers(let names) = currentList {
300− let present = Set(columns)
301− listedButAbsent = names.filter { !present.contains($0) && !savedHiddenColumns.contains($0) }
302− // A column the user hid is absent from these results precisely
303− // because this list was narrowed to exclude it.
304− excludesHiddenColumn = !savedHiddenColumns.isSubset(of: present)
305− }
306
187
307 let newColumnList: String
188 let newColumnList: String
308− // Use `*` whenever every column is visible, regardless of display order —
189+ if visible == columns {
309− // 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) {
314 newColumnList = "*"
190 newColumnList = "*"
315 } else {
191 } else {
316− newColumnList = (visible + listedButAbsent).map { Self.escapeSQLIdentifier($0) }.joined(separator: ", ")
192+ newColumnList = visible.map { Self.escapeSQLIdentifier($0) }.joined(separator: ", ")
317 }
193 }
318
194
319 sqlQuery.replaceSubrange(range, with: newColumnList)
195 sqlQuery.replaceSubrange(range, with: newColumnList)
320 }
196 }
321
197
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−
382 // MARK: - Column Configuration Persistence
198 // MARK: - Column Configuration Persistence
383
199
384 struct ColumnConfiguration: Codable {
200 struct ColumnConfiguration: Codable {
@@ -392,16 +208,7 @@ final class QueryViewModel {
392
208
393 private func saveColumnConfig() {
209 private func saveColumnConfig() {
394 guard let stream = currentStream else { return }
210 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))
211+ let config = ColumnConfiguration(order: columnOrder, hidden: hiddenColumns)
405 if let data = try? JSONEncoder().encode(config) {
212 if let data = try? JSONEncoder().encode(config) {
406 UserDefaults.standard.set(data, forKey: Self.columnConfigKey(for: stream))
213 UserDefaults.standard.set(data, forKey: Self.columnConfigKey(for: stream))
407 }
214 }
@@ -430,9 +237,7 @@ final class QueryViewModel {
430
237
431 guard let config else {
238 guard let config else {
432 columnOrder = extractedColumns
239 columnOrder = extractedColumns
433− savedColumnOrder = []
434 hiddenColumns = []
240 hiddenColumns = []
435− savedHiddenColumns = []
436 return
241 return
437 }
242 }
438
243
@@ -445,20 +250,7 @@ final class QueryViewModel {
445 merged.append(col)
250 merged.append(col)
446 }
251 }
447 columnOrder = merged
252 columnOrder = merged
448− savedColumnOrder = config.order
449− savedHiddenColumns = config.hidden
450 hiddenColumns = config.hidden.intersection(extractedSet)
253 hiddenColumns = config.hidden.intersection(extractedSet)
451−
452− // The `visibleCount > 1` guard in toggleColumnVisibility only covers
453− // user toggles. A saved config can hide every currently-extracted column
454− // (e.g. the stream's schema shrank so only previously-hidden columns
455− // remain), which would render the results table with zero columns and
456− // leave `updateSQLColumns` early-returning on the empty case. Keep at
457− // least the first column visible. Mirrors LiveTailViewModel.updateColumns.
458− if !columnOrder.isEmpty, hiddenColumns.count >= columnOrder.count,
459− let firstColumn = columnOrder.first {
460− hiddenColumns.remove(firstColumn)
461− }
462 }
254 }
463
255
464 private(set) var filteredResults: [LogRecord] = []
256 private(set) var filteredResults: [LogRecord] = []
@@ -481,22 +273,8 @@ final class QueryViewModel {
481 filteredResults = results
273 filteredResults = results
482 } else {
274 } else {
483 let text = filterText
275 let text = filterText
484− // The search-text cache is built asynchronously, so it may be empty
485− // or stale when the filter changes. `zip` would silently truncate to
486− // the shorter sequence and drop matching rows, so only use the cache
487− // when it lines up with the current results; otherwise compute the
488− // search text inline for correctness.
489− if cachedSearchTexts.count == results.count {
490− filteredResults = zip(results, cachedSearchTexts).compactMap { record, searchText in
276+ filteredResults = zip(results, cachedSearchTexts).compactMap { record, searchText in
491− searchText.localizedCaseInsensitiveContains(text) ? record : nil
277+ searchText.localizedCaseInsensitiveContains(text) ? record : nil
492− }
493− } else {
494− filteredResults = results.filter { record in
495− record.values
496− .map { $0.displayString }
497− .joined(separator: " ")
498− .localizedCaseInsensitiveContains(text)
499− }
500 }
278 }
501 }
279 }
502 }
280 }
@@ -540,13 +318,6 @@ final class QueryViewModel {
540
318
541 // Cancel any in-flight query
319 // Cancel any in-flight query
542 queryTask?.cancel()
320 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
550
321
551 isLoading = true
322 isLoading = true
552 errorMessage = nil
323 errorMessage = nil
@@ -555,44 +326,18 @@ final class QueryViewModel {
555 let startTime = CFAbsoluteTimeGetCurrent()
326 let startTime = CFAbsoluteTimeGetCurrent()
556
327
557 let task = Task {
328 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− }
568 do {
329 do {
569 try Task.checkCancellation()
330 try Task.checkCancellation()
570 let queryResults = try await client.query(sql: sql, startTime: startDate, endTime: endDate)
331 let queryResults = try await client.query(sql: sql, startTime: startDate, endTime: endDate)
571 try Task.checkCancellation()
332 try Task.checkCancellation()
572− // Re-check before committing. A superseding executeQuery() cancels
573− // this task (queryTask?.cancel()) before starting the next one, but
574− // cancellation landing just after the check above would otherwise
575− // let a stale query clobber the newer one's results/columns.
576− guard !Task.isCancelled else { return }
577− // Drop the response if a newer query (possibly for a different
578− // stream) has since been launched without cancelling this task.
579− guard latestQueryStream == stream else { return }
580
333
581 results = queryResults
334 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
586 queryDuration = CFAbsoluteTimeGetCurrent() - startTime
335 queryDuration = CFAbsoluteTimeGetCurrent() - startTime
587 resultCount = results.count
336 resultCount = results.count
588
337
589− // Detect if results were likely truncated. We know the cap for
338+ // Detect if results were likely truncated (only meaningful
590− // auto-generated queries; for user-entered SQL (which includes the
339+ // for auto-generated queries where we control the LIMIT)
591− // default query `setDefaultQuery` writes into the editor — the common
340+ resultsTruncated = usedAutoLimit && results.count == limit
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
596
341
597 // Extract columns in a single pass, with priority fields first,
342 // Extract columns in a single pass, with priority fields first,
598 // then apply any saved column configuration for this stream
343 // then apply any saved column configuration for this stream
@@ -619,36 +364,19 @@ final class QueryViewModel {
619 return
364 return
620 } catch {
365 } catch {
621 guard !Task.isCancelled else { return }
366 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 }
625 errorMessage = ParseableError.userFriendlyMessage(for: error)
367 errorMessage = ParseableError.userFriendlyMessage(for: error)
626 if let serverError = error as? ParseableError,
368 if let serverError = error as? ParseableError,
627 case .serverError(_, let msg) = serverError,
369 case .serverError(_, let msg) = serverError,
628− let pos = SQLErrorPosition.parse(from: msg),
370+ 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.)
637 errorRange = SQLTokenizer.errorHighlightRange(
371 errorRange = SQLTokenizer.errorHighlightRange(
638− line: pos.line, column: pos.column, in: sql
372+ line: pos.line, column: pos.column, in: sqlQuery
639 )
373 )
640 }
374 }
641 results = []
375 results = []
642 columns = []
376 columns = []
643− // Drop the previous query's column layout too, otherwise derived
644− // state like `visibleColumns` (and a CSV export reading it) would
645− // report stale column names after the failure.
646− columnOrder = []
647− hiddenColumns = []
648− savedHiddenColumns = []
649− savedColumnOrder = []
650− autoHiddenColumns = []
651 }
377 }
378+
379+ isLoading = false
652 }
380 }
653 queryTask = task
381 queryTask = task
654 await task.value
382 await task.value
@@ -665,20 +393,14 @@ final class QueryViewModel {
665
393
666 @MainActor
394 @MainActor
667 func loadSchema(client: ParseableClient?, stream: String?) async {
395 func loadSchema(client: ParseableClient?, stream: String?) async {
668− latestSchemaStream = stream
669 guard let client, let stream else {
396 guard let client, let stream else {
670 schemaFields = []
397 schemaFields = []
671 return
398 return
672 }
399 }
673 do {
400 do {
674 let schema = try await client.getStreamSchema(stream: stream)
401 let schema = try await client.getStreamSchema(stream: stream)
675− // A newer load (e.g. a rapid stream switch) may have superseded this
676− // one; only commit if this is still the stream last requested,
677− // otherwise we'd show the wrong stream's fields in autocomplete.
678− guard latestSchemaStream == stream else { return }
679 schemaFields = schema.fields
402 schemaFields = schema.fields
680 } catch {
403 } catch {
681− guard latestSchemaStream == stream else { return }
682 // Schema load is best-effort; don't surface errors
404 // Schema load is best-effort; don't surface errors
683 schemaFields = []
405 schemaFields = []
684 }
406 }
@@ -708,9 +430,7 @@ final class QueryViewModel {
708 var empty = Set(columns)
430 var empty = Set(columns)
709 for record in records {
431 for record in records {
710 if empty.isEmpty { break }
432 if empty.isEmpty { break }
711− // Iterate a snapshot: mutating `empty` while iterating it directly
712− // is undefined behavior and can skip elements or trap.
713− for column in Array(empty) {
433+ for column in empty {
714 if let value = record[column], value != .null, value != .string("") {
434 if let value = record[column], value != .null, value != .string("") {
715 empty.remove(column)
435 empty.remove(column)
716 }
436 }
@@ -719,66 +439,41 @@ final class QueryViewModel {
719 return empty
439 return empty
720 }
440 }
721
441
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.
725 func exportAsJSON() -> String {
442 func exportAsJSON() -> String {
726− let rows = filteredResults
727− guard !rows.isEmpty else { return "[]" }
443+ guard !results.isEmpty else { return "[]" }
728 let encoder = JSONEncoder()
444 let encoder = JSONEncoder()
729 encoder.outputFormatting = [.prettyPrinted, .sortedKeys]
445 encoder.outputFormatting = [.prettyPrinted, .sortedKeys]
730− if let data = try? encoder.encode(Self.projectRecords(rows, to: visibleColumns)),
446+ if let data = try? encoder.encode(results),
731 let string = String(data: data, encoding: .utf8) {
447 let string = String(data: data, encoding: .utf8) {
732 return string
448 return string
733 }
449 }
734 return "[]"
450 return "[]"
735 }
451 }
736
452
737− /// Exports the on-screen rows as CSV. Uses `filteredResults` for the same
738− /// reason as `exportAsJSON`.
739 func exportAsCSV() -> String {
453 func exportAsCSV() -> String {
740− Self.buildCSV(records: filteredResults, columns: visibleColumns)
454+ Self.buildCSV(records: results, columns: visibleColumns)
741− }
742−
743− /// Projects each record down to the given columns so JSON export, like CSV,
744− /// respects hidden columns instead of leaking every field. Safe to call from
745− /// any thread.
746− nonisolated static func projectRecords(_ records: [LogRecord], to columns: [String]) -> [LogRecord] {
747− // No column list means no restriction (columns not computed yet) — export
748− // the full records rather than stripping every field to an empty object.
749− guard !columns.isEmpty else { return records }
750− let allowed = Set(columns)
751− return records.map { record in record.filter { allowed.contains($0.key) } }
752 }
455 }
753
456
754 /// Builds a CSV string from records and columns. Safe to call from any thread.
457 /// Builds a CSV string from records and columns. Safe to call from any thread.
755− nonisolated static func buildCSV(records: [LogRecord], columns: [String]) -> String {
458+ static func buildCSV(records: [LogRecord], columns: [String]) -> String {
756 guard !records.isEmpty, !columns.isEmpty else { return "" }
459 guard !records.isEmpty, !columns.isEmpty else { return "" }
757
460
758− var csv = String()
461+ var lines: [String] = []
759− csv.reserveCapacity(columns.count * 50 * (records.count + 1))
462+ lines.reserveCapacity(records.count + 1)
463+ lines.append(columns.map { escapeCSV($0) }.joined(separator: ","))
760
464
761− // Header
762− for (i, col) in columns.enumerated() {
763− if i > 0 { csv.append(",") }
764− csv.append(escapeCSV(col))
765− }
766− csv.append("\n")
767−
768− // Rows
769 for record in records {
465 for record in records {
770− for (i, column) in columns.enumerated() {
466+ let row = columns.map { column in
771− if i > 0 { csv.append(",") }
772 let value = record[column]?.exportString ?? ""
467 let value = record[column]?.exportString ?? ""
773− csv.append(escapeCSV(value))
468+ return escapeCSV(value)
774 }
469 }
775− csv.append("\n")
470+ lines.append(row.joined(separator: ","))
776 }
471 }
777
472
778− return csv
473+ return lines.joined(separator: "\n") + "\n"
779 }
474 }
780
475
781− nonisolated private static func escapeCSV(_ value: String) -> String {
476+ private static func escapeCSV(_ value: String) -> String {
782 // Check at the Unicode scalar level to correctly detect \r and \n
477 // Check at the Unicode scalar level to correctly detect \r and \n
783 // inside \r\n (CRLF) grapheme clusters, which Swift's String.contains
478 // inside \r\n (CRLF) grapheme clusters, which Swift's String.contains
784 // treats as a single Character that doesn't match "\r" or "\n" alone.
479 // treats as a single Character that doesn't match "\r" or "\n" alone.
@@ -792,29 +487,10 @@ final class QueryViewModel {
792 }
487 }
793
488
794 func clearResults() {
489 func clearResults() {
795− // Cancel any in-flight work first. Without this, a query still running
796− // when the user switches streams or disconnects would pass its post-await
797− // cancellation check (it was never cancelled) and write its results +
798− // persist a column config for the *old* stream after this reset.
799− queryTask?.cancel()
800− queryTask = nil
801− searchTextTask?.cancel()
802− searchTextTask = nil
803− filterTask?.cancel()
804− filterTask = nil
805−
806− // The in-flight query was cancelled above, so its task returns via the
807− // CancellationError path and never reaches `isLoading = false`. Reset it
808− // here, otherwise deselecting the stream / disconnecting (which calls
809− // clearResults with no follow-up query) leaves the spinner stuck on.
810− isLoading = false
811−
812 results = []
490 results = []
813 columns = []
491 columns = []
814 columnOrder = []
492 columnOrder = []
815 hiddenColumns = []
493 hiddenColumns = []
816− savedHiddenColumns = []
817− savedColumnOrder = []
818 autoHiddenColumns = []
494 autoHiddenColumns = []
819 currentStream = nil
495 currentStream = nil
820 resultCount = 0
496 resultCount = 0
@@ -824,11 +500,6 @@ final class QueryViewModel {
824 errorRange = nil
500 errorRange = nil
825 resultsTruncated = false
501 resultsTruncated = false
826 schemaFields = []
502 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
832 }
503 }
833
504
834 /// Modifies `sqlQuery` to add a column-value filter condition.
505 /// Modifies `sqlQuery` to add a column-value filter condition.
@@ -838,139 +509,34 @@ final class QueryViewModel {
838 let condition: String
509 let condition: String
839 if let value, value != .null {
510 if let value, value != .null {
840 let literal = value.sqlLiteral
511 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)"
512+ condition = exclude ? "\(escapedCol) <> \(literal)" : "\(escapedCol) = \(literal)"
848 } else {
513 } else {
849 condition = exclude ? "\(escapedCol) IS NOT NULL" : "\(escapedCol) IS NULL"
514 condition = exclude ? "\(escapedCol) IS NOT NULL" : "\(escapedCol) IS NULL"
850 }
515 }
851
516
852− let sql = sqlQuery.trimmingCharacters(in: .whitespacesAndNewlines)
517+ var sql = sqlQuery.trimmingCharacters(in: .whitespacesAndNewlines)
853 if sql.isEmpty { return }
518 if sql.isEmpty { return }
854
519
855− // Locate the top-level WHERE and the first trailing clause using the
520+ let wherePattern = #"(?i)\bWHERE\b"#
856− // tokenizer rather than a raw regex, so keywords inside quoted
521+ let tailPattern = #"(?i)\b(ORDER\s+BY|GROUP\s+BY|HAVING|LIMIT)\b"#
857− // identifiers, string literals, comments, or subqueries are ignored.
522+
858− let tokens = SQLTokenizer.tokenize(sql)
523+ if let whereRange = sql.range(of: wherePattern, options: .regularExpression) {
859− var depth = 0
524+ let afterWhere = whereRange.upperBound
860− // WHERE and the trailing clauses only follow the top-level FROM. Keywords
525+ let searchRange = afterWhere..<sql.endIndex
861− // before it belong to the SELECT list — e.g. a field named `offset` or
526+ if let tailRange = sql.range(of: tailPattern, options: .regularExpression, range: searchRange) {
862− // `limit` (which the tokenizer reports as keywords) — and must not be
527+ sql.insert(contentsOf: "AND \(condition) ", at: tailRange.lowerBound)
863− // mistaken for clause starts.
864− var fromSeen = false
865− var whereIndex: Int?
866− var tailIndex: Int?
867− // Track whether the existing WHERE body has a top-level OR — only then does
868− // AND-ing a new condition need parentheses to preserve precedence.
869− var bodyHasTopLevelOr = false
870− // The last token that is actual SQL — not whitespace, a comment, or a
871− // statement-terminating `;`. New text is spliced in right after such
872− // tokens (never at the raw end of the string), so a trailing `;` or a
873− // trailing `-- comment` stays at the end instead of swallowing the
874− // appended condition: `... LIMIT 10;` must not become `... LIMIT 10; WHERE`,
875− // and `... -- note` must not become `... -- note WHERE <commented out>`.
876− func isSignificant(_ token: SQLTokenizer.Token) -> Bool {
877− !token.kind.isTrivia && token.kind != .other(";")
878− }
879− func nextSignificantKind(after index: Int) -> SQLTokenizer.TokenKind? {
880− tokens[(index + 1)...].first(where: { isSignificant($0) })?.kind
881− }
882− // Whether the keyword at `index` actually opens a trailing clause. ORDER and
883− // GROUP must be followed by BY (so `WITHIN GROUP (…)` or a column named
884− // `order` doesn't count), and LIMIT/OFFSET by their row count (so a column
885− // named `offset` in `WHERE offset > 5` doesn't cut the WHERE body short).
886− func startsTrailingClause(_ keyword: String, at index: Int) -> Bool {
887− switch keyword {
888− case "ORDER", "GROUP":
889− return nextSignificantKind(after: index) == .keyword("BY")
890− case "LIMIT", "OFFSET":
891− if case .number = nextSignificantKind(after: index) { return true }
892− return false
893− case "HAVING":
894− return true
895− default:
896− return false
897− }
898− }
899− var lastSignificant: Int?
900− for (idx, token) in tokens.enumerated() {
901− if isSignificant(token) { lastSignificant = idx }
902− switch token.kind {
903− case .leftParen:
904− depth += 1
905− case .rightParen:
906− 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 {
921− bodyHasTopLevelOr = true
922− }
923− default:
924− break
925− }
926− }
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...]))"
959 } else {
528 } else {
960− // An empty WHERE body (e.g. a half-written `... WHERE` with no
529+ sql += " AND \(condition)"
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...]))"
966 }
530 }
967− } else if let tailIndex {
968− let tailStart = tokens[tailIndex].range.lowerBound
969− sqlQuery = "\(sql[..<tailStart])WHERE \(condition) \(sql[tailStart...])"
970 } else {
531 } else {
971− let statementEnd = tokens[lastSignificant].range.upperBound
532+ if let tailRange = sql.range(of: tailPattern, options: .regularExpression) {
972− sqlQuery = "\(sql[..<statementEnd]) WHERE \(condition)\(separated(sql[statementEnd...]))"
533+ sql.insert(contentsOf: "WHERE \(condition) ", at: tailRange.lowerBound)
534+ } else {
535+ sql += " WHERE \(condition)"
536+ }
973 }
537 }
538+
539+ sqlQuery = sql
974 }
540 }
975
541
976 /// Sets the default query for the given stream, returning `true` if the
542 /// Sets the default query for the given stream, returning `true` if the
@@ -994,35 +560,19 @@ final class QueryViewModel {
994 }
560 }
995
561
996 /// Escapes a SQL identifier by doubling internal double-quotes, then wrapping in double-quotes.
562 /// Escapes a SQL identifier by doubling internal double-quotes, then wrapping in double-quotes.
997− nonisolated static func escapeSQLIdentifier(_ identifier: String) -> String {
563+ static func escapeSQLIdentifier(_ identifier: String) -> String {
998 "\"\(identifier.replacingOccurrences(of: "\"", with: "\"\""))\""
564 "\"\(identifier.replacingOccurrences(of: "\"", with: "\"\""))\""
999 }
565 }
1000
566
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−
1014 // MARK: - Query History
567 // MARK: - Query History
1015
568
1016 private func addToHistory(_ entry: QueryHistoryEntry) {
569 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()
1021 // Deduplicate consecutive identical queries
570 // Deduplicate consecutive identical queries
1022 if queryHistory.first?.sql == entry.sql { return }
571 if queryHistory.first?.sql == entry.sql { return }
1023 queryHistory.insert(entry, at: 0)
572 queryHistory.insert(entry, at: 0)
1024 if queryHistory.count > Self.maxHistory {
573 if queryHistory.count > Self.maxHistory {
1025 queryHistory = Array(queryHistory.prefix(Self.maxHistory))
574 queryHistory = Array(queryHistory.prefix(Self.maxHistory))
575+ historyIsFull = true
1026 }
576 }
1027 Self.saveHistory(queryHistory)
577 Self.saveHistory(queryHistory)
1028 }
578 }
ParseableViewer/Views/AlertsView.swift+4−32
@@ -5,10 +5,6 @@ struct AlertsView: View {
5 @State private var alertConfig: AlertConfig?
5 @State private var alertConfig: AlertConfig?
6 @State private var isLoading = false
6 @State private var isLoading = false
7 @State private var errorMessage: String?
7 @State private var errorMessage: String?
8− /// Bumped by every load. SwiftUI cancels the previous `.task(id:)` load on a
9− /// stream switch, but that load still resumes (with a cancellation error) —
10− /// and must not touch `isLoading`, which now belongs to the newer load.
11− @State private var loadGeneration = 0
12
8
13 var body: some View {
9 var body: some View {
14 Group {
10 Group {
@@ -67,7 +63,7 @@ struct AlertsView: View {
67 .frame(maxWidth: .infinity, maxHeight: .infinity)
63 .frame(maxWidth: .infinity, maxHeight: .infinity)
68 }
64 }
69 }
65 }
70− .task(id: "\(appState.activeConnection?.id.uuidString ?? "")|\(stream)") {
66+ .task(id: stream) {
71 await loadAlerts(stream: stream)
67 await loadAlerts(stream: stream)
72 }
68 }
73 } else {
69 } else {
@@ -80,31 +76,13 @@ struct AlertsView: View {
80
76
81 private func loadAlerts(stream: String) async {
77 private func loadAlerts(stream: String) async {
82 guard let client = appState.client else { return }
78 guard let client = appState.client else { return }
83− let connID = appState.activeConnection?.id
84− loadGeneration &+= 1
85− let generation = loadGeneration
86 isLoading = true
79 isLoading = true
87 errorMessage = nil
80 errorMessage = nil
88 alertConfig = nil
81 alertConfig = nil
89
82
90 do {
83 do {
91− let config = try await client.getAlerts(stream: stream)
84+ alertConfig = try await client.getAlerts(stream: stream)
92− // Superseded by a newer load: leave its spinner and state alone.
93− guard generation == loadGeneration else { return }
94− // The manual Refresh/Retry buttons spawn detached Tasks not tied to
95− // `.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.
99− // Still clear isLoading on the stale path: this task set the spinner,
100− // so leaving it true wedges the ProgressView and disables Refresh.
101− guard stream == appState.selectedStream,
102− connID == appState.activeConnection?.id else { isLoading = false; return }
103− alertConfig = config
104 } catch {
85 } catch {
105− guard generation == loadGeneration else { return }
106− guard stream == appState.selectedStream,
107− connID == appState.activeConnection?.id else { isLoading = false; return }
108 errorMessage = ParseableError.userFriendlyMessage(for: error)
86 errorMessage = ParseableError.userFriendlyMessage(for: error)
109 }
87 }
110
88
@@ -212,14 +190,8 @@ struct AlertRowView: View {
212 private var stateColor: Color {
190 private var stateColor: Color {
213 switch alert.state?.lowercased() {
191 switch alert.state?.lowercased() {
214 case "triggered": return .red
192 case "triggered": return .red
215− case "disabled", "silenced": return .gray
193+ case "disabled": return .gray
216− // Parseable's default, healthy state (and 2.x's "resolved") — not a warning.
194+ default: return .orange
217− case "not-triggered", "resolved": return .green
218− // A reported-but-unrecognized state is a genuine "needs attention" signal,
219− // so tint it orange; a missing state is simply unknown and stays neutral so
220− // the bell icon isn't painted as a warning for every stateless alert.
221− case .some: return .orange
222− case nil: return .secondary
223 }
195 }
224 }
196 }
225
197
ParseableViewer/Views/ConnectionSheet.swift+13−74
@@ -14,14 +14,6 @@ struct ConnectionSheet: View {
14 @State private var isSaving = false
14 @State private var isSaving = false
15 @State private var testResult: TestResult?
15 @State private var testResult: TestResult?
16 @State private var urlValidationError: String?
16 @State private var urlValidationError: String?
17− /// For a new connection, holds the connection created on the first save so a
18− /// retry (after a failed connect) updates it in place rather than appending a
19− /// duplicate.
20− @State private var createdConnection: ServerConnection?
21− /// Bumped on every field edit and every test run, so an in-flight Test
22− /// Connection whose fields have since changed doesn't post its (now stale)
23− /// success/failure under values that were never tested.
24− @State private var testGeneration = 0
25
17
26 enum TestResult {
18 enum TestResult {
27 case success(String)
19 case success(String)
@@ -47,15 +39,12 @@ struct ConnectionSheet: View {
47 TextField("Name:", text: $name)
39 TextField("Name:", text: $name)
48 .textFieldStyle(.roundedBorder)
40 .textFieldStyle(.roundedBorder)
49 .accessibilityLabel("Connection name")
41 .accessibilityLabel("Connection name")
50− .onChange(of: name) { _, _ in invalidateTestResult() }
51
42
52 TextField("Server URL:", text: $url)
43 TextField("Server URL:", text: $url)
53 .textFieldStyle(.roundedBorder)
44 .textFieldStyle(.roundedBorder)
54 .accessibilityLabel("Server URL")
45 .accessibilityLabel("Server URL")
55 .onChange(of: url) { _, newValue in
46 .onChange(of: url) { _, newValue in
56 urlValidationError = Self.validateURL(newValue)
47 urlValidationError = Self.validateURL(newValue)
57− // A prior test result no longer reflects the edited fields.
58− invalidateTestResult()
59 }
48 }
60
49
61 if let urlError = urlValidationError, !url.isEmpty {
50 if let urlError = urlValidationError, !url.isEmpty {
@@ -67,12 +56,10 @@ struct ConnectionSheet: View {
67 TextField("Username:", text: $username)
56 TextField("Username:", text: $username)
68 .textFieldStyle(.roundedBorder)
57 .textFieldStyle(.roundedBorder)
69 .accessibilityLabel("Username")
58 .accessibilityLabel("Username")
70− .onChange(of: username) { _, _ in invalidateTestResult() }
71
59
72 SecureField("Password:", text: $password)
60 SecureField("Password:", text: $password)
73 .textFieldStyle(.roundedBorder)
61 .textFieldStyle(.roundedBorder)
74 .accessibilityLabel("Password")
62 .accessibilityLabel("Password")
75− .onChange(of: password) { _, _ in invalidateTestResult() }
76 }
63 }
77 .formStyle(.grouped)
64 .formStyle(.grouped)
78 .padding()
65 .padding()
@@ -119,22 +106,10 @@ struct ConnectionSheet: View {
119 Button("Test Connection") {
106 Button("Test Connection") {
120 testConnection()
107 testConnection()
121 }
108 }
122− .disabled(
123− url.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty
124− || username.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty
125− || isTesting
126− || urlValidationError != nil
109+ .disabled(url.isEmpty || username.isEmpty || isTesting || urlValidationError != nil)
127− )
128 .accessibilityLabel("Test connection")
110 .accessibilityLabel("Test connection")
129
111
130 Button("Cancel") {
112 Button("Cancel") {
131− // A new connection is persisted on the first save so a retry
132− // updates it in place. If the user abandons it via Cancel
133− // (e.g. after a failed connect), remove that never-connected
134− // entry so it doesn't linger in the sidebar.
135− if let created = createdConnection {
136− appState.removeConnection(created)
137− }
138 password = ""
113 password = ""
139 dismiss()
114 dismiss()
140 }
115 }
@@ -144,13 +119,7 @@ struct ConnectionSheet: View {
144 saveAndConnect()
119 saveAndConnect()
145 }
120 }
146 .keyboardShortcut(.defaultAction)
121 .keyboardShortcut(.defaultAction)
147− .disabled(
148− name.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty
149− || url.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty
150− || username.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty
151− || isSaving
152− || urlValidationError != nil
122+ .disabled(name.isEmpty || url.isEmpty || username.isEmpty || isSaving || urlValidationError != nil)
153− )
154 }
123 }
155 .padding()
124 .padding()
156 }
125 }
@@ -162,47 +131,26 @@ struct ConnectionSheet: View {
162 username = connection.username
131 username = connection.username
163 password = connection.password
132 password = connection.password
164 }
133 }
165− // Validate the pre-filled URL on open. onChange(of:url) doesn't fire
166− // for the initial assignment above, so without this an invalid stored
167− // URL would show no error and pass the Save guard.
168− urlValidationError = Self.validateURL(url)
169 }
134 }
170 }
135 }
171
136
172− private func invalidateTestResult() {
173− testGeneration += 1
174− testResult = nil
175− }
176−
177 private func testConnection() {
137 private func testConnection() {
178 isTesting = true
138 isTesting = true
179 testResult = nil
139 testResult = nil
180− testGeneration += 1
181− let generation = testGeneration
182
140
183 Task {
141 Task {
184 do {
142 do {
185− let conn = ServerConnection(
143+ let conn = ServerConnection(name: name, url: url, username: username, password: password)
186− name: name.trimmingCharacters(in: .whitespacesAndNewlines),
187− url: url.trimmingCharacters(in: .whitespacesAndNewlines),
188− username: username.trimmingCharacters(in: .whitespacesAndNewlines),
189− password: password
190− )
191 let client = try ParseableClient(connection: conn)
144 let client = try ParseableClient(connection: conn)
192 try await client.checkHealth()
145 try await client.checkHealth()
193− // The health endpoint doesn't check credentials; see
194− // AppState.verifyCredentials.
195− try await AppState.verifyCredentials(with: client)
196 await MainActor.run {
146 await MainActor.run {
197− isTesting = false
198− guard generation == testGeneration else { return }
199 testResult = .success("Connection successful")
147 testResult = .success("Connection successful")
148+ isTesting = false
200 }
149 }
201 } catch {
150 } catch {
202 await MainActor.run {
151 await MainActor.run {
203− isTesting = false
204− guard generation == testGeneration else { return }
205 testResult = .failure(ParseableError.userFriendlyMessage(for: error))
152 testResult = .failure(ParseableError.userFriendlyMessage(for: error))
153+ isTesting = false
206 }
154 }
207 }
155 }
208 }
156 }
@@ -211,36 +159,27 @@ struct ConnectionSheet: View {
211 private func saveAndConnect() {
159 private func saveAndConnect() {
212 guard !isSaving else { return }
160 guard !isSaving else { return }
213
161
214− let trimmedName = name.trimmingCharacters(in: .whitespacesAndNewlines)
215− let trimmedURL = url.trimmingCharacters(in: .whitespacesAndNewlines)
216− let trimmedUsername = username.trimmingCharacters(in: .whitespacesAndNewlines)
217−
218 let conn: ServerConnection
162 let conn: ServerConnection
219− if let existing = connection ?? createdConnection {
163+ if let existing = connection {
220− conn = ServerConnection(id: existing.id, name: trimmedName, url: trimmedURL, username: trimmedUsername, password: password)
164+ conn = ServerConnection(id: existing.id, name: name, url: url, username: username, password: password)
221 appState.updateConnection(conn)
165 appState.updateConnection(conn)
222 } else {
166 } else {
223− conn = ServerConnection(name: trimmedName, url: trimmedURL, username: trimmedUsername, password: password)
167+ conn = ServerConnection(name: name, url: url, username: username, password: password)
224 appState.addConnection(conn)
168 appState.addConnection(conn)
225− createdConnection = conn
226 }
169 }
227
170
228 isSaving = true
171 isSaving = true
229 testResult = nil
172 testResult = nil
230
173
231 Task {
174 Task {
232− let success = await appState.connect(to: conn)
175+ await appState.connect(to: conn)
233 await MainActor.run {
176 await MainActor.run {
234 isSaving = false
177 isSaving = false
235− if success {
178+ if appState.errorMessage != nil {
179+ testResult = .failure(appState.errorMessage ?? "Connection failed")
180+ } else {
236 password = ""
181 password = ""
237 dismiss()
182 dismiss()
238− } else {
239− testResult = .failure(appState.errorMessage ?? "Connection failed")
240− // The sheet reports the failure inline; drop the app-wide alert
241− // performConnect also raised, which would otherwise surface
242− // behind/after the sheet (or twice, from the Settings window).
243− appState.showError = false
244 }
183 }
245 }
184 }
246 }
185 }
@@ -251,7 +190,7 @@ struct ConnectionSheet: View {
251 guard !trimmed.isEmpty else { return nil }
190 guard !trimmed.isEmpty else { return nil }
252
191
253 var normalized = trimmed
192 var normalized = trimmed
254− if !ServerConnection.hasURLScheme(normalized) {
193+ if !normalized.hasPrefix("http://") && !normalized.hasPrefix("https://") {
255 normalized = "https://\(normalized)"
194 normalized = "https://\(normalized)"
256 }
195 }
257
196
ParseableViewer/Views/ContentView.swift+36−72
@@ -10,7 +10,21 @@ struct ContentView: View {
10 SidebarView()
10 SidebarView()
11 } detail: {
11 } detail: {
12 if appState.isConnected {
12 if appState.isConnected {
13+ if appState.selectedStream != nil {
13− MainContentView()
14+ MainContentView()
15+ } else {
16+ VStack(spacing: 12) {
17+ Image(systemName: "sidebar.left")
18+ .font(.system(size: 48))
19+ .foregroundStyle(.secondary)
20+ Text("Select a stream")
21+ .font(.title2)
22+ .foregroundStyle(.secondary)
23+ Text("Choose a log stream from the sidebar to get started.")
24+ .foregroundStyle(.tertiary)
25+ }
26+ .frame(maxWidth: .infinity, maxHeight: .infinity)
27+ }
14 } else {
28 } else {
15 WelcomeView()
29 WelcomeView()
16 }
30 }
@@ -19,10 +33,7 @@ struct ContentView: View {
19 ConnectionSheet(connection: appState.editingConnection)
33 ConnectionSheet(connection: appState.editingConnection)
20 }
34 }
21 .alert("Error", isPresented: $appState.showError) {
35 .alert("Error", isPresented: $appState.showError) {
22− Button("OK") {
23− appState.showError = false
36+ Button("OK") { appState.showError = false }
24− appState.errorMessage = nil
25− }
26 } message: {
37 } message: {
27 Text(appState.errorMessage ?? "An unknown error occurred")
38 Text(appState.errorMessage ?? "An unknown error occurred")
28 }
39 }
@@ -31,18 +42,6 @@ struct ContentView: View {
31
42
32struct MainContentView: View {
43struct MainContentView: View {
33 @Environment(AppState.self) private var appState
44 @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− }
46
45
47 var body: some View {
46 var body: some View {
48 @Bindable var appState = appState
47 @Bindable var appState = appState
@@ -67,63 +66,28 @@ struct MainContentView: View {
67
66
68 Divider()
67 Divider()
69
68
70− // Content — only the active tab is instantiated. Stream-scoped tabs
69+ // Content — only the active tab is instantiated
71− // show a placeholder until a stream is selected; server-level tabs
72− // (Users, Server Info) render regardless.
73− if appState.currentTab.requiresStream && appState.selectedStream == nil {
74− SelectStreamPlaceholder()
75− } else {
76− switch appState.currentTab {
70+ switch appState.currentTab {
77− case .query:
71+ case .query:
78− QueryView(viewModel: queryViewModel)
72+ QueryView()
79− .id(AppState.AppTab.query)
73+ .id(AppState.AppTab.query)
80− case .liveTail:
74+ case .liveTail:
81− LiveTailView()
75+ LiveTailView()
82− .id(AppState.AppTab.liveTail)
76+ .id(AppState.AppTab.liveTail)
83− case .streamInfo:
77+ case .streamInfo:
84− StreamDetailView()
78+ StreamDetailView()
85− .id(AppState.AppTab.streamInfo)
79+ .id(AppState.AppTab.streamInfo)
86− case .alerts:
80+ case .alerts:
87− AlertsView()
81+ AlertsView()
88− .id(AppState.AppTab.alerts)
82+ .id(AppState.AppTab.alerts)
89− case .users:
83+ case .users:
90− UsersView()
84+ UsersView()
91− .id(AppState.AppTab.users)
85+ .id(AppState.AppTab.users)
92− case .serverInfo:
86+ case .serverInfo:
93− ServerInfoView()
87+ ServerInfoView()
94− .id(AppState.AppTab.serverInfo)
88+ .id(AppState.AppTab.serverInfo)
95− }
96 }
89 }
97 }
90 }
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− }
111− }
112−}
113−
114−struct SelectStreamPlaceholder: View {
115− var body: some View {
116− VStack(spacing: 12) {
117− Image(systemName: "sidebar.left")
118− .font(.system(size: 48))
119− .foregroundStyle(.secondary)
120− Text("Select a stream")
121− .font(.title2)
122− .foregroundStyle(.secondary)
123− Text("Choose a log stream from the sidebar to get started.")
124− .foregroundStyle(.tertiary)
125− }
126− .frame(maxWidth: .infinity, maxHeight: .infinity)
127 }
91 }
128}
92}
129
93
ParseableViewer/Views/LiveTailView.swift+41−121
@@ -3,10 +3,7 @@ import SwiftUI
3struct LiveTailView: View {
3struct LiveTailView: View {
4 @Environment(AppState.self) private var appState
4 @Environment(AppState.self) private var appState
5 @State private var viewModel = LiveTailViewModel()
5 @State private var viewModel = LiveTailViewModel()
6− /// Selection is tracked by entry id, not by record value: live-tail rows
7− /// frequently share identical content (repeated log lines), so comparing by
8− /// record value would select/toggle every matching row at once.
9− @State private var selectedEntryID: UUID?
6+ @State private var selectedRecord: LogRecord?
10 @State private var autoScroll = true
7 @State private var autoScroll = true
11 @State private var pulseOpacity: Double = 1.0
8 @State private var pulseOpacity: Double = 1.0
12 @State private var showColumnPopover = false
9 @State private var showColumnPopover = false
@@ -15,10 +12,8 @@ struct LiveTailView: View {
15 @State private var sortAscending = false
12 @State private var sortAscending = false
16 @State private var columnWidths: [String: CGFloat] = [:]
13 @State private var columnWidths: [String: CGFloat] = [:]
17 @State private var cachedSorted: [LiveTailViewModel.LiveTailEntry] = []
14 @State private var cachedSorted: [LiveTailViewModel.LiveTailEntry] = []
15+ @State private var cachedSeverities: [SeverityLevel] = []
18 @State private var severityColumnSet: Set<String> = []
16 @State private var severityColumnSet: Set<String> = []
19− /// Severity per entry id, precomputed on rebuild so it isn't recomputed for
20− /// every visible row on each scroll/render.
21− @State private var severityByID: [UUID: SeverityLevel] = [:]
22 @State private var liveTailSortTask: Task<Void, Never>?
17 @State private var liveTailSortTask: Task<Void, Never>?
23
18
24 var body: some View {
19 var body: some View {
@@ -115,7 +110,7 @@ struct LiveTailView: View {
115
110
116 Button {
111 Button {
117 viewModel.clear()
112 viewModel.clear()
118− selectedEntryID = nil
113+ selectedRecord = nil
119 sortColumn = nil
114 sortColumn = nil
120 columnWidths = [:]
115 columnWidths = [:]
121 cachedSorted = []
116 cachedSorted = []
@@ -123,10 +118,7 @@ struct LiveTailView: View {
123 Image(systemName: "trash")
118 Image(systemName: "trash")
124 }
119 }
125 .help("Clear entries")
120 .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)
121+ .keyboardShortcut(.delete, modifiers: .command)
130 .accessibilityLabel("Clear all entries")
122 .accessibilityLabel("Clear all entries")
131 }
123 }
132 .padding(8)
124 .padding(8)
@@ -139,21 +131,10 @@ struct LiveTailView: View {
139 Circle()
131 Circle()
140 .fill(viewModel.isPaused ? .yellow : .green)
132 .fill(viewModel.isPaused ? .yellow : .green)
141 .frame(width: 8, height: 8)
133 .frame(width: 8, height: 8)
142− .opacity(pulseOpacity)
134+ .opacity(viewModel.isPaused ? 1.0 : pulseOpacity)
143 .accessibilityLabel(viewModel.isPaused ? "Status: Paused" : "Status: Streaming")
135 .accessibilityLabel(viewModel.isPaused ? "Status: Paused" : "Status: Streaming")
144− // Drive the pulse with an explicit, re-triggerable animation.
136+ .animation(.easeInOut(duration: 1.0).repeatForever(autoreverses: true), value: pulseOpacity)
145− // The previous one-shot `pulseOpacity = 0.3` only changed the
137+ .onAppear { pulseOpacity = 0.3 }
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− }
157 .onDisappear { pulseOpacity = 1.0 }
138 .onDisappear { pulseOpacity = 1.0 }
158 Text(viewModel.isPaused ? "Paused" : "Streaming")
139 Text(viewModel.isPaused ? "Paused" : "Streaming")
159 .font(.caption)
140 .font(.caption)
@@ -267,125 +248,61 @@ struct LiveTailView: View {
267 .frame(minWidth: 400)
248 .frame(minWidth: 400)
268
249
269 // Detail pane
250 // Detail pane
270− if let id = selectedEntryID,
251+ if let record = selectedRecord {
271− let entry = cachedSorted.first(where: { $0.id == id }) {
272− LogDetailView(record: entry.record)
252+ LogDetailView(record: record)
273 .frame(minWidth: 280, idealWidth: 320)
253 .frame(minWidth: 280, idealWidth: 320)
274 }
254 }
275 }
255 }
276 .onDisappear {
256 .onDisappear {
277 viewModel.stop()
257 viewModel.stop()
278− liveTailSortTask?.cancel()
279 }
258 }
280 .onChange(of: appState.selectedStream) { _, _ in
259 .onChange(of: appState.selectedStream) { _, _ in
281 if viewModel.isRunning {
260 if viewModel.isRunning {
282 viewModel.stop()
261 viewModel.stop()
283 }
262 }
284 viewModel.clear()
263 viewModel.clear()
285− selectedEntryID = nil
264+ selectedRecord = nil
286 sortColumn = nil
265 sortColumn = nil
287 columnWidths = [:]
266 columnWidths = [:]
288 cachedSorted = []
267 cachedSorted = []
289 }
268 }
290 .onChange(of: viewModel.visibleColumns) { _, newColumns in
269 .onChange(of: viewModel.visibleColumns) { _, newColumns in
291− // Hiding the sort column leaves no header to show or change the sort,
270+ // Compute widths for any newly visible columns
292− // while rows stay ordered by the invisible value (and auto-scroll stays
271+ let records = viewModel.cachedFilteredRecords
293− // off). Drop it like LogTableView does; the sortColumn change rebuilds.
272+ for col in newColumns where columnWidths[col] == nil {
294− if let sortColumn, !newColumns.contains(sortColumn) {
273+ columnWidths[col] = idealColumnWidth(for: col, records: records)
295− self.sortColumn = nil
296− return
297 }
274 }
298− // Showing/hiding a column changes the severity column set, so rebuild
299− // (which also fills in widths for any newly visible columns). A
300− // width-only update would leave row severity tinting stale until the
301− // next poll.
302− rebuildSortedEntries()
303− }
304− .onChange(of: viewModel.filteredEntriesGeneration) { _, _ in
305− // Kept on the always-present container (not inside liveTailTable, which
306− // is only mounted once visibleColumns is non-empty). A generation bump
307− // that lands while the table is unmounted — e.g. the first poll arriving
308− // before columns exist — would otherwise be dropped, leaving the new
309− // entries unrendered until some other change happened to trigger a rebuild.
310− rebuildSortedEntries()
311 }
275 }
312 .onAppear {
276 .onAppear {
313 rebuildSortedEntries()
277 rebuildSortedEntries()
314 }
278 }
315 }
279 }
316
280
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−
327− /// Computes widths for any visible columns that don't have one yet. Covers
328− /// the case where switching to a stream with an identical column set doesn't
329− /// fire `onChange(of: visibleColumns)`, which would otherwise leave widths
330− /// empty (falling back to the default width) after `selectedStream` cleared
331− /// them.
332− private func ensureColumnWidths() {
333− let records = viewModel.cachedFilteredRecords
334− for col in viewModel.visibleColumns where columnWidths[col] == nil {
335− columnWidths[col] = idealColumnWidth(for: col, records: records)
336− }
337− }
338−
339 private func rebuildSortedEntries() {
281 private func rebuildSortedEntries() {
340− ensureColumnWidths()
341 let entries = viewModel.cachedFilteredEntries
282 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− }
349 let cols = viewModel.visibleColumns
283 let cols = viewModel.visibleColumns
350− let sevCols = buildSeverityColumnSet(columns: cols)
351− // Precompute severity per entry; sorting doesn't change the set.
352− let severityMap = Dictionary(
353− entries.map { ($0.id, extractSeverity(from: $0.record, severityColumns: sevCols)) },
354− uniquingKeysWith: { first, _ in first }
355− )
356− // Cancel any pending sort task before either path: when the sort is
357− // cleared (guard below), a previously-scheduled task could otherwise
358− // wake after its 150ms delay and overwrite the unsorted result with
359− // stale sorted entries.
360− liveTailSortTask?.cancel()
361− // Severity is keyed by entry id and is independent of row order, so
362− // publish it synchronously in both paths. Deferring it into the
363− // debounced sort task (as the sorted path used to) left rows tinted from
364− // the previous severity-column set for ~150ms — or indefinitely if rapid
365− // changes kept cancelling the task before it fired.
366− severityColumnSet = sevCols
367− severityByID = severityMap
368 guard let col = sortColumn else {
284 guard let col = sortColumn else {
369 cachedSorted = entries
285 cachedSorted = entries
286+ cachedSeverities = entries.map { extractSeverity(from: $0.record) }
287+ severityColumnSet = buildSeverityColumnSet(columns: cols)
370 return
288 return
371 }
289 }
290+ liveTailSortTask?.cancel()
372 let asc = sortAscending
291 let asc = sortAscending
373 liveTailSortTask = Task {
292 liveTailSortTask = Task {
374 try? await Task.sleep(for: .milliseconds(150))
293 try? await Task.sleep(for: .milliseconds(150))
375 guard !Task.isCancelled else { return }
294 guard !Task.isCancelled else { return }
376 let sorted = await Task.detached(priority: .userInitiated) {
295 let sorted = await Task.detached(priority: .userInitiated) {
377− // `sorted(by:)` is not guaranteed stable and entry ids are random
378− // UUIDs, so break ties on arrival order (the buffer index) to stop
379− // equal-keyed rows from reshuffling on every poll.
380− entries.enumerated().sorted { a, b in
296+ entries.sorted { a, b in
381− let aVal = a.element.record[col] ?? .null
297+ let aVal = a.record[col] ?? .null
382− let bVal = b.element.record[col] ?? .null
298+ let bVal = b.record[col] ?? .null
383− if aVal != bVal { return asc ? aVal < bVal : bVal < aVal }
299+ return asc ? aVal < bVal : bVal < aVal
384− return a.offset < b.offset
300+ }
385− }.map(\.element)
386 }.value
301 }.value
387 guard !Task.isCancelled else { return }
302 guard !Task.isCancelled else { return }
388 cachedSorted = sorted
303 cachedSorted = sorted
304+ cachedSeverities = sorted.map { extractSeverity(from: $0.record) }
305+ severityColumnSet = buildSeverityColumnSet(columns: cols)
389 }
306 }
390 }
307 }
391
308
@@ -394,25 +311,26 @@ struct LiveTailView: View {
394 ScrollView([.horizontal, .vertical]) {
311 ScrollView([.horizontal, .vertical]) {
395 LazyVStack(alignment: .leading, spacing: 0, pinnedViews: [.sectionHeaders]) {
312 LazyVStack(alignment: .leading, spacing: 0, pinnedViews: [.sectionHeaders]) {
396 Section {
313 Section {
397− ForEach(Array(cachedSorted.enumerated()), id: \.element.id) { index, entry in
314+ ForEach(cachedSorted.indices, id: \.self) { index in
398 LogRowView(
315 LogRowView(
399− record: entry.record,
316+ record: cachedSorted[index].record,
400 columns: viewModel.visibleColumns,
317 columns: viewModel.visibleColumns,
401 columnWidths: columnWidths,
318 columnWidths: columnWidths,
402− isSelected: selectedEntryID == entry.id,
319+ isSelected: selectedRecord == cachedSorted[index].record,
403 isAlternate: index % 2 == 1,
320 isAlternate: index % 2 == 1,
404− severity: severityByID[entry.id] ?? .unknown,
321+ severity: index < cachedSeverities.count ? cachedSeverities[index] : .unknown,
405 severityColumns: severityColumnSet,
322 severityColumns: severityColumnSet,
406 wrapText: wrapText,
323 wrapText: wrapText,
407 onCellFilter: { column, value, exclude in
324 onCellFilter: { column, value, exclude in
408 viewModel.addColumnFilter(column: column, value: value, exclude: exclude)
325 viewModel.addColumnFilter(column: column, value: value, exclude: exclude)
409 }
326 }
410 )
327 )
328+ .id(cachedSorted[index].id)
411 .onTapGesture {
329 .onTapGesture {
412− if selectedEntryID == entry.id {
330+ if selectedRecord == cachedSorted[index].record {
413− selectedEntryID = nil
331+ selectedRecord = nil
414 } else {
332 } else {
415− selectedEntryID = entry.id
333+ selectedRecord = cachedSorted[index].record
416 }
334 }
417 }
335 }
418 }
336 }
@@ -433,20 +351,22 @@ struct LiveTailView: View {
433 }
351 }
434 }
352 }
435 }
353 }
354+ .onChange(of: viewModel.filteredEntriesGeneration) { _, _ in
355+ rebuildSortedEntries()
356+ }
436 .onChange(of: sortColumn) { _, _ in
357 .onChange(of: sortColumn) { _, _ in
437 rebuildSortedEntries()
358 rebuildSortedEntries()
438 }
359 }
439 .onChange(of: sortAscending) { _, _ in
360 .onChange(of: sortAscending) { _, _ in
440 rebuildSortedEntries()
361 rebuildSortedEntries()
441 }
362 }
442− // Scroll when the rendered list (`cachedSorted`) changes, targeting
443− // its last element. Key off the last element's id rather than the
444− // count: once the buffer is pinned at `maxEntries`, the count stops
445− // changing while new rows keep arriving, so a count-based trigger
446− // would silently stall auto-scroll.
447− .onChange(of: cachedSorted.last?.id) { _, _ in
363+ .onChange(of: viewModel.entries.count) { _, _ in
448 guard autoScroll, sortColumn == nil else { return }
364 guard autoScroll, sortColumn == nil else { return }
365+ let hasFilters = !viewModel.filterText.isEmpty || !viewModel.columnFilters.isEmpty
366+ let target = hasFilters
367+ ? viewModel.cachedFilteredEntries.last
368+ : viewModel.entries.last
449− if let target = cachedSorted.last {
369+ if let target {
450 proxy.scrollTo(target.id, anchor: .bottom)
370 proxy.scrollTo(target.id, anchor: .bottom)
451 }
371 }
452 }
372 }
ParseableViewer/Views/LogDetailView.swift+8−26
@@ -4,7 +4,6 @@ struct LogDetailView: View {
4 let record: LogRecord
4 let record: LogRecord
5 @State private var viewMode: ViewMode = .formatted
5 @State private var viewMode: ViewMode = .formatted
6 @State private var showCopyConfirmation = false
6 @State private var showCopyConfirmation = false
7− @State private var copyConfirmationTask: Task<Void, Never>?
8
7
9 enum ViewMode: String, CaseIterable {
8 enum ViewMode: String, CaseIterable {
10 case formatted = "Formatted"
9 case formatted = "Formatted"
@@ -50,12 +49,6 @@ struct LogDetailView: View {
50 }
49 }
51 }
50 }
52 .background(Color(nsColor: .controlBackgroundColor))
51 .background(Color(nsColor: .controlBackgroundColor))
53− .onChange(of: record) { _, _ in
54− // Selecting a different row reuses this view with a new record; clear
55− // any lingering checkmark/timer so the confirmation doesn't carry over.
56− copyConfirmationTask?.cancel()
57− showCopyConfirmation = false
58− }
59 }
52 }
60
53
61 private func copyToClipboard() {
54 private func copyToClipboard() {
@@ -68,12 +61,8 @@ struct LogDetailView: View {
68 guard success else { return }
61 guard success else { return }
69
62
70 showCopyConfirmation = true
63 showCopyConfirmation = true
71− // Cancel any in-flight timer so a rapid re-copy doesn't let an
64+ Task {
72− // earlier task clear the checkmark while a newer copy is active.
73− copyConfirmationTask?.cancel()
74− copyConfirmationTask = Task {
75 try? await Task.sleep(for: .seconds(1.5))
65 try? await Task.sleep(for: .seconds(1.5))
76− guard !Task.isCancelled else { return }
77 showCopyConfirmation = false
66 showCopyConfirmation = false
78 }
67 }
79 }
68 }
@@ -82,25 +71,25 @@ struct LogDetailView: View {
82
71
83struct FormattedRecordView: View {
72struct FormattedRecordView: View {
84 let record: LogRecord
73 let record: LogRecord
85− let sortedKeys: [String]
86
74
87− init(record: LogRecord) {
75+ var sortedKeys: [String] {
88− self.record = record
89 let priority = ["p_timestamp", "level", "severity", "message", "msg"]
76 let priority = ["p_timestamp", "level", "severity", "message", "msg"]
90 var keys = record.keys.sorted()
77 var keys = record.keys.sorted()
91 var result: [String] = []
78 var result: [String] = []
79+
92 for p in priority {
80 for p in priority {
93 if let idx = keys.firstIndex(of: p) {
81 if let idx = keys.firstIndex(of: p) {
94 result.append(keys.remove(at: idx))
82 result.append(keys.remove(at: idx))
95 }
83 }
96 }
84 }
97 result.append(contentsOf: keys)
85 result.append(contentsOf: keys)
98− self.sortedKeys = result
86+ return result
99 }
87 }
100
88
101 var body: some View {
89 var body: some View {
90+ let keys = sortedKeys
102 VStack(alignment: .leading, spacing: 1) {
91 VStack(alignment: .leading, spacing: 1) {
103− ForEach(Array(sortedKeys.enumerated()), id: \.element) { index, key in
92+ ForEach(Array(keys.enumerated()), id: \.element) { index, key in
104 HStack(alignment: .top, spacing: 8) {
93 HStack(alignment: .top, spacing: 8) {
105 Text(key)
94 Text(key)
106 .font(.system(.caption, design: .monospaced))
95 .font(.system(.caption, design: .monospaced))
@@ -157,11 +146,8 @@ struct JSONValueView: View {
157 Text(String(v))
146 Text(String(v))
158 .font(.system(.caption, design: .monospaced))
147 .font(.system(.caption, design: .monospaced))
159 .foregroundStyle(Color(nsColor: .systemPurple))
148 .foregroundStyle(Color(nsColor: .systemPurple))
160− case .double:
149+ case .double(let v):
161− // Use displayString so whole-number doubles render as "3" (not "3.0"),
150+ Text(String(v))
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)
165 .font(.system(.caption, design: .monospaced))
151 .font(.system(.caption, design: .monospaced))
166 .foregroundStyle(Color(nsColor: .systemPurple))
152 .foregroundStyle(Color(nsColor: .systemPurple))
167 case .string(let v):
153 case .string(let v):
@@ -214,10 +200,6 @@ struct RawJSONView: View {
214 .padding(8)
200 .padding(8)
215 .frame(maxWidth: .infinity, alignment: .leading)
201 .frame(maxWidth: .infinity, alignment: .leading)
216 .task(id: record) {
202 .task(id: record) {
217− // Drop the previous record's highlight first so we fall back to the
218− // new record's plain text while the new highlight computes, instead
219− // of briefly showing the old record's highlighted JSON.
220− highlightedText = nil
221 let json = JSONValue.object(record).prettyPrinted()
203 let json = JSONValue.object(record).prettyPrinted()
222 highlightedText = JSONSyntaxHighlighter.highlight(json)
204 highlightedText = JSONSyntaxHighlighter.highlight(json)
223 }
205 }
ParseableViewer/Views/LogTableView.swift+74−230
@@ -20,13 +20,7 @@ func idealColumnWidth(for column: String, records: [LogRecord]) -> CGFloat {
20 let sampleCount = min(records.count, 200)
20 let sampleCount = min(records.count, 200)
21
21
22 var widest = measureTextWidth(column, font: headerMeasureFont)
22 var widest = measureTextWidth(column, font: headerMeasureFont)
23− // Sample evenly across the whole result set, not just the first N rows —
24− // otherwise a wide value that sorts beyond row N is never measured and the
25− // column auto-fits too narrow after sorting. Map each of the `sampleCount`
26− // samples to a spread-out index so we hit exactly `sampleCount` distinct rows
27− // (a stride with an integer step would skip rows yet still undersample).
28− for s in 0..<sampleCount {
23+ for i in 0..<sampleCount {
29− let i = s * records.count / sampleCount
30 let text = records[i][column]?.displayString ?? ""
24 let text = records[i][column]?.displayString ?? ""
31 widest = max(widest, measureTextWidth(text, font: cellMeasureFont))
25 widest = max(widest, measureTextWidth(text, font: cellMeasureFont))
32 }
26 }
@@ -120,52 +114,19 @@ func parseSeverity(from value: String) -> SeverityLevel {
120 return .unknown
114 return .unknown
121}
115}
122
116
123−/// Columns (lowercased) that carry an OpenTelemetry `severity_number` (1–24)
124−/// rather than an RFC 5424 syslog severity (0–7). The two scales overlap, so
125−/// numeric values from these columns must be interpreted on the OTel scale.
126−private let otelSeverityColumns: Set<String> = ["severity_number", "otel.severity"]
127−
128−/// Maps an OpenTelemetry `severity_number` (1–24) to a ``SeverityLevel``.
129−/// Higher is more severe, in contrast to RFC 5424.
130−func parseOTelSeverityNumber(_ num: Int) -> SeverityLevel {
131− switch num {
132− case 1...4: return .trace
133− case 5...8: return .debug
134− case 9...12: return .info
135− case 13...16: return .warning
136− case 17...20: return .error
137− case 21...24: return .fatal
138− default: return .unknown
139− }
140−}
141−
142−/// Extracts the severity level from a log record by checking only known severity columns.
117+/// Extracts the severity level from a log record by checking common column names.
143−func extractSeverity(from record: LogRecord, severityColumns: Set<String>) -> SeverityLevel {
118+func extractSeverity(from record: LogRecord) -> SeverityLevel {
144− // Iterate in a deterministic (sorted) order so that, when a record carries
145− // more than one severity-like column with conflicting values, the "winning"
146− // column is stable across runs rather than dependent on Set iteration order.
147− for col in severityColumns.sorted() {
148− guard let value = record[col] else { continue }
119+ for (key, value) in record {
120+ if severityColumnNames.contains(key.lowercased()) {
149− let str: String
121+ let str: String
150− switch value {
122+ switch value {
151− case .string(let s): str = s
123+ case .string(let s): str = s
152− case .int(let i): str = String(i)
124+ case .int(let i): str = String(i)
153− // Numeric severities (e.g. OTel severity_number) sometimes decode as a
154− // double; stringify the integral value so they still parse.
155− case .double(let d) where d == d.rounded() && abs(d) < 1e15:
156− str = String(format: "%.0f", d)
157− default: continue
125+ default: continue
158− }
126+ }
159− // OpenTelemetry severity columns use a 1–24 scale that collides with
127+ let level = parseSeverity(from: str)
160− // RFC 5424's 0–7, so disambiguate numeric values by column name.
161− if otelSeverityColumns.contains(col.lowercased()),
162− let num = Int(str.trimmingCharacters(in: .whitespaces)) {
163− let level = parseOTelSeverityNumber(num)
164 if level != .unknown { return level }
128 if level != .unknown { return level }
165− continue
166 }
129 }
167− let level = parseSeverity(from: str)
168− if level != .unknown { return level }
169 }
130 }
170 return .unknown
131 return .unknown
171}
132}
@@ -194,12 +155,6 @@ func buildSeverityColumnSet(columns: [String]) -> Set<String> {
194
155
195// MARK: - LogTableView
156// MARK: - LogTableView
196
157
197−struct IndexedRecord: Identifiable {
198− let id: Int
199− let record: LogRecord
200− let severity: SeverityLevel
201−}
202−
203struct LogTableView: View {
158struct LogTableView: View {
204 let records: [LogRecord]
159 let records: [LogRecord]
205 let columns: [String]
160 let columns: [String]
@@ -211,7 +166,8 @@ struct LogTableView: View {
211 @State private var sortColumn: String?
166 @State private var sortColumn: String?
212 @State private var sortAscending = false
167 @State private var sortAscending = false
213 @State private var selectedIndex: Int?
168 @State private var selectedIndex: Int?
214− @State private var cachedSorted: [IndexedRecord] = []
169+ @State private var cachedSorted: [LogRecord] = []
170+ @State private var cachedSeverities: [SeverityLevel] = []
215 @State private var severityColumnSet: Set<String> = []
171 @State private var severityColumnSet: Set<String> = []
216 @State private var sortTask: Task<Void, Never>?
172 @State private var sortTask: Task<Void, Never>?
217 @State private var widthTask: Task<Void, Never>?
173 @State private var widthTask: Task<Void, Never>?
@@ -240,25 +196,25 @@ struct LogTableView: View {
240 ScrollView([.horizontal, .vertical]) {
196 ScrollView([.horizontal, .vertical]) {
241 LazyVStack(alignment: .leading, spacing: 0, pinnedViews: [.sectionHeaders]) {
197 LazyVStack(alignment: .leading, spacing: 0, pinnedViews: [.sectionHeaders]) {
242 Section {
198 Section {
243− ForEach(Array(cachedSorted.enumerated()), id: \.element.id) { index, item in
199+ ForEach(cachedSorted.indices, id: \.self) { index in
244 LogRowView(
200 LogRowView(
245− record: item.record,
201+ record: cachedSorted[index],
246 columns: columns,
202 columns: columns,
247 columnWidths: columnWidths,
203 columnWidths: columnWidths,
248− isSelected: selectedIndex == item.id,
204+ isSelected: selectedIndex == index,
249 isAlternate: index % 2 == 1,
205 isAlternate: index % 2 == 1,
250− severity: item.severity,
206+ severity: index < cachedSeverities.count ? cachedSeverities[index] : .unknown,
251 severityColumns: severityColumnSet,
207 severityColumns: severityColumnSet,
252 wrapText: wrapText,
208 wrapText: wrapText,
253 onCellFilter: onCellFilter
209 onCellFilter: onCellFilter
254 )
210 )
255 .onTapGesture {
211 .onTapGesture {
256− if selectedIndex == item.id {
212+ if selectedIndex == index {
257 selectedIndex = nil
213 selectedIndex = nil
258 selectedRecord = nil
214 selectedRecord = nil
259 } else {
215 } else {
260− selectedIndex = item.id
216+ selectedIndex = index
261− selectedRecord = item.record
217+ selectedRecord = cachedSorted[index]
262 }
218 }
263 }
219 }
264 }
220 }
@@ -290,75 +246,29 @@ struct LogTableView: View {
290 }
246 }
291 }
247 }
292 .onChange(of: records) { _, newRecords in
248 .onChange(of: records) { _, newRecords in
249+ widthTask?.cancel()
250+ let cols = columns
251+ widthTask = Task.detached(priority: .userInitiated) {
293− recomputeColumnWidths(columns: columns, records: newRecords)
252+ let widths = computeColumnWidths(columns: cols, records: newRecords)
294− debouncedRebuildSort()
253+ guard !Task.isCancelled else { return }
295− }
254+ await MainActor.run { columnWidths = widths }
296− .onChange(of: columns) { _, newColumns in
297− // New columns (e.g. shown via the Column Manager) need widths
298− // computed; the width task otherwise only runs on a records
299− // change, leaving them at the default width until the next query.
300− recomputeColumnWidths(columns: newColumns, records: records)
301− // After a stream switch the previous sort column may not exist
302− // in the new schema, which would silently produce a no-op sort
303− // (every row compares as .null). Drop it so rows fall back to
304− // their natural order.
305− if let sortColumn, !newColumns.contains(sortColumn) {
306− self.sortColumn = nil
307− // The sortColumn change triggers its own rebuild below.
308− } else {
309− // Columns changed without dropping the sort column (e.g.
310− // hiding/showing a severity column via the Column Manager).
311− // Rebuild so the severity column set — and therefore row
312− // tinting — reflects the new columns.
313− debouncedRebuildSort()
314 }
255 }
256+ debouncedRebuildSort()
315 }
257 }
316 .onChange(of: sortColumn) { _, _ in debouncedRebuildSort() }
258 .onChange(of: sortColumn) { _, _ in debouncedRebuildSort() }
317 .onChange(of: sortAscending) { _, _ in debouncedRebuildSort() }
259 .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− }
326 .onAppear {
260 .onAppear {
327− // This branch is unmounted while results are empty (e.g. across a
261+ let cols = columns
328− // stream switch, which clears them first), so `onChange(of: columns)`
262+ let recs = records
329− // never sees the switch. Drop a sort column the new columns don't
263+ widthTask = Task.detached(priority: .userInitiated) {
330− // have — or hide — before sorting, or rows would be ordered by an
264+ let widths = computeColumnWidths(columns: cols, records: recs)
331− // invisible column with no header showing the sort.
265+ await MainActor.run { columnWidths = widths }
332− if let sortColumn, !columns.contains(sortColumn) {
333− self.sortColumn = nil
334 }
266 }
335− recomputeColumnWidths(columns: columns, records: records)
336 rebuildSort()
267 rebuildSort()
337 }
268 }
338 }
269 }
339 }
270 }
340
271
341− /// Computes column widths off the main actor and merges them into
342− /// `columnWidths`, filling only columns that don't yet have a width.
343− /// Preserves any width the user dragged or auto-fit; a wholesale overwrite
344− /// would discard manual resizes on every re-query/filter/refresh. Runs on
345− /// both records changes and columns-only changes so newly shown columns get
346− /// a fitted width rather than the default.
347− private func recomputeColumnWidths(columns cols: [String], records recs: [LogRecord]) {
348− widthTask?.cancel()
349− widthTask = Task.detached(priority: .userInitiated) {
350− let widths = computeColumnWidths(columns: cols, records: recs)
351− guard !Task.isCancelled else { return }
352− await MainActor.run {
353− var merged = columnWidths
354− for (col, width) in widths where merged[col] == nil {
355− merged[col] = width
356− }
357− columnWidths = merged
358− }
359− }
360− }
361−
362 private func debouncedRebuildSort() {
272 private func debouncedRebuildSort() {
363 sortTask?.cancel()
273 sortTask?.cancel()
364 sortTask = Task {
274 sortTask = Task {
@@ -367,78 +277,49 @@ struct LogTableView: View {
367 let recs = records
277 let recs = records
368 let col = sortColumn
278 let col = sortColumn
369 let asc = sortAscending
279 let asc = sortAscending
370− let sevCols = buildSeverityColumnSet(columns: columns)
280+ let sorted: [LogRecord]
281+ if let col {
371− let indexed: [IndexedRecord] = await Task.detached(priority: .userInitiated) {
282+ sorted = await Task.detached(priority: .userInitiated) {
372− let enumerated = Array(recs.enumerated())
373− let sorted: [(offset: Int, element: LogRecord)]
374− if let col {
375− sorted = enumerated.sorted { a, b in
283+ recs.sorted { a, b in
376− let aVal = a.element[col] ?? .null
284+ let aVal = a[col] ?? .null
377− let bVal = b.element[col] ?? .null
285+ let bVal = b[col] ?? .null
378− // `sorted(by:)` is not guaranteed stable, so break ties on
379− // the original index to keep equal-keyed rows in a fixed
380− // order across re-sorts (otherwise low-cardinality columns
381− // like `level`/`status` would reshuffle on every refresh).
382− if aVal != bVal { return asc ? aVal < bVal : bVal < aVal }
286+ return asc ? aVal < bVal : bVal < aVal
383− return a.offset < b.offset
384 }
287 }
288+ }.value
385− } else {
289+ } else {
386− sorted = enumerated
290+ sorted = recs
387− }
291+ }
388− return sorted.map {
389− IndexedRecord(id: $0.offset, record: $0.element, severity: extractSeverity(from: $0.element, severityColumns: sevCols))
390− }
391− }.value
392 guard !Task.isCancelled else { return }
292 guard !Task.isCancelled else { return }
393− cachedSorted = indexed
293+ cachedSorted = sorted
294+ cachedSeverities = sorted.map { extractSeverity(from: $0) }
394− severityColumnSet = sevCols
295+ severityColumnSet = buildSeverityColumnSet(columns: columns)
395− reconcileSelection()
396− }
397− }
398−
399− /// Re-establishes the current selection after `cachedSorted` is rebuilt.
400− /// Prefers the stable row id so a plain re-sort keeps the exact selected row
401− /// (rather than jumping to the first duplicate). Falls back to value matching
402− /// only when the records themselves changed, and clears both the index and
403− /// the bound record when nothing matches so the detail pane can't go stale.
404− private func reconcileSelection() {
405− if let selectedIndex,
406− let match = cachedSorted.first(where: { $0.id == selectedIndex }),
407− match.record == selectedRecord {
408− return
409− }
410− if let selectedRecord,
296+ if let selectedRecord,
411− let match = cachedSorted.first(where: { $0.record == selectedRecord }) {
297+ let idx = cachedSorted.firstIndex(where: { $0 == selectedRecord }) {
412− selectedIndex = match.id
298+ selectedIndex = idx
413− } else {
299+ } else {
414− selectedIndex = nil
300+ selectedIndex = nil
415− self.selectedRecord = nil
301+ }
416 }
302 }
417 }
303 }
418
304
419 private func rebuildSort() {
305 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()
423− let sevCols = buildSeverityColumnSet(columns: columns)
424− let enumerated = Array(records.enumerated())
425− let sorted: [(offset: Int, element: LogRecord)]
426 if let sortColumn {
306 if let sortColumn {
427− sorted = enumerated.sorted { a, b in
307+ cachedSorted = records.sorted { a, b in
428− let aVal = a.element[sortColumn] ?? .null
308+ let aVal = a[sortColumn] ?? .null
429− let bVal = b.element[sortColumn] ?? .null
309+ let bVal = b[sortColumn] ?? .null
430− // Stable tiebreak on original index — see the async path above.
431− if aVal != bVal { return sortAscending ? aVal < bVal : bVal < aVal }
310+ return sortAscending ? aVal < bVal : bVal < aVal
432− return a.offset < b.offset
433 }
311 }
434 } else {
312 } else {
435− sorted = enumerated
313+ cachedSorted = records
436 }
314 }
437− cachedSorted = sorted.map {
315+ cachedSeverities = cachedSorted.map { extractSeverity(from: $0) }
438− IndexedRecord(id: $0.offset, record: $0.element, severity: extractSeverity(from: $0.element, severityColumns: sevCols))
316+ severityColumnSet = buildSeverityColumnSet(columns: columns)
317+ if let selectedRecord,
318+ let idx = cachedSorted.firstIndex(where: { $0 == selectedRecord }) {
319+ selectedIndex = idx
320+ } else {
321+ selectedIndex = nil
439 }
322 }
440− severityColumnSet = sevCols
441− reconcileSelection()
442 }
323 }
443}
324}
444
325
@@ -495,28 +376,12 @@ struct LogHeaderView: View {
495
376
496 @State private var draggedColumn: String?
377 @State private var draggedColumn: String?
497 @State private var dropTargetColumn: String?
378 @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()
379+ @State private var uniqueValuesCache: [String: [JSONValue]] = [:]
505− @State private var recordsGeneration = 0
506−
507− private final class UniqueValuesCache {
508− var generation = -1
509− var values: [String: [JSONValue]] = [:]
510− }
511
380
512 private static let maxFilterValues = 20
381 private static let maxFilterValues = 20
513
382
514 private func uniqueValues(for column: String) -> [JSONValue] {
383 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] {
384+ if let cached = uniqueValuesCache[column] {
520 return cached
385 return cached
521 }
386 }
522 var seen = Set<JSONValue>()
387 var seen = Set<JSONValue>()
@@ -528,7 +393,7 @@ struct LogHeaderView: View {
528 }
393 }
529 }
394 }
530 let sorted = result.sorted()
395 let sorted = result.sorted()
531− uniqueValuesCache.values[column] = sorted
396+ uniqueValuesCache[column] = sorted
532 return sorted
397 return sorted
533 }
398 }
534
399
@@ -671,7 +536,7 @@ struct LogHeaderView: View {
671 .background(Color(nsColor: .controlBackgroundColor))
536 .background(Color(nsColor: .controlBackgroundColor))
672 .border(Color.secondary.opacity(0.2), width: 0.5)
537 .border(Color.secondary.opacity(0.2), width: 0.5)
673 .onChange(of: records) { _, _ in
538 .onChange(of: records) { _, _ in
674− recordsGeneration &+= 1
539+ uniqueValuesCache = [:]
675 }
540 }
676 }
541 }
677}
542}
@@ -704,26 +569,14 @@ private struct ColumnDropDelegate: DropDelegate {
704 }
569 }
705
570
706 func performDrop(info: DropInfo) -> Bool {
571 func performDrop(info: DropInfo) -> Bool {
707− guard let dragged = draggedColumn, dragged != column,
572+ guard let dragged = draggedColumn, dragged != column else {
708− let provider = info.itemProviders(for: [UTType.plainText]).first else {
709 draggedColumn = nil
573 draggedColumn = nil
710 dropTargetColumn = nil
574 dropTargetColumn = nil
711 return false
575 return false
712 }
576 }
577+ onMoveColumn?(dragged, column)
713 draggedColumn = nil
578 draggedColumn = nil
714 dropTargetColumn = nil
579 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− }
727 return true
580 return true
728 }
581 }
729
582
@@ -758,10 +611,7 @@ struct LogRowView: View {
758 .foregroundStyle(colorForValue(column: column, value: value))
611 .foregroundStyle(colorForValue(column: column, value: value))
759 .contextMenu {
612 .contextMenu {
760 Button("Copy Value") {
613 Button("Copy Value") {
761− // Use exportString, not displayString: the latter
762− // renders nested arrays/objects as placeholders like
763− // "[3 items]", so copying would lose the real data.
764− let text = value?.exportString ?? ""
614+ let text = value?.displayString ?? ""
765 NSPasteboard.general.clearContents()
615 NSPasteboard.general.clearContents()
766 _ = NSPasteboard.general.setString(text, forType: .string)
616 _ = NSPasteboard.general.setString(text, forType: .string)
767 }
617 }
@@ -771,17 +621,11 @@ struct LogRowView: View {
771 }
621 }
772 if let onCellFilter {
622 if let onCellFilter {
773 Divider()
623 Divider()
774− // Normalize a null cell to `nil` so this produces an
775− // IS NULL / IS NOT NULL filter, matching the header
776− // "Filter by Value" path. Passing `.null` straight
777− // through would build an `= null` value filter, which
778− // the live-tail in-memory matcher treats differently.
779− let filterValue: JSONValue? = value == .null ? nil : value
780 Button("Filter by This Value") {
624 Button("Filter by This Value") {
781− onCellFilter(column, filterValue, false)
625+ onCellFilter(column, value, false)
782 }
626 }
783 Button("Exclude This Value") {
627 Button("Exclude This Value") {
784− onCellFilter(column, filterValue, true)
628+ onCellFilter(column, value, true)
785 }
629 }
786 }
630 }
787 }
631 }
ParseableViewer/Views/QueryView.swift+28−111
@@ -1,22 +1,8 @@
1import SwiftUI
1import SwiftUI
2
2
3−/// Composite of the inputs that decide which query QueryView should run. Watched
4−/// by a single `.onChange` so a stream switch and a pending saved-filter (which
5−/// arrive in the same update) are handled together in one pass — two separate
6−/// `.onChange` handlers would race and could clobber the filter with the default
7−/// query depending on their firing order.
8−private struct QueryViewContext: Equatable {
9− let stream: String?
10− let pendingSQL: String?
11−}
12−
13struct QueryView: View {
3struct QueryView: View {
14 @Environment(AppState.self) private var appState
4 @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
5+ @State private var viewModel = QueryViewModel()
20 @State private var showSaveFilterSheet = false
6 @State private var showSaveFilterSheet = false
21 @State private var saveFilterName = ""
7 @State private var saveFilterName = ""
22 @State private var isSavingFilter = false
8 @State private var isSavingFilter = false
@@ -93,7 +79,7 @@ struct QueryView: View {
93 } label: {
79 } label: {
94 Image(systemName: "square.and.arrow.up")
80 Image(systemName: "square.and.arrow.up")
95 }
81 }
96− .disabled(viewModel.filteredResults.isEmpty)
82+ .disabled(viewModel.results.isEmpty)
97 .accessibilityLabel("Export results")
83 .accessibilityLabel("Export results")
98 .help("Export results")
84 .help("Export results")
99
85
@@ -251,23 +237,6 @@ struct QueryView: View {
251 isLoading: viewModel.isLoading,
237 isLoading: viewModel.isLoading,
252 wrapText: wrapText,
238 wrapText: wrapText,
253 onCellFilter: { column, value, exclude in
239 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− }
271 viewModel.addColumnFilter(column: column, value: value, exclude: exclude)
240 viewModel.addColumnFilter(column: column, value: value, exclude: exclude)
272 Task {
241 Task {
273 await viewModel.executeQuery(
242 await viewModel.executeQuery(
@@ -282,36 +251,19 @@ struct QueryView: View {
282 )
251 )
283 }
252 }
284 }
253 }
285− .onChange(of: QueryViewContext(stream: appState.selectedStream, pendingSQL: appState.pendingFilterSQL)) { old, new in
254+ .onChange(of: appState.selectedStream) { oldValue, newValue in
286− guard let stream = new.stream else {
287− viewModel.clearResults()
255+ viewModel.clearResults()
288− return
256+ if let stream = newValue {
289− }
257+ let didSetDefault = viewModel.setDefaultQuery(stream: stream, previousStream: oldValue)
290− let streamChanged = old.stream != new.stream
291− if streamChanged {
292− viewModel.clearResults()
293− viewModel.boundContext = QueryViewModel.BoundContext(
294− connectionID: appState.activeConnection?.id, stream: stream
295− )
296 Task {
258 Task {
297 await viewModel.loadSchema(client: appState.client, stream: stream)
259 await viewModel.loadSchema(client: appState.client, stream: stream)
298 }
260 }
299− }
300− // A pending saved filter (set together with the stream switch, or on
301− // its own for the already-selected stream) takes priority over the
302− // default query. Consuming it here in the same handler that owns the
303− // stream switch avoids the previous two-handler race.
304− if let sql = appState.pendingFilterSQL {
305− appState.pendingFilterSQL = nil
306− viewModel.sqlQuery = sql
307− Task {
308− await viewModel.executeQuery(client: appState.client, stream: stream)
309− }
310− } else if streamChanged {
311− let didSetDefault = viewModel.setDefaultQuery(stream: stream, previousStream: old.stream)
312 if didSetDefault {
261 if didSetDefault {
313 Task {
262 Task {
314− await viewModel.executeQuery(client: appState.client, stream: stream)
263+ await viewModel.executeQuery(
264+ client: appState.client,
265+ stream: stream
266+ )
315 }
267 }
316 }
268 }
317 }
269 }
@@ -327,63 +279,28 @@ struct QueryView: View {
327 }
279 }
328 .onAppear {
280 .onAppear {
329 if let stream = appState.selectedStream {
281 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 {
282+ if viewModel.schemaFields.isEmpty {
351 Task {
283 Task {
352 await viewModel.loadSchema(client: appState.client, stream: stream)
284 await viewModel.loadSchema(client: appState.client, stream: stream)
353 }
285 }
354 }
286 }
355− // A saved filter may have set pendingFilterSQL before this view was
287+ let didSetDefault = viewModel.setDefaultQuery(stream: stream)
356− // mounted (e.g. the filter was launched from another tab). .onChange
288+ if didSetDefault && viewModel.results.isEmpty {
357− // doesn't fire for a value that changed before the view entered the
358− // hierarchy, so honor it here instead of overwriting the editor with
359− // the default query.
360− if let sql = appState.pendingFilterSQL {
361− viewModel.sqlQuery = sql
362− appState.pendingFilterSQL = nil
363 Task {
289 Task {
364 await viewModel.executeQuery(
290 await viewModel.executeQuery(
365 client: appState.client,
291 client: appState.client,
366 stream: stream
292 stream: stream
367 )
293 )
368 }
294 }
369− } else {
370− let didSetDefault = viewModel.setDefaultQuery(stream: stream, previousStream: previousStream)
371− if didSetDefault && viewModel.results.isEmpty {
372− Task {
373− await viewModel.executeQuery(
374− client: appState.client,
375− stream: stream
376− )
377− }
378− }
379 }
295 }
380 }
296 }
381 }
297 }
298+ .onDisappear {
299+ if viewModel.isLoading {
300+ viewModel.cancelQuery()
301+ }
302+ }
382 .onChange(of: appState.queryRefreshToken) { _, _ in
303 .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 }
387 Task {
304 Task {
388 await viewModel.executeQuery(
305 await viewModel.executeQuery(
389 client: appState.client,
306 client: appState.client,
@@ -391,6 +308,12 @@ struct QueryView: View {
391 )
308 )
392 }
309 }
393 }
310 }
311+ .onChange(of: appState.pendingFilterSQL) { _, newSQL in
312+ if let sql = newSQL {
313+ viewModel.sqlQuery = sql
314+ appState.pendingFilterSQL = nil
315+ }
316+ }
394 .alert("Export Error", isPresented: $showExportError) {
317 .alert("Export Error", isPresented: $showExportError) {
395 Button("OK") {}
318 Button("OK") {}
396 } message: {
319 } message: {
@@ -447,10 +370,7 @@ struct QueryView: View {
447 panel.begin { result in
370 panel.begin { result in
448 guard result == .OK, let url = panel.url else { return }
371 guard result == .OK, let url = panel.url else { return }
449 let isCSV = url.pathExtension.lowercased() == "csv"
372 let isCSV = url.pathExtension.lowercased() == "csv"
450− // Export what's on screen: the table renders `filteredResults`, so a
451− // user who has typed a result filter expects the export to contain
452− // those rows, not the full unfiltered set.
453− let results = viewModel.filteredResults
373+ let results = viewModel.results
454 let columns = viewModel.visibleColumns
374 let columns = viewModel.visibleColumns
455 Task.detached(priority: .userInitiated) {
375 Task.detached(priority: .userInitiated) {
456 let content: String?
376 let content: String?
@@ -459,10 +379,7 @@ struct QueryView: View {
459 } else {
379 } else {
460 let encoder = JSONEncoder()
380 let encoder = JSONEncoder()
461 encoder.outputFormatting = [.prettyPrinted, .sortedKeys]
381 encoder.outputFormatting = [.prettyPrinted, .sortedKeys]
462− // Project to visible columns so JSON export matches CSV and
463− // doesn't leak hidden fields.
464− let projected = QueryViewModel.projectRecords(results, to: columns)
465− content = (try? encoder.encode(projected))
382+ content = (try? encoder.encode(results))
466 .flatMap { String(data: $0, encoding: .utf8) }
383 .flatMap { String(data: $0, encoding: .utf8) }
467 }
384 }
468 guard let content else {
385 guard let content else {
@@ -496,7 +413,7 @@ struct ColumnManagerView: View {
496 Text("Columns")
413 Text("Columns")
497 .font(.headline)
414 .font(.headline)
498 Spacer()
415 Spacer()
499− if viewModel.hasHiddenColumns || viewModel.columnOrder != viewModel.columns {
416+ if !viewModel.hiddenColumns.isEmpty || !viewModel.autoHiddenColumns.isEmpty || viewModel.columnOrder != viewModel.columns {
500 Button("Reset") {
417 Button("Reset") {
501 viewModel.resetColumnConfig()
418 viewModel.resetColumnConfig()
502 }
419 }
@@ -550,7 +467,7 @@ struct ColumnManagerView: View {
550 }
467 }
551 .listStyle(.plain)
468 .listStyle(.plain)
552
469
553− if viewModel.hasHiddenColumns {
470+ if !viewModel.hiddenColumns.isEmpty || !viewModel.autoHiddenColumns.isEmpty {
554 Divider()
471 Divider()
555 Button("Show All Columns") {
472 Button("Show All Columns") {
556 viewModel.showAllColumns()
473 viewModel.showAllColumns()
ParseableViewer/Views/SQLAutoComplete.swift+18−186
@@ -10,23 +10,18 @@ struct SQLCompletionItem: Hashable {
10 case column
10 case column
11 }
11 }
12
12
13− /// The text inserted into the editor when the completion is accepted. For
14− /// tables this is the quoted identifier (`"name"`) so the resulting SQL is
15− /// valid even when the stream name contains spaces or other special chars.
16 let text: String
13 let text: String
17 let kind: Kind
14 let kind: Kind
18 let detail: String?
15 let detail: String?
19− /// The bare text used for prefix/exact-match comparison against what the
16+ /// The raw text to insert (without quoting). For tables this equals the
20− /// user typed. For tables this is the unquoted stream name, since the typed
17+ /// unquoted stream name so prefix matching works correctly.
21− /// prefix never includes the surrounding quotes. This is NOT what gets
22− /// inserted — see `text`.
23− let matchText: String
18+ let insertText: String
24
19
25− init(text: String, kind: Kind, detail: String? = nil, matchText: String? = nil) {
20+ init(text: String, kind: Kind, detail: String? = nil, insertText: String? = nil) {
26 self.text = text
21 self.text = text
27 self.kind = kind
22 self.kind = kind
28 self.detail = detail
23 self.detail = detail
29− self.matchText = matchText ?? text
24+ self.insertText = insertText ?? text
30 }
25 }
31
26
32 var kindLabel: String {
27 var kindLabel: String {
@@ -86,59 +81,14 @@ enum SQLCompletionProvider {
86 }
81 }
87 }
82 }
88
83
89− // The matching prefix is only the text before the caret (what the user has
90− // typed so far), but the replacement range spans the whole word — scan
91− // forward past any trailing word characters so a mid-token completion
92− // replaces the entire token instead of duplicating its tail.
93− var wordEnd = cursorPosition
94− while wordEnd < nsText.length {
95− if isWordCharacter(nsText.character(at: wordEnd)) {
96− wordEnd += 1
97− } else {
98− break
99− }
100− }
101−
102 let prefix = nsText.substring(with: NSRange(location: wordStart, length: cursorPosition - wordStart))
84 let prefix = nsText.substring(with: NSRange(location: wordStart, length: cursorPosition - wordStart))
85+ let range = NSRange(location: wordStart, length: cursorPosition - wordStart)
103
86
104 guard !prefix.isEmpty else {
87 guard !prefix.isEmpty else {
105− return ([], "", NSRange(location: wordStart, length: wordEnd - wordStart))
88+ return ([], "", range)
106− }
107−
108− // Words typed inside a comment or a string literal aren't SQL, so offer
109− // nothing there. Otherwise the popup opens (e.g. `-- grouped by date`
110− // matches DATE) and the Return/Tab meant to end the line or keep typing is
111− // captured to accept a keyword, rewriting the comment or the literal value.
112− if isInsideCommentOrStringLiteral(text, offset: cursorPosition) {
113− return ([], prefix, NSRange(location: wordStart, length: wordEnd - wordStart))
114 }
89 }
115
90
116 let context = determineContext(text: text, position: wordStart)
91 let context = determineContext(text: text, position: wordStart)
117−
118− // Table (and column) completions supply their own quoting. If the user has
119− // already typed quotes directly around the word (common for stream names
120− // with spaces, e.g. `FROM "my"`), absorb them into the replacement range so
121− // the inserted `"name"` replaces the stray quotes instead of producing
122− // `""name""`. Mirror `insertCompletion`, which absorbs leading and trailing
123− // quotes independently for any `.table`/`.column` item — so apply this
124− // wherever those completions are offered (`.tableRef`, `.columnRef` and
125− // `.general`).
126− var replaceStart = wordStart
127− var replaceEnd = wordEnd
128− let offersIdentifiers: Bool
129− switch context {
130− case .tableRef, .columnRef, .general: offersIdentifiers = true
131− case .afterOrder, .afterGroup: offersIdentifiers = false
132− }
133− if offersIdentifiers {
134− if wordStart > 0, nsText.character(at: wordStart - 1) == 0x22 {
135− replaceStart -= 1
136− }
137− if wordEnd < nsText.length, nsText.character(at: wordEnd) == 0x22 {
138− replaceEnd += 1
139− }
140− }
141− let range = NSRange(location: replaceStart, length: replaceEnd - replaceStart)
142 let uppercasePrefix = prefix.uppercased()
92 let uppercasePrefix = prefix.uppercased()
143
93
144 var items: [SQLCompletionItem] = []
94 var items: [SQLCompletionItem] = []
@@ -149,14 +99,14 @@ enum SQLCompletionProvider {
149 items.append(SQLCompletionItem(
99 items.append(SQLCompletionItem(
150 text: "\"\(name)\"",
100 text: "\"\(name)\"",
151 kind: .table,
101 kind: .table,
152− matchText: name
102+ insertText: name
153 ))
103 ))
154 }
104 }
155
105
156 case .columnRef:
106 case .columnRef:
157 for field in schemaFields.sorted(by: { $0.name < $1.name })
107 for field in schemaFields.sorted(by: { $0.name < $1.name })
158 where field.name.uppercased().hasPrefix(uppercasePrefix) {
108 where field.name.uppercased().hasPrefix(uppercasePrefix) {
159− items.append(columnItem(for: field))
109+ items.append(SQLCompletionItem(text: field.name, kind: .column, detail: field.dataType))
160 }
110 }
161 for fn in SQLSyntaxHighlighter.sortedFunctions where fn.hasPrefix(uppercasePrefix) {
111 for fn in SQLSyntaxHighlighter.sortedFunctions where fn.hasPrefix(uppercasePrefix) {
162 items.append(SQLCompletionItem(text: fn, kind: .function))
112 items.append(SQLCompletionItem(text: fn, kind: .function))
@@ -181,110 +131,27 @@ enum SQLCompletionProvider {
181 items.append(SQLCompletionItem(
131 items.append(SQLCompletionItem(
182 text: "\"\(name)\"",
132 text: "\"\(name)\"",
183 kind: .table,
133 kind: .table,
184− matchText: name
134+ insertText: name
185 ))
135 ))
186 }
136 }
187 for field in schemaFields.sorted(by: { $0.name < $1.name })
137 for field in schemaFields.sorted(by: { $0.name < $1.name })
188 where field.name.uppercased().hasPrefix(uppercasePrefix) {
138 where field.name.uppercased().hasPrefix(uppercasePrefix) {
189− items.append(columnItem(for: field))
139+ items.append(SQLCompletionItem(text: field.name, kind: .column, detail: field.dataType))
190 }
140 }
191 }
141 }
192
142
193− // Don't show the popup when the only match is an exact hit. Compare the
143+ // Don't show the popup when the only match is an exact hit
194− // match text (unquoted), not the display text — a table's display text is
195− // wrapped in quotes (`"name"`) and would never match the bare prefix.
196− // Columns are the exception: one that needs quoting but was typed bare
197− // (`statusCode`, which DataFusion would lowercase) must still be offered,
198− // so a column only counts as an exact hit when accepting would leave the
199− // text unchanged (the range already spans any quotes the user typed).
200− if items.count == 1 {
201− let only = items[0]
202− let isExactHit = only.kind == .column
203− ? nsText.substring(with: range) == only.text
204− : only.matchText.uppercased() == uppercasePrefix
144+ if items.count == 1 && items[0].text.uppercased() == uppercasePrefix {
205− if isExactHit { return ([], prefix, range) }
145+ return ([], prefix, range)
206 }
146 }
207
147
208 return (items, prefix, range)
148 return (items, prefix, range)
209 }
149 }
210
150
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−
270 // MARK: - Context Detection
151 // MARK: - Context Detection
271
152
272 static func determineContext(text: String, position: Int) -> SQLCompletionContext {
153 static func determineContext(text: String, position: Int) -> SQLCompletionContext {
273− // `position` is a UTF-16 offset (computed via NSString), so slice with
274− // NSString too. `String.prefix` counts grapheme clusters and would slice
275− // at the wrong place when non-BMP characters (e.g. emoji) precede it.
276− let before = (text as NSString).substring(to: position)
154+ let before = String(text.prefix(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−
288 let tokens = tokenize(before)
155 let tokens = tokenize(before)
289
156
290 guard let lastToken = tokens.last?.uppercased() else {
157 guard let lastToken = tokens.last?.uppercased() else {
@@ -302,10 +169,7 @@ enum SQLCompletionProvider {
302 case "GROUP":
169 case "GROUP":
303 return .afterGroup
170 return .afterGroup
304 default:
171 default:
305− // After a `,` or an opening `(` the caret continues an argument/column
306− // list (e.g. `SELECT COUNT(`, `WHERE status IN (`, `SELECT a, `), so
307− // scan back to the governing keyword to decide column vs table context.
308− if lastToken == "," || lastToken == "(" {
172+ if lastToken == "," {
309 for token in tokens.reversed() {
173 for token in tokens.reversed() {
310 let upper = token.uppercased()
174 let upper = token.uppercased()
311 if ["SELECT", "BY", "WHERE", "HAVING", "ON"].contains(upper) {
175 if ["SELECT", "BY", "WHERE", "HAVING", "ON"].contains(upper) {
@@ -364,12 +228,7 @@ enum SQLCompletionProvider {
364 (ch >= 0x41 && ch <= 0x5A) || // A-Z
228 (ch >= 0x41 && ch <= 0x5A) || // A-Z
365 (ch >= 0x61 && ch <= 0x7A) || // a-z
229 (ch >= 0x61 && ch <= 0x7A) || // a-z
366 (ch >= 0x30 && ch <= 0x39) || // 0-9
230 (ch >= 0x30 && ch <= 0x39) || // 0-9
367− ch == 0x5F || // _
231+ ch == 0x5F // _
368− ch >= 0x80 // any non-ASCII code unit: Unicode letters
369− // and digits in identifiers (e.g. accented or CJK schema field names), plus
370− // both halves of a surrogate pair. SQL delimiters — whitespace, operators,
371− // quotes, parens, commas — are all ASCII (< 0x80), so this only sweeps in
372− // genuine identifier characters and won't run a word past a token boundary.
373 }
232 }
374}
233}
375
234
@@ -465,12 +324,7 @@ final class SQLCompletionPopup: NSObject, NSTableViewDataSource, NSTableViewDele
465
324
466 let rowCount = min(items.count, Self.maxVisibleRows)
325 let rowCount = min(items.count, Self.maxVisibleRows)
467 let height = CGFloat(rowCount) * Self.rowHeight + 4
326 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,
327+ let frame = NSRect(x: panel.frame.origin.x, y: panel.frame.origin.y,
474 width: Self.panelWidth, height: height)
328 width: Self.panelWidth, height: height)
475 panel.setFrame(frame, display: true)
329 panel.setFrame(frame, display: true)
476 }
330 }
@@ -485,17 +339,6 @@ final class SQLCompletionPopup: NSObject, NSTableViewDataSource, NSTableViewDele
485 panel.orderFront(nil)
339 panel.orderFront(nil)
486 }
340 }
487
341
488− /// Moves an already-visible popup so it stays anchored to the caret as the
489− /// user keeps typing (otherwise it lingers at its original position).
490− func reposition(at screenPoint: NSPoint) {
491− guard panel.isVisible else { return }
492− let rowCount = min(items.count, Self.maxVisibleRows)
493− let height = CGFloat(rowCount) * Self.rowHeight + 4
494− let frame = NSRect(x: screenPoint.x, y: screenPoint.y - height,
495− width: Self.panelWidth, height: height)
496− panel.setFrame(frame, display: true)
497− }
498−
499 func hide() {
342 func hide() {
500 panel.parent?.removeChildWindow(panel)
343 panel.parent?.removeChildWindow(panel)
501 panel.orderOut(nil)
344 panel.orderOut(nil)
@@ -529,17 +372,6 @@ final class SQLCompletionPopup: NSObject, NSTableViewDataSource, NSTableViewDele
529
372
530 // MARK: - NSTableViewDelegate
373 // MARK: - NSTableViewDelegate
531
374
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−
543 func tableView(_ tableView: NSTableView, viewFor tableColumn: NSTableColumn?, row: Int) -> NSView? {
375 func tableView(_ tableView: NSTableView, viewFor tableColumn: NSTableColumn?, row: Int) -> NSView? {
544 guard row < items.count else { return nil }
376 guard row < items.count else { return nil }
545 let item = items[row]
377 let item = items[row]
ParseableViewer/Views/SQLEditorView.swift+9−102
@@ -57,22 +57,11 @@ struct SQLEditorView: NSViewRepresentable {
57 // Only update when text changed externally (not from user typing)
57 // Only update when text changed externally (not from user typing)
58 if textView.string != text && !context.coordinator.isEditing {
58 if textView.string != text && !context.coordinator.isEditing {
59 let selectedRanges = textView.selectedRanges
59 let selectedRanges = textView.selectedRanges
60− context.coordinator.replaceText(with: text, in: textView)
60+ textView.string = text
61 context.coordinator.applyHighlighting(to: textView)
61 context.coordinator.applyHighlighting(to: textView)
62− // NSRange is UTF-16 based; compare against the NSString length, not
62+ let validRanges = selectedRanges.filter { NSMaxRange($0.rangeValue) <= textView.string.count }
63− // String.count (grapheme count), or wide characters drop the selection.
64− // Clamp each range to the new length rather than discarding the ones that
65− // overflow, so replacing the text with a shorter string keeps the caret
66− // at the end instead of losing the selection entirely.
67− let length = (textView.string as NSString).length
68− let clampedRanges = selectedRanges.map { value -> NSValue in
69− let range = value.rangeValue
70− let location = min(range.location, length)
71− let len = min(range.length, length - location)
72− return NSValue(range: NSRange(location: location, length: len))
73− }
74− if !clampedRanges.isEmpty {
63+ if !validRanges.isEmpty {
75− textView.selectedRanges = clampedRanges
64+ textView.selectedRanges = validRanges
76 }
65 }
77 }
66 }
78 context.coordinator.updateErrorHighlight(in: textView, errorRange: errorRange)
67 context.coordinator.updateErrorHighlight(in: textView, errorRange: errorRange)
@@ -82,10 +71,6 @@ struct SQLEditorView: NSViewRepresentable {
82 Coordinator(text: $text, streamNames: streamNames, schemaFields: schemaFields)
71 Coordinator(text: $text, streamNames: streamNames, schemaFields: schemaFields)
83 }
72 }
84
73
85− static func dismantleNSView(_ nsView: NSScrollView, coordinator: Coordinator) {
86− coordinator.cleanup()
87− }
88−
89 final class Coordinator: NSObject, NSTextViewDelegate {
74 final class Coordinator: NSObject, NSTextViewDelegate {
90 var text: Binding<String>
75 var text: Binding<String>
91 var isEditing = false
76 var isEditing = false
@@ -101,11 +86,6 @@ struct SQLEditorView: NSViewRepresentable {
101 /// Tracks whether we are inserting a completion so that `textDidChange`
86 /// Tracks whether we are inserting a completion so that `textDidChange`
102 /// doesn't re-trigger the popup for the replacement.
87 /// doesn't re-trigger the popup for the replacement.
103 private var isInsertingCompletion = false
88 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
109
89
110 init(text: Binding<String>, streamNames: [String], schemaFields: [SchemaField]) {
90 init(text: Binding<String>, streamNames: [String], schemaFields: [SchemaField]) {
111 self.text = text
91 self.text = text
@@ -121,8 +101,7 @@ struct SQLEditorView: NSViewRepresentable {
121 // MARK: - NSTextViewDelegate
101 // MARK: - NSTextViewDelegate
122
102
123 func textDidChange(_ notification: Notification) {
103 func textDidChange(_ notification: Notification) {
124− guard let textView = notification.object as? NSTextView,
104+ guard let textView = notification.object as? NSTextView else { return }
125− !isApplyingModelChange else { return }
126 isEditing = true
105 isEditing = true
127 clearErrorHighlight(in: textView)
106 clearErrorHighlight(in: textView)
128 text.wrappedValue = textView.string
107 text.wrappedValue = textView.string
@@ -134,34 +113,6 @@ struct SQLEditorView: NSViewRepresentable {
134 }
113 }
135 }
114 }
136
115
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−
165 func textView(_ textView: NSTextView, doCommandBy commandSelector: Selector) -> Bool {
116 func textView(_ textView: NSTextView, doCommandBy commandSelector: Selector) -> Bool {
166 guard completionPopup.isVisible else { return false }
117 guard completionPopup.isVisible else { return false }
167
118
@@ -189,13 +140,6 @@ struct SQLEditorView: NSViewRepresentable {
189 // MARK: - Completions
140 // MARK: - Completions
190
141
191 private func updateCompletions(for textView: NSTextView) {
142 private func updateCompletions(for textView: NSTextView) {
192− // Capture the text view that the popup is being presented for. The
193− // popup's accept handler (e.g. a double-click in the borderless popup
194− // window) doesn't necessarily route through the begin-editing /
195− // selection-change delegate callbacks first, so relying on those
196− // alone to record the target can drop an accepted completion.
197− lastTextView = textView
198−
199 let cursorPosition = textView.selectedRange().location
143 let cursorPosition = textView.selectedRange().location
200 let result = SQLCompletionProvider.completions(
144 let result = SQLCompletionProvider.completions(
201 for: textView.string,
145 for: textView.string,
@@ -211,22 +155,12 @@ struct SQLEditorView: NSViewRepresentable {
211
155
212 completionPopup.update(items: result.items)
156 completionPopup.update(items: result.items)
213
157
214− if let window = textView.window {
158+ if !completionPopup.isVisible, let window = textView.window {
215 let screenPoint = cursorScreenPoint(for: textView)
159 let screenPoint = cursorScreenPoint(for: textView)
216− if completionPopup.isVisible {
217− completionPopup.reposition(at: screenPoint)
218− } else {
219− completionPopup.show(at: screenPoint, in: window)
160+ completionPopup.show(at: screenPoint, in: window)
220− }
221 }
161 }
222 }
162 }
223
163
224− /// Hides the completion popup when the editor is torn down so the
225− /// borderless child window can't linger after the view disappears.
226− func cleanup() {
227− completionPopup.hide()
228− }
229−
230 private func insertCompletion(_ item: SQLCompletionItem) {
164 private func insertCompletion(_ item: SQLCompletionItem) {
231 // Find the text view — walk up from the popup's parent window
165 // Find the text view — walk up from the popup's parent window
232 guard let textView = findTextView() else { return }
166 guard let textView = findTextView() else { return }
@@ -234,9 +168,7 @@ struct SQLEditorView: NSViewRepresentable {
234 let cursorPosition = textView.selectedRange().location
168 let cursorPosition = textView.selectedRange().location
235 let nsText = textView.string as NSString
169 let nsText = textView.string as NSString
236
170
237− // Find the full word range to replace. Scan backward for the start and
171+ // Find the prefix range to replace
238− // forward for the end so a mid-token caret (e.g. SEL|ECT) replaces the
239− // entire word rather than leaving the trailing fragment ("SELECTECT").
240 var wordStart = cursorPosition
172 var wordStart = cursorPosition
241 while wordStart > 0 {
173 while wordStart > 0 {
242 let ch = nsText.character(at: wordStart - 1)
174 let ch = nsText.character(at: wordStart - 1)
@@ -247,32 +179,7 @@ struct SQLEditorView: NSViewRepresentable {
247 }
179 }
248 }
180 }
249
181
250− var wordEnd = cursorPosition
251− while wordEnd < nsText.length {
252− let ch = nsText.character(at: wordEnd)
253− if SQLCompletionProvider.isWordCharacter(ch) {
254− wordEnd += 1
255− } else {
256− break
257− }
258− }
259−
260− // Table and column completions carry their own quoting (a column is
261− // quoted only when it needs it); absorb any double-quote the user typed
262− // directly around the word so the inserted identifier replaces it
263− // instead of leaving a stray quote. Leading and trailing are handled
264− // independently: a closing quote can exist without an opening one (e.g.
265− // `FROM my"`), which would otherwise produce a dangling `"name""`.
266− if item.kind == .table || item.kind == .column {
267− if wordStart > 0, nsText.character(at: wordStart - 1) == 0x22 {
268− wordStart -= 1
269− }
270− if wordEnd < nsText.length, nsText.character(at: wordEnd) == 0x22 {
271− wordEnd += 1
272− }
273− }
274−
275− let replaceRange = NSRange(location: wordStart, length: wordEnd - wordStart)
182+ let replaceRange = NSRange(location: wordStart, length: cursorPosition - wordStart)
276
183
277 isInsertingCompletion = true
184 isInsertingCompletion = true
278 defer { isInsertingCompletion = false }
185 defer { isInsertingCompletion = false }
ParseableViewer/Views/ServerInfoView.swift+17−71
@@ -3,15 +3,9 @@ import SwiftUI
3struct ServerInfoView: View {
3struct ServerInfoView: View {
4 @Environment(AppState.self) private var appState
4 @Environment(AppState.self) private var appState
5 @State private var about: ServerAbout?
5 @State private var about: ServerAbout?
6− // Start in the loading state: the `.task` always runs `loadInfo()` on appear, so
7− // initializing to `false` would paint the `else` branch's red "Unhealthy" dot for
8− // one frame before the check begins. Begin neutral ("Checking…") instead.
9− @State private var isLoading = true
6+ @State private var isLoading = false
10 @State private var isHealthy = false
7 @State private var isHealthy = false
11 @State private var errorMessage: String?
8 @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
15
9
16 var body: some View {
10 var body: some View {
17 ScrollView {
11 ScrollView {
@@ -22,7 +16,7 @@ struct ServerInfoView: View {
22 .fontWeight(.semibold)
16 .fontWeight(.semibold)
23 Spacer()
17 Spacer()
24 Button {
18 Button {
25− Task { await loadInfo(forceRefresh: true) }
19+ Task { await loadInfo() }
26 } label: {
20 } label: {
27 Image(systemName: "arrow.clockwise")
21 Image(systemName: "arrow.clockwise")
28 }
22 }
@@ -47,31 +41,18 @@ struct ServerInfoView: View {
47 // Health status
41 // Health status
48 GroupBox("Health") {
42 GroupBox("Health") {
49 HStack {
43 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()
44+ 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)
45+ .fill(isHealthy ? .green : .red)
64− .frame(width: 12, height: 12)
46+ .frame(width: 12, height: 12)
65− .accessibilityLabel(isHealthy ? "Healthy" : "Unhealthy")
47+ .accessibilityLabel(isHealthy ? "Healthy" : "Unhealthy")
66− Text(isHealthy ? "Healthy" : "Unhealthy")
48+ Text(isHealthy ? "Healthy" : "Unhealthy")
67− .fontWeight(.medium)
49+ .fontWeight(.medium)
68− }
69 Spacer()
50 Spacer()
70 }
51 }
71 .padding(8)
52 .padding(8)
72 }
53 }
73
54
74− if let about {
55+ if let about = about ?? appState.serverAbout {
75 GroupBox("Server Details") {
56 GroupBox("Server Details") {
76 VStack(alignment: .leading, spacing: 8) {
57 VStack(alignment: .leading, spacing: 8) {
77 InfoRow(label: "Version", value: about.version ?? "N/A")
58 InfoRow(label: "Version", value: about.version ?? "N/A")
@@ -142,67 +123,32 @@ struct ServerInfoView: View {
142 }
123 }
143 .padding()
124 .padding()
144 }
125 }
145− // Key on the active connection so switching (or reconnecting to a
126+ .task {
146− // different) server reloads instead of showing the previous server's info.
147− .task(id: appState.activeConnection?.id) {
148− // Drop the previous server's details immediately on switch so they
149− // aren't shown while the newly-selected server's info loads. (Local
150− // @State persists across the .task id change, so it must be cleared
151− // explicitly.) The manual Refresh button keeps the current values.
152− about = nil
153− isHealthy = false
154− errorMessage = nil
155 await loadInfo()
127 await loadInfo()
156 }
128 }
157 }
129 }
158
130
159− private func loadInfo(forceRefresh: Bool = false) async {
131+ private func loadInfo() 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 }
132+ guard let client = appState.client else { return }
164− // `getAbout()` is cached for 60 s; the Refresh button must bypass that.
165− if forceRefresh {
166− await client.invalidateCache()
167− }
168− let connID = appState.activeConnection?.id
169− loadGeneration &+= 1
170− let generation = loadGeneration
171 isLoading = true
133 isLoading = true
172 errorMessage = nil
134 errorMessage = nil
173− // Seed from the app-wide cache so first load isn't blank, but never fall
174− // back to it on refresh failure (that would show stale data under the error).
175− if about == nil { about = appState.serverAbout }
176
135
177 async let healthCheck: Void = client.checkHealth()
136 async let healthCheck: Void = client.checkHealth()
178 async let aboutResult = client.getAbout()
137 async let aboutResult = client.getAbout()
179
138
180− let healthy: Bool
181 do {
139 do {
182 try await healthCheck
140 try await healthCheck
183− healthy = true
141+ isHealthy = true
184 } catch {
142 } catch {
185− healthy = false
143+ isHealthy = false
186 }
144 }
187− let aboutValue: ServerAbout?
188− let aboutError: String?
189 do {
145 do {
190− aboutValue = try await aboutResult
146+ about = try await aboutResult
191− aboutError = nil
192 } catch {
147 } catch {
193− aboutValue = nil
148+ about = nil
194− aboutError = ParseableError.userFriendlyMessage(for: error)
149+ errorMessage = ParseableError.userFriendlyMessage(for: error)
195 }
150 }
196
151
197− // Drop results if the active connection changed while awaiting, so a slow
198− // load for the previous server can't overwrite the current one's state.
199− // Still clear isLoading: this task owns the spinner it set, and leaving it
200− // true on the stale path wedges the ProgressView and disables Refresh.
201− guard generation == loadGeneration else { return }
202− guard connID == appState.activeConnection?.id else { isLoading = false; return }
203− isHealthy = healthy
204− about = aboutValue
205− errorMessage = aboutError
206 isLoading = false
152 isLoading = false
207 }
153 }
208}
154}
ParseableViewer/Views/SettingsView.swift+2−21
@@ -6,13 +6,6 @@ struct SettingsView: View {
6 @AppStorage("maxQueryResults") private var maxQueryResults = 1000
6 @AppStorage("maxQueryResults") private var maxQueryResults = 1000
7 @AppStorage("liveTailPollInterval") private var liveTailPollInterval = 2.0
7 @AppStorage("liveTailPollInterval") private var liveTailPollInterval = 2.0
8 @AppStorage("liveTailMaxEntries") private var liveTailMaxEntries = 5000
8 @AppStorage("liveTailMaxEntries") private var liveTailMaxEntries = 5000
9− // The Settings scene is a separate window from ContentView. A .sheet presents
10− // over the window hosting the modifier, and binding both windows to the shared
11− // appState.showConnectionSheet would present two sheets at once. So the Settings
12− // window drives its own connection sheet with local state, fully independent of
13− // the main window's.
14− @State private var showConnectionSheet = false
15− @State private var sheetConnection: ServerConnection?
16 var body: some View {
9 var body: some View {
17 TabView {
10 TabView {
18 // General settings
11 // General settings
@@ -83,20 +76,11 @@ struct SettingsView: View {
83 .clipShape(RoundedRectangle(cornerRadius: 4))
76 .clipShape(RoundedRectangle(cornerRadius: 4))
84 }
77 }
85
78
86− Button {
87− sheetConnection = connection
88− showConnectionSheet = true
89− } label: {
90− Image(systemName: "pencil")
91− }
92− .help("Edit connection")
93−
94 Button(role: .destructive) {
79 Button(role: .destructive) {
95 appState.removeConnection(connection)
80 appState.removeConnection(connection)
96 } label: {
81 } label: {
97 Image(systemName: "trash")
82 Image(systemName: "trash")
98 }
83 }
99− .help("Delete connection")
100 }
84 }
101 .padding(.vertical, 4)
85 .padding(.vertical, 4)
102 }
86 }
@@ -105,8 +89,8 @@ struct SettingsView: View {
105 HStack {
89 HStack {
106 Spacer()
90 Spacer()
107 Button("Add Connection...") {
91 Button("Add Connection...") {
108− sheetConnection = nil
92+ appState.editingConnection = nil
109− showConnectionSheet = true
93+ appState.showConnectionSheet = true
110 }
94 }
111 }
95 }
112 .padding()
96 .padding()
@@ -116,8 +100,5 @@ struct SettingsView: View {
116 }
100 }
117 }
101 }
118 .frame(width: 500, height: 400)
102 .frame(width: 500, height: 400)
119− .sheet(isPresented: $showConnectionSheet) {
120− ConnectionSheet(connection: sheetConnection)
121− }
122 }
103 }
123}
104}
ParseableViewer/Views/SidebarView.swift+9−46
@@ -6,19 +6,11 @@ struct SidebarView: View {
6 @State private var newStreamName = ""
6 @State private var newStreamName = ""
7 @State private var showDeleteConfirm = false
7 @State private var showDeleteConfirm = false
8 @State private var streamToDelete: String?
8 @State private var streamToDelete: String?
9− /// The connection `streamToDelete` was picked on. Stream names aren't unique
10− /// across servers, and the delete runs against whatever client is current
11− /// when the user confirms — so a server switch while the stats lookup was in
12− /// flight could otherwise delete a same-named stream on the *new* server.
13− @State private var streamToDeleteConnectionID: UUID?
14− /// The client the stream was picked with. Editing the active connection's URL
15− /// keeps its id but installs a new client for a possibly different server.
16− @State private var streamToDeleteClient: ParseableClient?
17 @State private var deleteStreamDetail: String?
9 @State private var deleteStreamDetail: String?
18
10
19 /// Validates a stream name, returning an error message or nil if valid.
11 /// Validates a stream name, returning an error message or nil if valid.
20 static func validateStreamName(_ name: String) -> String? {
12 static func validateStreamName(_ name: String) -> String? {
21− if name.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty {
13+ if name.trimmingCharacters(in: .whitespaces).isEmpty {
22 return "Stream name cannot be empty."
14 return "Stream name cannot be empty."
23 }
15 }
24 if name.count > 255 {
16 if name.count > 255 {
@@ -66,21 +58,11 @@ struct SidebarView: View {
66 }
58 }
67 Divider()
59 Divider()
68 Button("Delete...", role: .destructive) {
60 Button("Delete...", role: .destructive) {
69− // Capture the target so a slower stats fetch
70− // for a *previously* clicked stream can't write
71− // its detail (or raise the confirm) against the
72− // stream the user has since selected — which
73− // would show the wrong stats on a destructive
74− // confirmation.
75− let target = stream.name
76− let targetConnectionID = appState.activeConnection?.id
77− streamToDelete = target
61+ streamToDelete = stream.name
78− streamToDeleteConnectionID = targetConnectionID
79− streamToDeleteClient = appState.client
80 deleteStreamDetail = nil
62 deleteStreamDetail = nil
81 Task {
63 Task {
82 if let client = appState.client,
64 if let client = appState.client,
83− let stats = try? await client.getStreamStats(stream: target) {
65+ let stats = try? await client.getStreamStats(stream: stream.name) {
84 var parts: [String] = []
66 var parts: [String] = []
85 if let count = stats.ingestion?.lifetime_count {
67 if let count = stats.ingestion?.lifetime_count {
86 parts.append("\(count) total records ingested")
68 parts.append("\(count) total records ingested")
@@ -88,15 +70,11 @@ struct SidebarView: View {
88 if let size = stats.storage?.lifetime_size ?? stats.storage?.size {
70 if let size = stats.storage?.lifetime_size ?? stats.storage?.size {
89 parts.append("\(size) storage used")
71 parts.append("\(size) storage used")
90 }
72 }
91− if !parts.isEmpty, streamToDelete == target {
73+ if !parts.isEmpty {
92 deleteStreamDetail = parts.joined(separator: ", ")
74 deleteStreamDetail = parts.joined(separator: ", ")
93 }
75 }
94 }
76 }
95− if streamToDelete == target,
96− appState.activeConnection?.id == targetConnectionID,
97− appState.client === streamToDeleteClient {
98− showDeleteConfirm = true
77+ showDeleteConfirm = true
99− }
100 }
78 }
101 }
79 }
102 }
80 }
@@ -177,11 +155,10 @@ struct SidebarView: View {
177 .alert("Create Log Stream", isPresented: $showCreateStream) {
155 .alert("Create Log Stream", isPresented: $showCreateStream) {
178 TextField("Stream name", text: $newStreamName)
156 TextField("Stream name", text: $newStreamName)
179 Button("Create") {
157 Button("Create") {
180− let name = newStreamName.trimmingCharacters(in: .whitespacesAndNewlines)
158+ let name = newStreamName.trimmingCharacters(in: .whitespaces)
181− guard !name.isEmpty else { newStreamName = ""; return }
159+ guard !name.isEmpty else { return }
182 if let error = Self.validateStreamName(name) {
160 if let error = Self.validateStreamName(name) {
183 appState.showErrorMessage(error)
161 appState.showErrorMessage(error)
184− newStreamName = ""
185 return
162 return
186 }
163 }
187 Task {
164 Task {
@@ -200,11 +177,6 @@ struct SidebarView: View {
200 .alert("Delete Stream", isPresented: $showDeleteConfirm) {
177 .alert("Delete Stream", isPresented: $showDeleteConfirm) {
201 Button("Delete", role: .destructive) {
178 Button("Delete", role: .destructive) {
202 if let name = streamToDelete {
179 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− }
208 Task {
180 Task {
209 do {
181 do {
210 try await appState.deleteStream(name: name)
182 try await appState.deleteStream(name: name)
@@ -243,12 +215,7 @@ struct ConnectionStatusView: View {
243 if newID == ConnectionStatusView.addConnectionID {
215 if newID == ConnectionStatusView.addConnectionID {
244 appState.editingConnection = nil
216 appState.editingConnection = nil
245 appState.showConnectionSheet = true
217 appState.showConnectionSheet = true
246− } else if newID != connection.id,
247− let conn = appState.connections.first(where: { $0.id == newID }) {
218+ } else if let conn = appState.connections.first(where: { $0.id == newID }) {
248− // Only reconnect when a *different* connection is
249− // picked. Re-selecting the active one would tear
250− // down and rebuild the client for no reason (a full
251− // health-check + stream reload, with a visible flicker).
252 Task { await appState.connect(to: conn) }
219 Task { await appState.connect(to: conn) }
253 }
220 }
254 }
221 }
@@ -313,10 +280,6 @@ struct FilterRow: View {
313
280
314 var body: some View {
281 var body: some View {
315 Button {
282 Button {
316− // Don't navigate to a stream that no longer exists: it would drive
317− // detail views to query a deleted stream (surfacing 404s) and leave
318− // the sidebar selection pointing at no row.
319− guard streamExists else { return }
320 appState.selectedStream = filter.streamName
283 appState.selectedStream = filter.streamName
321 if let sql = filter.query.filterQuery {
284 if let sql = filter.query.filterQuery {
322 appState.pendingFilterSQL = sql
285 appState.pendingFilterSQL = sql
@@ -325,7 +288,7 @@ struct FilterRow: View {
325 } label: {
288 } label: {
326 HStack {
289 HStack {
327 Image(systemName: "bookmark")
290 Image(systemName: "bookmark")
328− .foregroundStyle(streamExists ? Color.secondary : Color.orange)
291+ .foregroundStyle(streamExists ? .orange : .secondary)
329 VStack(alignment: .leading) {
292 VStack(alignment: .leading) {
330 Text(filter.filterName)
293 Text(filter.filterName)
331 .lineLimit(1)
294 .lineLimit(1)
ParseableViewer/Views/StreamDetailView.swift+17−56
@@ -8,9 +8,6 @@ struct StreamDetailView: View {
8 @State private var retention: [RetentionConfig] = []
8 @State private var retention: [RetentionConfig] = []
9 @State private var isLoading = false
9 @State private var isLoading = false
10 @State private var errorMessage: String?
10 @State private var errorMessage: String?
11− /// Bumped by every load. A superseded load (its `.task(id:)` cancelled by a
12− /// stream switch) still resumes, and must not clear the newer load's spinner.
13− @State private var loadGeneration = 0
14
11
15 var body: some View {
12 var body: some View {
16 Group {
13 Group {
@@ -23,12 +20,20 @@ struct StreamDetailView: View {
23 Text(stream)
20 Text(stream)
24 .font(.title2)
21 .font(.title2)
25 .fontWeight(.semibold)
22 .fontWeight(.semibold)
23+
24+ if let info {
25+ if let created = info.createdAt {
26+ Text("Created: \(created)")
27+ .font(.caption)
28+ .foregroundStyle(.secondary)
29+ }
30+ }
26 }
31 }
27
32
28 Spacer()
33 Spacer()
29
34
30 Button {
35 Button {
31− Task { await loadData(stream: stream, forceRefresh: true) }
36+ Task { await loadData(stream: stream) }
32 } label: {
37 } label: {
33 Image(systemName: "arrow.clockwise")
38 Image(systemName: "arrow.clockwise")
34 }
39 }
@@ -47,7 +52,7 @@ struct StreamDetailView: View {
47 Text(error)
52 Text(error)
48 Spacer()
53 Spacer()
49 Button("Retry") {
54 Button("Retry") {
50− Task { await loadData(stream: stream, forceRefresh: true) }
55+ Task { await loadData(stream: stream) }
51 }
56 }
52 .buttonStyle(.bordered)
57 .buttonStyle(.bordered)
53 .controlSize(.small)
58 .controlSize(.small)
@@ -160,11 +165,7 @@ struct StreamDetailView: View {
160 }
165 }
161 .padding()
166 .padding()
162 }
167 }
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)") {
168+ .task(id: stream) {
168 await loadData(stream: stream)
169 await loadData(stream: stream)
169 }
170 }
170 } else {
171 } else {
@@ -177,22 +178,8 @@ struct StreamDetailView: View {
177 }
178 }
178 }
179 }
179
180
180− private func loadData(stream: String, forceRefresh: Bool = false) async {
181+ private func loadData(stream: String) async {
181 guard let client = appState.client else { return }
182 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
196 isLoading = true
183 isLoading = true
197 errorMessage = nil
184 errorMessage = nil
198 // Clear previous stream's data so it isn't shown under the new header
185 // Clear previous stream's data so it isn't shown under the new header
@@ -209,43 +196,17 @@ struct StreamDetailView: View {
209
196
210 var failures: [String] = []
197 var failures: [String] = []
211 var sawNotFound = false
198 var sawNotFound = false
212− var newSchema: StreamSchema?
213− var newStats: StreamStats?
214− var newInfo: StreamInfo?
215− var newRetention: [RetentionConfig] = []
216
199
217 func checkNotFound(_ error: Error) {
200 func checkNotFound(_ error: Error) {
218 if case .serverError(let code, _) = error as? ParseableError, code == 404 { sawNotFound = true }
201 if case .serverError(let code, _) = error as? ParseableError, code == 404 { sawNotFound = true }
219 }
202 }
220
203
221− do { newSchema = try await schemaFetch } catch { failures.append("schema"); checkNotFound(error) }
204+ do { schema = try await schemaFetch } catch { schema = nil; failures.append("schema"); checkNotFound(error) }
222− do { newStats = try await statsFetch } catch { failures.append("stats"); checkNotFound(error) }
205+ do { stats = try await statsFetch } catch { stats = nil; failures.append("stats"); checkNotFound(error) }
223− do { newInfo = try await infoFetch } catch { failures.append("info"); checkNotFound(error) }
206+ do { info = try await infoFetch } catch { info = nil; failures.append("info"); checkNotFound(error) }
224− do { newRetention = try await retentionFetch } catch { failures.append("retention"); checkNotFound(error) }
207+ do { retention = try await retentionFetch } catch { retention = []; failures.append("retention"); checkNotFound(error) }
225−
226− // The manual Refresh/Retry buttons spawn detached Tasks that aren't tied to
227− // `.task(id: stream)` cancellation, so a slow refresh for the old stream can
228− // 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− }
238−
239− schema = newSchema
240− stats = newStats
241− info = newInfo
242− retention = newRetention
243
208
244− // A deleted stream makes these endpoints 404. Requiring *all four* to
245− // fail was too strict (e.g. retention can succeed with an empty result),
246− // so a deleted stream often fell through to the generic message. Treat
247− // a 404 alongside multiple failures as "stream gone".
248− if sawNotFound && failures.count >= 2 {
209+ if sawNotFound && failures.count == 4 {
249 errorMessage = "Stream \"\(stream)\" was not found. It may have been deleted."
210 errorMessage = "Stream \"\(stream)\" was not found. It may have been deleted."
250 } else if !failures.isEmpty {
211 } else if !failures.isEmpty {
251 errorMessage = "Failed to load: \(failures.joined(separator: ", "))"
212 errorMessage = "Failed to load: \(failures.joined(separator: ", "))"
ParseableViewer/Views/SyntaxHighlighting.swift+29−32
@@ -49,23 +49,10 @@ enum SQLSyntaxHighlighter {
49 static let sortedFunctions: [String] = functions.sorted()
49 static let sortedFunctions: [String] = functions.sorted()
50
50
51 // Pre-compiled regexes (created once, reused every keystroke)
51 // Pre-compiled regexes (created once, reused every keystroke)
52− //
53− // String literals, quoted identifiers, and comments in ONE alternation, so a
54− // single left-to-right scan claims each span for whichever construct starts
55− // first — exactly how a SQL lexer reads it. Separate passes can't agree: an
56− // apostrophe inside a comment (`-- don't`) opened a bogus string that painted
57− // every following line red, and a `"` inside a string (or any quote inside a
58− // block comment) did the same. Capture groups: 1 = 'string', 2 = "identifier",
59− // 3 = `--` line comment or `/* */` block comment (unterminated runs to the end).
60− private static let literalOrCommentRegex = try! NSRegularExpression(
52+ private static let commentRegex = try! NSRegularExpression(pattern: "--[^\n]*", options: [.caseInsensitive])
61− pattern: "('[^']*(?:''[^']*)*')|(\"[^\"]*(?:\"\"[^\"]*)*\")|(--[^\\n]*|/\\*[\\s\\S]*?(?:\\*/|\\z))",
53+ private static let singleQuoteRegex = try! NSRegularExpression(pattern: "'[^']*(?:''[^']*)*'", options: [.caseInsensitive])
62− options: []
54+ private static let doubleQuoteRegex = try! NSRegularExpression(pattern: "\"[^\"]*(?:\"\"[^\"]*)*\"", options: [.caseInsensitive])
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: [])
55+ private static let numberRegex = try! NSRegularExpression(pattern: "\\b\\d+(?:\\.\\d+)?\\b", options: [])
69 private static let keywordRegex: NSRegularExpression = {
56 private static let keywordRegex: NSRegularExpression = {
70 let pattern = "\\b(?:" + sortedKeywords.joined(separator: "|") + ")\\b"
57 let pattern = "\\b(?:" + sortedKeywords.joined(separator: "|") + ")\\b"
71 return try! NSRegularExpression(pattern: pattern, options: [.caseInsensitive])
58 return try! NSRegularExpression(pattern: pattern, options: [.caseInsensitive])
@@ -89,21 +76,17 @@ enum SQLSyntaxHighlighter {
89 let nsText = text as NSString
76 let nsText = text as NSString
90 var protectedRanges: [NSRange] = []
77 var protectedRanges: [NSRange] = []
91
78
79+ // 1. Comments (-- to end of line)
80+ applyRegex(commentRegex, to: textStorage, text: nsText,
81+ color: .secondaryLabelColor, protectedRanges: &protectedRanges)
82+
92− // 1–3. Single-quoted strings, double-quoted identifiers, and comments, in
83+ // 2. Single-quoted strings
93− // one pass so none of them can start inside another (`'a--b'` stays a
84+ applyRegex(singleQuoteRegex, to: textStorage, text: nsText,
94− // string, `-- don't` stays a comment).
85+ color: .systemRed, protectedRanges: &protectedRanges)
95− let literalMatches = literalOrCommentRegex.matches(
86+
96− in: text, range: NSRange(location: 0, length: nsText.length)
87+ // 3. Double-quoted identifiers
97− )
88+ applyRegex(doubleQuoteRegex, to: textStorage, text: nsText,
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)
89+ color: .systemRed, protectedRanges: &protectedRanges)
106− }
107
90
108 // 4. Numbers (outside protected ranges)
91 // 4. Numbers (outside protected ranges)
109 applyUnprotectedRegex(numberRegex, to: textStorage, text: nsText,
92 applyUnprotectedRegex(numberRegex, to: textStorage, text: nsText,
@@ -120,6 +103,20 @@ enum SQLSyntaxHighlighter {
120 protectedRanges: protectedRanges)
103 protectedRanges: protectedRanges)
121 }
104 }
122
105
106+ private static func applyRegex(
107+ _ regex: NSRegularExpression,
108+ to textStorage: NSTextStorage,
109+ text: NSString,
110+ color: NSColor,
111+ protectedRanges: inout [NSRange]
112+ ) {
113+ let matches = regex.matches(in: text as String, range: NSRange(location: 0, length: text.length))
114+ for match in matches {
115+ textStorage.addAttribute(.foregroundColor, value: color, range: match.range)
116+ protectedRanges.append(match.range)
117+ }
118+ }
119+
123 private static func applyUnprotectedRegex(
120 private static func applyUnprotectedRegex(
124 _ regex: NSRegularExpression,
121 _ regex: NSRegularExpression,
125 to textStorage: NSTextStorage,
122 to textStorage: NSTextStorage,
ParseableViewer/Views/TimeRangePicker.swift+3−25
@@ -31,27 +31,6 @@ struct TimeRangePicker: View {
31 .font(.caption)
31 .font(.caption)
32 }
32 }
33 }
33 }
34− .onChange(of: showCustomPicker) { _, isShown in
35− // Dismissing the popover by clicking outside bypasses the "Done"
36− // button's `customEnd > customStart` validation, which could leave an
37− // inverted or zero-width range selected (the menu already set
38− // `option == .custom`). Sanitize on close so a backwards range can
39− // never reach a query.
40− guard !isShown else { return }
41− if customEnd <= customStart {
42− customEnd = customStart.addingTimeInterval(3600)
43− }
44− // Commit on *every* close path, not just the "Done" button. Closing
45− // the popover by clicking outside is the common dismissal gesture;
46− // without this the picker reads "Custom" while results still reflect
47− // the previously selected preset, since QueryView only re-queries on
48− // `timeRangeOption` changes (which it skips for `.custom`) — never on
49− // the custom dates. Done sets `showCustomPicker = false` and lets this
50− // handler do the single commit, so there's no double-query.
51− if option == .custom {
52− onCommit?()
53− }
54− }
55 .popover(isPresented: $showCustomPicker) {
34 .popover(isPresented: $showCustomPicker) {
56 VStack(spacing: 12) {
35 VStack(spacing: 12) {
57 Text("Custom Time Range")
36 Text("Custom Time Range")
@@ -62,7 +41,7 @@ struct TimeRangePicker: View {
62 DatePicker("End:", selection: $customEnd)
41 DatePicker("End:", selection: $customEnd)
63 .datePickerStyle(.field)
42 .datePickerStyle(.field)
64
43
65− if customEnd <= customStart {
44+ if customEnd < customStart {
66 HStack(spacing: 4) {
45 HStack(spacing: 4) {
67 Image(systemName: "exclamationmark.triangle.fill")
46 Image(systemName: "exclamationmark.triangle.fill")
68 Text("End date must be after start date")
47 Text("End date must be after start date")
@@ -99,12 +78,11 @@ struct TimeRangePicker: View {
99 Spacer()
78 Spacer()
100
79
101 Button("Done") {
80 Button("Done") {
102− // Just close — the `showCustomPicker` change handler is the
103− // single commit point for all close paths (Done + click-out).
104 showCustomPicker = false
81 showCustomPicker = false
82+ onCommit?()
105 }
83 }
106 .buttonStyle(.borderedProminent)
84 .buttonStyle(.borderedProminent)
107− .disabled(customEnd <= customStart)
85+ .disabled(customEnd < customStart)
108 }
86 }
109 }
87 }
110 .padding()
88 .padding()
ParseableViewer/Views/UsersView.swift+3−26
@@ -5,9 +5,6 @@ struct UsersView: View {
5 @State private var users: [UserInfo] = []
5 @State private var users: [UserInfo] = []
6 @State private var isLoading = false
6 @State private var isLoading = false
7 @State private var errorMessage: String?
7 @State private var errorMessage: String?
8− /// Bumped by every load so a superseded (cancelled) load can't clear the
9− /// newer load's spinner.
10− @State private var loadGeneration = 0
11
8
12 var body: some View {
9 var body: some View {
13 VStack(spacing: 0) {
10 VStack(spacing: 0) {
@@ -75,42 +72,22 @@ struct UsersView: View {
75 }
72 }
76 }
73 }
77 }
74 }
78− // Key on the active connection so switching (or reconnecting to a
75+ .task {
79− // different) server reloads instead of showing the previous server's users.
80− .task(id: appState.activeConnection?.id) {
81 await loadUsers()
76 await loadUsers()
82 }
77 }
83 }
78 }
84
79
85 private func loadUsers() async {
80 private func loadUsers() async {
86 guard let client = appState.client else { return }
81 guard let client = appState.client else { return }
87− let connID = appState.activeConnection?.id
88− loadGeneration &+= 1
89− let generation = loadGeneration
90 isLoading = true
82 isLoading = true
91 errorMessage = nil
83 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 = []
96
84
97− let loaded: [UserInfo]?
98− let loadError: String?
99 do {
85 do {
100− loaded = try await client.listUsers()
86+ users = try await client.listUsers()
101− loadError = nil
102 } catch {
87 } catch {
103− loaded = nil
104− loadError = ParseableError.userFriendlyMessage(for: error)
88+ errorMessage = ParseableError.userFriendlyMessage(for: error)
105 }
89 }
106
90
107− // Drop results if the active connection changed while awaiting. Still clear
108− // isLoading: this task set the spinner, so leaving it true on the stale path
109− // wedges the ProgressView and disables Refresh.
110− guard generation == loadGeneration else { return }
111− guard connID == appState.activeConnection?.id else { isLoading = false; return }
112− if let loaded { users = loaded }
113− errorMessage = loadError
114 isLoading = false
91 isLoading = false
115 }
92 }
116}
93}
ParseableViewerTests/ConnectionStoreTests.swift+33−71
@@ -2,25 +2,25 @@ import XCTest
2@testable import ParseableViewer
2@testable import ParseableViewer
3
3
4final class ConnectionStoreTests: XCTestCase {
4final class ConnectionStoreTests: XCTestCase {
5− /// Whether the data-protection keychain is available in this environment.
5+ private let testSuiteKey = "parseable_connections_test"
6− /// CI runners may lack the entitlements needed for `kSecUseDataProtectionKeychain`.
7− private static let keychainAvailable: Bool = {
8− let probe = "keychain-probe"
9− let saved = KeychainService.saveData(Data(probe.utf8), for: "_test_probe")
10− if saved {
11− KeychainService.deleteData(for: "_test_probe")
12− }
13− return saved
14− }()
15
6
16 override func tearDown() {
7 override func tearDown() {
17 super.tearDown()
8 super.tearDown()
18− KeychainService.deleteData(for: "parseable_connections_list")
9+ // Clean up any test data from UserDefaults
10+ UserDefaults.standard.removeObject(forKey: "parseable_connections")
19− KeychainService.deleteData(for: "parseable_active_connection_id")
11+ UserDefaults.standard.removeObject(forKey: "parseable_active_connection_id")
12+ }
13+
14+ // MARK: - Active connection ID
15+
16+ func testSaveAndLoadActiveConnectionID() {
17+ let id = UUID()
18+ ConnectionStore.saveActiveConnectionID(id)
19+ let loaded = ConnectionStore.loadActiveConnectionID()
20+ XCTAssertEqual(loaded, id)
20 }
21 }
21
22
22− func testSaveNilActiveConnectionID() throws {
23+ func testSaveNilActiveConnectionID() {
23− try XCTSkipUnless(Self.keychainAvailable, "Data-protection keychain unavailable")
24 let id = UUID()
24 let id = UUID()
25 ConnectionStore.saveActiveConnectionID(id)
25 ConnectionStore.saveActiveConnectionID(id)
26 ConnectionStore.saveActiveConnectionID(nil)
26 ConnectionStore.saveActiveConnectionID(nil)
@@ -29,7 +29,7 @@ final class ConnectionStoreTests: XCTestCase {
29 }
29 }
30
30
31 func testLoadActiveConnectionIDWhenEmpty() {
31 func testLoadActiveConnectionIDWhenEmpty() {
32− KeychainService.deleteData(for: "parseable_active_connection_id")
32+ UserDefaults.standard.removeObject(forKey: "parseable_active_connection_id")
33 let loaded = ConnectionStore.loadActiveConnectionID()
33 let loaded = ConnectionStore.loadActiveConnectionID()
34 XCTAssertNil(loaded)
34 XCTAssertNil(loaded)
35 }
35 }
@@ -37,74 +37,35 @@ final class ConnectionStoreTests: XCTestCase {
37 // MARK: - Connection persistence
37 // MARK: - Connection persistence
38
38
39 func testLoadConnectionsWhenEmpty() {
39 func testLoadConnectionsWhenEmpty() {
40− KeychainService.deleteData(for: "parseable_connections_list")
40+ UserDefaults.standard.removeObject(forKey: "parseable_connections")
41 let connections = ConnectionStore.loadConnections()
41 let connections = ConnectionStore.loadConnections()
42 XCTAssertTrue(connections.isEmpty)
42 XCTAssertTrue(connections.isEmpty)
43 }
43 }
44
44
45− func testSaveAndLoadActiveConnectionID() throws {
45+ func testSaveAndLoadConnections() {
46− try XCTSkipUnless(Self.keychainAvailable, "Data-protection keychain unavailable")
47− let id = UUID()
48− ConnectionStore.saveActiveConnectionID(id)
49− let loaded = ConnectionStore.loadActiveConnectionID()
50− XCTAssertEqual(loaded, id)
51− }
52−
53− func testRoundTripConnectionPersistence() throws {
54− try XCTSkipUnless(Self.keychainAvailable, "Data-protection keychain unavailable")
55− let conn = ServerConnection(
46+ let connection = ServerConnection(
56− name: "Prod",
47+ name: "Test Server",
57 url: "https://logs.example.com",
48 url: "https://logs.example.com",
58 username: "admin",
49 username: "admin",
59− password: "s3cret!"
50+ password: "secret123"
60 )
51 )
61− ConnectionStore.saveConnections([conn])
52+ ConnectionStore.saveConnections([connection])
62−
63 let loaded = ConnectionStore.loadConnections()
53 let loaded = ConnectionStore.loadConnections()
64 XCTAssertEqual(loaded.count, 1)
54 XCTAssertEqual(loaded.count, 1)
65− XCTAssertEqual(loaded.first?.id, conn.id)
55+ XCTAssertEqual(loaded[0].name, "Test Server")
66− XCTAssertEqual(loaded.first?.name, "Prod")
67− XCTAssertEqual(loaded.first?.url, "https://logs.example.com")
56+ XCTAssertEqual(loaded[0].url, "https://logs.example.com")
68− XCTAssertEqual(loaded.first?.username, "admin")
57+ XCTAssertEqual(loaded[0].username, "admin")
69− XCTAssertEqual(loaded.first?.password, "s3cret!")
70−
71− // Clean up
72− KeychainService.deletePassword(for: conn.id)
73 }
58 }
74
59
75− func testRoundTripMultipleConnections() throws {
60+ func testSaveMultipleConnections() {
76− try XCTSkipUnless(Self.keychainAvailable, "Data-protection keychain unavailable")
61+ let c1 = ServerConnection(name: "Prod", url: "https://prod.example.com", username: "admin", password: "p1")
77− let conn1 = ServerConnection(
78− name: "Server A",
79− url: "https://a.example.com",
80− username: "user1",
81− password: "pass1"
82− )
83− let conn2 = ServerConnection(
62+ let c2 = ServerConnection(name: "Dev", url: "https://dev.example.com", username: "dev", password: "p2")
84− name: "Server B",
85− url: "https://b.example.com",
86− username: "user2",
87− password: "pass2"
88− )
89− ConnectionStore.saveConnections([conn1, conn2])
63+ ConnectionStore.saveConnections([c1, c2])
90−
91 let loaded = ConnectionStore.loadConnections()
64 let loaded = ConnectionStore.loadConnections()
92 XCTAssertEqual(loaded.count, 2)
65 XCTAssertEqual(loaded.count, 2)
93−
94− let loadedA = loaded.first { $0.id == conn1.id }
95− let loadedB = loaded.first { $0.id == conn2.id }
96− XCTAssertEqual(loadedA?.name, "Server A")
97− XCTAssertEqual(loadedA?.password, "pass1")
98− XCTAssertEqual(loadedB?.name, "Server B")
99− XCTAssertEqual(loadedB?.password, "pass2")
100−
101− // Clean up
102− KeychainService.deletePassword(for: conn1.id)
103− KeychainService.deletePassword(for: conn2.id)
104 }
66 }
105
67
106− func testPasswordNotInKeychainConnectionData() throws {
68+ func testPasswordNotInUserDefaults() {
107− try XCTSkipUnless(Self.keychainAvailable, "Data-protection keychain unavailable")
108 let connection = ServerConnection(
69 let connection = ServerConnection(
109 name: "Test",
70 name: "Test",
110 url: "https://example.com",
71 url: "https://example.com",
@@ -113,11 +74,12 @@ final class ConnectionStoreTests: XCTestCase {
113 )
74 )
114 ConnectionStore.saveConnections([connection])
75 ConnectionStore.saveConnections([connection])
115
76
116− // Verify password is NOT in the connections list data
77+ // Verify password is NOT in the UserDefaults data
117− if let data = KeychainService.loadData(for: "parseable_connections_list"),
78+ if let data = UserDefaults.standard.data(forKey: "parseable_connections"),
118 let jsonString = String(data: data, encoding: .utf8) {
79 let jsonString = String(data: data, encoding: .utf8) {
119 XCTAssertFalse(jsonString.contains("supersecret"),
80 XCTAssertFalse(jsonString.contains("supersecret"),
120− "Password should not be stored in connection list JSON")
81+ "Password should not be stored in UserDefaults JSON")
121 }
82 }
122 }
83 }
84+
123}
85}
ParseableViewerTests/JSONValueTests.swift−18
@@ -75,24 +75,6 @@ final class JSONValueTests: XCTestCase {
75 XCTAssertEqual(original, decoded)
75 XCTAssertEqual(original, decoded)
76 }
76 }
77
77
78− func testEncodeNonFiniteDoubleAsNull() throws {
79− // A non-finite double must encode as JSON null rather than throwing — a
80− // single infinity/NaN otherwise aborts the whole JSONEncoder pass and
81− // collapses exportAsJSON() to "[]" (total silent data loss).
82− let encoder = JSONEncoder()
83− for value: Double in [.infinity, -.infinity, .nan] {
84− let data = try encoder.encode(JSONValue.double(value))
85− XCTAssertEqual(String(data: data, encoding: .utf8), "null")
86− }
87− }
88−
89− func testEncodeArrayWithNonFiniteDoubleDoesNotThrow() throws {
90− // Mirrors the export path: one non-finite value must not poison the rest.
91− let original: JSONValue = .array([.int(1), .double(.infinity), .string("ok")])
92− let data = try JSONEncoder().encode(original)
93− XCTAssertEqual(String(data: data, encoding: .utf8), "[1,null,\"ok\"]")
94− }
95−
96 // MARK: - displayString
78 // MARK: - displayString
97
79
98 func testDisplayStringNull() {
80 func testDisplayStringNull() {
ParseableViewerTests/LiveTailViewModelTests.swift+28−53
@@ -63,38 +63,43 @@ final class LiveTailViewModelTests: XCTestCase {
63 // MARK: - Timestamp parsing
63 // MARK: - Timestamp parsing
64
64
65 func testParseTimestampISO8601WithFractionalSeconds() {
65 func testParseTimestampISO8601WithFractionalSeconds() {
66+ let vm = LiveTailViewModel()
66 let record: LogRecord = ["p_timestamp": .string("2024-06-15T10:30:45.123Z")]
67 let record: LogRecord = ["p_timestamp": .string("2024-06-15T10:30:45.123Z")]
67− let date = LiveTailViewModel.parseTimestamp(from: record)
68+ let date = vm.parseTimestamp(from: record)
68 XCTAssertNotNil(date)
69 XCTAssertNotNil(date)
69 }
70 }
70
71
71 func testParseTimestampISO8601Basic() {
72 func testParseTimestampISO8601Basic() {
73+ let vm = LiveTailViewModel()
72 let record: LogRecord = ["p_timestamp": .string("2024-06-15T10:30:45Z")]
74 let record: LogRecord = ["p_timestamp": .string("2024-06-15T10:30:45Z")]
73− let date = LiveTailViewModel.parseTimestamp(from: record)
75+ let date = vm.parseTimestamp(from: record)
74 XCTAssertNotNil(date)
76 XCTAssertNotNil(date)
75 }
77 }
76
78
77 func testParseTimestampFallbackFields() {
79 func testParseTimestampFallbackFields() {
80+ let vm = LiveTailViewModel()
81+
78 // "timestamp" field
82 // "timestamp" field
79 let r1: LogRecord = ["timestamp": .string("2024-06-15T10:30:45Z")]
83 let r1: LogRecord = ["timestamp": .string("2024-06-15T10:30:45Z")]
80− XCTAssertNotNil(LiveTailViewModel.parseTimestamp(from: r1))
84+ XCTAssertNotNil(vm.parseTimestamp(from: r1))
81
85
82 // "time" field
86 // "time" field
83 let r2: LogRecord = ["time": .string("2024-06-15T10:30:45Z")]
87 let r2: LogRecord = ["time": .string("2024-06-15T10:30:45Z")]
84− XCTAssertNotNil(LiveTailViewModel.parseTimestamp(from: r2))
88+ XCTAssertNotNil(vm.parseTimestamp(from: r2))
85
89
86 // "@timestamp" field
90 // "@timestamp" field
87 let r3: LogRecord = ["@timestamp": .string("2024-06-15T10:30:45Z")]
91 let r3: LogRecord = ["@timestamp": .string("2024-06-15T10:30:45Z")]
88− XCTAssertNotNil(LiveTailViewModel.parseTimestamp(from: r3))
92+ XCTAssertNotNil(vm.parseTimestamp(from: r3))
89 }
93 }
90
94
91 func testParseTimestampPriorityOrder() {
95 func testParseTimestampPriorityOrder() {
96+ let vm = LiveTailViewModel()
92 // p_timestamp should take priority
97 // p_timestamp should take priority
93 let record: LogRecord = [
98 let record: LogRecord = [
94 "p_timestamp": .string("2024-01-01T00:00:00Z"),
99 "p_timestamp": .string("2024-01-01T00:00:00Z"),
95 "timestamp": .string("2024-12-31T23:59:59Z")
100 "timestamp": .string("2024-12-31T23:59:59Z")
96 ]
101 ]
97− let date = LiveTailViewModel.parseTimestamp(from: record)
102+ let date = vm.parseTimestamp(from: record)
98 XCTAssertNotNil(date)
103 XCTAssertNotNil(date)
99
104
100 let calendar = Calendar.current
105 let calendar = Calendar.current
@@ -104,57 +109,64 @@ final class LiveTailViewModelTests: XCTestCase {
104 }
109 }
105
110
106 func testParseTimestampNoTimestampField() {
111 func testParseTimestampNoTimestampField() {
112+ let vm = LiveTailViewModel()
107 let record: LogRecord = ["message": .string("no timestamp")]
113 let record: LogRecord = ["message": .string("no timestamp")]
108− let date = LiveTailViewModel.parseTimestamp(from: record)
114+ let date = vm.parseTimestamp(from: record)
109 XCTAssertNil(date)
115 XCTAssertNil(date)
110 }
116 }
111
117
112 func testParseTimestampNonStringValue() {
118 func testParseTimestampNonStringValue() {
119+ let vm = LiveTailViewModel()
113 let record: LogRecord = ["p_timestamp": .int(1718446245)]
120 let record: LogRecord = ["p_timestamp": .int(1718446245)]
114− let date = LiveTailViewModel.parseTimestamp(from: record)
121+ let date = vm.parseTimestamp(from: record)
115 XCTAssertNil(date)
122 XCTAssertNil(date)
116 }
123 }
117
124
118 func testParseTimestampInvalidString() {
125 func testParseTimestampInvalidString() {
126+ let vm = LiveTailViewModel()
119 let record: LogRecord = ["p_timestamp": .string("not-a-date")]
127 let record: LogRecord = ["p_timestamp": .string("not-a-date")]
120− let date = LiveTailViewModel.parseTimestamp(from: record)
128+ let date = vm.parseTimestamp(from: record)
121 XCTAssertNil(date)
129 XCTAssertNil(date)
122 }
130 }
123
131
124 // MARK: - Summary building
132 // MARK: - Summary building
125
133
126 func testBuildSummaryWithLevelAndMessage() {
134 func testBuildSummaryWithLevelAndMessage() {
135+ let vm = LiveTailViewModel()
127 let record: LogRecord = [
136 let record: LogRecord = [
128 "level": .string("info"),
137 "level": .string("info"),
129 "message": .string("Server started")
138 "message": .string("Server started")
130 ]
139 ]
131− let summary = LiveTailViewModel.buildSummary(from: record)
140+ let summary = vm.buildSummary(from: record)
132 XCTAssertTrue(summary.contains("[info]"))
141 XCTAssertTrue(summary.contains("[info]"))
133 XCTAssertTrue(summary.contains("Server started"))
142 XCTAssertTrue(summary.contains("Server started"))
134 }
143 }
135
144
136 func testBuildSummaryAlternateFieldNames() {
145 func testBuildSummaryAlternateFieldNames() {
146+ let vm = LiveTailViewModel()
137 let record: LogRecord = [
147 let record: LogRecord = [
138 "severity": .string("warn"),
148 "severity": .string("warn"),
139 "msg": .string("Disk almost full")
149 "msg": .string("Disk almost full")
140 ]
150 ]
141− let summary = LiveTailViewModel.buildSummary(from: record)
151+ let summary = vm.buildSummary(from: record)
142 XCTAssertTrue(summary.contains("[warn]"))
152 XCTAssertTrue(summary.contains("[warn]"))
143 XCTAssertTrue(summary.contains("Disk almost full"))
153 XCTAssertTrue(summary.contains("Disk almost full"))
144 }
154 }
145
155
146 func testBuildSummaryFallbackToScalarFields() {
156 func testBuildSummaryFallbackToScalarFields() {
157+ let vm = LiveTailViewModel()
147 let record: LogRecord = [
158 let record: LogRecord = [
148 "status": .int(200),
159 "status": .int(200),
149 "path": .string("/api/health")
160 "path": .string("/api/health")
150 ]
161 ]
151− let summary = LiveTailViewModel.buildSummary(from: record)
162+ let summary = vm.buildSummary(from: record)
152 XCTAssertFalse(summary.isEmpty)
163 XCTAssertFalse(summary.isEmpty)
153 }
164 }
154
165
155 func testBuildSummaryEmptyRecord() {
166 func testBuildSummaryEmptyRecord() {
167+ let vm = LiveTailViewModel()
156 let record: LogRecord = [:]
168 let record: LogRecord = [:]
157− let summary = LiveTailViewModel.buildSummary(from: record)
169+ let summary = vm.buildSummary(from: record)
158 XCTAssertEqual(summary, "")
170 XCTAssertEqual(summary, "")
159 }
171 }
160
172
@@ -257,11 +269,12 @@ final class LiveTailViewModelTests: XCTestCase {
257 }
269 }
258
270
259 func testBuildSummaryWithLogField() {
271 func testBuildSummaryWithLogField() {
272+ let vm = LiveTailViewModel()
260 let record: LogRecord = [
273 let record: LogRecord = [
261 "log_level": .string("debug"),
274 "log_level": .string("debug"),
262 "log": .string("Processing request")
275 "log": .string("Processing request")
263 ]
276 ]
264− let summary = LiveTailViewModel.buildSummary(from: record)
277+ let summary = vm.buildSummary(from: record)
265 XCTAssertTrue(summary.contains("[debug]"))
278 XCTAssertTrue(summary.contains("[debug]"))
266 XCTAssertTrue(summary.contains("Processing request"))
279 XCTAssertTrue(summary.contains("Processing request"))
267 }
280 }
@@ -404,23 +417,10 @@ final class LiveTailViewModelTests: XCTestCase {
404 XCTAssertEqual(vm.columnFilters.count, 1)
417 XCTAssertEqual(vm.columnFilters.count, 1)
405 }
418 }
406
419
407− func testAddFilterSameColumnIncludeReplaces() {
420+ func testAddFilterDifferentValueIsAdditive() {
408 let vm = LiveTailViewModel()
421 let vm = LiveTailViewModel()
409 vm.addColumnFilter(column: "level", value: .string("error"), exclude: false)
422 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.
413 vm.addColumnFilter(column: "level", value: .string("warn"), exclude: false)
423 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)
424 XCTAssertEqual(vm.columnFilters.count, 2)
424 XCTAssertEqual(vm.columnFilters.count, 2)
425 }
425 }
426
426
@@ -461,29 +461,4 @@ final class LiveTailViewModelTests: XCTestCase {
461 let filter = LiveTailViewModel.ColumnFilter(column: "field", value: nil, exclude: false)
461 let filter = LiveTailViewModel.ColumnFilter(column: "field", value: nil, exclude: false)
462 XCTAssertEqual(filter.displayLabel, "field = null")
462 XCTAssertEqual(filter.displayLabel, "field = null")
463 }
463 }
464−
465− // MARK: - Zone-less timestamps / saved column order
466−
467− func testParseTimestampTreatsZonelessAsUTC() {
468− // Parseable serializes p_timestamp without a zone designator.
469− let naive = LiveTailViewModel.parseTimestamp(from: ["p_timestamp": .string("2024-06-15T10:30:45.123")])
470− let zoned = LiveTailViewModel.parseTimestamp(from: ["p_timestamp": .string("2024-06-15T10:30:45.123Z")])
471− XCTAssertNotNil(naive)
472− XCTAssertEqual(naive, zoned)
473− let naiveWhole = LiveTailViewModel.parseTimestamp(from: ["p_timestamp": .string("2024-06-15T10:30:45")])
474− XCTAssertEqual(naiveWhole, LiveTailViewModel.parseTimestamp(from: ["p_timestamp": .string("2024-06-15T10:30:45Z")]))
475− }
476−
477− func testMergedColumnOrderRestoresSavedPosition() {
478− let merged = QueryViewModel.mergedColumnOrder(
479− ["p_timestamp", "level", "message"],
480− inserting: ["trace_id"],
481− reference: ["p_timestamp", "trace_id", "level", "message"]
482− )
483− XCTAssertEqual(merged, ["p_timestamp", "trace_id", "level", "message"])
484− }
485−
486− func testMergedColumnOrderAppendsColumnsWithoutSavedPosition() {
487− XCTAssertEqual(QueryViewModel.mergedColumnOrder(["a"], inserting: ["b"], reference: []), ["a", "b"])
488− }
489}
464}
ParseableViewerTests/LogTableHelperTests.swift+7−14
@@ -161,44 +161,37 @@ final class LogTableHelperTests: XCTestCase {
161
161
162 func testExtractSeverityFromLevelColumn() {
162 func testExtractSeverityFromLevelColumn() {
163 let record: LogRecord = ["level": .string("error"), "message": .string("boom")]
163 let record: LogRecord = ["level": .string("error"), "message": .string("boom")]
164− let sevCols = buildSeverityColumnSet(columns: Array(record.keys))
165− XCTAssertEqual(extractSeverity(from: record, severityColumns: sevCols), .error)
164+ XCTAssertEqual(extractSeverity(from: record), .error)
166 }
165 }
167
166
168 func testExtractSeverityFromSeverityColumn() {
167 func testExtractSeverityFromSeverityColumn() {
169 let record: LogRecord = ["severity": .string("warning"), "msg": .string("watch out")]
168 let record: LogRecord = ["severity": .string("warning"), "msg": .string("watch out")]
170− let sevCols = buildSeverityColumnSet(columns: Array(record.keys))
171− XCTAssertEqual(extractSeverity(from: record, severityColumns: sevCols), .warning)
169+ XCTAssertEqual(extractSeverity(from: record), .warning)
172 }
170 }
173
171
174 func testExtractSeverityFromSeverityTextColumn() {
172 func testExtractSeverityFromSeverityTextColumn() {
175 let record: LogRecord = ["severity_text": .string("FATAL")]
173 let record: LogRecord = ["severity_text": .string("FATAL")]
176− let sevCols = buildSeverityColumnSet(columns: Array(record.keys))
177− XCTAssertEqual(extractSeverity(from: record, severityColumns: sevCols), .fatal)
174+ XCTAssertEqual(extractSeverity(from: record), .fatal)
178 }
175 }
179
176
180 func testExtractSeverityFromLogLevelColumn() {
177 func testExtractSeverityFromLogLevelColumn() {
181 let record: LogRecord = ["log_level": .string("info")]
178 let record: LogRecord = ["log_level": .string("info")]
182− let sevCols = buildSeverityColumnSet(columns: Array(record.keys))
183− XCTAssertEqual(extractSeverity(from: record, severityColumns: sevCols), .info)
179+ XCTAssertEqual(extractSeverity(from: record), .info)
184 }
180 }
185
181
186 func testExtractSeverityFromNumericValue() {
182 func testExtractSeverityFromNumericValue() {
187 let record: LogRecord = ["priority": .int(3)]
183 let record: LogRecord = ["priority": .int(3)]
188− let sevCols = buildSeverityColumnSet(columns: Array(record.keys))
189− XCTAssertEqual(extractSeverity(from: record, severityColumns: sevCols), .error)
184+ XCTAssertEqual(extractSeverity(from: record), .error)
190 }
185 }
191
186
192 func testExtractSeverityNoSeverityColumn() {
187 func testExtractSeverityNoSeverityColumn() {
193 let record: LogRecord = ["message": .string("hello"), "timestamp": .string("now")]
188 let record: LogRecord = ["message": .string("hello"), "timestamp": .string("now")]
194− let sevCols = buildSeverityColumnSet(columns: Array(record.keys))
195− XCTAssertEqual(extractSeverity(from: record, severityColumns: sevCols), .unknown)
189+ XCTAssertEqual(extractSeverity(from: record), .unknown)
196 }
190 }
197
191
198 func testExtractSeverityUnrecognizedValue() {
192 func testExtractSeverityUnrecognizedValue() {
199 let record: LogRecord = ["level": .string("custom_level")]
193 let record: LogRecord = ["level": .string("custom_level")]
200− let sevCols = buildSeverityColumnSet(columns: Array(record.keys))
201− XCTAssertEqual(extractSeverity(from: record, severityColumns: sevCols), .unknown)
194+ XCTAssertEqual(extractSeverity(from: record), .unknown)
202 }
195 }
203
196
204 // MARK: - severityRowTint
197 // MARK: - severityRowTint
ParseableViewerTests/ParseableClientTests.swift−58
@@ -646,62 +646,4 @@ final class ParseableClientTests: XCTestCase {
646 // Carriage returns in values should be wrapped in quotes per RFC 4180
646 // Carriage returns in values should be wrapped in quotes per RFC 4180
647 XCTAssertTrue(csv.contains("\"line1\r\nline2\""))
647 XCTAssertTrue(csv.contains("\"line1\r\nline2\""))
648 }
648 }
649−
650− // MARK: - Server response key spellings
651−
652− func testServerAboutDecodesCamelCaseKeys() throws {
653− let json = #"{"uiVersion": "v1.2", "deploymentId": "01H", "grpcPort": 8001}"#.data(using: .utf8)!
654− let about = try JSONDecoder().decode(ServerAbout.self, from: json)
655− XCTAssertEqual(about.uiVersion, "v1.2")
656− XCTAssertEqual(about.deploymentId, "01H")
657− XCTAssertEqual(about.grpcPort, 8001)
658− }
659−
660− func testStreamInfoDecodesSnakeCaseKeys() throws {
661− let json = #"{"created-at": "c", "first-event-at": "f", "time_partition": "ts", "static_schema_flag": "true", "cache_enabled": true}"#
662− .data(using: .utf8)!
663− let info = try JSONDecoder().decode(StreamInfo.self, from: json)
664− XCTAssertEqual(info.createdAt, "c")
665− XCTAssertEqual(info.firstEventAt, "f")
666− XCTAssertEqual(info.timePartition, "ts")
667− XCTAssertEqual(info.staticSchemaFlag, true)
668− XCTAssertEqual(info.cacheEnabled, true)
669− }
670−
671− func testStreamInfoDecodesCamelCaseKeys() throws {
672− let json = #"{"createdAt": "c", "firstEventAt": "f", "timePartition": "ts", "staticSchemaFlag": "true"}"#
673− .data(using: .utf8)!
674− let info = try JSONDecoder().decode(StreamInfo.self, from: json)
675− XCTAssertEqual(info.createdAt, "c")
676− XCTAssertEqual(info.firstEventAt, "f")
677− XCTAssertEqual(info.timePartition, "ts")
678− XCTAssertEqual(info.staticSchemaFlag, true)
679− }
680−
681− func testStorageStatsReadsFormatAndUnitSuffixedSizes() throws {
682− let json = #"{"storage": {"size": "2048 Bytes", "format": "parquet"}}"#.data(using: .utf8)!
683− let stats = try JSONDecoder().decode(StreamStats.self, from: json)
684− XCTAssertEqual(stats.storage?.type, "parquet")
685− XCTAssertEqual(stats.storage?.size, ByteCountFormatter.string(fromByteCount: 2048, countStyle: .file))
686− }
687−
688− func testSchemaFieldDescribesParameterizedArrowTypes() throws {
689− let json = #"{"name": "p_timestamp", "data_type": {"Timestamp": ["Millisecond", null]}}"#.data(using: .utf8)!
690− let field = try JSONDecoder().decode(SchemaField.self, from: json)
691− XCTAssertEqual(field.dataType, "Timestamp(Millisecond, None)")
692− }
693−
694− func testLegacyAlertRuleWithObjectConfigKeepsRuleType() throws {
695− let json = #"{"name": "a", "rule": {"type": "column", "config": {"column": "status", "operator": "=", "value": 500}}}"#
696− .data(using: .utf8)!
697− let rule = try JSONDecoder().decode(AlertRule.self, from: json)
698− XCTAssertEqual(rule.rule?.type, "column")
699− XCTAssertNotNil(rule.rule?.config)
700− }
701−
702− func testServerAboutDecodesObjectLicense() throws {
703− let json = #"{"license": {"name": "AGPL-3.0-only", "plan": "OSS"}}"#.data(using: .utf8)!
704− let about = try JSONDecoder().decode(ServerAbout.self, from: json)
705− XCTAssertEqual(about.license, "AGPL-3.0-only (OSS)")
706− }
707}
649}
ParseableViewerTests/QueryViewModelTests.swift+2−165
@@ -1,8 +1,6 @@
1import XCTest
1import XCTest
2@testable import ParseableViewer
2@testable import ParseableViewer
3
3
4−// QueryViewModel is @MainActor, so its tests must run on the main actor too.
5−@MainActor
6final class QueryViewModelTests: XCTestCase {
4final class QueryViewModelTests: XCTestCase {
7 // MARK: - Column extraction
5 // MARK: - Column extraction
8
6
@@ -140,19 +138,6 @@ final class QueryViewModelTests: XCTestCase {
140 XCTAssertTrue(json.contains("\"value\""))
138 XCTAssertTrue(json.contains("\"value\""))
141 }
139 }
142
140
143− func testProjectRecordsExcludesHiddenColumns() {
144− let records: [LogRecord] = [["visible": .string("v"), "hidden": .string("secret")]]
145− let projected = QueryViewModel.projectRecords(records, to: ["visible"])
146− XCTAssertEqual(projected.first?.keys.sorted(), ["visible"])
147− XCTAssertNil(projected.first?["hidden"], "Hidden column must not leak into export")
148− }
149−
150− func testProjectRecordsEmptyColumnsKeepsAllFields() {
151− let records: [LogRecord] = [["a": .int(1), "b": .int(2)]]
152− let projected = QueryViewModel.projectRecords(records, to: [])
153− XCTAssertEqual(projected.first?.keys.sorted(), ["a", "b"])
154− }
155−
156 // MARK: - Time range
141 // MARK: - Time range
157
142
158 func testTimeRangeOptionsAllHaveValues() {
143 func testTimeRangeOptionsAllHaveValues() {
@@ -235,7 +220,7 @@ final class QueryViewModelTests: XCTestCase {
235 let vm = QueryViewModel()
220 let vm = QueryViewModel()
236 vm.sqlQuery = "SELECT * FROM \"logs\" ORDER BY p_timestamp DESC LIMIT 1000"
221 vm.sqlQuery = "SELECT * FROM \"logs\" ORDER BY p_timestamp DESC LIMIT 1000"
237 vm.addColumnFilter(column: "level", value: .string("debug"), exclude: true)
222 vm.addColumnFilter(column: "level", value: .string("debug"), exclude: true)
238− XCTAssertTrue(vm.sqlQuery.contains("WHERE \"level\" IS DISTINCT FROM 'debug'"))
223+ XCTAssertTrue(vm.sqlQuery.contains("WHERE \"level\" <> 'debug'"))
239 XCTAssertTrue(vm.sqlQuery.contains("ORDER BY"))
224 XCTAssertTrue(vm.sqlQuery.contains("ORDER BY"))
240 }
225 }
241
226
@@ -261,15 +246,6 @@ final class QueryViewModelTests: XCTestCase {
261 XCTAssertTrue(vm.sqlQuery.contains("WHERE \"level\" = 'error'"))
246 XCTAssertTrue(vm.sqlQuery.contains("WHERE \"level\" = 'error'"))
262 }
247 }
263
248
264− func testAddColumnFilterParenthesizesExistingOr() {
265− // An existing top-level OR must be parenthesized before AND-ing the new
266− // condition so precedence is preserved.
267− let vm = QueryViewModel()
268− vm.sqlQuery = "SELECT * FROM \"logs\" WHERE \"a\" = 1 OR \"b\" = 2"
269− vm.addColumnFilter(column: "host", value: .string("web-1"), exclude: false)
270− XCTAssertTrue(vm.sqlQuery.contains("(\"a\" = 1 OR \"b\" = 2) AND \"host\" = 'web-1'"))
271− }
272−
273 func testAddColumnFilterIntValue() {
249 func testAddColumnFilterIntValue() {
274 let vm = QueryViewModel()
250 let vm = QueryViewModel()
275 vm.sqlQuery = "SELECT * FROM \"logs\""
251 vm.sqlQuery = "SELECT * FROM \"logs\""
@@ -288,7 +264,7 @@ final class QueryViewModelTests: XCTestCase {
288 let vm = QueryViewModel()
264 let vm = QueryViewModel()
289 vm.sqlQuery = "SELECT * FROM \"logs\""
265 vm.sqlQuery = "SELECT * FROM \"logs\""
290 vm.addColumnFilter(column: "level", value: .string("warn"), exclude: true)
266 vm.addColumnFilter(column: "level", value: .string("warn"), exclude: true)
291− XCTAssertTrue(vm.sqlQuery.contains("WHERE \"level\" IS DISTINCT FROM 'warn'"))
267+ XCTAssertTrue(vm.sqlQuery.contains("WHERE \"level\" <> 'warn'"))
292 }
268 }
293
269
294 func testAddColumnFilterEscapesSingleQuotesInValue() {
270 func testAddColumnFilterEscapesSingleQuotesInValue() {
@@ -359,25 +335,6 @@ final class QueryViewModelTests: XCTestCase {
359 XCTAssertEqual(vm.columnOrder, ["c", "a", "b"])
335 XCTAssertEqual(vm.columnOrder, ["c", "a", "b"])
360 }
336 }
361
337
362− func testMoveColumnRightwardLandsAfterTarget() {
363− // Rightward move (e.g. the "Move Right" context action, which passes the
364− // adjacent column as the target) must land the column *after* the target,
365− // not produce a no-op.
366− let vm = QueryViewModel()
367− vm.columns = ["a", "b", "c"]
368− vm.columnOrder = ["a", "b", "c"]
369− vm.moveColumn("b", to: "c")
370− XCTAssertEqual(vm.columnOrder, ["a", "c", "b"])
371− }
372−
373− func testMoveColumnLeftwardLandsBeforeTarget() {
374− let vm = QueryViewModel()
375− vm.columns = ["a", "b", "c"]
376− vm.columnOrder = ["a", "b", "c"]
377− vm.moveColumn("b", to: "a")
378− XCTAssertEqual(vm.columnOrder, ["b", "a", "c"])
379− }
380−
381 func testMoveColumnByIndexSet() {
338 func testMoveColumnByIndexSet() {
382 let vm = QueryViewModel()
339 let vm = QueryViewModel()
383 vm.columns = ["a", "b", "c"]
340 vm.columns = ["a", "b", "c"]
@@ -671,125 +628,5 @@ final class QueryViewModelTests: XCTestCase {
671 let empty = vm.emptyColumns(in: records, columns: ["a"])
628 let empty = vm.emptyColumns(in: records, columns: ["a"])
672 XCTAssertTrue(empty.isEmpty)
629 XCTAssertTrue(empty.isEmpty)
673 }
630 }
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− }
794}
631}
795
632
ParseableViewerTests/SQLAutoCompleteTests.swift−56
@@ -275,62 +275,6 @@ final class SQLAutoCompleteTests: XCTestCase {
275 let ctx = SQLCompletionProvider.determineContext(text: "SELECT * FROM a LEFT JOIN ", position: 26)
275 let ctx = SQLCompletionProvider.determineContext(text: "SELECT * FROM a LEFT JOIN ", position: 26)
276 XCTAssertEqual(ctx, .tableRef)
276 XCTAssertEqual(ctx, .tableRef)
277 }
277 }
278−
279− // MARK: - Identifier quoting / non-SQL regions
280−
281− func testColumnCompletionQuotesNamesThatNeedIt() {
282− let fields = [SchemaField(name: "statusCode", dataType: "Int64"), SchemaField(name: "status", dataType: "Utf8")]
283− let result = SQLCompletionProvider.completions(
284− for: "SELECT * FROM t WHERE stat", cursorPosition: 26,
285− streamNames: [], schemaFields: fields
286− )
287− let columns = result.items.filter { $0.kind == .column }.map(\.text)
288− XCTAssertEqual(columns, ["status", "\"statusCode\""])
289− }
290−
291− func testNoCompletionsInsideLineComment() {
292− let text = "-- grouped by date"
293− let result = SQLCompletionProvider.completions(
294− for: text, cursorPosition: (text as NSString).length,
295− streamNames: streams, schemaFields: fields
296− )
297− XCTAssertTrue(result.items.isEmpty)
298− }
299−
300− func testNoCompletionsInsideStringLiteral() {
301− let text = "SELECT * FROM t WHERE msg LIKE '%sel"
302− let result = SQLCompletionProvider.completions(
303− for: text, cursorPosition: (text as NSString).length,
304− streamNames: streams, schemaFields: fields
305− )
306− XCTAssertTrue(result.items.isEmpty)
307− }
308−
309− func testFullyTypedMixedCaseColumnStillOffersQuotedForm() {
310− let fields = [SchemaField(name: "statusCode", dataType: "Int64")]
311− let bare = "SELECT * FROM t WHERE statusCode"
312− let result = SQLCompletionProvider.completions(
313− for: bare, cursorPosition: (bare as NSString).length,
314− streamNames: [], schemaFields: fields
315− )
316− XCTAssertEqual(result.items.map(\.text), ["\"statusCode\""])
317−
318− // Already quoted: accepting would change nothing, so no popup.
319− let quoted = "SELECT * FROM t WHERE \"statusCode\""
320− let quotedResult = SQLCompletionProvider.completions(
321− for: quoted, cursorPosition: (quoted as NSString).length - 1,
322− streamNames: [], schemaFields: fields
323− )
324− XCTAssertTrue(quotedResult.items.isEmpty)
325− }
326−
327− func testReservedWordColumnIsQuoted() {
328− let fields = [SchemaField(name: "order", dataType: "Utf8")]
329− let result = SQLCompletionProvider.completions(
330− for: "SELECT ord", cursorPosition: 10, streamNames: [], schemaFields: fields
331− )
332− XCTAssertEqual(result.items.filter { $0.kind == .column }.map(\.text), ["\"order\""])
333− }
334}
278}
335
279
336// Make SQLCompletionContext Equatable for assertions
280// Make SQLCompletionContext Equatable for assertions
ParseableViewerTests/SQLTokenizerTests.swift−15
@@ -348,19 +348,4 @@ final class SQLTokenizerTests: XCTestCase {
348 let sql = "SELECT * FROM t"
348 let sql = "SELECT * FROM t"
349 XCTAssertNil(SQLTokenizer.errorHighlightRange(line: 99, column: 1, in: sql))
349 XCTAssertNil(SQLTokenizer.errorHighlightRange(line: 99, column: 1, in: sql))
350 }
350 }
351−
352− // MARK: - CRLF line endings
353−
354− func testCRLFIsWhitespaceAndEndsLineComment() {
355− // Swift treats "\r\n" as a single Character, equal to neither "\r" nor "\n".
356− let sql = "SELECT * -- all\r\nFROM t"
357− let kinds = SQLTokenizer.tokenize(sql).map(\.kind).filter { !$0.isTrivia }
358− XCTAssertEqual(kinds, [.keyword("SELECT"), .star, .keyword("FROM"), .identifier("t")])
359− }
360−
361− func testSelectColumnListRangeExcludesCRLF() {
362− let sql = "SELECT *\r\nFROM t"
363− let range = SQLTokenizer.selectColumnListRange(in: sql)
364− XCTAssertEqual(range.map { String(sql[$0]) }, "*")
365− }
366}
351}
ParseableViewerTests/ServerConnectionTests.swift−10
@@ -77,14 +77,4 @@ final class ServerConnectionTests: XCTestCase {
77 let conn = ServerConnection(name: "test", url: "https://", username: "admin", password: "pass")
77 let conn = ServerConnection(name: "test", url: "https://", username: "admin", password: "pass")
78 XCTAssertNil(conn.baseURL)
78 XCTAssertNil(conn.baseURL)
79 }
79 }
80−
81− func testBaseURLAcceptsUppercaseScheme() {
82− let conn = ServerConnection(name: "t", url: "HTTP://10.0.0.5:8000", username: "u", password: "p")
83− XCTAssertEqual(conn.baseURL?.absoluteString, "http://10.0.0.5:8000")
84− }
85−
86− func testBaseURLRejectsNonHTTPScheme() {
87− let conn = ServerConnection(name: "t", url: "ftp://example.com", username: "u", password: "p")
88− XCTAssertNil(conn.baseURL)
89− }
90}
80}
ParseableViewerTests/SyntaxHighlightingTests.swift−10
@@ -140,16 +140,6 @@ final class SyntaxHighlightingTests: XCTestCase {
140 "Double-quoted identifiers should differ from keywords")
140 "Double-quoted identifiers should differ from keywords")
141 }
141 }
142
142
143− func testSQLApostropheInCommentDoesNotStartString() {
144− // The later quoted literal gives the stray apostrophe a closing partner, so
145− // separate string/comment passes painted everything in between as a string.
146− let sql = "-- don't\nSELECT * FROM t WHERE a = 'b'"
147− let ts = sqlHighlight(sql)
148− let selectIdx = (sql as NSString).range(of: "SELECT").location
149− XCTAssertEqual(colorAt(ts, selectIdx), colorAt(sqlHighlight("SELECT x"), 0),
150− "An apostrophe inside a comment must not open a string literal")
151− }
152−
153 // MARK: - JSON Highlighting
143 // MARK: - JSON Highlighting
154
144
155 func testJSONHighlightProducesOutput() {
145 func testJSONHighlightProducesOutput() {
README.md+1−1
@@ -143,7 +143,7 @@ ParseableViewer/
143| `Cmd+R` | Refresh streams |
143| `Cmd+R` | Refresh streams |
144| `Cmd+Enter` | Run query |
144| `Cmd+Enter` | Run query |
145| `Cmd+P` | Pause/resume live tail |
145| `Cmd+P` | Pause/resume live tail |
146−| `Cmd+K` | Clear live tail entries |
146+| `Cmd+Delete` | Clear live tail entries |
147| `Cmd+Ctrl+S` | Toggle sidebar |
147| `Cmd+Ctrl+S` | Toggle sidebar |
148
148
149## License
149## License