git.iko.soy/parseable-macos
on 2d089aa help sign in
54 files changed+835−9454
since splitsnapshots
unifiedsplit
.github/workflows/build.yml+33−44 CLAUDE.md+3−3 ParseableViewer.xcodeproj/project.pbxproj−660 ParseableViewer.xcodeproj/project.xcworkspace/contents.xcworkspacedata−7 ParseableViewer.xcodeproj/xcshareddata/xcschemes/ParseableViewer.xcscheme−120 ParseableViewer/App/AppState.swift+34−271 ParseableViewer/App/ParseableViewerApp.swift+2−40 ParseableViewer/Info.plist−5 ParseableViewer/Models/AlertModels.swift+3−71 ParseableViewer/Models/JSONValue.swift+22−236 ParseableViewer/Models/LogStream.swift+17−149 ParseableViewer/Models/ParseableFilter.swift−79 ParseableViewer/Models/SQLTokenizer.swift−413 ParseableViewer/Models/ServerConnection.swift+2−35 ParseableViewer/Models/ServerInfo.swift+10−47 ParseableViewer/ParseableViewer.entitlements+6−1 ParseableViewer/Resources/Assets.xcassets/AppIcon.appiconset/Contents.json+45−1 ParseableViewer/Resources/Assets.xcassets/AppIcon.appiconset/icon.png ParseableViewer/Services/ConnectionStore.swift+44−19 ParseableViewer/Services/KeychainService.swift+26−76 ParseableViewer/Services/ParseableClient.swift+47−319 ParseableViewer/ViewModels/LiveTailViewModel.swift+57−594 ParseableViewer/ViewModels/QueryViewModel.swift+35−742 ParseableViewer/Views/AboutView.swift−36 ParseableViewer/Views/AlertsView.swift+8−99 ParseableViewer/Views/ConnectionSheet.swift+13−74 ParseableViewer/Views/ContentView.swift+39−75 ParseableViewer/Views/LiveTailView.swift+44−369 ParseableViewer/Views/LogDetailView.swift+23−47 ParseableViewer/Views/LogTableView.swift+49−562 ParseableViewer/Views/QueryView.swift+72−338 ParseableViewer/Views/SQLAutoComplete.swift−590 ParseableViewer/Views/SQLEditorView.swift−403 ParseableViewer/Views/ServerInfoView.swift+17−71 ParseableViewer/Views/SettingsView.swift+2−21 ParseableViewer/Views/SidebarView.swift+48−101 ParseableViewer/Views/StreamDetailView.swift+17−56 ParseableViewer/Views/SyntaxHighlighting.swift−251 ParseableViewer/Views/TimeRangePicker.swift+2−26 ParseableViewer/Views/UsersView.swift+3−26 ParseableViewerTests/ConnectionStoreTests.swift+80−71 ParseableViewerTests/JSONValueTests.swift−53 ParseableViewerTests/LiveTailViewModelTests.swift+28−236 ParseableViewerTests/LogStreamTests.swift−74 ParseableViewerTests/LogTableHelperTests.swift−148 ParseableViewerTests/ParseableClientTests.swift−221 ParseableViewerTests/QueryViewModelTests.swift+2−592 ParseableViewerTests/SQLAutoCompleteTests.swift−337 ParseableViewerTests/SQLTokenizerTests.swift−366 ParseableViewerTests/ServerConnectionTests.swift−10 ParseableViewerTests/SyntaxHighlightingTests.swift−232 README.md+2−8 parseable-viewer.rb−19 project.yml−10
.github/workflows/build.yml+33−44
@@ -3,16 +3,14 @@ name: Build and Release
33on:
44 push:
55 branches: [master, main]
6+ tags:
7+ - 'v*'
68 pull_request:
79 branches: [master, main]
810
911jobs:
1012 build:
1113 runs-on: macos-15
12− permissions:
13− id-token: write
14− attestations: write
15− contents: read
1614 strategy:
1715 matrix:
1816 include:
@@ -73,11 +71,6 @@ jobs:
7371 exit 1
7472 fi
7573
76− - name: Attest artifact
77− uses: actions/attest-build-provenance@v2
78− with:
79− subject-path: ParseableViewer-${{ matrix.arch }}.zip
80−
8174 - name: Upload artifact
8275 uses: actions/upload-artifact@v4
8376 with:
@@ -87,10 +80,6 @@ jobs:
8780 create-universal:
8881 needs: build
8982 runs-on: macos-15
90− permissions:
91− id-token: write
92− attestations: write
93− contents: read
9483 steps:
9584 - name: Checkout
9685 uses: actions/checkout@v4
@@ -138,63 +127,63 @@ jobs:
138127 cd universal_app
139128 zip -r -y "../ParseableViewer-universal.zip" "Parseable Viewer.app"
140129
141− - name: Attest universal artifact
142− uses: actions/attest-build-provenance@v2
143− with:
144− subject-path: ParseableViewer-universal.zip
145−
146130 - name: Upload universal artifact
147131 uses: actions/upload-artifact@v4
148132 with:
149133 name: ParseableViewer-universal
150134 path: ParseableViewer-universal.zip
151135
152− release:
136+ prerelease:
153137 needs: create-universal
154138 runs-on: macos-15
155− concurrency:
156− group: release
157− cancel-in-progress: false
158− if: github.event_name == 'push'
139+ if: github.event_name == 'push' && !startsWith(github.ref, 'refs/tags/')
159140 permissions:
160141 contents: write
161− id-token: write
162− attestations: write
163142 steps:
164− - name: Checkout
165− uses: actions/checkout@v4
166−
167− - name: Compute version from commit timestamp
168− id: version
169− run: |
170− COMMIT_TIMESTAMP=$(TZ=UTC git log -1 --format='%cd' --date=format-local:'%Y.%m.%d.%H%M')
171− echo "timestamp=$COMMIT_TIMESTAMP" >> "$GITHUB_OUTPUT"
172−
173143 - name: Download all artifacts
174144 uses: actions/download-artifact@v4
175145 with:
176146 path: artifacts
177147
178− - name: Attest release artifacts
179− uses: actions/attest-build-provenance@v2
148+ - name: Create or update prerelease
149+ uses: softprops/action-gh-release@v2
180150 with:
181− subject-path: |
151+ tag_name: latest
152+ name: Latest Build
153+ body: |
154+ Automated prerelease build from commit ${{ github.sha }}.
155+
156+ **Branch:** ${{ github.ref_name }}
157+ **Commit:** [`${{ github.sha }}`](${{ github.server_url }}/${{ github.repository }}/commit/${{ github.sha }})
158+ prerelease: true
159+ make_latest: false
160+ files: |
182161 artifacts/ParseableViewer-arm64/ParseableViewer-arm64.zip
183162 artifacts/ParseableViewer-x86_64/ParseableViewer-x86_64.zip
184163 artifacts/ParseableViewer-universal/ParseableViewer-universal.zip
164+ env:
165+ GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
185166
186− - name: Create release
167+ release:
168+ needs: create-universal
169+ runs-on: macos-15
170+ if: startsWith(github.ref, 'refs/tags/v')
171+ permissions:
172+ contents: write
173+ steps:
174+ - name: Download all artifacts
175+ uses: actions/download-artifact@v4
176+ with:
177+ path: artifacts
178+
179+ - name: Create Draft Release
187180 uses: softprops/action-gh-release@v2
188181 with:
189− tag_name: ${{ steps.version.outputs.timestamp }}
190− name: ${{ steps.version.outputs.timestamp }}
191− body: |
192− **Branch:** ${{ github.ref_name }}
193− **Commit:** [`${{ github.sha }}`](${{ github.server_url }}/${{ github.repository }}/commit/${{ github.sha }})
182+ draft: true
183+ generate_release_notes: true
194184 files: |
195185 artifacts/ParseableViewer-arm64/ParseableViewer-arm64.zip
196186 artifacts/ParseableViewer-x86_64/ParseableViewer-x86_64.zip
197187 artifacts/ParseableViewer-universal/ParseableViewer-universal.zip
198188 env:
199189 GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
200−
CLAUDE.md+3−3
@@ -31,7 +31,7 @@ CI builds on `macos-15` runners via `.github/workflows/build.yml`, producing arm
3131- **Concurrency**: Swift structured concurrency (`async/await`, `@MainActor`, strict `Sendable` conformance)
3232- **Networking**: `URLSession` async/await, no external dependencies
3333- **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
3535
3636### Source layout
3737
@@ -83,10 +83,10 @@ Live tail in Parseable uses gRPC Arrow Flight streaming. This app approximates i
8383- Models use defensive `try?` decoding for optional fields to tolerate API version differences
8484- Views follow the macOS pattern: `NavigationSplitView` sidebar + detail, with tab switching in the detail pane
8585- 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`
8787- Stream names are SQL-escaped via `escapeSQLIdentifier` (double-quote wrapping) and URL-encoded via `encodePathComponent` for API paths
8888- 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
9090
9191## Common patterns
9292
ParseableViewer.xcodeproj/project.pbxprojdeleted−660
@@ -1,660 +0,0 @@
1−// !$*UTF8*$!
2−{
3− archiveVersion = 1;
4− classes = {
5− };
6− objectVersion = 77;
7− objects = {
8−
9−/* Begin PBXBuildFile section */
10− 073AFD1207B61E46CA993E98 /* LogStream.swift in Sources */ = {isa = PBXBuildFile; fileRef = 194F81AC44933EFE8C5F9824 /* LogStream.swift */; };
11− 0E0B747CF1B650BF70A7DC56 /* LiveTailView.swift in Sources */ = {isa = PBXBuildFile; fileRef = DA23566B4B78428A8A5D17D0 /* LiveTailView.swift */; };
12− 11A5740A00A2E29D4EC77B66 /* SQLAutoComplete.swift in Sources */ = {isa = PBXBuildFile; fileRef = DBF33043C9CB623F028C5E5C /* SQLAutoComplete.swift */; };
13− 15A0B57711D4B39118F09D93 /* AppState.swift in Sources */ = {isa = PBXBuildFile; fileRef = 93D1C596856E6FB5C39249DD /* AppState.swift */; };
14− 1DF7164680B3EAF1F5E4C47E /* TimeRangePicker.swift in Sources */ = {isa = PBXBuildFile; fileRef = B96F0F5013CC46086EFEDA5A /* TimeRangePicker.swift */; };
15− 1E07C534FCC62B7B5CC15DDB /* ContentView.swift in Sources */ = {isa = PBXBuildFile; fileRef = C3EB52E9BEDCAF82C6A155FA /* ContentView.swift */; };
16− 2C35EBC496AF8DAB215800F0 /* LogDetailView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5AA610FFA85948D794C5A4CC /* LogDetailView.swift */; };
17− 2E3E0E23D3388B90955AD9A8 /* KeychainService.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8D36767781BD4F0650C1CD27 /* KeychainService.swift */; };
18− 308E024A853EDA08411FE6B0 /* UsersView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 187537810EBBCC784604017D /* UsersView.swift */; };
19− 355EA224A0C07271A0F9EBDA /* AboutView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1FFECFECB73C6B7DA1D0F99F /* AboutView.swift */; };
20− 3CDE9527F8C25B7C32571CB4 /* ConnectionStoreTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 996D7D107A1959CF67E82DF5 /* ConnectionStoreTests.swift */; };
21− 3F7EB642D3EBA91FCC962F15 /* ServerInfo.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6B7C589A8DC2293DDDE4D60F /* ServerInfo.swift */; };
22− 3FDB32277A679A14D1455A7D /* SQLAutoCompleteTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2B692AC5D403FB0CBB28935B /* SQLAutoCompleteTests.swift */; };
23− 45286251E8F4CF0718E0595C /* LiveTailViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 03F29CF341F7B9EB2A9B0326 /* LiveTailViewModel.swift */; };
24− 47E1670726D4104D80F3F952 /* StreamDetailView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 45C3FC0434D42379E8103913 /* StreamDetailView.swift */; };
25− 4A269FCA596D52F688BB055A /* AlertsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 878573090C3AB36A21A32DFA /* AlertsView.swift */; };
26− 4EBF4D4702F460A6AD962232 /* SyntaxHighlighting.swift in Sources */ = {isa = PBXBuildFile; fileRef = A6596EAA474BC10AE4741FBF /* SyntaxHighlighting.swift */; };
27− 5033B2FE28A2EEB990CA2114 /* ConnectionStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7C7F0C26B0065609D29197D7 /* ConnectionStore.swift */; };
28− 507DF05FC7A223B2F7E701EF /* SyntaxHighlightingTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2535C31705B3ADC1C8628380 /* SyntaxHighlightingTests.swift */; };
29− 55A93DAF2D88AAB1F42B5E04 /* ConnectionSheet.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9DF1D667CE043D657046ABC1 /* ConnectionSheet.swift */; };
30− 5681C7459B58D71A83496890 /* QueryViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = BB957A716A1554537EA69333 /* QueryViewModel.swift */; };
31− 5BC9FA9419A918C16937E4D3 /* LiveTailViewModelTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = F4200B45B223178AE1426FE7 /* LiveTailViewModelTests.swift */; };
32− 62014FBBAD5AA68F2C768DEF /* LogTableHelperTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = CFBAD6576A7594337CCC7952 /* LogTableHelperTests.swift */; };
33− 69485FFE5D25180B422D9230 /* QueryView.swift in Sources */ = {isa = PBXBuildFile; fileRef = E18F652C0ED65976591C0E9A /* QueryView.swift */; };
34− 70CC22F812F9151CBA042375 /* SettingsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = F65288FEAE1281DB05D97147 /* SettingsView.swift */; };
35− 7128E20D6592E907210BBF1A /* SidebarView.swift in Sources */ = {isa = PBXBuildFile; fileRef = C0519F396B1D5C6335B0F1B7 /* SidebarView.swift */; };
36− 822B3C27B35EA429E522AFB4 /* LogTableView.swift in Sources */ = {isa = PBXBuildFile; fileRef = FC934220A4BBA1B37F944573 /* LogTableView.swift */; };
37− 835943BF8C2C9AB9CA203B2F /* ParseableClient.swift in Sources */ = {isa = PBXBuildFile; fileRef = 30A1771C7B952AEB93E35ADF /* ParseableClient.swift */; };
38− 836F637D54951D3BC6A2AF11 /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 4EB9B6688633BC4E8D968065 /* Assets.xcassets */; };
39− 8FD945D3E83049275E44335E /* SQLTokenizer.swift in Sources */ = {isa = PBXBuildFile; fileRef = B107964BA8F8E4AF1DC8566D /* SQLTokenizer.swift */; };
40− 9C860DEEFE4446A152C18B62 /* ParseableViewerApp.swift in Sources */ = {isa = PBXBuildFile; fileRef = 62C07A9EF327212C2C44D281 /* ParseableViewerApp.swift */; };
41− A8B0548F0793B57AF05FA272 /* SQLTokenizerTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9FBB7888D01E1AD72BF342BC /* SQLTokenizerTests.swift */; };
42− B1BBABAB489838F84E670DD6 /* AlertModels.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4D3CDFCD82AF6D988009AC5A /* AlertModels.swift */; };
43− BA06B242DE817368D1BAD194 /* SQLEditorView.swift in Sources */ = {isa = PBXBuildFile; fileRef = AF186DF367E2C7EEE4780D14 /* SQLEditorView.swift */; };
44− BAA005C4A067CB6EBA4294DE /* ServerInfoView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6A83116AF430E859E6A7FDF0 /* ServerInfoView.swift */; };
45− BCEB51336ECF5827F888614E /* ServerConnectionTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = D4F0DA80D3E3AD784E8B06E7 /* ServerConnectionTests.swift */; };
46− CCA017BAFD0977B66B5CCBCD /* JSONValue.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0856310107CC1545CE5B627C /* JSONValue.swift */; };
47− D9C618815EEBE79EE7529EC2 /* QueryViewModelTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8FBAA52C12C4E02A735DA6EA /* QueryViewModelTests.swift */; };
48− DE4DD2EB56F92820EE01CBE1 /* ServerConnection.swift in Sources */ = {isa = PBXBuildFile; fileRef = 18D05E439E6063E901A3E7EC /* ServerConnection.swift */; };
49− EF5ECFAAF8B2485D452021EB /* LogStreamTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = FDBC51E5343A4BE35D8FF997 /* LogStreamTests.swift */; };
50− F48FF7028BEB8C84234C018B /* JSONValueTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0E9559128AC1709175C3A392 /* JSONValueTests.swift */; };
51− F691C8702AF273C0B9D79963 /* ParseableFilter.swift in Sources */ = {isa = PBXBuildFile; fileRef = B0ED2520AE908C73BF5C00AA /* ParseableFilter.swift */; };
52− F8F9730A83587099FF50E77B /* ParseableClientTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = C86F88A71716091F7703BC3A /* ParseableClientTests.swift */; };
53−/* End PBXBuildFile section */
54−
55−/* Begin PBXContainerItemProxy section */
56− 31032B334B864955687341D2 /* PBXContainerItemProxy */ = {
57− isa = PBXContainerItemProxy;
58− containerPortal = AFB3E6B7F230B722DBCD4143 /* Project object */;
59− proxyType = 1;
60− remoteGlobalIDString = 2BFA29C07896DADE27FE8DF9;
61− remoteInfo = ParseableViewer;
62− };
63−/* End PBXContainerItemProxy section */
64−
65−/* Begin PBXFileReference section */
66− 03F29CF341F7B9EB2A9B0326 /* LiveTailViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LiveTailViewModel.swift; sourceTree = "<group>"; };
67− 0856310107CC1545CE5B627C /* JSONValue.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = JSONValue.swift; sourceTree = "<group>"; };
68− 0E9559128AC1709175C3A392 /* JSONValueTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = JSONValueTests.swift; sourceTree = "<group>"; };
69− 187537810EBBCC784604017D /* UsersView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UsersView.swift; sourceTree = "<group>"; };
70− 18D05E439E6063E901A3E7EC /* ServerConnection.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ServerConnection.swift; sourceTree = "<group>"; };
71− 194F81AC44933EFE8C5F9824 /* LogStream.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LogStream.swift; sourceTree = "<group>"; };
72− 1FFECFECB73C6B7DA1D0F99F /* AboutView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AboutView.swift; sourceTree = "<group>"; };
73− 2535C31705B3ADC1C8628380 /* SyntaxHighlightingTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SyntaxHighlightingTests.swift; sourceTree = "<group>"; };
74− 2B692AC5D403FB0CBB28935B /* SQLAutoCompleteTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SQLAutoCompleteTests.swift; sourceTree = "<group>"; };
75− 30A1771C7B952AEB93E35ADF /* ParseableClient.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ParseableClient.swift; sourceTree = "<group>"; };
76− 45C3FC0434D42379E8103913 /* StreamDetailView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StreamDetailView.swift; sourceTree = "<group>"; };
77− 4D3CDFCD82AF6D988009AC5A /* AlertModels.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AlertModels.swift; sourceTree = "<group>"; };
78− 4EB9B6688633BC4E8D968065 /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = "<group>"; };
79− 5AA610FFA85948D794C5A4CC /* LogDetailView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LogDetailView.swift; sourceTree = "<group>"; };
80− 62C07A9EF327212C2C44D281 /* ParseableViewerApp.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ParseableViewerApp.swift; sourceTree = "<group>"; };
81− 6A83116AF430E859E6A7FDF0 /* ServerInfoView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ServerInfoView.swift; sourceTree = "<group>"; };
82− 6B7C589A8DC2293DDDE4D60F /* ServerInfo.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ServerInfo.swift; sourceTree = "<group>"; };
83− 7C7F0C26B0065609D29197D7 /* ConnectionStore.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ConnectionStore.swift; sourceTree = "<group>"; };
84− 81003B1CAEBB75C72076AE85 /* ParseableViewerTests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = ParseableViewerTests.xctest; sourceTree = BUILT_PRODUCTS_DIR; };
85− 878573090C3AB36A21A32DFA /* AlertsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AlertsView.swift; sourceTree = "<group>"; };
86− 8D36767781BD4F0650C1CD27 /* KeychainService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = KeychainService.swift; sourceTree = "<group>"; };
87− 8FBAA52C12C4E02A735DA6EA /* QueryViewModelTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = QueryViewModelTests.swift; sourceTree = "<group>"; };
88− 93D1C596856E6FB5C39249DD /* AppState.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppState.swift; sourceTree = "<group>"; };
89− 996D7D107A1959CF67E82DF5 /* ConnectionStoreTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ConnectionStoreTests.swift; sourceTree = "<group>"; };
90− 9DF1D667CE043D657046ABC1 /* ConnectionSheet.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ConnectionSheet.swift; sourceTree = "<group>"; };
91− 9FBB7888D01E1AD72BF342BC /* SQLTokenizerTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SQLTokenizerTests.swift; sourceTree = "<group>"; };
92− A6596EAA474BC10AE4741FBF /* SyntaxHighlighting.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SyntaxHighlighting.swift; sourceTree = "<group>"; };
93− AF186DF367E2C7EEE4780D14 /* SQLEditorView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SQLEditorView.swift; sourceTree = "<group>"; };
94− B0ED2520AE908C73BF5C00AA /* ParseableFilter.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ParseableFilter.swift; sourceTree = "<group>"; };
95− B107964BA8F8E4AF1DC8566D /* SQLTokenizer.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SQLTokenizer.swift; sourceTree = "<group>"; };
96− B2F7F4E5D5A9542B5E7E6C62 /* ParseableViewer.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = ParseableViewer.app; sourceTree = BUILT_PRODUCTS_DIR; };
97− B38EDA7C380889A9DF25566E /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist; path = Info.plist; sourceTree = "<group>"; };
98− B96F0F5013CC46086EFEDA5A /* TimeRangePicker.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TimeRangePicker.swift; sourceTree = "<group>"; };
99− BB957A716A1554537EA69333 /* QueryViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = QueryViewModel.swift; sourceTree = "<group>"; };
100− C0519F396B1D5C6335B0F1B7 /* SidebarView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SidebarView.swift; sourceTree = "<group>"; };
101− C3EB52E9BEDCAF82C6A155FA /* ContentView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ContentView.swift; sourceTree = "<group>"; };
102− C86F88A71716091F7703BC3A /* ParseableClientTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ParseableClientTests.swift; sourceTree = "<group>"; };
103− CFBAD6576A7594337CCC7952 /* LogTableHelperTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LogTableHelperTests.swift; sourceTree = "<group>"; };
104− D4F0DA80D3E3AD784E8B06E7 /* ServerConnectionTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ServerConnectionTests.swift; sourceTree = "<group>"; };
105− DA23566B4B78428A8A5D17D0 /* LiveTailView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LiveTailView.swift; sourceTree = "<group>"; };
106− DBF33043C9CB623F028C5E5C /* SQLAutoComplete.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SQLAutoComplete.swift; sourceTree = "<group>"; };
107− E18F652C0ED65976591C0E9A /* QueryView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = QueryView.swift; sourceTree = "<group>"; };
108− F0C9C3804CDFF785226EA452 /* ParseableViewer.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = ParseableViewer.entitlements; sourceTree = "<group>"; };
109− F4200B45B223178AE1426FE7 /* LiveTailViewModelTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LiveTailViewModelTests.swift; sourceTree = "<group>"; };
110− F65288FEAE1281DB05D97147 /* SettingsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SettingsView.swift; sourceTree = "<group>"; };
111− FC934220A4BBA1B37F944573 /* LogTableView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LogTableView.swift; sourceTree = "<group>"; };
112− FDBC51E5343A4BE35D8FF997 /* LogStreamTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LogStreamTests.swift; sourceTree = "<group>"; };
113−/* End PBXFileReference section */
114−
115−/* Begin PBXGroup section */
116− 078D5FA1E5D2884218FF9433 /* Services */ = {
117− isa = PBXGroup;
118− children = (
119− 7C7F0C26B0065609D29197D7 /* ConnectionStore.swift */,
120− 8D36767781BD4F0650C1CD27 /* KeychainService.swift */,
121− 30A1771C7B952AEB93E35ADF /* ParseableClient.swift */,
122− );
123− path = Services;
124− sourceTree = "<group>";
125− };
126− 171EC835252990F6B7BBB45D /* Views */ = {
127− isa = PBXGroup;
128− children = (
129− 1FFECFECB73C6B7DA1D0F99F /* AboutView.swift */,
130− 878573090C3AB36A21A32DFA /* AlertsView.swift */,
131− 9DF1D667CE043D657046ABC1 /* ConnectionSheet.swift */,
132− C3EB52E9BEDCAF82C6A155FA /* ContentView.swift */,
133− DA23566B4B78428A8A5D17D0 /* LiveTailView.swift */,
134− 5AA610FFA85948D794C5A4CC /* LogDetailView.swift */,
135− FC934220A4BBA1B37F944573 /* LogTableView.swift */,
136− E18F652C0ED65976591C0E9A /* QueryView.swift */,
137− 6A83116AF430E859E6A7FDF0 /* ServerInfoView.swift */,
138− F65288FEAE1281DB05D97147 /* SettingsView.swift */,
139− C0519F396B1D5C6335B0F1B7 /* SidebarView.swift */,
140− DBF33043C9CB623F028C5E5C /* SQLAutoComplete.swift */,
141− AF186DF367E2C7EEE4780D14 /* SQLEditorView.swift */,
142− 45C3FC0434D42379E8103913 /* StreamDetailView.swift */,
143− A6596EAA474BC10AE4741FBF /* SyntaxHighlighting.swift */,
144− B96F0F5013CC46086EFEDA5A /* TimeRangePicker.swift */,
145− 187537810EBBCC784604017D /* UsersView.swift */,
146− );
147− path = Views;
148− sourceTree = "<group>";
149− };
150− 40B5CA6A795614C8B53AAD76 /* ParseableViewerTests */ = {
151− isa = PBXGroup;
152− children = (
153− 996D7D107A1959CF67E82DF5 /* ConnectionStoreTests.swift */,
154− 0E9559128AC1709175C3A392 /* JSONValueTests.swift */,
155− F4200B45B223178AE1426FE7 /* LiveTailViewModelTests.swift */,
156− FDBC51E5343A4BE35D8FF997 /* LogStreamTests.swift */,
157− CFBAD6576A7594337CCC7952 /* LogTableHelperTests.swift */,
158− C86F88A71716091F7703BC3A /* ParseableClientTests.swift */,
159− 8FBAA52C12C4E02A735DA6EA /* QueryViewModelTests.swift */,
160− D4F0DA80D3E3AD784E8B06E7 /* ServerConnectionTests.swift */,
161− 2B692AC5D403FB0CBB28935B /* SQLAutoCompleteTests.swift */,
162− 9FBB7888D01E1AD72BF342BC /* SQLTokenizerTests.swift */,
163− 2535C31705B3ADC1C8628380 /* SyntaxHighlightingTests.swift */,
164− );
165− path = ParseableViewerTests;
166− sourceTree = "<group>";
167− };
168− 6F721D552C363ADAC39442BB /* Resources */ = {
169− isa = PBXGroup;
170− children = (
171− 4EB9B6688633BC4E8D968065 /* Assets.xcassets */,
172− );
173− path = Resources;
174− sourceTree = "<group>";
175− };
176− 8A5E1C4DD012FF60BE222F22 /* Models */ = {
177− isa = PBXGroup;
178− children = (
179− 4D3CDFCD82AF6D988009AC5A /* AlertModels.swift */,
180− 0856310107CC1545CE5B627C /* JSONValue.swift */,
181− 194F81AC44933EFE8C5F9824 /* LogStream.swift */,
182− B0ED2520AE908C73BF5C00AA /* ParseableFilter.swift */,
183− 18D05E439E6063E901A3E7EC /* ServerConnection.swift */,
184− 6B7C589A8DC2293DDDE4D60F /* ServerInfo.swift */,
185− B107964BA8F8E4AF1DC8566D /* SQLTokenizer.swift */,
186− );
187− path = Models;
188− sourceTree = "<group>";
189− };
190− 8E8BE77500B325C361C4F4DD /* App */ = {
191− isa = PBXGroup;
192− children = (
193− 93D1C596856E6FB5C39249DD /* AppState.swift */,
194− 62C07A9EF327212C2C44D281 /* ParseableViewerApp.swift */,
195− );
196− path = App;
197− sourceTree = "<group>";
198− };
199− D06ED124AC4DFCB31F6EF0C1 = {
200− isa = PBXGroup;
201− children = (
202− F71281985A6BB118039F256B /* ParseableViewer */,
203− 40B5CA6A795614C8B53AAD76 /* ParseableViewerTests */,
204− D37830AB19DD594BCD2B5499 /* Products */,
205− );
206− sourceTree = "<group>";
207− };
208− D37830AB19DD594BCD2B5499 /* Products */ = {
209− isa = PBXGroup;
210− children = (
211− B2F7F4E5D5A9542B5E7E6C62 /* ParseableViewer.app */,
212− 81003B1CAEBB75C72076AE85 /* ParseableViewerTests.xctest */,
213− );
214− name = Products;
215− sourceTree = "<group>";
216− };
217− D5139E26A9B5E21217ED3842 /* ViewModels */ = {
218− isa = PBXGroup;
219− children = (
220− 03F29CF341F7B9EB2A9B0326 /* LiveTailViewModel.swift */,
221− BB957A716A1554537EA69333 /* QueryViewModel.swift */,
222− );
223− path = ViewModels;
224− sourceTree = "<group>";
225− };
226− F71281985A6BB118039F256B /* ParseableViewer */ = {
227− isa = PBXGroup;
228− children = (
229− B38EDA7C380889A9DF25566E /* Info.plist */,
230− F0C9C3804CDFF785226EA452 /* ParseableViewer.entitlements */,
231− 8E8BE77500B325C361C4F4DD /* App */,
232− 8A5E1C4DD012FF60BE222F22 /* Models */,
233− 6F721D552C363ADAC39442BB /* Resources */,
234− 078D5FA1E5D2884218FF9433 /* Services */,
235− D5139E26A9B5E21217ED3842 /* ViewModels */,
236− 171EC835252990F6B7BBB45D /* Views */,
237− );
238− path = ParseableViewer;
239− sourceTree = "<group>";
240− };
241−/* End PBXGroup section */
242−
243−/* Begin PBXNativeTarget section */
244− 2BFA29C07896DADE27FE8DF9 /* ParseableViewer */ = {
245− isa = PBXNativeTarget;
246− buildConfigurationList = 9AA32267150BB93C4ECA4508 /* Build configuration list for PBXNativeTarget "ParseableViewer" */;
247− buildPhases = (
248− E7518B0D053D4667ECBB99E3 /* Sources */,
249− 27EFF17FAE6D34920BFD0BB2 /* Resources */,
250− B271B96656D788CF64318006 /* Set Version from Git Commit Timestamp */,
251− );
252− buildRules = (
253− );
254− dependencies = (
255− );
256− name = ParseableViewer;
257− packageProductDependencies = (
258− );
259− productName = ParseableViewer;
260− productReference = B2F7F4E5D5A9542B5E7E6C62 /* ParseableViewer.app */;
261− productType = "com.apple.product-type.application";
262− };
263− E39BE21B6A9A7720B55CA1FB /* ParseableViewerTests */ = {
264− isa = PBXNativeTarget;
265− buildConfigurationList = E74244CF9426D49C14DD00DF /* Build configuration list for PBXNativeTarget "ParseableViewerTests" */;
266− buildPhases = (
267− F7A337D06360FBCA62555BF6 /* Sources */,
268− );
269− buildRules = (
270− );
271− dependencies = (
272− C4A66B65B7BBF9C28D40C249 /* PBXTargetDependency */,
273− );
274− name = ParseableViewerTests;
275− packageProductDependencies = (
276− );
277− productName = ParseableViewerTests;
278− productReference = 81003B1CAEBB75C72076AE85 /* ParseableViewerTests.xctest */;
279− productType = "com.apple.product-type.bundle.unit-test";
280− };
281−/* End PBXNativeTarget section */
282−
283−/* Begin PBXProject section */
284− AFB3E6B7F230B722DBCD4143 /* Project object */ = {
285− isa = PBXProject;
286− attributes = {
287− BuildIndependentTargetsInParallel = YES;
288− LastUpgradeCheck = 1620;
289− TargetAttributes = {
290− 2BFA29C07896DADE27FE8DF9 = {
291− ProvisioningStyle = Manual;
292− };
293− };
294− };
295− buildConfigurationList = 91629F84D63DA522312E65D6 /* Build configuration list for PBXProject "ParseableViewer" */;
296− developmentRegion = en;
297− hasScannedForEncodings = 0;
298− knownRegions = (
299− Base,
300− en,
301− );
302− mainGroup = D06ED124AC4DFCB31F6EF0C1;
303− minimizedProjectReferenceProxies = 1;
304− preferredProjectObjectVersion = 77;
305− productRefGroup = D37830AB19DD594BCD2B5499 /* Products */;
306− projectDirPath = "";
307− projectRoot = "";
308− targets = (
309− 2BFA29C07896DADE27FE8DF9 /* ParseableViewer */,
310− E39BE21B6A9A7720B55CA1FB /* ParseableViewerTests */,
311− );
312− };
313−/* End PBXProject section */
314−
315−/* Begin PBXResourcesBuildPhase section */
316− 27EFF17FAE6D34920BFD0BB2 /* Resources */ = {
317− isa = PBXResourcesBuildPhase;
318− buildActionMask = 2147483647;
319− files = (
320− 836F637D54951D3BC6A2AF11 /* Assets.xcassets in Resources */,
321− );
322− runOnlyForDeploymentPostprocessing = 0;
323− };
324−/* End PBXResourcesBuildPhase section */
325−
326−/* Begin PBXShellScriptBuildPhase section */
327− B271B96656D788CF64318006 /* Set Version from Git Commit Timestamp */ = {
328− isa = PBXShellScriptBuildPhase;
329− alwaysOutOfDate = 1;
330− buildActionMask = 2147483647;
331− files = (
332− );
333− inputFileListPaths = (
334− );
335− inputPaths = (
336− );
337− name = "Set Version from Git Commit Timestamp";
338− outputFileListPaths = (
339− );
340− outputPaths = (
341− );
342− runOnlyForDeploymentPostprocessing = 0;
343− shellPath = /bin/sh;
344− shellScript = "COMMIT_TIMESTAMP=$(TZ=UTC git log -1 --format='%cd' --date=format-local:'%Y.%m.%d.%H%M' 2>/dev/null)\nif [ -n \"$COMMIT_TIMESTAMP\" ]; then\n /usr/libexec/PlistBuddy -c \"Set :CFBundleVersion ${COMMIT_TIMESTAMP}\" \"${TARGET_BUILD_DIR}/${INFOPLIST_PATH}\"\n /usr/libexec/PlistBuddy -c \"Set :CFBundleShortVersionString ${COMMIT_TIMESTAMP}\" \"${TARGET_BUILD_DIR}/${INFOPLIST_PATH}\"\nfi\n";
345− };
346−/* End PBXShellScriptBuildPhase section */
347−
348−/* Begin PBXSourcesBuildPhase section */
349− E7518B0D053D4667ECBB99E3 /* Sources */ = {
350− isa = PBXSourcesBuildPhase;
351− buildActionMask = 2147483647;
352− files = (
353− 355EA224A0C07271A0F9EBDA /* AboutView.swift in Sources */,
354− B1BBABAB489838F84E670DD6 /* AlertModels.swift in Sources */,
355− 4A269FCA596D52F688BB055A /* AlertsView.swift in Sources */,
356− 15A0B57711D4B39118F09D93 /* AppState.swift in Sources */,
357− 55A93DAF2D88AAB1F42B5E04 /* ConnectionSheet.swift in Sources */,
358− 5033B2FE28A2EEB990CA2114 /* ConnectionStore.swift in Sources */,
359− 1E07C534FCC62B7B5CC15DDB /* ContentView.swift in Sources */,
360− CCA017BAFD0977B66B5CCBCD /* JSONValue.swift in Sources */,
361− 2E3E0E23D3388B90955AD9A8 /* KeychainService.swift in Sources */,
362− 0E0B747CF1B650BF70A7DC56 /* LiveTailView.swift in Sources */,
363− 45286251E8F4CF0718E0595C /* LiveTailViewModel.swift in Sources */,
364− 2C35EBC496AF8DAB215800F0 /* LogDetailView.swift in Sources */,
365− 073AFD1207B61E46CA993E98 /* LogStream.swift in Sources */,
366− 822B3C27B35EA429E522AFB4 /* LogTableView.swift in Sources */,
367− 835943BF8C2C9AB9CA203B2F /* ParseableClient.swift in Sources */,
368− F691C8702AF273C0B9D79963 /* ParseableFilter.swift in Sources */,
369− 9C860DEEFE4446A152C18B62 /* ParseableViewerApp.swift in Sources */,
370− 69485FFE5D25180B422D9230 /* QueryView.swift in Sources */,
371− 5681C7459B58D71A83496890 /* QueryViewModel.swift in Sources */,
372− 11A5740A00A2E29D4EC77B66 /* SQLAutoComplete.swift in Sources */,
373− BA06B242DE817368D1BAD194 /* SQLEditorView.swift in Sources */,
374− 8FD945D3E83049275E44335E /* SQLTokenizer.swift in Sources */,
375− DE4DD2EB56F92820EE01CBE1 /* ServerConnection.swift in Sources */,
376− 3F7EB642D3EBA91FCC962F15 /* ServerInfo.swift in Sources */,
377− BAA005C4A067CB6EBA4294DE /* ServerInfoView.swift in Sources */,
378− 70CC22F812F9151CBA042375 /* SettingsView.swift in Sources */,
379− 7128E20D6592E907210BBF1A /* SidebarView.swift in Sources */,
380− 47E1670726D4104D80F3F952 /* StreamDetailView.swift in Sources */,
381− 4EBF4D4702F460A6AD962232 /* SyntaxHighlighting.swift in Sources */,
382− 1DF7164680B3EAF1F5E4C47E /* TimeRangePicker.swift in Sources */,
383− 308E024A853EDA08411FE6B0 /* UsersView.swift in Sources */,
384− );
385− runOnlyForDeploymentPostprocessing = 0;
386− };
387− F7A337D06360FBCA62555BF6 /* Sources */ = {
388− isa = PBXSourcesBuildPhase;
389− buildActionMask = 2147483647;
390− files = (
391− 3CDE9527F8C25B7C32571CB4 /* ConnectionStoreTests.swift in Sources */,
392− F48FF7028BEB8C84234C018B /* JSONValueTests.swift in Sources */,
393− 5BC9FA9419A918C16937E4D3 /* LiveTailViewModelTests.swift in Sources */,
394− EF5ECFAAF8B2485D452021EB /* LogStreamTests.swift in Sources */,
395− 62014FBBAD5AA68F2C768DEF /* LogTableHelperTests.swift in Sources */,
396− F8F9730A83587099FF50E77B /* ParseableClientTests.swift in Sources */,
397− D9C618815EEBE79EE7529EC2 /* QueryViewModelTests.swift in Sources */,
398− 3FDB32277A679A14D1455A7D /* SQLAutoCompleteTests.swift in Sources */,
399− A8B0548F0793B57AF05FA272 /* SQLTokenizerTests.swift in Sources */,
400− BCEB51336ECF5827F888614E /* ServerConnectionTests.swift in Sources */,
401− 507DF05FC7A223B2F7E701EF /* SyntaxHighlightingTests.swift in Sources */,
402− );
403− runOnlyForDeploymentPostprocessing = 0;
404− };
405−/* End PBXSourcesBuildPhase section */
406−
407−/* Begin PBXTargetDependency section */
408− C4A66B65B7BBF9C28D40C249 /* PBXTargetDependency */ = {
409− isa = PBXTargetDependency;
410− target = 2BFA29C07896DADE27FE8DF9 /* ParseableViewer */;
411− targetProxy = 31032B334B864955687341D2 /* PBXContainerItemProxy */;
412− };
413−/* End PBXTargetDependency section */
414−
415−/* Begin XCBuildConfiguration section */
416− 07794B90ACFADCCF82C3DC80 /* Debug */ = {
417− isa = XCBuildConfiguration;
418− buildSettings = {
419− ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
420− CODE_SIGN_ENTITLEMENTS = ParseableViewer/ParseableViewer.entitlements;
421− CODE_SIGN_IDENTITY = "-";
422− CODE_SIGN_STYLE = Manual;
423− COMBINE_HIDPI_IMAGES = YES;
424− CURRENT_PROJECT_VERSION = 1;
425− ENABLE_HARDENED_RUNTIME = YES;
426− INFOPLIST_FILE = ParseableViewer/Info.plist;
427− LD_RUNPATH_SEARCH_PATHS = (
428− "$(inherited)",
429− "@executable_path/../Frameworks",
430− );
431− MACOSX_DEPLOYMENT_TARGET = 14.0;
432− MARKETING_VERSION = 1.0.0;
433− PRODUCT_BUNDLE_IDENTIFIER = com.parseableviewer.app;
434− PRODUCT_MODULE_NAME = ParseableViewer;
435− PRODUCT_NAME = "Parseable Viewer";
436− SDKROOT = macosx;
437− SWIFT_ACTIVE_COMPILATION_CONDITIONS = DEBUG;
438− SWIFT_OPTIMIZATION_LEVEL = "-Onone";
439− SWIFT_VERSION = 5.9;
440− };
441− name = Debug;
442− };
443− 1E24A379628C0BC01BF229A8 /* Debug */ = {
444− isa = XCBuildConfiguration;
445− buildSettings = {
446− ALWAYS_SEARCH_USER_PATHS = NO;
447− CLANG_ANALYZER_NONNULL = YES;
448− CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE;
449− CLANG_CXX_LANGUAGE_STANDARD = "gnu++14";
450− CLANG_CXX_LIBRARY = "libc++";
451− CLANG_ENABLE_MODULES = YES;
452− CLANG_ENABLE_OBJC_ARC = YES;
453− CLANG_ENABLE_OBJC_WEAK = YES;
454− CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES;
455− CLANG_WARN_BOOL_CONVERSION = YES;
456− CLANG_WARN_COMMA = YES;
457− CLANG_WARN_CONSTANT_CONVERSION = YES;
458− CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES;
459− CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR;
460− CLANG_WARN_DOCUMENTATION_COMMENTS = YES;
461− CLANG_WARN_EMPTY_BODY = YES;
462− CLANG_WARN_ENUM_CONVERSION = YES;
463− CLANG_WARN_INFINITE_RECURSION = YES;
464− CLANG_WARN_INT_CONVERSION = YES;
465− CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES;
466− CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES;
467− CLANG_WARN_OBJC_LITERAL_CONVERSION = YES;
468− CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR;
469− CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES;
470− CLANG_WARN_RANGE_LOOP_ANALYSIS = YES;
471− CLANG_WARN_STRICT_PROTOTYPES = YES;
472− CLANG_WARN_SUSPICIOUS_MOVE = YES;
473− CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE;
474− CLANG_WARN_UNREACHABLE_CODE = YES;
475− CLANG_WARN__DUPLICATE_METHOD_MATCH = YES;
476− COPY_PHASE_STRIP = NO;
477− DEBUG_INFORMATION_FORMAT = dwarf;
478− ENABLE_STRICT_OBJC_MSGSEND = YES;
479− ENABLE_TESTABILITY = YES;
480− GCC_C_LANGUAGE_STANDARD = gnu11;
481− GCC_DYNAMIC_NO_PIC = NO;
482− GCC_NO_COMMON_BLOCKS = YES;
483− GCC_OPTIMIZATION_LEVEL = 0;
484− GCC_PREPROCESSOR_DEFINITIONS = (
485− "$(inherited)",
486− "DEBUG=1",
487− );
488− GCC_WARN_64_TO_32_BIT_CONVERSION = YES;
489− GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR;
490− GCC_WARN_UNDECLARED_SELECTOR = YES;
491− GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE;
492− GCC_WARN_UNUSED_FUNCTION = YES;
493− GCC_WARN_UNUSED_VARIABLE = YES;
494− MACOSX_DEPLOYMENT_TARGET = 14.0;
495− MTL_ENABLE_DEBUG_INFO = INCLUDE_SOURCE;
496− MTL_FAST_MATH = YES;
497− ONLY_ACTIVE_ARCH = YES;
498− PRODUCT_NAME = "$(TARGET_NAME)";
499− SDKROOT = macosx;
500− SWIFT_ACTIVE_COMPILATION_CONDITIONS = DEBUG;
501− SWIFT_OPTIMIZATION_LEVEL = "-Onone";
502− SWIFT_VERSION = 5.0;
503− };
504− name = Debug;
505− };
506− 292D3E9E352934CD0271F677 /* Release */ = {
507− isa = XCBuildConfiguration;
508− buildSettings = {
509− ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
510− CODE_SIGN_ENTITLEMENTS = ParseableViewer/ParseableViewer.entitlements;
511− CODE_SIGN_IDENTITY = "-";
512− CODE_SIGN_STYLE = Manual;
513− COMBINE_HIDPI_IMAGES = YES;
514− CURRENT_PROJECT_VERSION = 1;
515− ENABLE_HARDENED_RUNTIME = YES;
516− INFOPLIST_FILE = ParseableViewer/Info.plist;
517− LD_RUNPATH_SEARCH_PATHS = (
518− "$(inherited)",
519− "@executable_path/../Frameworks",
520− );
521− MACOSX_DEPLOYMENT_TARGET = 14.0;
522− MARKETING_VERSION = 1.0.0;
523− PRODUCT_BUNDLE_IDENTIFIER = com.parseableviewer.app;
524− PRODUCT_MODULE_NAME = ParseableViewer;
525− PRODUCT_NAME = "Parseable Viewer";
526− SDKROOT = macosx;
527− SWIFT_COMPILATION_MODE = wholemodule;
528− SWIFT_OPTIMIZATION_LEVEL = "-O";
529− SWIFT_VERSION = 5.9;
530− };
531− name = Release;
532− };
533− 5C0248843F0B397AF07C25EF /* Debug */ = {
534− isa = XCBuildConfiguration;
535− buildSettings = {
536− BUNDLE_LOADER = "$(BUILT_PRODUCTS_DIR)/Parseable Viewer.app/Contents/MacOS/Parseable Viewer";
537− COMBINE_HIDPI_IMAGES = YES;
538− GENERATE_INFOPLIST_FILE = YES;
539− LD_RUNPATH_SEARCH_PATHS = (
540− "$(inherited)",
541− "@executable_path/../Frameworks",
542− "@loader_path/../Frameworks",
543− );
544− MACOSX_DEPLOYMENT_TARGET = 14.0;
545− PRODUCT_BUNDLE_IDENTIFIER = com.parseableviewer.ParseableViewerTests;
546− SDKROOT = macosx;
547− SWIFT_VERSION = 5.9;
548− TEST_HOST = "$(BUNDLE_LOADER)";
549− };
550− name = Debug;
551− };
552− 8344458AE89EAC72B8A11105 /* Release */ = {
553− isa = XCBuildConfiguration;
554− buildSettings = {
555− BUNDLE_LOADER = "$(BUILT_PRODUCTS_DIR)/Parseable Viewer.app/Contents/MacOS/Parseable Viewer";
556− COMBINE_HIDPI_IMAGES = YES;
557− GENERATE_INFOPLIST_FILE = YES;
558− LD_RUNPATH_SEARCH_PATHS = (
559− "$(inherited)",
560− "@executable_path/../Frameworks",
561− "@loader_path/../Frameworks",
562− );
563− MACOSX_DEPLOYMENT_TARGET = 14.0;
564− PRODUCT_BUNDLE_IDENTIFIER = com.parseableviewer.ParseableViewerTests;
565− SDKROOT = macosx;
566− SWIFT_VERSION = 5.9;
567− TEST_HOST = "$(BUNDLE_LOADER)";
568− };
569− name = Release;
570− };
571− DE9928E6B896437015DE8F25 /* Release */ = {
572− isa = XCBuildConfiguration;
573− buildSettings = {
574− ALWAYS_SEARCH_USER_PATHS = NO;
575− CLANG_ANALYZER_NONNULL = YES;
576− CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE;
577− CLANG_CXX_LANGUAGE_STANDARD = "gnu++14";
578− CLANG_CXX_LIBRARY = "libc++";
579− CLANG_ENABLE_MODULES = YES;
580− CLANG_ENABLE_OBJC_ARC = YES;
581− CLANG_ENABLE_OBJC_WEAK = YES;
582− CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES;
583− CLANG_WARN_BOOL_CONVERSION = YES;
584− CLANG_WARN_COMMA = YES;
585− CLANG_WARN_CONSTANT_CONVERSION = YES;
586− CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES;
587− CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR;
588− CLANG_WARN_DOCUMENTATION_COMMENTS = YES;
589− CLANG_WARN_EMPTY_BODY = YES;
590− CLANG_WARN_ENUM_CONVERSION = YES;
591− CLANG_WARN_INFINITE_RECURSION = YES;
592− CLANG_WARN_INT_CONVERSION = YES;
593− CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES;
594− CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES;
595− CLANG_WARN_OBJC_LITERAL_CONVERSION = YES;
596− CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR;
597− CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES;
598− CLANG_WARN_RANGE_LOOP_ANALYSIS = YES;
599− CLANG_WARN_STRICT_PROTOTYPES = YES;
600− CLANG_WARN_SUSPICIOUS_MOVE = YES;
601− CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE;
602− CLANG_WARN_UNREACHABLE_CODE = YES;
603− CLANG_WARN__DUPLICATE_METHOD_MATCH = YES;
604− COPY_PHASE_STRIP = NO;
605− DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym";
606− ENABLE_NS_ASSERTIONS = NO;
607− ENABLE_STRICT_OBJC_MSGSEND = YES;
608− GCC_C_LANGUAGE_STANDARD = gnu11;
609− GCC_NO_COMMON_BLOCKS = YES;
610− GCC_WARN_64_TO_32_BIT_CONVERSION = YES;
611− GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR;
612− GCC_WARN_UNDECLARED_SELECTOR = YES;
613− GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE;
614− GCC_WARN_UNUSED_FUNCTION = YES;
615− GCC_WARN_UNUSED_VARIABLE = YES;
616− MACOSX_DEPLOYMENT_TARGET = 14.0;
617− MTL_ENABLE_DEBUG_INFO = NO;
618− MTL_FAST_MATH = YES;
619− PRODUCT_NAME = "$(TARGET_NAME)";
620− SDKROOT = macosx;
621− SWIFT_COMPILATION_MODE = wholemodule;
622− SWIFT_OPTIMIZATION_LEVEL = "-O";
623− SWIFT_VERSION = 5.0;
624− };
625− name = Release;
626− };
627−/* End XCBuildConfiguration section */
628−
629−/* Begin XCConfigurationList section */
630− 91629F84D63DA522312E65D6 /* Build configuration list for PBXProject "ParseableViewer" */ = {
631− isa = XCConfigurationList;
632− buildConfigurations = (
633− 1E24A379628C0BC01BF229A8 /* Debug */,
634− DE9928E6B896437015DE8F25 /* Release */,
635− );
636− defaultConfigurationIsVisible = 0;
637− defaultConfigurationName = Release;
638− };
639− 9AA32267150BB93C4ECA4508 /* Build configuration list for PBXNativeTarget "ParseableViewer" */ = {
640− isa = XCConfigurationList;
641− buildConfigurations = (
642− 07794B90ACFADCCF82C3DC80 /* Debug */,
643− 292D3E9E352934CD0271F677 /* Release */,
644− );
645− defaultConfigurationIsVisible = 0;
646− defaultConfigurationName = Release;
647− };
648− E74244CF9426D49C14DD00DF /* Build configuration list for PBXNativeTarget "ParseableViewerTests" */ = {
649− isa = XCConfigurationList;
650− buildConfigurations = (
651− 5C0248843F0B397AF07C25EF /* Debug */,
652− 8344458AE89EAC72B8A11105 /* Release */,
653− );
654− defaultConfigurationIsVisible = 0;
655− defaultConfigurationName = Release;
656− };
657−/* End XCConfigurationList section */
658− };
659− rootObject = AFB3E6B7F230B722DBCD4143 /* Project object */;
660−}
ParseableViewer.xcodeproj/project.xcworkspace/contents.xcworkspacedatadeleted−7
@@ -1,7 +0,0 @@
1−<?xml version="1.0" encoding="UTF-8"?>
2−<Workspace
3− version = "1.0">
4− <FileRef
5− location = "self:">
6− </FileRef>
7−</Workspace>
ParseableViewer.xcodeproj/xcshareddata/xcschemes/ParseableViewer.xcschemedeleted−120
@@ -1,120 +0,0 @@
1−<?xml version="1.0" encoding="UTF-8"?>
2−<Scheme
3− LastUpgradeVersion = "1620"
4− version = "1.7">
5− <BuildAction
6− parallelizeBuildables = "YES"
7− buildImplicitDependencies = "YES"
8− runPostActionsOnFailure = "NO">
9− <BuildActionEntries>
10− <BuildActionEntry
11− buildForTesting = "YES"
12− buildForRunning = "YES"
13− buildForProfiling = "YES"
14− buildForArchiving = "YES"
15− buildForAnalyzing = "YES">
16− <BuildableReference
17− BuildableIdentifier = "primary"
18− BlueprintIdentifier = "2BFA29C07896DADE27FE8DF9"
19− BuildableName = "ParseableViewer.app"
20− BlueprintName = "ParseableViewer"
21− ReferencedContainer = "container:ParseableViewer.xcodeproj">
22− </BuildableReference>
23− </BuildActionEntry>
24− <BuildActionEntry
25− buildForTesting = "YES"
26− buildForRunning = "NO"
27− buildForProfiling = "NO"
28− buildForArchiving = "NO"
29− buildForAnalyzing = "NO">
30− <BuildableReference
31− BuildableIdentifier = "primary"
32− BlueprintIdentifier = "E39BE21B6A9A7720B55CA1FB"
33− BuildableName = "ParseableViewerTests.xctest"
34− BlueprintName = "ParseableViewerTests"
35− ReferencedContainer = "container:ParseableViewer.xcodeproj">
36− </BuildableReference>
37− </BuildActionEntry>
38− </BuildActionEntries>
39− </BuildAction>
40− <TestAction
41− buildConfiguration = "Debug"
42− selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB"
43− selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB"
44− shouldUseLaunchSchemeArgsEnv = "YES"
45− onlyGenerateCoverageForSpecifiedTargets = "NO">
46− <MacroExpansion>
47− <BuildableReference
48− BuildableIdentifier = "primary"
49− BlueprintIdentifier = "2BFA29C07896DADE27FE8DF9"
50− BuildableName = "ParseableViewer.app"
51− BlueprintName = "ParseableViewer"
52− ReferencedContainer = "container:ParseableViewer.xcodeproj">
53− </BuildableReference>
54− </MacroExpansion>
55− <Testables>
56− <TestableReference
57− skipped = "NO"
58− parallelizable = "NO">
59− <BuildableReference
60− BuildableIdentifier = "primary"
61− BlueprintIdentifier = "E39BE21B6A9A7720B55CA1FB"
62− BuildableName = "ParseableViewerTests.xctest"
63− BlueprintName = "ParseableViewerTests"
64− ReferencedContainer = "container:ParseableViewer.xcodeproj">
65− </BuildableReference>
66− </TestableReference>
67− </Testables>
68− <CommandLineArguments>
69− </CommandLineArguments>
70− </TestAction>
71− <LaunchAction
72− buildConfiguration = "Debug"
73− selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB"
74− selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB"
75− launchStyle = "0"
76− useCustomWorkingDirectory = "NO"
77− ignoresPersistentStateOnLaunch = "NO"
78− debugDocumentVersioning = "YES"
79− debugServiceExtension = "internal"
80− allowLocationSimulation = "YES">
81− <BuildableProductRunnable
82− runnableDebuggingMode = "0">
83− <BuildableReference
84− BuildableIdentifier = "primary"
85− BlueprintIdentifier = "2BFA29C07896DADE27FE8DF9"
86− BuildableName = "ParseableViewer.app"
87− BlueprintName = "ParseableViewer"
88− ReferencedContainer = "container:ParseableViewer.xcodeproj">
89− </BuildableReference>
90− </BuildableProductRunnable>
91− <CommandLineArguments>
92− </CommandLineArguments>
93− </LaunchAction>
94− <ProfileAction
95− buildConfiguration = "Release"
96− shouldUseLaunchSchemeArgsEnv = "YES"
97− savedToolIdentifier = ""
98− useCustomWorkingDirectory = "NO"
99− debugDocumentVersioning = "YES">
100− <BuildableProductRunnable
101− runnableDebuggingMode = "0">
102− <BuildableReference
103− BuildableIdentifier = "primary"
104− BlueprintIdentifier = "2BFA29C07896DADE27FE8DF9"
105− BuildableName = "ParseableViewer.app"
106− BlueprintName = "ParseableViewer"
107− ReferencedContainer = "container:ParseableViewer.xcodeproj">
108− </BuildableReference>
109− </BuildableProductRunnable>
110− <CommandLineArguments>
111− </CommandLineArguments>
112− </ProfileAction>
113− <AnalyzeAction
114− buildConfiguration = "Debug">
115− </AnalyzeAction>
116− <ArchiveAction
117− buildConfiguration = "Release"
118− revealArchiveInOrganizer = "YES">
119− </ArchiveAction>
120−</Scheme>
ParseableViewer/App/AppState.swift+34−271
@@ -14,11 +14,7 @@ final class AppState {
1414 private(set) var isNetworkAvailable = true
1515 private let networkMonitor = NWPathMonitor()
1616 private let networkMonitorQueue = DispatchQueue(label: "com.parseableviewer.network-monitor")
17− private var connectTask: Task<Bool, Never>?
18− /// The connection `connectTask` is connecting to. `activeConnection` only
19− /// switches once the new server answers, so this is what identifies an
20− /// in-flight connect whose target the user deletes (see `removeConnection`).
21− private var connectingConnectionID: UUID?
17+ private var connectTask: Task<Void, Never>?
2218
2319 // MARK: - Stream State
2420 var streams: [LogStream] = []
@@ -36,15 +32,10 @@ final class AppState {
3632 var errorMessage: String?
3733 var showError = false
3834
39− // MARK: - Filters
40− var filters: [ParseableFilter] = []
41− var isLoadingFilters = false
42− /// Set by sidebar when user clicks a filter; consumed by QueryView.
43− var pendingFilterSQL: String?
44−
45− // MARK: - Query Refresh
46− /// Incremented by the Cmd+R shortcut; QueryView observes changes to re-execute the current query.
47− var queryRefreshToken = UUID()
35+ // MARK: - Saved Queries
36+ var savedQueries: [SavedQuery] = []
37+ /// Set by sidebar when user clicks a saved query; consumed by QueryView.
38+ var pendingSavedQuerySQL: String?
4839
4940 enum AppTab: String, CaseIterable, Identifiable {
5041 case query = "Query"
@@ -66,22 +57,13 @@ final class AppState {
6657 case .serverInfo: return "server.rack"
6758 }
6859 }
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− }
7860 }
7961
8062 var filteredStreams: [LogStream] {
8163 if streamSearchText.isEmpty {
82− return streams.sorted()
64+ return streams
8365 }
84− return streams.filter { $0.name.localizedCaseInsensitiveContains(streamSearchText) }.sorted()
66+ return streams.filter { $0.name.localizedCaseInsensitiveContains(streamSearchText) }
8567 }
8668
8769 deinit {
@@ -90,6 +72,7 @@ final class AppState {
9072
9173 init() {
9274 connections = ConnectionStore.loadConnections()
75+ savedQueries = SavedQueryStore.load()
9376
9477 networkMonitor.pathUpdateHandler = { [weak self] path in
9578 Task { @MainActor [weak self] in
@@ -100,9 +83,8 @@ final class AppState {
10083
10184 if let activeID = ConnectionStore.loadActiveConnectionID(),
10285 let connection = connections.first(where: { $0.id == activeID }) {
103− connectingConnectionID = connection.id
104− connectTask = Task { @MainActor [weak self] in
105− await self?.performConnect(to: connection) ?? false
86+ connectTask = Task { @MainActor in
87+ await connect(to: connection)
10688 }
10789 }
10890 }
@@ -110,71 +92,23 @@ final class AppState {
11092 // MARK: - Connection Management
11193
11294 @MainActor
113− @discardableResult
114− func connect(to connection: ServerConnection) async -> Bool {
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 }
95+ func connect(to connection: ServerConnection) async {
96+ guard !isConnecting else { return }
13797
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) {
98+ if !isNetworkAvailable {
14399 self.errorMessage = "No internet connection. Check your network and try again."
144100 self.showError = true
145− return false
101+ return
146102 }
147103
148104 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".
154105 errorMessage = nil
155− showError = false
156106
157107 // When switching to a different server, clear stream-specific state
158108 // so stale selections don't reference streams that don't exist.
159109 if activeConnection?.id != connection.id {
160110 selectedStream = nil
161111 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
178112 }
179113
180114 do {
@@ -182,12 +116,6 @@ final class AppState {
182116
183117 // Test connection — throws on non-200 or network failure
184118 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 }
191119
192120 self.client = newClient
193121 self.activeConnection = connection
@@ -195,41 +123,18 @@ final class AppState {
195123
196124 ConnectionStore.saveActiveConnectionID(connection.id)
197125
198− // Load server info, streams, and filters (best-effort; don't fail the connection)
126+ // Load server info and streams (best-effort; don't fail the connection)
199127 async let about = newClient.getAbout()
200128 async let streamList = newClient.listStreams()
201− async let filterList = newClient.listFilters()
202129
203− let aboutResult = try? await about
204− let filterResult = (try? await filterList) ?? []
205− let streamResult: Result<[LogStream], Error>
130+ self.serverAbout = try? await about
206131 do {
207− streamResult = .success(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
132+ self.streams = try await streamList
221133 self.streamLoadError = nil
222− // Like refreshStreams: a selection that doesn't exist on this server
223− // (e.g. one picked from a stale list while the connect was pending)
224− // would keep every stream tab querying a missing stream.
225− if let selected = selectedStream, !list.contains(where: { $0.name == selected }) {
226− selectedStream = nil
227− }
228− case .failure(let error):
134+ } catch {
229135 self.streams = []
230136 self.streamLoadError = ParseableError.userFriendlyMessage(for: error)
231137 }
232− return true
233138 } catch {
234139 // If the task was cancelled (e.g. user clicked Disconnect during
235140 // auto-reconnect), skip the error alert — disconnect() already
@@ -240,16 +145,10 @@ final class AppState {
240145 self.isConnected = false
241146 self.client = nil
242147 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 = []
250148 }
251− return false
252149 }
150+
151+ isConnecting = false
253152 }
254153
255154 @MainActor
@@ -262,53 +161,17 @@ final class AppState {
262161 streams = []
263162 selectedStream = nil
264163 serverAbout = nil
265− filters = []
266− // Clear transient stream/UI state too, otherwise a leftover stream-load
267− // error keeps rendering in the sidebar after disconnect and a stale search
268− // filter / tab carries into the next connection.
269− streamLoadError = nil
270− isLoadingStreams = false
271− streamSearchText = ""
272− currentTab = .query
273164 ConnectionStore.saveActiveConnectionID(nil)
274165 }
275166
276167 @MainActor
277168 func refreshStreams() async {
278169 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
287170 isLoadingStreams = true
288171 streamLoadError = nil
289172 do {
290− let loaded = try await client.listStreams()
291− // Drop results if the active connection changed while awaiting. Still
292− // clear isLoadingStreams: this task set the spinner, so leaving it true
293− // wedges the indicator and disables Refresh. Compare the client too:
294− // editing the active connection's URL/credentials keeps its id but
295− // installs a new client for a possibly different server.
296− guard connID == activeConnection?.id, client === self.client else {
297− isLoadingStreams = false
298− return
299− }
300− streams = loaded
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− }
173+ streams = try await client.listStreams()
307174 } catch {
308− guard connID == activeConnection?.id, client === self.client else {
309− isLoadingStreams = false
310− return
311− }
312175 let message = ParseableError.userFriendlyMessage(for: error)
313176 streamLoadError = message
314177 self.errorMessage = "Failed to load streams: \(message)"
@@ -327,45 +190,10 @@ final class AppState {
327190
328191 @MainActor
329192 func updateConnection(_ connection: ServerConnection) {
330− let previous = connections.first(where: { $0.id == connection.id })
331193 if let index = connections.firstIndex(where: { $0.id == connection.id }) {
332194 connections[index] = connection
333195 ConnectionStore.saveConnections(connections)
334196 }
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− }
369197 }
370198
371199 @MainActor
@@ -375,49 +203,9 @@ final class AppState {
375203 ConnectionStore.saveConnections(connections)
376204 if activeConnection?.id == connection.id {
377205 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− }
396206 }
397207 }
398208
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−
421209 // MARK: - Stream Management
422210
423211 @MainActor
@@ -434,52 +222,27 @@ final class AppState {
434222 if selectedStream == name {
435223 selectedStream = nil
436224 }
437− await refreshFilters()
225+ // Remove saved queries that referenced the deleted stream
226+ let orphaned = savedQueries.filter { $0.stream == name }
227+ if !orphaned.isEmpty {
228+ savedQueries.removeAll { $0.stream == name }
229+ SavedQueryStore.save(savedQueries)
230+ }
438231 await refreshStreams()
439232 }
440233
441− // MARK: - Filters
234+ // MARK: - Saved Queries
442235
443236 @MainActor
444− func refreshFilters() async {
445− guard let client else { return }
446− // Capture the connection this fetch belongs to. A disconnect during the
447− // await would otherwise let the (now-stale) result overwrite the cleanup
448− // disconnect() performed, resurrecting the old server's filters in the
449− // disconnected sidebar. Same guard pattern as refreshStreams.
450− let connID = activeConnection?.id
451− isLoadingFilters = true
452− let loaded = (try? await client.listFilters()) ?? []
453− guard connID == activeConnection?.id, client === self.client else {
454− isLoadingFilters = false
455− return
456− }
457− filters = loaded
458− isLoadingFilters = false
237+ func addSavedQuery(_ query: SavedQuery) {
238+ savedQueries.append(query)
239+ SavedQueryStore.save(savedQueries)
459240 }
460241
461242 @MainActor
462− func saveFilter(name: String, sql: String, stream: String) async throws {
463− guard let client else { throw ParseableError.notConnected }
464− let filter = ParseableFilter(
465− filterName: name,
466− streamName: stream,
467− query: FilterQuery(filterType: "sql", filterQuery: sql)
468− )
469− let saved = try await client.createFilter(filter)
470− filters.append(saved)
471− }
472−
473− @MainActor
474− func deleteFilter(_ filter: ParseableFilter) async throws {
475− guard let client else { throw ParseableError.notConnected }
476− // A filter without a server-assigned id was never persisted remotely, so
477− // there's nothing to delete on the server — just drop it locally. Returning
478− // early here would silently leave the row in place with no error.
479− if let filterId = filter.filterId {
480− try await client.deleteFilter(id: filterId)
481− }
482− filters.removeAll { $0.id == filter.id }
243+ func removeSavedQuery(_ query: SavedQuery) {
244+ savedQueries.removeAll { $0.id == query.id }
245+ SavedQueryStore.save(savedQueries)
483246 }
484247
485248 // MARK: - Error Handling
ParseableViewer/App/ParseableViewerApp.swift+2−40
@@ -4,13 +4,6 @@ import SwiftUI
44struct ParseableViewerApp: App {
55 @State private var appState = AppState()
66
7− init() {
8− // All window content shares one AppState (see the File > New group below),
9− // so don't offer window tabs either: the tab bar's "+" would open a second
10− // window just like File > New Window.
11− NSWindow.allowsAutomaticWindowTabbing = false
12− }
13−
147 var body: some Scene {
158 WindowGroup {
169 ContentView()
@@ -20,16 +13,7 @@ struct ParseableViewerApp: App {
2013 .windowStyle(.titleBar)
2114 .defaultSize(width: 1200, height: 800)
2215 .commands {
23− CommandGroup(replacing: .appInfo) {
24− AboutCommand()
25− }
26−
27− // Replace (rather than extend) the File > New group to drop "New Window":
28− // connection, sheet, alert, selection and pending-filter state all live
29− // in the single shared AppState, so a second window would present every
30− // sheet and alert twice and race the first window's QueryView for
31− // saved-filter SQL.
32− CommandGroup(replacing: .newItem) {
16+ CommandGroup(after: .newItem) {
3317 Button("New Connection...") {
3418 appState.editingConnection = nil
3519 appState.showConnectionSheet = true
@@ -38,18 +22,12 @@ struct ParseableViewerApp: App {
3822
3923 Divider()
4024
41− Button("Refresh Query") {
42− appState.queryRefreshToken = UUID()
43− }
44− .keyboardShortcut("r", modifiers: [.command])
45− .disabled(!appState.isConnected)
46−
4725 Button("Refresh Streams") {
4826 Task {
4927 await appState.refreshStreams()
5028 }
5129 }
52− .keyboardShortcut("r", modifiers: [.command, .shift])
30+ .keyboardShortcut("r", modifiers: [.command])
5331 .disabled(!appState.isConnected)
5432 }
5533
@@ -63,25 +41,9 @@ struct ParseableViewerApp: App {
6341 }
6442 }
6543
66− Window("About Parseable Viewer", id: "about") {
67− AboutView()
68− }
69− .windowStyle(.hiddenTitleBar)
70− .windowResizability(.contentSize)
71−
7244 Settings {
7345 SettingsView()
7446 .environment(appState)
7547 }
7648 }
7749}
78−
79−private struct AboutCommand: View {
80− @Environment(\.openWindow) private var openWindow
81−
82− var body: some View {
83− Button("About Parseable Viewer") {
84− openWindow(id: "about")
85− }
86− }
87−}
ParseableViewer/Info.plist−5
@@ -24,11 +24,6 @@
2424 <string>public.app-category.developer-tools</string>
2525 <key>LSMinimumSystemVersion</key>
2626 <string>$(MACOSX_DEPLOYMENT_TARGET)</string>
27− <key>NSAppTransportSecurity</key>
28− <dict>
29− <key>NSAllowsArbitraryLoads</key>
30− <true/>
31− </dict>
3227 <key>NSMainNibFile</key>
3328 <string></string>
3429 <key>NSPrincipalClass</key>
ParseableViewer/Models/AlertModels.swift+3−71
@@ -10,7 +10,7 @@ struct AlertConfig: Codable, Sendable {
1010 }
1111
1212 init(from decoder: Decoder) throws {
13− // Handle both {"alerts": [...], "version": "..."} and direct array [...]
13+ // Handle both {"alerts": [...]} and direct array
1414 if let container = try? decoder.container(keyedBy: CodingKeys.self) {
1515 self.alerts = try? container.decode([AlertRule].self, forKey: .alerts)
1616 self.version = try? container.decode(String.self, forKey: .version)
@@ -27,85 +27,17 @@ struct AlertConfig: Codable, Sendable {
2727}
2828
2929struct AlertRule: Identifiable, Codable, Sendable {
30− // Modern Parseable API fields (from /api/v1/alerts summary)
31− let alertId: String?
32− let title: String?
33− let severity: String?
34− let state: String?
35− let alertType: String?
36− let notificationState: String?
37− let created: String?
38− let tags: [String]?
39− let datasets: [String]?
40− let lastTriggeredAt: String?
41−
42− // Legacy per-stream API fields
43− let name: String?
30+ let name: String
4431 let message: String?
4532 let rule: AlertRuleSpec?
4633 let targets: [AlertTarget]?
4734
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`.
54− var displayName: String { title ?? name ?? "Unnamed Alert" }
55−
56− var id: String { alertId ?? name ?? title ?? localID.uuidString }
57−
58− private enum CodingKeys: String, CodingKey {
59− case alertId = "id"
60− case title, severity, state, alertType
61− case notificationState, created, tags, datasets
62− case lastTriggeredAt
63− case name, message, rule, targets
64− }
65−
66− init(from decoder: Decoder) throws {
67− let container = try decoder.container(keyedBy: CodingKeys.self)
68− self.alertId = try? container.decode(String.self, forKey: .alertId)
69− self.title = try? container.decode(String.self, forKey: .title)
70− self.severity = try? container.decode(String.self, forKey: .severity)
71− self.state = try? container.decode(String.self, forKey: .state)
72− self.alertType = try? container.decode(String.self, forKey: .alertType)
73− self.notificationState = try? container.decode(String.self, forKey: .notificationState)
74− self.created = try? container.decode(String.self, forKey: .created)
75− self.tags = try? container.decode([String].self, forKey: .tags)
76− self.datasets = try? container.decode([String].self, forKey: .datasets)
77− self.lastTriggeredAt = try? container.decode(String.self, forKey: .lastTriggeredAt)
78− self.name = try? container.decode(String.self, forKey: .name)
79− self.message = try? container.decode(String.self, forKey: .message)
80− self.rule = try? container.decode(AlertRuleSpec.self, forKey: .rule)
81− self.targets = try? container.decode([AlertTarget].self, forKey: .targets)
82− }
35+ var id: String { name }
8336}
8437
8538struct AlertRuleSpec: Codable, Sendable {
8639 let type: String?
8740 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− }
10941}
11042
11143struct AlertTarget: Codable, Sendable {
ParseableViewer/Models/JSONValue.swift+22−236
@@ -37,14 +37,7 @@ enum JSONValue: Codable, Hashable, Sendable, Comparable {
3737 case .null: try container.encodeNil()
3838 case .bool(let v): try container.encode(v)
3939 case .int(let v): try container.encode(v)
40− case .double(let v):
41− // JSON has no representation for infinity/NaN, and JSONEncoder's
42− // default `.throw` strategy would abort the *entire* encode — turning
43− // a single non-finite value into a total export failure (`exportAsJSON`
44− // collapses to "[]"). Encode it as null to match displayString,
45− // compactJSON/jsonNumber, and the CSV export, which all render
46− // non-finite doubles as "null".
47− if v.isFinite { try container.encode(v) } else { try container.encodeNil() }
40+ case .double(let v): try container.encode(v)
4841 case .string(let v): try container.encode(v)
4942 case .array(let v): try container.encode(v)
5043 case .object(let v): try container.encode(v)
@@ -57,14 +50,7 @@ enum JSONValue: Codable, Hashable, Sendable, Comparable {
5750 case .bool(let v): return v ? "true" : "false"
5851 case .int(let v): return String(v)
5952 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" }
6453 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" }
6854 return String(format: "%.0f", v)
6955 }
7056 return String(v)
@@ -74,49 +60,21 @@ enum JSONValue: Codable, Hashable, Sendable, Comparable {
7460 }
7561 }
7662
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−
9663 /// Like `displayString` but serializes arrays/objects as compact JSON
9764 /// instead of placeholders like "[3 items]". Used for CSV export.
9865 var exportString: String {
9966 switch self {
10067 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
10273 default:
10374 return displayString
10475 }
10576 }
10677
107− /// Returns a SQL literal representation of this value for use in WHERE clauses.
108− var sqlLiteral: String {
109− switch self {
110− case .null: return "NULL"
111− case .bool(let v): return v ? "true" : "false"
112− case .int(let v): return String(v)
113− case .double(let v): return v.isFinite ? String(v) : "NULL"
114− case .string(let v): return "'\(v.replacingOccurrences(of: "'", with: "''"))'"
115− case .array, .object:
116− return "'\(exportString.replacingOccurrences(of: "'", with: "''"))'"
117− }
118− }
119−
12078 var isScalar: Bool {
12179 switch self {
12280 case .array, .object: return false
@@ -151,7 +109,7 @@ enum JSONValue: Codable, Hashable, Sendable, Comparable {
151109 case .null: return "null"
152110 case .bool(let v): return v ? "true" : "false"
153111 case .int(let v): return String(v)
154− case .double(let v): return Self.jsonNumber(v)
112+ case .double(let v): return String(v)
155113 case .string(let v): return "\"\(Self.escapeJSONString(v))\""
156114 case .array(let items):
157115 if items.isEmpty { return "[]" }
@@ -174,201 +132,43 @@ enum JSONValue: Codable, Hashable, Sendable, Comparable {
174132 /// Escapes special characters for valid JSON string output.
175133 static func escapeJSONString(_ s: String) -> String {
176134 var result = ""
177− result.unicodeScalars.reserveCapacity(s.unicodeScalars.count)
178− for scalar in s.unicodeScalars {
179− switch scalar {
135+ result.reserveCapacity(s.count)
136+ for char in s {
137+ switch char {
180138 case "\"": result += "\\\""
181139 case "\\": result += "\\\\"
182140 case "\n": result += "\\n"
183141 case "\r": result += "\\r"
184142 case "\t": result += "\\t"
185143 default:
186− // Any control character (U+0000–U+001F), including those that
187− // would otherwise combine into a grapheme cluster, must be
188− // escaped to produce valid JSON.
189− if scalar.value < 0x20 {
190− result += String(format: "\\u%04x", scalar.value)
144+ if char.asciiValue.map({ $0 < 0x20 }) == true {
145+ let code = char.asciiValue!
146+ result += String(format: "\\u%04x", code)
191147 } else {
192− result.unicodeScalars.append(scalar)
148+ result.append(char)
193149 }
194150 }
195151 }
196152 return result
197153 }
198154
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−
212155 /// Type-aware comparison: numbers compare numerically, strings
213156 /// lexicographically, nulls sort first, bools sort false < true.
214− /// Different types are ordered by a stable type rank so that `<` is a strict
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").
157+ /// Cross-type comparisons fall back to displayString.
218158 static func < (lhs: JSONValue, rhs: JSONValue) -> Bool {
219− if lhs.typeRank != rhs.typeRank { return lhs.typeRank < rhs.typeRank }
220159 switch (lhs, rhs) {
221160 case (.null, .null): return false
161+ case (.null, _): return true
162+ case (_, .null): return false
222163 case (.bool(let a), .bool(let b)): return !a && b
223164 case (.int(let a), .int(let b)): return a < b
224− case (.double(let a), .double(let b)): return doubleLessThan(a, b)
225− case (.int(let a), .double(let b)): return intLessThanDouble(a, b)
226− case (.double(let a), .int(let b)): return doubleLessThanInt(a, b)
165+ case (.double(let a), .double(let b)): return a < b
166+ case (.int(let a), .double(let b)): return Double(a) < b
167+ case (.double(let a), .int(let b)): return a < Double(b)
227168 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)
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)
169+ return a.localizedStandardCompare(b) == .orderedAscending
239170 default:
240− return false
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)
171+ return lhs.displayString < rhs.displayString
372172 }
373173 }
374174}
@@ -379,18 +179,4 @@ typealias LogRecord = [String: JSONValue]
379179struct QueryResponse: Codable, Sendable {
380180 let records: [LogRecord]
381181 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− }
396182}
ParseableViewer/Models/LogStream.swift+17−149
@@ -1,6 +1,6 @@
11import Foundation
22
3−struct LogStream: Identifiable, Codable, Hashable, Sendable, Comparable {
3+struct LogStream: Identifiable, Codable, Hashable, Sendable {
44 let name: String
55
66 var id: String { name }
@@ -9,17 +9,6 @@ struct LogStream: Identifiable, Codable, Hashable, Sendable, Comparable {
99 self.name = name
1010 }
1111
12− static func < (lhs: LogStream, rhs: LogStream) -> Bool {
13− // Fall back to a raw comparison when the localized comparison reports
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− }
22−
2312 init(from decoder: Decoder) throws {
2413 // Handle both {"name": "x"} and plain string formats
2514 if let container = try? decoder.container(keyedBy: CodingKeys.self) {
@@ -80,111 +69,11 @@ struct SchemaField: Identifiable, Codable, Hashable, Sendable {
8069 if let typeStr = try? container.decode(String.self, forKey: .dataType) {
8170 self.dataType = typeStr
8271 } else if let typeObj = try? container.decode(JSONValue.self, forKey: .dataType) {
83− self.dataType = Self.describeArrowType(typeObj)
72+ self.dataType = typeObj.displayString
8473 } else {
8574 self.dataType = "Unknown"
8675 }
8776 }
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− }
18877}
18978
19079struct StreamStats: Codable, Sendable {
@@ -207,20 +96,16 @@ struct StreamStats: Codable, Sendable {
20796 self.lifetime_count = try? container.decode(Int.self, forKey: .lifetime_count)
20897 // Handle size as string or number
20998 if let s = try? container.decode(String.self, forKey: .size) {
210− self.size = formatByteSize(s)
99+ self.size = s
211100 } else if let n = try? container.decode(Int.self, forKey: .size) {
212− self.size = formatByteSize(n)
213− } else if let d = try? container.decode(Double.self, forKey: .size) {
214− self.size = formatByteSize(d)
101+ self.size = ByteCountFormatter.string(fromByteCount: Int64(n), countStyle: .file)
215102 } else {
216103 self.size = nil
217104 }
218105 if let s = try? container.decode(String.self, forKey: .lifetime_size) {
219− self.lifetime_size = formatByteSize(s)
106+ self.lifetime_size = s
220107 } else if let n = try? container.decode(Int.self, forKey: .lifetime_size) {
221− self.lifetime_size = formatByteSize(n)
222− } else if let d = try? container.decode(Double.self, forKey: .lifetime_size) {
223− self.lifetime_size = formatByteSize(d)
108+ self.lifetime_size = ByteCountFormatter.string(fromByteCount: Int64(n), countStyle: .file)
224109 } else {
225110 self.lifetime_size = nil
226111 }
@@ -238,26 +123,18 @@ struct StreamStats: Codable, Sendable {
238123
239124 init(from decoder: Decoder) throws {
240125 let container = try decoder.container(keyedBy: CodingKeys.self)
241− // Parseable names this field `format` (e.g. "parquet") in every
242− // version; `type` never appears, so "Storage Type" always read N/A.
243− // Keep `type` as a fallback.
244− self.type = (try? decoder.container(keyedBy: FlexibleCodingKey.self))?
245− .firstString("format", "type")
126+ self.type = try? container.decode(String.self, forKey: .type)
246127 if let s = try? container.decode(String.self, forKey: .size) {
247− self.size = formatByteSize(s)
128+ self.size = s
248129 } else if let n = try? container.decode(Int.self, forKey: .size) {
249− self.size = formatByteSize(n)
250− } else if let d = try? container.decode(Double.self, forKey: .size) {
251− self.size = formatByteSize(d)
130+ self.size = ByteCountFormatter.string(fromByteCount: Int64(n), countStyle: .file)
252131 } else {
253132 self.size = nil
254133 }
255134 if let s = try? container.decode(String.self, forKey: .lifetime_size) {
256− self.lifetime_size = formatByteSize(s)
135+ self.lifetime_size = s
257136 } else if let n = try? container.decode(Int.self, forKey: .lifetime_size) {
258− self.lifetime_size = formatByteSize(n)
259− } else if let d = try? container.decode(Double.self, forKey: .lifetime_size) {
260− self.lifetime_size = formatByteSize(d)
137+ self.lifetime_size = ByteCountFormatter.string(fromByteCount: Int64(n), countStyle: .file)
261138 } else {
262139 self.lifetime_size = nil
263140 }
@@ -285,20 +162,11 @@ struct StreamInfo: Codable, Sendable {
285162 }
286163
287164 init(from decoder: Decoder) throws {
288− // The /info key spellings vary by Parseable version: 1.x–2.x send
289− // `created-at`/`first-event-at` with the other fields in snake_case
290− // (`cache_enabled`, `time_partition`, `static_schema_flag`), while newer
291− // servers send camelCase throughout (`createdAt`, `timePartition`, …).
292− // The kebab-case `cache-enabled`/`time-partition`/`static-schema-flag`
293− // decoded previously were never sent by any version, so those rows always
294− // showed "N/A". The schema flag also arrives as the string "true".
295− let container = try decoder.container(keyedBy: FlexibleCodingKey.self)
296− self.createdAt = container.firstString("created-at", "createdAt", "created_at")
297− self.firstEventAt = container.firstString("first-event-at", "firstEventAt", "first_event_at")
298− self.cacheEnabled = container.firstBool("cache_enabled", "cacheEnabled", "cache-enabled")
299− self.timePartition = container.firstString("time_partition", "timePartition", "time-partition")
300− self.staticSchemaFlag = container.firstBool(
301− "static_schema_flag", "staticSchemaFlag", "static-schema-flag"
302− )
165+ let container = try decoder.container(keyedBy: CodingKeys.self)
166+ self.createdAt = try? container.decode(String.self, forKey: .createdAt)
167+ self.firstEventAt = try? container.decode(String.self, forKey: .firstEventAt)
168+ self.cacheEnabled = try? container.decode(Bool.self, forKey: .cacheEnabled)
169+ self.timePartition = try? container.decode(String.self, forKey: .timePartition)
170+ self.staticSchemaFlag = try? container.decode(Bool.self, forKey: .staticSchemaFlag)
303171 }
304172}
ParseableViewer/Models/ParseableFilter.swiftdeleted−79
@@ -1,79 +0,0 @@
1−import Foundation
2−
3−struct FilterQuery: Codable, Hashable, Sendable {
4− var filterType: String
5− var filterQuery: String?
6− var filterBuilder: JSONValue?
7−
8− enum CodingKeys: String, CodingKey {
9− case filterType = "filter_type"
10− case filterQuery = "filter_query"
11− case filterBuilder = "filter_builder"
12− }
13−
14− init(filterType: String = "sql", filterQuery: String? = nil, filterBuilder: JSONValue? = nil) {
15− self.filterType = filterType
16− self.filterQuery = filterQuery
17− self.filterBuilder = filterBuilder
18− }
19−
20− init(from decoder: Decoder) throws {
21− let container = try decoder.container(keyedBy: CodingKeys.self)
22− filterType = (try? container.decode(String.self, forKey: .filterType)) ?? "sql"
23− filterQuery = try? container.decode(String.self, forKey: .filterQuery)
24− filterBuilder = try? container.decode(JSONValue.self, forKey: .filterBuilder)
25− }
26−}
27−
28−struct ParseableFilter: Identifiable, Codable, Hashable, Sendable {
29− var filterId: String?
30− var filterName: String
31− var streamName: String
32− var query: FilterQuery
33− var version: String?
34− var userId: String?
35− var timeFilter: JSONValue?
36−
37− var id: String {
38− filterId ?? "\(streamName):\(filterName)"
39− }
40−
41− enum CodingKeys: String, CodingKey {
42− case filterId = "filter_id"
43− case filterName = "filter_name"
44− case streamName = "stream_name"
45− case query
46− case version
47− case userId = "user_id"
48− case timeFilter = "time_filter"
49− }
50−
51− init(
52− filterId: String? = nil,
53− filterName: String,
54− streamName: String,
55− query: FilterQuery,
56− version: String? = nil,
57− userId: String? = nil,
58− timeFilter: JSONValue? = nil
59− ) {
60− self.filterId = filterId
61− self.filterName = filterName
62− self.streamName = streamName
63− self.query = query
64− self.version = version
65− self.userId = userId
66− self.timeFilter = timeFilter
67− }
68−
69− init(from decoder: Decoder) throws {
70− let container = try decoder.container(keyedBy: CodingKeys.self)
71− filterId = try? container.decode(String.self, forKey: .filterId)
72− filterName = try container.decode(String.self, forKey: .filterName)
73− streamName = try container.decode(String.self, forKey: .streamName)
74− query = try container.decode(FilterQuery.self, forKey: .query)
75− version = try? container.decode(String.self, forKey: .version)
76− userId = try? container.decode(String.self, forKey: .userId)
77− timeFilter = try? container.decode(JSONValue.self, forKey: .timeFilter)
78− }
79−}
ParseableViewer/Models/SQLTokenizer.swiftdeleted−413
@@ -1,413 +0,0 @@
1−import Foundation
2−
3−/// Parsed line/column position from a DataFusion error message.
4−struct SQLErrorPosition: Equatable, Sendable {
5− let line: Int // 1-based
6− let column: Int // 1-based
7−
8− /// Parses a position from a DataFusion error string such as
9− /// `"Expected: an expression, found: FROM at Line: 1, Column 15"`.
10− static func parse(from message: String) -> SQLErrorPosition? {
11− // DataFusion sometimes includes a colon after "Column" and sometimes doesn't
12− let pattern = #"Line:\s*(\d+),\s*Column:?\s*(\d+)"#
13− guard let regex = try? NSRegularExpression(pattern: pattern),
14− let match = regex.firstMatch(in: message, range: NSRange(message.startIndex..., in: message)),
15− let lineRange = Range(match.range(at: 1), in: message),
16− let colRange = Range(match.range(at: 2), in: message),
17− let line = Int(message[lineRange]),
18− let col = Int(message[colRange]) else {
19− return nil
20− }
21− return SQLErrorPosition(line: line, column: col)
22− }
23−}
24−
25−/// A minimal SQL tokenizer and parser for rewriting SELECT column lists.
26−struct SQLTokenizer: Sendable {
27−
28− enum TokenKind: Equatable, Sendable {
29− case keyword(String) // Uppercased SQL keyword
30− case identifier(String) // Bare identifier
31− case quotedIdentifier(String) // Content without quotes, `""` unescaped
32− case stringLiteral(String) // Content without quotes, `''` unescaped
33− case number(String)
34− case comma
35− case star
36− case leftParen
37− case rightParen
38− case whitespace
39− case lineComment
40− case blockComment
41− case other(String)
42−
43− var isTrivia: Bool {
44− switch self {
45− case .whitespace, .lineComment, .blockComment: return true
46− default: return false
47− }
48− }
49− }
50−
51− struct Token: Equatable, Sendable {
52− let kind: TokenKind
53− let range: Range<String.Index>
54− }
55−
56− // MARK: - Keywords
57−
58− private static let keywords: Set<String> = [
59− "SELECT", "DISTINCT", "FROM", "WHERE", "GROUP", "BY", "HAVING",
60− "ORDER", "LIMIT", "OFFSET", "AS", "AND", "OR", "NOT", "IN", "IS",
61− "NULL", "LIKE", "BETWEEN", "CASE", "WHEN", "THEN", "ELSE", "END",
62− "JOIN", "ON", "LEFT", "RIGHT", "INNER", "OUTER", "CROSS", "FULL",
63− "UNION", "ALL", "INTERSECT", "EXCEPT", "INSERT", "UPDATE", "DELETE",
64− "CREATE", "DROP", "ALTER", "SET", "INTO", "VALUES", "ASC", "DESC",
65− "EXISTS", "TRUE", "FALSE", "WITH", "RECURSIVE", "OVER", "PARTITION",
66− "WINDOW", "ROWS", "RANGE", "UNBOUNDED", "PRECEDING", "FOLLOWING",
67− "CURRENT", "ROW", "FILTER", "LATERAL", "NATURAL", "USING", "FETCH",
68− "FIRST", "LAST", "NEXT", "ONLY", "TIES", "TOP",
69− ]
70−
71− // MARK: - Tokenizer
72−
73− static func tokenize(_ sql: String) -> [Token] {
74− var tokens: [Token] = []
75− var i = sql.startIndex
76−
77− while i < sql.endIndex {
78− let start = i
79− let c = sql[i]
80−
81− switch c {
82− // Whitespace
83− case _ where isWhitespace(c):
84− advance(&i, in: sql, while: isWhitespace)
85− tokens.append(Token(kind: .whitespace, range: start..<i))
86−
87− // Line comment
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) })
97− tokens.append(Token(kind: .lineComment, range: start..<i))
98−
99− // Block comment
100− case "/" where peek(sql, at: i, offset: 1) == "*":
101− sql.formIndex(&i, offsetBy: 2)
102− while i < sql.endIndex {
103− if sql[i] == "*", peek(sql, at: i, offset: 1) == "/" {
104− sql.formIndex(&i, offsetBy: 2)
105− break
106− }
107− sql.formIndex(after: &i)
108− }
109− tokens.append(Token(kind: .blockComment, range: start..<i))
110−
111− // String literal
112− case "'":
113− let value = consumeQuoted(&i, in: sql, quote: "'")
114− tokens.append(Token(kind: .stringLiteral(value), range: start..<i))
115−
116− // Quoted identifier
117− case "\"":
118− let value = consumeQuoted(&i, in: sql, quote: "\"")
119− tokens.append(Token(kind: .quotedIdentifier(value), range: start..<i))
120−
121− // Punctuation
122− case "(":
123− sql.formIndex(after: &i)
124− tokens.append(Token(kind: .leftParen, range: start..<i))
125− case ")":
126− sql.formIndex(after: &i)
127− tokens.append(Token(kind: .rightParen, range: start..<i))
128− case ",":
129− sql.formIndex(after: &i)
130− tokens.append(Token(kind: .comma, range: start..<i))
131− case "*":
132− sql.formIndex(after: &i)
133− tokens.append(Token(kind: .star, range: start..<i))
134−
135− // Number (digit or leading dot followed by digit)
136− case _ where isASCIIDigit(c) || (c == "." && peek(sql, at: i, offset: 1).map(isASCIIDigit) == true):
137− consumeNumber(&i, in: sql)
138− tokens.append(Token(kind: .number(String(sql[start..<i])), range: start..<i))
139−
140− // Identifier or keyword
141− case _ where isASCIILetter(c) || c == "_":
142− advance(&i, in: sql, while: { isASCIILetter($0) || isASCIIDigit($0) || $0 == "_" })
143− let word = String(sql[start..<i])
144− if keywords.contains(word.uppercased()) {
145− tokens.append(Token(kind: .keyword(word.uppercased()), range: start..<i))
146− } else {
147− tokens.append(Token(kind: .identifier(word), range: start..<i))
148− }
149−
150− // Everything else (operators, semicolons, etc.)
151− default:
152− sql.formIndex(after: &i)
153− tokens.append(Token(kind: .other(String(c)), range: start..<i))
154− }
155− }
156−
157− return tokens
158− }
159−
160− // MARK: - Parser
161−
162− /// Returns the source range of the column list in a `SELECT` statement,
163− /// i.e. the text between `SELECT [DISTINCT]` and the top-level `FROM`.
164− /// Returns `nil` if the SQL cannot be parsed as a simple SELECT.
165− static func selectColumnListRange(in sql: String) -> Range<String.Index>? {
166− let tokens = tokenize(sql)
167− var idx = 0
168−
169− func skipTrivia() {
170− while idx < tokens.count, tokens[idx].kind.isTrivia {
171− idx += 1
172− }
173− }
174−
175− // Expect SELECT
176− skipTrivia()
177− guard idx < tokens.count, tokens[idx].kind == .keyword("SELECT") else { return nil }
178− idx += 1
179−
180− // Optional DISTINCT
181− skipTrivia()
182− if idx < tokens.count, tokens[idx].kind == .keyword("DISTINCT") {
183− idx += 1
184− }
185−
186− // Skip trivia — column list starts at the next real token
187− skipTrivia()
188− guard idx < tokens.count else { return nil }
189− let columnStartIdx = idx
190−
191− // Walk forward, tracking paren depth, to find top-level FROM
192− var depth = 0
193− var fromIdx: Int?
194− for j in columnStartIdx..<tokens.count {
195− switch tokens[j].kind {
196− case .leftParen:
197− depth += 1
198− case .rightParen:
199− depth = max(0, depth - 1)
200− case .keyword("FROM") where depth == 0:
201− fromIdx = j
202− default:
203− break
204− }
205− if fromIdx != nil { break }
206− }
207−
208− guard let fromIdx, fromIdx > columnStartIdx else { return nil }
209−
210− // Walk backwards from FROM to find the last non-trivia token
211− var lastColIdx = fromIdx - 1
212− while lastColIdx >= columnStartIdx, tokens[lastColIdx].kind.isTrivia {
213− lastColIdx -= 1
214− }
215− guard lastColIdx >= columnStartIdx else { return nil }
216−
217− return tokens[columnStartIdx].range.lowerBound..<tokens[lastColIdx].range.upperBound
218− }
219−
220− // MARK: - Private helpers
221−
222− private static func peek(_ sql: String, at i: String.Index, offset: Int) -> Character? {
223− guard let idx = sql.index(i, offsetBy: offset, limitedBy: sql.endIndex),
224− idx < sql.endIndex else { return nil }
225− return sql[idx]
226− }
227−
228− private static func advance(
229− _ i: inout String.Index, in sql: String,
230− while predicate: (Character) -> Bool
231− ) {
232− while i < sql.endIndex, predicate(sql[i]) {
233− sql.formIndex(after: &i)
234− }
235− }
236−
237− /// Consumes a quoted string/identifier starting at the opening quote.
238− /// Handles doubled-quote escapes (e.g. `''` inside strings, `""` inside identifiers).
239− /// Advances `i` past the closing quote (or to end-of-string if unterminated).
240− private static func consumeQuoted(
241− _ i: inout String.Index, in sql: String, quote: Character
242− ) -> String {
243− sql.formIndex(after: &i) // skip opening quote
244− var value = ""
245− while i < sql.endIndex {
246− if sql[i] == quote {
247− let next = sql.index(after: i)
248− if next < sql.endIndex, sql[next] == quote {
249− // Doubled quote — escape
250− value.append(quote)
251− i = sql.index(after: next)
252− } else {
253− // Closing quote
254− i = next
255− return value
256− }
257− } else {
258− value.append(sql[i])
259− sql.formIndex(after: &i)
260− }
261− }
262− return value // unterminated
263− }
264−
265− /// Swift iterates grapheme clusters, and a CRLF pair is the single Character
266− /// `"\r\n"`, which is equal to neither `"\r"` nor `"\n"`. Match it explicitly
267− /// so Windows line endings (e.g. pasted text) tokenize like LF/CR ones.
268− private static func isLineBreak(_ c: Character) -> Bool {
269− c == "\n" || c == "\r" || c == "\r\n"
270− }
271−
272− private static func isWhitespace(_ c: Character) -> Bool {
273− c == " " || c == "\t" || isLineBreak(c)
274− }
275−
276− /// SQL number literals use only ASCII digits. `Character.isNumber` also
277− /// matches Unicode numerics (`²`, `½`, Arabic-Indic digits, etc.), which
278− /// DataFusion's lexer rejects — accepting them here would tokenize those
279− /// characters as numbers and diverge token offsets from the server, throwing
280− /// off `errorHighlightRange` mapping.
281− private static func isASCIIDigit(_ c: Character) -> Bool {
282− c.isASCII && c.isNumber
283− }
284−
285− /// Bare (unquoted) SQL identifiers use only ASCII letters. `Character.isLetter`
286− /// matches the full Unicode letter range (`café`, `naïve`, etc.), which
287− /// DataFusion's lexer would require to be double-quoted — accepting them bare
288− /// here would tokenize them as identifiers and diverge token offsets from the
289− /// server, throwing off `errorHighlightRange` mapping (same rationale as
290− /// `isASCIIDigit`).
291− private static func isASCIILetter(_ c: Character) -> Bool {
292− c.isASCII && c.isLetter
293− }
294−
295− 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
300− if c == "." {
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" {
311− var lookahead = sql.index(after: i)
312− if lookahead < sql.endIndex, sql[lookahead] == "+" || sql[lookahead] == "-" {
313− sql.formIndex(after: &lookahead)
314− }
315− if lookahead < sql.endIndex, isASCIIDigit(sql[lookahead]) {
316− i = lookahead
317− advance(&i, in: sql, while: { isASCIIDigit($0) })
318− }
319− }
320− }
321−
322− // MARK: - Error position helpers
323−
324− /// Converts a 1-based line/column into a UTF-16 offset within `sql`.
325− /// Returns `nil` if the position is out of bounds.
326− static func characterOffset(line: Int, column: Int, in sql: String) -> Int? {
327− guard line >= 1, column >= 1 else { return nil }
328− let nsString = sql as NSString
329− let length = nsString.length
330−
331− // Walk to the start of the target line
332− var currentLine = 1
333− var i = 0
334− while currentLine < line {
335− guard i < length else { return nil }
336− if nsString.character(at: i) == 0x0A { // \n
337− currentLine += 1
338− }
339− i += 1
340− }
341−
342− // Advance (column - 1) characters from the line start. `column` is a
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 }
363− return offset
364− }
365−
366− /// Returns the `NSRange` of the token at the given UTF-16 offset.
367− /// If the offset falls on whitespace/trivia, snaps to the next non-trivia token.
368− static func tokenRange(atOffset offset: Int, in sql: String) -> NSRange? {
369− let tokens = tokenize(sql)
370− let nsString = sql as NSString
371−
372− // Convert token Swift ranges to NSRanges for comparison
373− for (idx, token) in tokens.enumerated() {
374− let nsRange = NSRange(token.range, in: sql)
375− if offset >= nsRange.location && offset < nsRange.location + nsRange.length {
376− if token.kind.isTrivia {
377− // Snap to the next non-trivia token
378− for next in tokens[(idx + 1)...] {
379− if !next.kind.isTrivia {
380− return NSRange(next.range, in: sql)
381− }
382− }
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
392− }
393− return nsRange
394− }
395− }
396−
397− // Offset is at or past end — return last non-trivia token
398− if offset >= nsString.length {
399− for token in tokens.reversed() {
400− if !token.kind.isTrivia {
401− return NSRange(token.range, in: sql)
402− }
403− }
404− }
405− return nil
406− }
407−
408− /// Convenience: parses a line/column into an `NSRange` for the enclosing token.
409− static func errorHighlightRange(line: Int, column: Int, in sql: String) -> NSRange? {
410− guard let offset = characterOffset(line: line, column: column, in: sql) else { return nil }
411− return tokenRange(atOffset: offset, in: sql)
412− }
413−}
ParseableViewer/Models/ServerConnection.swift+2−35
@@ -37,51 +37,18 @@ struct ServerConnection: Identifiable, Codable, Hashable, Sendable {
3737 try container.encode(username, forKey: .username)
3838 }
3939
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−
5640 var baseURL: URL? {
5741 var urlString = url.trimmingCharacters(in: .whitespacesAndNewlines)
5842 if urlString.isEmpty { return nil }
59− if let schemeRange = Self.urlSchemeRange(in: urlString) {
60− // Normalize the (case-insensitive) scheme so `HTTP://host` behaves
61− // exactly like `http://host` everywhere downstream.
62− urlString.replaceSubrange(schemeRange, with: urlString[schemeRange].lowercased())
63− } else {
43+ if !urlString.hasPrefix("http://") && !urlString.hasPrefix("https://") {
6444 urlString = "https://\(urlString)"
6545 }
6646 while urlString.hasSuffix("/") {
6747 urlString.removeLast()
6848 }
69− guard let parsed = URL(string: urlString), parsed.host != nil,
70− parsed.scheme == "http" || parsed.scheme == "https" else {
49+ guard let parsed = URL(string: urlString), parsed.host != nil else {
7150 return nil
7251 }
7352 return parsed
7453 }
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− }
8754}
ParseableViewer/Models/ServerInfo.swift+10−47
@@ -15,59 +15,33 @@ struct ServerAbout: Codable, Sendable {
1515 let llmActive: Bool?
1616 let oidcActive: Bool?
1717
18− // Parseable's /about handler has always sent camelCase keys (`uiVersion`,
19− // `deploymentId`, `grpcPort`), like the other fields here. Decoding only the
20− // snake_case spellings — which the server never sends — left UI Version and
21− // Deployment ID permanently "N/A" and hid the gRPC Port row. The snake_case
22− // forms are kept as a fallback.
2318 private enum CodingKeys: String, CodingKey {
2419 case version, commit, mode, staging, store, license
25− case uiVersion
26− case deploymentId
27− case grpcPort
20+ case uiVersion = "ui_version"
21+ case deploymentId = "deployment_id"
22+ case grpcPort = "grpc_port"
2823 case updateAvailable
2924 case latestVersion
3025 case llmActive
3126 case oidcActive
3227 }
3328
34− private enum SnakeCaseKeys: String, CodingKey {
35− case uiVersion = "ui_version"
36− case deploymentId = "deployment_id"
37− case grpcPort = "grpc_port"
38− }
39−
4029 init(from decoder: Decoder) throws {
4130 let container = try decoder.container(keyedBy: CodingKeys.self)
42− let snakeCase = try? decoder.container(keyedBy: SnakeCaseKeys.self)
4331 self.version = try? container.decode(String.self, forKey: .version)
44− self.uiVersion = (try? container.decode(String.self, forKey: .uiVersion))
45− ?? (try? snakeCase?.decode(String.self, forKey: .uiVersion))
32+ self.uiVersion = try? container.decode(String.self, forKey: .uiVersion)
4633 self.commit = try? container.decode(String.self, forKey: .commit)
47− self.deploymentId = (try? container.decode(String.self, forKey: .deploymentId))
48− ?? (try? snakeCase?.decode(String.self, forKey: .deploymentId))
34+ self.deploymentId = try? container.decode(String.self, forKey: .deploymentId)
4935 self.mode = try? container.decode(String.self, forKey: .mode)
5036 self.staging = try? container.decode(String.self, forKey: .staging)
5137 self.store = try? container.decode(StoreInfo.self, forKey: .store)
52− self.license = (try? container.decode(String.self, forKey: .license))
53− ?? (try? container.decode(JSONValue.self, forKey: .license)).flatMap(Self.describeLicense)
54− self.grpcPort = (try? container.decode(Int.self, forKey: .grpcPort))
55− ?? (try? snakeCase?.decode(Int.self, forKey: .grpcPort))
38+ self.license = try? container.decode(String.self, forKey: .license)
39+ self.grpcPort = try? container.decode(Int.self, forKey: .grpcPort)
5640 self.updateAvailable = try? container.decode(Bool.self, forKey: .updateAvailable)
5741 self.latestVersion = try? container.decode(String.self, forKey: .latestVersion)
5842 self.llmActive = try? container.decode(Bool.self, forKey: .llmActive)
5943 self.oidcActive = try? container.decode(Bool.self, forKey: .oidcActive)
6044 }
61−
62− /// Current servers send `license` as an object (`{"name": "AGPL-3.0-only",
63− /// "plan": "OSS", …}`) rather than a string, which decoded to nil ("N/A").
64− private static func describeLicense(_ value: JSONValue) -> String? {
65− guard case .object(let fields) = value, let name = fields["name"]?.stringValue else { return nil }
66− if let plan = fields["plan"]?.stringValue, !plan.isEmpty {
67− return "\(name) (\(plan))"
68− }
69− return name
70− }
7145}
7246
7347struct StoreInfo: Codable, Sendable {
@@ -111,20 +85,9 @@ struct UserInfo: Identifiable, Codable, Sendable {
11185 }
11286
11387 init(from decoder: Decoder) throws {
114− // Handle both {"id": "x", ...} and a bare "x" string element.
115− if let container = try? decoder.container(keyedBy: CodingKeys.self),
116− container.contains(.id) {
117− // Decode `id` defensively: the server may quote it as a string or
118− // send it as a number/other JSON. Fall back to JSONValue.displayString
119− // so a single odd entry doesn't fail the whole user list (matching the
120− // file's otherwise-defensive decoding).
121− if let stringID = try? container.decode(String.self, forKey: .id) {
122− self.id = stringID
123− } else if let valueID = try? container.decode(JSONValue.self, forKey: .id) {
124− self.id = valueID.displayString
125− } else {
126− self.id = ""
127− }
88+ // Handle both {"id": "x", ...} and variations
89+ if let container = try? decoder.container(keyedBy: CodingKeys.self) {
90+ self.id = try container.decode(String.self, forKey: .id)
12891 self.method = try? container.decode(String.self, forKey: .method)
12992 } else {
13093 let container = try decoder.singleValueContainer()
ParseableViewer/ParseableViewer.entitlements+6−1
@@ -1,5 +1,10 @@
11<?xml version="1.0" encoding="UTF-8"?>
22<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
33<plist version="1.0">
4−<dict/>
4+<dict>
5+ <key>com.apple.security.app-sandbox</key>
6+ <true/>
7+ <key>com.apple.security.network.client</key>
8+ <true/>
9+</dict>
510</plist>
ParseableViewer/Resources/Assets.xcassets/AppIcon.appiconset/Contents.json+45−1
@@ -1,7 +1,51 @@
11{
22 "images" : [
33 {
4− "filename" : "icon.png",
4+ "idiom" : "mac",
5+ "scale" : "1x",
6+ "size" : "16x16"
7+ },
8+ {
9+ "idiom" : "mac",
10+ "scale" : "2x",
11+ "size" : "16x16"
12+ },
13+ {
14+ "idiom" : "mac",
15+ "scale" : "1x",
16+ "size" : "32x32"
17+ },
18+ {
19+ "idiom" : "mac",
20+ "scale" : "2x",
21+ "size" : "32x32"
22+ },
23+ {
24+ "idiom" : "mac",
25+ "scale" : "1x",
26+ "size" : "128x128"
27+ },
28+ {
29+ "idiom" : "mac",
30+ "scale" : "2x",
31+ "size" : "128x128"
32+ },
33+ {
34+ "idiom" : "mac",
35+ "scale" : "1x",
36+ "size" : "256x256"
37+ },
38+ {
39+ "idiom" : "mac",
40+ "scale" : "2x",
41+ "size" : "256x256"
42+ },
43+ {
44+ "idiom" : "mac",
45+ "scale" : "1x",
46+ "size" : "512x512"
47+ },
48+ {
549 "idiom" : "mac",
650 "scale" : "2x",
751 "size" : "512x512"
ParseableViewer/Resources/Assets.xcassets/AppIcon.appiconset/icon.pngdeleted

Binary file not shown.

ParseableViewer/Services/ConnectionStore.swift+44−19
@@ -1,29 +1,26 @@
11import Foundation
22
33final class ConnectionStore {
4− private static let keychainConnectionsKey = "parseable_connections_list"
5− private static let keychainActiveIDKey = "parseable_active_connection_id"
4+ private static let storageKey = "parseable_connections"
5+ private static let activeConnectionKey = "parseable_active_connection_id"
66
77 static func loadConnections() -> [ServerConnection] {
8− guard let data = KeychainService.loadData(for: keychainConnectionsKey) else {
8+ guard let data = UserDefaults.standard.data(forKey: storageKey) else {
99 return []
1010 }
1111 return (try? JSONDecoder().decode([ServerConnection].self, from: data)) ?? []
1212 }
1313
1414 static func saveConnections(_ connections: [ServerConnection]) {
15− for connection in connections where !connection.password.isEmpty {
16− // Only upsert non-empty passwords. We must NOT delete on an empty
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)
15+ for connection in connections {
16+ if !connection.password.isEmpty {
17+ KeychainService.savePassword(connection.password, for: connection.id)
18+ } else {
19+ KeychainService.deletePassword(for: connection.id)
20+ }
2421 }
2522 if let data = try? JSONEncoder().encode(connections) {
26− KeychainService.saveData(data, for: keychainConnectionsKey)
23+ UserDefaults.standard.set(data, forKey: storageKey)
2724 }
2825 }
2926
@@ -32,18 +29,46 @@ final class ConnectionStore {
3229 }
3330
3431 static func loadActiveConnectionID() -> UUID? {
35− guard let data = KeychainService.loadData(for: keychainActiveIDKey),
36− let string = String(data: data, encoding: .utf8) else {
32+ guard let string = UserDefaults.standard.string(forKey: activeConnectionKey) else {
3733 return nil
3834 }
3935 return UUID(uuidString: string)
4036 }
4137
4238 static func saveActiveConnectionID(_ id: UUID?) {
43− if let id, let data = id.uuidString.data(using: .utf8) {
44− KeychainService.saveData(data, for: keychainActiveIDKey)
45− } else {
46− KeychainService.deleteData(for: keychainActiveIDKey)
39+ UserDefaults.standard.set(id?.uuidString, forKey: activeConnectionKey)
40+ }
41+}
42+
43+final class SavedQueryStore {
44+ private static let storageKey = "parseable_saved_queries"
45+
46+ static func load() -> [SavedQuery] {
47+ guard let data = UserDefaults.standard.data(forKey: storageKey) else {
48+ return []
49+ }
50+ return (try? JSONDecoder().decode([SavedQuery].self, from: data)) ?? []
51+ }
52+
53+ static func save(_ queries: [SavedQuery]) {
54+ if let data = try? JSONEncoder().encode(queries) {
55+ UserDefaults.standard.set(data, forKey: storageKey)
4756 }
4857 }
4958}
59+
60+struct SavedQuery: Identifiable, Codable, Hashable, Sendable {
61+ var id: UUID
62+ var name: String
63+ var sql: String
64+ var stream: String
65+ var createdAt: Date
66+
67+ init(id: UUID = UUID(), name: String, sql: String, stream: String, createdAt: Date = Date()) {
68+ self.id = id
69+ self.name = name
70+ self.sql = sql
71+ self.stream = stream
72+ self.createdAt = createdAt
73+ }
74+}
ParseableViewer/Services/KeychainService.swift+26−76
@@ -8,15 +8,32 @@ enum KeychainService {
88
99 @discardableResult
1010 static func savePassword(_ password: String, for connectionID: UUID) -> Bool {
11+ let account = connectionID.uuidString
12+ deletePassword(for: connectionID)
13+
1114 guard let data = password.data(using: .utf8) else { return false }
12− return upsert(data, query: baseQuery(for: connectionID), label: connectionID.uuidString)
15+ let query: [String: Any] = [
16+ kSecClass as String: kSecClassGenericPassword,
17+ kSecAttrService as String: service,
18+ kSecAttrAccount as String: account,
19+ kSecValueData as String: data
20+ ]
21+ let status = SecItemAdd(query as CFDictionary, nil)
22+ if status != errSecSuccess {
23+ logger.error("Failed to save password for \(account): OSStatus \(status)")
24+ }
25+ return status == errSecSuccess
1326 }
1427
1528 static func loadPassword(for connectionID: UUID) -> String? {
1629 let account = connectionID.uuidString
17− var query = baseQuery(for: connectionID)
18− query[kSecReturnData as String] = true
19− query[kSecMatchLimit as String] = kSecMatchLimitOne
30+ let query: [String: Any] = [
31+ kSecClass as String: kSecClassGenericPassword,
32+ kSecAttrService as String: service,
33+ kSecAttrAccount as String: account,
34+ kSecReturnData as String: true,
35+ kSecMatchLimit as String: kSecMatchLimitOne
36+ ]
2037
2138 var result: AnyObject?
2239 let status = SecItemCopyMatching(query as CFDictionary, &result)
@@ -30,82 +47,15 @@ enum KeychainService {
3047 @discardableResult
3148 static func deletePassword(for connectionID: UUID) -> Bool {
3249 let account = connectionID.uuidString
33− let query = baseQuery(for: connectionID)
50+ let query: [String: Any] = [
51+ kSecClass as String: kSecClassGenericPassword,
52+ kSecAttrService as String: service,
53+ kSecAttrAccount as String: account
54+ ]
3455 let status = SecItemDelete(query as CFDictionary)
3556 if status != errSecSuccess && status != errSecItemNotFound {
3657 logger.error("Failed to delete password for \(account): OSStatus \(status)")
3758 }
3859 return status == errSecSuccess || status == errSecItemNotFound
3960 }
40−
41− // MARK: - Generic data storage (string-keyed)
42−
43− @discardableResult
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] {
101− baseQuery(for: connectionID.uuidString)
102− }
103−
104− private static func baseQuery(for account: String) -> [String: Any] {
105− [
106− kSecClass as String: kSecClassGenericPassword,
107− kSecAttrService as String: service,
108− kSecAttrAccount as String: account
109− ]
110− }
11161}
ParseableViewer/Services/ParseableClient.swift+47−319
@@ -51,8 +51,6 @@ enum ParseableError: LocalizedError {
5151 return "Connection timed out. The server may be unreachable."
5252 case NSURLErrorSecureConnectionFailed, NSURLErrorServerCertificateUntrusted:
5353 return "SSL/TLS error. The server certificate may be invalid or untrusted."
54− case NSURLErrorCancelled:
55− return "Request cancelled"
5654 default:
5755 return "Network error: \(error.localizedDescription)"
5856 }
@@ -61,132 +59,27 @@ enum ParseableError: LocalizedError {
6159 }
6260}
6361
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−
14362final class ParseableClient: Sendable {
14463 let baseURL: URL
14564 let username: String
14665 let password: String
14766 private let session: URLSession
148− private let authHeader: String
149− private let cache = ResponseCache()
150−
151− // A fresh decoder per call. JSONDecoder.decode isn't documented as thread-safe,
152− // and these methods run concurrently (e.g. schema/stats/info fetched in parallel),
153− // so a single shared instance would be a data race. Constructing one is cheap.
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 {
67+
68+ private static let isoFormatter: ISO8601DateFormatter = {
16169 let f = ISO8601DateFormatter()
16270 f.formatOptions = [.withInternetDateTime, .withFractionalSeconds]
16371 return f
164− }
72+ }()
16573
16674 init(url: URL, username: String, password: String) {
16775 self.baseURL = url
16876 self.username = username
16977 self.password = password
17078
171− let credentials = "\(username):\(password)"
172− if let data = credentials.data(using: .utf8) {
173− self.authHeader = "Basic \(data.base64EncodedString())"
174− } else {
175− self.authHeader = "Basic "
176− }
177−
17879 let config = URLSessionConfiguration.default
17980 config.timeoutIntervalForRequest = 30
18081 config.timeoutIntervalForResource = 120
181− // Install a delegate that drops the Authorization header on cross-origin
182− // redirects so Basic credentials can't be forwarded to an unrelated host.
183− // The session retains the delegate until `finishTasksAndInvalidate()` in
184− // deinit releases it, so there's no leak.
185− self.session = URLSession(
186− configuration: config,
187− delegate: RedirectSanitizer(baseURL: url),
188− delegateQueue: nil
189− )
82+ self.session = URLSession(configuration: config)
19083 }
19184
19285 convenience init(connection: ServerConnection) throws {
@@ -202,19 +95,20 @@ final class ParseableClient: Sendable {
20295 session.finishTasksAndInvalidate()
20396 }
20497
205− 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 {
211− throw ParseableError.invalidURL
98+ private var authHeader: String {
99+ let credentials = "\(username):\(password)"
100+ guard let data = credentials.data(using: .utf8) else {
101+ return "Basic "
212102 }
213− components.percentEncodedPath += path
103+ return "Basic \(data.base64EncodedString())"
104+ }
105+
106+ private func buildRequest(method: String, path: String, body: Data? = nil, queryItems: [URLQueryItem]? = nil) throws -> URLRequest {
107+ var components = URLComponents(url: baseURL.appendingPathComponent(path), resolvingAgainstBaseURL: false)
214108 if let queryItems {
215− components.queryItems = queryItems
109+ components?.queryItems = queryItems
216110 }
217− guard let url = components.url else {
111+ guard let url = components?.url else {
218112 throw ParseableError.invalidURL
219113 }
220114
@@ -242,11 +136,7 @@ final class ParseableClient: Sendable {
242136 }
243137
244138 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) ?? "")
248− .trimmingCharacters(in: .whitespacesAndNewlines)
249− let message = raw.isEmpty ? "Unknown error" : String(raw.prefix(500))
139+ let message = String(data: data, encoding: .utf8) ?? "Unknown error"
250140 throw ParseableError.serverError(httpResponse.statusCode, message)
251141 }
252142
@@ -264,28 +154,21 @@ final class ParseableClient: Sendable {
264154 if httpResponse.statusCode == 401 {
265155 throw ParseableError.unauthorized
266156 }
267− // Accept any 2xx — a HEAD /liveness can legitimately return 204, etc.
268− guard (200...299).contains(httpResponse.statusCode) else {
157+ guard httpResponse.statusCode == 200 else {
269158 throw ParseableError.serverError(httpResponse.statusCode, "Health check failed")
270159 }
271160 }
272161
273162 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− }
278163 let data = try await performRequest(method: "GET", path: "/api/v1/about")
279− let result = try Self.jsonDecoder.decode(ServerAbout.self, from: data)
280− await cache.set(key, value: result)
281− return result
164+ return try JSONDecoder().decode(ServerAbout.self, from: data)
282165 }
283166
284167 // MARK: - Log Streams
285168
286169 func listStreams() async throws -> [LogStream] {
287170 let data = try await performRequest(method: "GET", path: "/api/v1/logstream")
288− return try Self.jsonDecoder.decode([LogStream].self, from: data)
171+ return try JSONDecoder().decode([LogStream].self, from: data)
289172 }
290173
291174 private static func encodePathComponent(_ name: String) throws -> String {
@@ -301,62 +184,38 @@ final class ParseableClient: Sendable {
301184 func createStream(name: String) async throws {
302185 let encoded = try Self.encodePathComponent(name)
303186 _ = 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()
307187 }
308188
309189 func deleteStream(name: String) async throws {
310190 let encoded = try Self.encodePathComponent(name)
311191 _ = 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()
314192 }
315193
316194 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− }
321195 let encoded = try Self.encodePathComponent(stream)
322196 let data = try await performRequest(method: "GET", path: "/api/v1/logstream/\(encoded)/schema")
323− let result = try Self.jsonDecoder.decode(StreamSchema.self, from: data)
324− await cache.set(key, value: result)
325− return result
197+ return try JSONDecoder().decode(StreamSchema.self, from: data)
326198 }
327199
328200 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− }
333201 let encoded = try Self.encodePathComponent(stream)
334202 let data = try await performRequest(method: "GET", path: "/api/v1/logstream/\(encoded)/stats")
335− let result = try Self.jsonDecoder.decode(StreamStats.self, from: data)
336− await cache.set(key, value: result)
337− return result
203+ return try JSONDecoder().decode(StreamStats.self, from: data)
338204 }
339205
340206 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− }
345207 let encoded = try Self.encodePathComponent(stream)
346208 let data = try await performRequest(method: "GET", path: "/api/v1/logstream/\(encoded)/info")
347− let result = try Self.jsonDecoder.decode(StreamInfo.self, from: data)
348− await cache.set(key, value: result)
349− return result
209+ return try JSONDecoder().decode(StreamInfo.self, from: data)
350210 }
351211
352212 // MARK: - Query
353213
354214 func query(sql: String, startTime: Date, endTime: Date) async throws -> [LogRecord] {
355− let formatter = Self.isoFormatter
356215 let body: [String: Any] = [
357216 "query": sql,
358− "startTime": formatter.string(from: startTime),
359− "endTime": formatter.string(from: endTime)
217+ "startTime": Self.isoFormatter.string(from: startTime),
218+ "endTime": Self.isoFormatter.string(from: endTime)
360219 ]
361220
362221 let bodyData = try JSONSerialization.data(withJSONObject: body)
@@ -366,138 +225,36 @@ final class ParseableClient: Sendable {
366225 return []
367226 }
368227
369− // Peek at the first meaningful byte to choose the decoder path:
370− // '{' → wrapped response, '[' → direct array. Skip a leading UTF-8 BOM
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)
228+ // Handle both response formats:
229+ // 1. Wrapped: {"records": [...], "fields": [...]}
230+ // 2. Direct array: [...]
231+ if let wrapped = try? JSONDecoder().decode(QueryResponse.self, from: responseData) {
232+ return wrapped.records
377233 }
378− let firstByte = sniff.first(where: { $0 != 0x20 && $0 != 0x0A && $0 != 0x0D && $0 != 0x09 })
379− // A body of only whitespace/BOM carries no records — treat it like an
380− // empty response rather than feeding it to the array decoder, which would
381− // throw a confusing "Unexpected query response format".
382− guard let firstByte else { return [] }
383− if firstByte == 0x7B { // '{'
384− do {
385− return try Self.jsonDecoder.decode(QueryResponse.self, from: responseData).records
386− } catch {
387− throw ParseableError.decodingError("Unexpected query response format")
388− }
389− } else {
390− do {
391− return try Self.jsonDecoder.decode([LogRecord].self, from: responseData)
392− } catch {
393− throw ParseableError.decodingError("Unexpected query response format")
394− }
234+ do {
235+ return try JSONDecoder().decode([LogRecord].self, from: responseData)
236+ } catch {
237+ throw ParseableError.decodingError("Unexpected query response format")
395238 }
396239 }
397240
398241 // MARK: - Alerts
399242
400243 func getAlerts(stream: String) async throws -> AlertConfig {
401− // Try the new API first. Fall back to the legacy per-stream endpoint on
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−
244+ // Try new API first; only fall back to legacy per-stream endpoint on 404
455245 do {
456− let (data, config) = try await modernAlerts()
457− // AlertConfig decodes leniently (every field via try?), so a 200 with
458− // an unexpected body yields an empty, non-throwing config rather than
459− // a DecodingError — indistinguishable from a server that genuinely has
460− // no alerts. Only fall back to the legacy per-stream endpoint when the
461− // body ISN'T a recognizable alerts container. Any top-level JSON array
462− // OR object is the modern endpoint answering — including an empty object
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
246+ let data = try await performRequest(method: "GET", path: "/api/v1/alerts")
247+ return try JSONDecoder().decode(AlertConfig.self, from: data)
248+ } catch let error as ParseableError {
249+ if case .serverError(let code, _) = error, code == 404 {
250+ // New endpoint not available, try legacy below
251+ } else {
252+ throw error
499253 }
500254 }
255+ let encoded = try Self.encodePathComponent(stream)
256+ let data = try await performRequest(method: "GET", path: "/api/v1/logstream/\(encoded)/alert")
257+ return try JSONDecoder().decode(AlertConfig.self, from: data)
501258 }
502259
503260 // MARK: - Retention
@@ -507,17 +264,11 @@ final class ParseableClient: Sendable {
507264 let data = try await performRequest(method: "GET", path: "/api/v1/logstream/\(encoded)/retention")
508265 if data.isEmpty { return [] }
509266 // Handle both array and single object response
510− if let array = try? Self.jsonDecoder.decode([RetentionConfig].self, from: data) {
267+ if let array = try? JSONDecoder().decode([RetentionConfig].self, from: data) {
511268 return array
512269 }
513270 do {
514− let single = try Self.jsonDecoder.decode(RetentionConfig.self, from: data)
515− // RetentionConfig decodes every field with `try?`, so any JSON object
516− // (including `{}` or an error body) decodes to an all-nil config. Treat
517− // an empty config as "no retention" rather than a phantom policy entry.
518− guard single.description != nil || single.duration != nil || single.action != nil else {
519− return []
520− }
271+ let single = try JSONDecoder().decode(RetentionConfig.self, from: data)
521272 return [single]
522273 } catch {
523274 throw ParseableError.decodingError("Unexpected retention response format")
@@ -528,29 +279,6 @@ final class ParseableClient: Sendable {
528279
529280 func listUsers() async throws -> [UserInfo] {
530281 let data = try await performRequest(method: "GET", path: "/api/v1/user")
531− return try Self.jsonDecoder.decode([UserInfo].self, from: data)
532− }
533−
534− // MARK: - Filters
535−
536− func listFilters() async throws -> [ParseableFilter] {
537− let data = try await performRequest(method: "GET", path: "/api/v1/filters")
538− if data.isEmpty { return [] }
539− return try Self.jsonDecoder.decode([ParseableFilter].self, from: data)
540− }
541−
542− func createFilter(_ filter: ParseableFilter) async throws -> ParseableFilter {
543− let body = try JSONEncoder().encode(filter)
544− let data = try await performRequest(method: "POST", path: "/api/v1/filters", body: body)
545− return try Self.jsonDecoder.decode(ParseableFilter.self, from: data)
546− }
547−
548− func deleteFilter(id: String) async throws {
549− let encoded = try Self.encodePathComponent(id)
550− _ = try await performRequest(method: "DELETE", path: "/api/v1/filters/\(encoded)")
551− }
552−
553− func invalidateCache() async {
554− await cache.invalidate()
282+ return try JSONDecoder().decode([UserInfo].self, from: data)
555283 }
556284}
ParseableViewer/ViewModels/LiveTailViewModel.swift+57−594
@@ -7,96 +7,39 @@ final class LiveTailViewModel {
77 var entries: [LiveTailEntry] = []
88 var isRunning = false
99 var isPaused = false
10− var filterText = "" {
11− didSet { rebuildFilteredEntries() }
12− }
13− var columnFilters: [ColumnFilter] = []
10+ var filterText = ""
1411 var pollInterval: TimeInterval = 2.0
1512 var maxEntries = 5000
1613 var errorMessage: String?
1714 var droppedCount = 0
1815 private(set) var lastPollTime: Date?
1916
20− // Column management
21− var columns: [String] = []
22− var columnOrder: [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?
37−
3817 // nonisolated(unsafe) so deinit can invalidate the timer.
3918 // Only mutated from @MainActor methods (start/stop).
4019 nonisolated(unsafe) private var timer: Timer?
4120 private var lastTimestamp: Date?
42− /// How many records with each content fingerprint have been delivered. A
43− /// count rather than a set: Parseable stamps every event of one ingest request
44− /// with the same millisecond `p_timestamp`, so genuinely repeated events (the
45− /// same log line emitted three times) fingerprint identically. A set collapsed
46− /// them into one row; counting keeps each occurrence while still suppressing
47− /// the re-fetch of the same rows by the next overlapping poll window.
48− private var seenFingerprints: [String: Int] = [:]
49− /// Insertion-ordered mirror of `seenFingerprints` keys (oldest first), used to
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> = []
21+ private var seenFingerprints: Set<String> = []
5922 private var consecutiveErrors = 0
6023 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
7024
7125 deinit {
72− // Timer.invalidate() must run on the run loop the timer was scheduled on
73− // (RunLoop.main, see start()). deinit is nonisolated and can run off the
74− // main thread, so hop to the main queue rather than invalidating inline.
75− // The scheduled timer only weakly references self, so deferring teardown
76− // past deallocation is safe — at worst it fires once more as a no-op
77− // before being invalidated. stop() remains the normal teardown path.
78− if let timer {
79− DispatchQueue.main.async { timer.invalidate() }
80− }
26+ timer?.invalidate()
8127 }
8228
83− // Cached formatters — nonisolated(unsafe) so static methods can use them from Task.detached
84− nonisolated(unsafe) private static let displayFormatter: DateFormatter = {
29+ // Cached formatters to avoid per-poll allocation
30+ private static let displayFormatter: DateFormatter = {
8531 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")
8932 f.dateFormat = "HH:mm:ss.SSS"
9033 return f
9134 }()
9235
93− nonisolated(unsafe) private static let isoFractional: ISO8601DateFormatter = {
36+ private static let isoFractional: ISO8601DateFormatter = {
9437 let f = ISO8601DateFormatter()
9538 f.formatOptions = [.withInternetDateTime, .withFractionalSeconds]
9639 return f
9740 }()
9841
99− nonisolated(unsafe) private static let isoBasic: ISO8601DateFormatter = {
42+ private static let isoBasic: ISO8601DateFormatter = {
10043 let f = ISO8601DateFormatter()
10144 f.formatOptions = [.withInternetDateTime]
10245 return f
@@ -108,99 +51,18 @@ final class LiveTailViewModel {
10851 let record: LogRecord
10952 let displayTimestamp: String
11053 let summary: String
111− let fingerprint: String
112− }
113−
114− struct ColumnFilter: Identifiable, Equatable, Sendable {
115− let id = UUID()
116− let column: String
117− let value: JSONValue?
118− let exclude: Bool
119−
120− var displayLabel: String {
121− let op = exclude ? "≠" : "="
122− let val = value?.displayString ?? "null"
123− return "\(column) \(op) \(val)"
124− }
125− }
126−
127− private(set) var cachedFilteredEntries: [LiveTailEntry] = []
128− var cachedFilteredRecords: [LogRecord] {
129− cachedFilteredEntries.map { $0.record }
130− }
131− private(set) var filteredEntriesGeneration: Int = 0
132−
133− private func rebuildFilteredEntries() {
134− let hasColumnFilters = !columnFilters.isEmpty
135− let hasTextFilter = !filterText.isEmpty
136− let activeFilters = columnFilters
137− let text = filterText
138−
139− let result: [LiveTailEntry]
140− if !hasColumnFilters && !hasTextFilter {
141− result = entries
142− } else {
143− result = entries.filter { entry in
144− if hasColumnFilters {
145− for filter in activeFilters {
146− let recordValue = entry.record[filter.column]
147− let matches: Bool
148− if filter.value == nil || filter.value == .null {
149− matches = recordValue == nil || recordValue == .null
150− } else {
151− matches = recordValue == filter.value
152− }
153− if filter.exclude ? matches : !matches {
154− return false
155− }
156− }
157− }
158− if hasTextFilter {
159− if !entry.summary.localizedCaseInsensitiveContains(text) &&
160− !entry.record.values.contains(where: { $0.displayString.localizedCaseInsensitiveContains(text) }) {
161− return false
162− }
163− }
164− return true
165− }
166− }
167−
168− cachedFilteredEntries = result
169− filteredEntriesGeneration += 1
17054 }
17155
172− func addColumnFilter(column: String, value: JSONValue?, exclude: Bool) {
173− // Remove any existing filter on the same column+value regardless of its
174− // exclude flag. Include (col = X) and exclude (col ≠ X) on the same value
175− // are mutually exclusive; without dropping the `exclude` match too, toggling
176− // include↔exclude stacks both and the AND'd filters match zero rows, so the
177− // tail silently empties.
178− columnFilters.removeAll { $0.column == column && $0.value == value }
179− if !exclude {
180− // Include filters are AND-combined in rebuildFilteredEntries, but a column
181− // can only equal one value, so two includes on the same column with
182− // different values (e.g. level = info then level = error) can never both
183− // match and the tail would silently empty. A new include therefore
184− // replaces any prior include on that column. (Excludes legitimately stack:
185− // col ≠ a AND col ≠ b is meaningful, so they are left untouched here.)
186− columnFilters.removeAll { $0.column == column && !$0.exclude }
56+ var filteredEntries: [LiveTailEntry] {
57+ guard !filterText.isEmpty else { return entries }
58+ return entries.filter { entry in
59+ entry.summary.localizedCaseInsensitiveContains(filterText) ||
60+ entry.record.values.contains { $0.displayString.localizedCaseInsensitiveContains(filterText) }
18761 }
188− columnFilters.append(ColumnFilter(column: column, value: value, exclude: exclude))
189− rebuildFilteredEntries()
190− }
191−
192− func removeColumnFilter(_ filter: ColumnFilter) {
193− columnFilters.removeAll { $0.id == filter.id }
194− rebuildFilteredEntries()
195− }
196−
197− func clearColumnFilters() {
198− columnFilters.removeAll()
199− rebuildFilteredEntries()
20062 }
20163
20264 var entryCount: Int { entries.count }
203− var displayedCount: Int { cachedFilteredEntries.count }
65+ var displayedCount: Int { filteredEntries.count }
20466
20567 func start(client: ParseableClient?, stream: String?) {
20668 guard let client, let stream, !isRunning else { return }
@@ -211,53 +73,25 @@ final class LiveTailViewModel {
21173 let storedMax = UserDefaults.standard.integer(forKey: "liveTailMaxEntries")
21274 if storedMax > 0 { maxEntries = storedMax }
21375
214− pollGeneration += 1
21576 isRunning = true
21677 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
22278 errorMessage = nil
22379 entries = []
224− seenFingerprints = [:]
225− seenFingerprintOrder = []
226− allKnownKeys = []
80+ seenFingerprints = []
22781 droppedCount = 0
22882 consecutiveErrors = 0
22983 lastPollTime = nil
23084 lastTimestamp = Date()
231− columns = []
232− columnOrder = []
233− hiddenColumns = []
234− savedHiddenColumns = []
235− savedColumnOrder = []
236− columnFilters = []
237− // Clear the text filter too. Leaving it set while clearing the column-filter
238− // chips is inconsistent and would let a stale search from a previous stream
239− // make the fresh tail come up appearing empty or partially filtered.
240− filterText = ""
241− currentStream = stream
242− rebuildFilteredEntries()
243−
244− // Install the timer in `.common` modes so polling keeps firing while the
245− // main run loop is in event-tracking mode (scrolling, dragging a column,
246− // an open menu/popover). A `.default`-only timer — what
247− // `scheduledTimer(withTimeInterval:)` installs — silently pauses during
248− // those interactions, freezing the "live" tail until the user lets go.
249− let pollTimer = Timer(timeInterval: pollInterval, repeats: true) { [weak self] _ in
85+
86+ timer = Timer.scheduledTimer(withTimeInterval: pollInterval, repeats: true) { [weak self] _ in
25087 Task { @MainActor [weak self] in
25188 guard let self, !self.isPaused else { return }
25289 await self.poll(client: client, stream: stream)
25390 }
25491 }
255− RunLoop.main.add(pollTimer, forMode: .common)
256− timer = pollTimer
25792 }
25893
25994 func stop() {
260− pollGeneration += 1
26195 timer?.invalidate()
26296 timer = nil
26397 isRunning = false
@@ -269,203 +103,12 @@ final class LiveTailViewModel {
269103 }
270104
271105 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
277106 entries = []
278− // Keep the dedup fingerprints (unlike start(), which begins a new session):
279− // every poll looks back at least 90 s regardless of `lastTimestamp` (see
280− // `safeStart` in poll()), so the records just cleared are re-fetched by the
281− // next poll. With an emptied fingerprint set they all passed dedup again and
282− // the tail refilled with exactly what the user cleared. The set stays
283− // bounded by the eviction in poll().
284− lastTimestamp = Date()
285− allKnownKeys = []
107+ seenFingerprints = []
286108 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 = []
295− columnOrder = []
296− hiddenColumns = []
297− savedHiddenColumns = []
298− savedColumnOrder = []
299− columnFilters = []
300− // Match start(): clear the text filter alongside the column-filter chips so
301− // a clear()ed tail isn't immediately re-hidden by a lingering search string.
302− filterText = ""
303− rebuildFilteredEntries()
304− }
305−
306− // MARK: - Column Management
307−
308− var visibleColumns: [String] {
309− columnOrder.filter { !hiddenColumns.contains($0) }
310− }
311−
312− func toggleColumnVisibility(_ column: String) {
313− if hiddenColumns.contains(column) {
314− hiddenColumns.remove(column)
315− savedHiddenColumns.remove(column)
316− } else {
317− let visibleCount = columnOrder.count - hiddenColumns.count
318− if visibleCount > 1 {
319− hiddenColumns.insert(column)
320− savedHiddenColumns.insert(column)
321− }
322− }
323− saveColumnConfig()
324− }
325−
326− func showAllColumns() {
327− hiddenColumns.removeAll()
328− savedHiddenColumns.removeAll()
329− saveColumnConfig()
330− }
331−
332− func moveColumn(from source: IndexSet, to destination: Int) {
333− columnOrder.move(fromOffsets: source, toOffset: destination)
334− saveColumnConfig()
335− }
336−
337− func moveColumn(_ column: String, to targetColumn: String) {
338− guard let fromIndex = columnOrder.firstIndex(of: column),
339− let toIndex = columnOrder.firstIndex(of: targetColumn),
340− fromIndex != toIndex else { return }
341− let item = columnOrder.remove(at: fromIndex)
342− // Match the drop-indicator semantics: a rightward move (fromIndex < toIndex)
343− // lands the column *after* the target, a leftward move lands it *before*.
344− // After removing an earlier element the target shifts down by one, so
345− // inserting at `toIndex` lands after the target for rightward moves; for
346− // leftward moves the target is unshifted and `toIndex` lands before it.
347− let adjusted = toIndex
348− columnOrder.insert(item, at: adjusted)
349− saveColumnConfig()
350− }
351−
352− func resetColumnConfig() {
353− columnOrder = columns
354− savedColumnOrder = []
355− hiddenColumns.removeAll()
356− savedHiddenColumns.removeAll()
357− saveColumnConfig()
358− }
359−
360− // MARK: - Column Configuration Persistence
361−
362− private static func columnConfigKey(for stream: String) -> String {
363− "parseable_livetail_column_config_\(stream)"
364− }
365−
366− private func saveColumnConfig() {
367− 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)
378− if let data = try? JSONEncoder().encode(config) {
379− UserDefaults.standard.set(data, forKey: Self.columnConfigKey(for: stream))
380− }
381− }
382−
383− private static func loadColumnConfig(for stream: String) -> QueryViewModel.ColumnConfiguration? {
384− guard let data = UserDefaults.standard.data(forKey: columnConfigKey(for: stream)) else { return nil }
385− return try? JSONDecoder().decode(QueryViewModel.ColumnConfiguration.self, from: data)
386− }
387−
388− // MARK: - Column Extraction
389−
390− private func extractColumnsFromKeys(_ keys: Set<String>) -> [String] {
391− let priorityFields = ["p_timestamp", "p_tags", "p_metadata", "level", "severity", "message", "msg"]
392− var remaining = keys
393− var ordered: [String] = []
394− for field in priorityFields {
395− if remaining.remove(field) != nil {
396− ordered.append(field)
397− }
398− }
399− ordered.append(contentsOf: remaining.sorted())
400− return ordered
401− }
402−
403− private func updateColumns(stream: String) {
404− let extracted = extractColumnsFromKeys(allKnownKeys)
405−
406− if columns.isEmpty {
407− // First time — apply saved config
408− columns = extracted
409− currentStream = stream
410−
411− if let config = Self.loadColumnConfig(for: stream) {
412− let extractedSet = Set(extracted)
413− var merged = config.order.filter { extractedSet.contains($0) }
414− let mergedSet = Set(merged)
415− for col in extracted where !mergedSet.contains(col) {
416− merged.append(col)
417− }
418− columnOrder = merged
419− savedColumnOrder = config.order
420− savedHiddenColumns = config.hidden
421− hiddenColumns = config.hidden.intersection(extractedSet)
422− } else {
423− columnOrder = extracted
424− savedColumnOrder = []
425− savedHiddenColumns = []
426− hiddenColumns = []
427− }
428− } else {
429− // Merge: keep existing order, append any new columns
430− let existingSet = Set(columnOrder)
431− var newCols = [String]()
432− for col in extracted where !existingSet.contains(col) {
433− newCols.append(col)
434− }
435− if !newCols.isEmpty {
436− columns.append(contentsOf: newCols)
437− columnOrder = QueryViewModel.mergedColumnOrder(columnOrder, inserting: newCols, reference: savedColumnOrder)
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− }
442− }
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− }
453109 }
454110
455111 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−
469112 let now = Date()
470113 // The Parseable server truncates query time-ranges to minute
471114 // boundaries, so a narrow window (< 60 s) can collapse to a
@@ -475,59 +118,27 @@ final class LiveTailViewModel {
475118 let idealStart = lastTimestamp ?? now.addingTimeInterval(-30)
476119 let safeStart = now.addingTimeInterval(-90)
477120 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"
121+ let sql = "SELECT * FROM \(QueryViewModel.escapeSQLIdentifier(stream)) ORDER BY p_timestamp DESC LIMIT 200"
483122
484123 do {
485124 let records = try await client.query(sql: sql, startTime: queryStart, endTime: now)
486125
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
126+ var newEntries: [LiveTailEntry] = []
517127
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 }
128+ for record in records {
129+ let fp = Self.fingerprint(for: record)
130+ guard !seenFingerprints.contains(fp) else { continue }
131+ seenFingerprints.insert(fp)
521132
522− // Authoritative dedup on main actor
523− var newEntries: [LiveTailEntry] = []
524− for candidate in candidateEntries
525− where candidate.occurrence > (seenFingerprints[candidate.entry.fingerprint] ?? 0) {
526− newEntries.append(candidate.entry)
527− }
528− for (fp, count) in batchCounts where count > (seenFingerprints[fp] ?? 0) {
529− if seenFingerprints[fp] == nil { seenFingerprintOrder.append(fp) }
530− seenFingerprints[fp] = count
133+ let timestamp = parseTimestamp(from: record) ?? now
134+ let summary = buildSummary(from: record)
135+
136+ newEntries.append(LiveTailEntry(
137+ timestamp: timestamp,
138+ record: record,
139+ displayTimestamp: Self.displayFormatter.string(from: timestamp),
140+ summary: summary
141+ ))
531142 }
532143
533144 if !newEntries.isEmpty {
@@ -536,57 +147,12 @@ final class LiveTailViewModel {
536147 if entries.count > maxEntries {
537148 let excess = entries.count - maxEntries
538149 droppedCount += excess
539− // Entries are not globally time-sorted across polls (a late or
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− }
150+ entries.removeFirst(excess)
553151
554− // Bound the seen-set on every poll, not only when the buffer
555− // overflows: clear() keeps fingerprints while emptying `entries`,
556− // so the set can outgrow the cap without the buffer ever filling.
557− if seenFingerprints.count > maxSeenFingerprints {
558− // Evict the OLDEST surplus fingerprints by insertion order,
559− // not by what's currently buffered. Collapsing to only the
560− // buffered set would drop recently-evicted fingerprints that
561− // still fall inside the next poll's ~90 s look-back window,
562− // re-admitting those records as duplicates. Keeping the
563− // most-recently-seen tail preserves a full window of evicted
564− // fingerprints. Buffered fingerprints are always retained so
565− // a re-fetch of a buffered record stays deduplicated.
566− let overflow = seenFingerprints.count - maxSeenFingerprints
567− let buffered = Set(entries.map { $0.fingerprint })
568− let toDrop = Set(seenFingerprintOrder.prefix(overflow))
569− .subtracting(buffered)
570− if !toDrop.isEmpty {
571− for fp in toDrop { seenFingerprints.removeValue(forKey: fp) }
572− seenFingerprintOrder.removeAll { toDrop.contains($0) }
573− }
152+ // Rebuild fingerprint set from remaining entries to bound memory
153+ // and ensure dropped entries can be re-detected if they reappear.
154+ seenFingerprints = Set(entries.map { Self.fingerprint(for: $0.record) })
574155 }
575−
576− // Only scan new records for new keys
577− var hasNewKeys = false
578− for entry in newEntries {
579− for key in entry.record.keys {
580− if allKnownKeys.insert(key).inserted {
581− hasNewKeys = true
582− }
583− }
584− }
585− if hasNewKeys {
586− updateColumns(stream: stream)
587− }
588−
589− rebuildFilteredEntries()
590156 }
591157
592158 lastTimestamp = now
@@ -594,10 +160,6 @@ final class LiveTailViewModel {
594160 errorMessage = nil
595161 consecutiveErrors = 0
596162 } 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 }
601163 consecutiveErrors += 1
602164 if consecutiveErrors >= Self.maxConsecutiveErrors {
603165 errorMessage = "\(ParseableError.userFriendlyMessage(for: error)) — stopped after \(consecutiveErrors) consecutive failures"
@@ -608,140 +170,43 @@ final class LiveTailViewModel {
608170 }
609171 }
610172
611− /// Deterministic content-based fingerprint using XOR with golden-ratio mixing.
612− /// Order-independent: O(k) instead of O(k log k) per record.
613− nonisolated static func fingerprint(for record: LogRecord) -> String {
614− var xorAccum0: UInt64 = 0
615− var xorAccum1: UInt64 = 0
616− for (key, value) in record {
617− var kh0: UInt64 = 14695981039346656037
618− var kh1: UInt64 = 14695981039346656037 &* 31
173+ /// Deterministic content-based fingerprint using sorted key-value pairs and FNV-1a.
174+ static func fingerprint(for record: LogRecord) -> String {
175+ var h0: UInt64 = 14695981039346656037
176+ var h1: UInt64 = 14695981039346656037 &* 31
177+ for key in record.keys.sorted() {
619178 for byte in key.utf8 {
620− kh0 = (kh0 ^ UInt64(byte)) &* 1099511628211
621− kh1 = (kh1 ^ UInt64(byte)) &* 6700417
622− }
623− kh0 = (kh0 ^ 0) &* 1099511628211 // separator
624− hashJSONValue(value, h0: &kh0, h1: &kh1)
625− kh1 = (kh1 ^ 0xFF) &* 6700417 // field separator
626− xorAccum0 ^= kh0 &* 0x9E3779B97F4A7C15
627− xorAccum1 ^= kh1 &* 0x9E3779B97F4A7C15
628− }
629− let h0 = 14695981039346656037 &+ xorAccum0
630− let h1 = (14695981039346656037 &* 31) &+ xorAccum1
631− return String(h0, radix: 36) + String(h1, radix: 36)
632− }
633−
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.
648− /// Uses type-discriminator bytes to prevent cross-type collisions.
649− nonisolated private static func hashJSONValue(_ value: JSONValue, h0: inout UInt64, h1: inout UInt64) {
650− switch value {
651− case .null:
652− h0 = (h0 ^ 0x00) &* 1099511628211
653− h1 = (h1 ^ 0x00) &* 6700417
654− case .bool(let b):
655− h0 = (h0 ^ 0x01) &* 1099511628211
656− h1 = (h1 ^ 0x01) &* 6700417
657− let byte: UInt64 = b ? 1 : 0
658− h0 = (h0 ^ byte) &* 1099511628211
659− 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):
666− // If the integer round-trips losslessly through Double, hash via the
667− // Double path so `.int(5)` and `.double(5.0)` (which are ==) share a
668− // fingerprint. Integers beyond 2^53 lose precision in Double, so two
669− // distinct values (e.g. 2^53 and 2^53+1) would otherwise collide and
670− // the second would be silently dropped as a phantom duplicate — the
671− // dedup set has no `==` fallback. No Double can equal such a
672− // non-representable integer, so hashing its exact 64-bit pattern can't
673− // wrongly merge it with a `.double`.
674− if let exact = Int(exactly: Double(i)), exact == i {
675− hashNumberBits(Double(i).bitPattern, h0: &h0, h1: &h1)
676− } else {
677− hashNumberBits(UInt64(bitPattern: Int64(i)), h0: &h0, h1: &h1)
678− }
679− case .double(let d):
680− // Canonicalize NaN (so all NaNs fold together) AND signed zero: JSONValue
681− // equality treats `-0.0 == 0.0`, but their raw bit patterns differ, which
682− // would otherwise let an identical record slip past dedup as a phantom row.
683− let canonical: Double = d.isNaN ? .nan : (d == 0 ? 0.0 : d)
684− hashNumberBits(canonical.bitPattern, h0: &h0, h1: &h1)
685− case .string(let s):
686− h0 = (h0 ^ 0x04) &* 1099511628211
687− h1 = (h1 ^ 0x04) &* 6700417
688− for byte in s.utf8 {
689179 h0 = (h0 ^ UInt64(byte)) &* 1099511628211
690180 h1 = (h1 ^ UInt64(byte)) &* 6700417
691181 }
692− case .array(let arr):
693− h0 = (h0 ^ 0x05) &* 1099511628211
694− h1 = (h1 ^ 0x05) &* 6700417
695− for element in arr {
696− hashJSONValue(element, h0: &h0, h1: &h1)
697− h0 = (h0 ^ 0xFE) &* 1099511628211 // element separator
698− h1 = (h1 ^ 0xFE) &* 6700417
699− }
700− case .object(let dict):
701− h0 = (h0 ^ 0x06) &* 1099511628211
702− h1 = (h1 ^ 0x06) &* 6700417
703− var xor0: UInt64 = 0
704− var xor1: UInt64 = 0
705− for (key, value) in dict {
706− var kh0: UInt64 = 14695981039346656037
707− var kh1: UInt64 = 14695981039346656037 &* 31
708− for byte in key.utf8 {
709− kh0 = (kh0 ^ UInt64(byte)) &* 1099511628211
710− kh1 = (kh1 ^ UInt64(byte)) &* 6700417
182+ h0 = (h0 ^ 0) &* 1099511628211 // separator
183+ if let val = record[key] {
184+ // Use exportString so nested objects/arrays contribute their
185+ // actual content to the hash, not just their field count.
186+ for byte in val.exportString.utf8 {
187+ h0 = (h0 ^ UInt64(byte)) &* 1099511628211
188+ h1 = (h1 ^ UInt64(byte)) &* 6700417
711189 }
712− kh0 = (kh0 ^ 0xFD) &* 1099511628211 // key-value separator
713− hashJSONValue(value, h0: &kh0, h1: &kh1)
714− xor0 ^= kh0 &* 0x9E3779B97F4A7C15
715− xor1 ^= kh1 &* 0x9E3779B97F4A7C15
716190 }
717− h0 = h0 &+ xor0
718− h1 = h1 &+ xor1
191+ h1 = (h1 ^ 0xFF) &* 6700417 // field separator
719192 }
193+ return String(h0, radix: 36) + String(h1, radix: 36)
720194 }
721195
722− nonisolated static func parseTimestamp(from record: LogRecord) -> Date? {
196+ func parseTimestamp(from record: LogRecord) -> Date? {
723197 guard let value = record["p_timestamp"] ?? record["timestamp"] ?? record["time"] ?? record["@timestamp"] else {
724198 return nil
725199 }
726200 if case .string(let str) = value {
727− if let date = isoFractional.date(from: str) ?? isoBasic.date(from: str) {
201+ if let date = Self.isoFractional.date(from: str) {
728202 return date
729203 }
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)
204+ return Self.isoBasic.date(from: str)
740205 }
741206 return nil
742207 }
743208
744− nonisolated static func buildSummary(from record: LogRecord) -> String {
209+ func buildSummary(from record: LogRecord) -> String {
745210 var parts: [String] = []
746211
747212 if let level = record["level"] ?? record["severity"] ?? record["log_level"] {
@@ -753,16 +218,14 @@ final class LiveTailViewModel {
753218 }
754219
755220 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.
759221 let scalarFields = record
760222 .filter { $0.key != "p_timestamp" && $0.key != "p_tags" && $0.key != "p_metadata" }
761− .filter { $0.value.isScalar }
762223 .sorted { $0.key < $1.key }
763224 .prefix(3)
764225 for (key, value) in scalarFields {
765− parts.append("\(key)=\(value.displayString)")
226+ if value.isScalar {
227+ parts.append("\(key)=\(value.displayString)")
228+ }
766229 }
767230 }
768231
ParseableViewer/ViewModels/QueryViewModel.swift+35−742
@@ -2,120 +2,24 @@ import Foundation
22import SwiftUI
33
44@Observable
5−@MainActor
65final class QueryViewModel {
7− 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− }
6+ var sqlQuery = ""
157 var results: [LogRecord] = [] {
16− didSet {
17− // Build search texts asynchronously
18− searchTextTask?.cancel()
19− cachedSearchTexts = []
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: " ")
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− }
52− }
8+ didSet { updateFilteredResults() }
539 }
54− 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
5910 var columns: [String] = []
60− var columnOrder: [String] = []
61− var hiddenColumns: Set<String> = []
62− /// The full persisted set of hidden columns for the current stream, including
63− /// ones absent from the current result. Hiding a column drops it from the
64− /// SELECT list, so it's missing from the very next result and `hiddenColumns`
65− /// (only the columns present) forgets it — the next save would then erase the
66− /// preference. This keeps the intent, mirroring LiveTailViewModel.
67− private(set) var savedHiddenColumns: Set<String> = []
68− /// The persisted column order including columns absent from the current
69− /// result (the order counterpart of `savedHiddenColumns`), so saving doesn't
70− /// drop a hidden column's position and it returns to its place when shown.
71− private var savedColumnOrder: [String] = []
72− var autoHiddenColumns: Set<String> = []
73− /// Column config from a saved query, applied on next query execution.
74− var pendingColumnConfig: ColumnConfiguration?
75−
76− /// The connection and stream the editor text and results belong to. The view
77− /// model outlives QueryView (switching tabs keeps the query), so the view
78− /// compares this on appear to catch a stream/server change made while another
79− /// tab was showing.
80− struct BoundContext: Equatable {
81− let connectionID: UUID?
82− let stream: String
83− }
84− var boundContext: BoundContext?
85− private var currentStream: String?
8611 var isLoading = false
8712 var errorMessage: String?
88− var errorRange: NSRange?
8913 var resultCount = 0
9014 var queryDuration: TimeInterval?
9115 var selectedLogEntry: LogRecord?
9216 var filterText = "" {
93− didSet { debouncedUpdateFilter() }
17+ didSet { updateFilteredResults() }
9418 }
95− private var filterTask: Task<Void, Never>?
9619 var resultsTruncated = false
9720
98− // Schema fields for autocomplete
99− 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−
10921 // Query task for cancellation
11022 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
11923
12024 // Time range
12125 var timeRangeOption: TimeRangeOption = .last1Hour
@@ -124,10 +28,7 @@ final class QueryViewModel {
12428
12529 // Query history
12630 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 }
31+ var historyIsFull = false
13132 private static let maxHistory = 50
13233
13334 struct QueryHistoryEntry: Identifiable, Codable, Sendable {
@@ -207,295 +108,15 @@ final class QueryViewModel {
207108 return timeRangeOption.dateRange().end
208109 }
209110
210− var visibleColumns: [String] {
211− let allHidden = hiddenColumns.union(autoHiddenColumns)
212− return columnOrder.filter { !allHidden.contains($0) }
213− }
214−
215− /// Whether any column is hidden — including saved-hidden columns that aren't
216− /// in the current result, which "Show All" can bring back.
217− var hasHiddenColumns: Bool {
218− !hiddenColumns.isEmpty || !autoHiddenColumns.isEmpty || !savedHiddenColumns.isEmpty
219− }
220−
221− func toggleColumnVisibility(_ column: String) {
222− if hiddenColumns.contains(column) {
223− hiddenColumns.remove(column)
224− savedHiddenColumns.remove(column)
225− } else if autoHiddenColumns.contains(column) {
226− autoHiddenColumns.remove(column)
227− } else {
228− // Don't allow hiding all columns
229− let allHidden = hiddenColumns.union(autoHiddenColumns)
230− let visibleCount = columnOrder.count - allHidden.count
231− if visibleCount > 1 {
232− hiddenColumns.insert(column)
233− savedHiddenColumns.insert(column)
234− }
235− }
236− saveColumnConfig()
237− updateSQLColumns()
238− }
239−
240− func showAllColumns() {
241− hiddenColumns.removeAll()
242− savedHiddenColumns.removeAll()
243− autoHiddenColumns.removeAll()
244− saveColumnConfig()
245− updateSQLColumns()
246− }
247−
248− func moveColumn(from source: IndexSet, to destination: Int) {
249− columnOrder.move(fromOffsets: source, toOffset: destination)
250− saveColumnConfig()
251− updateSQLColumns()
252− }
253−
254− func moveColumn(_ column: String, to targetColumn: String) {
255− guard let fromIndex = columnOrder.firstIndex(of: column),
256− let toIndex = columnOrder.firstIndex(of: targetColumn),
257− fromIndex != toIndex else { return }
258− let item = columnOrder.remove(at: fromIndex)
259− // Match the drop-indicator semantics: a rightward move (fromIndex < toIndex)
260− // lands the column *after* the target, a leftward move lands it *before*.
261− // After removing an earlier element the target shifts down by one, so
262− // inserting at `toIndex` lands after the target for rightward moves; for
263− // leftward moves the target is unshifted and `toIndex` lands before it.
264− let adjusted = toIndex
265− columnOrder.insert(item, at: adjusted)
266− saveColumnConfig()
267− updateSQLColumns()
268− }
269−
270− func resetColumnConfig() {
271− columnOrder = columns
272− savedColumnOrder = []
273− hiddenColumns.removeAll()
274− savedHiddenColumns.removeAll()
275− autoHiddenColumns.removeAll()
276− saveColumnConfig()
277− updateSQLColumns()
278− }
279−
280− /// Rewrites the SELECT clause of `sqlQuery` to reflect the user's column
281− /// choices (order and hidden columns; auto-hidden columns stay display-only).
282− func updateSQLColumns() {
283− guard let range = SQLTokenizer.selectColumnListRange(in: sqlQuery),
284− let currentList = Self.parseColumnList(sqlQuery[range]) else { return }
285−
286− // Project the user's choices only. `autoHiddenColumns` is a display
287− // heuristic for the current rows (the field is empty in all of them);
288− // baking it into the SQL would stop the field from ever being fetched
289− // again, even for a time range where it has values.
290− let visible = columnOrder.filter { !hiddenColumns.contains($0) }
291− guard !visible.isEmpty else { return }
292−
293− // Columns an explicit list names that aren't in the results: Parseable
294− // omits all-null fields from query JSON, so a sparse listed field can be
295− // missing from `columns`. Keep it (unless the user hid it) rather than
296− // silently dropping it from the SELECT on the next rewrite.
297− var listedButAbsent: [String] = []
298− var excludesHiddenColumn = false
299− if case .identifiers(let names) = currentList {
300− let present = Set(columns)
301− listedButAbsent = names.filter { !present.contains($0) && !savedHiddenColumns.contains($0) }
302− // A column the user hid is absent from these results precisely
303− // because this list was narrowed to exclude it.
304− excludesHiddenColumn = !savedHiddenColumns.isSubset(of: present)
305− }
306−
307− let newColumnList: String
308− // Use `*` whenever every column is visible, regardless of display order —
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 = "*"
315− } else {
316− newColumnList = (visible + listedButAbsent).map { Self.escapeSQLIdentifier($0) }.joined(separator: ", ")
317− }
318−
319− sqlQuery.replaceSubrange(range, with: newColumnList)
320− }
321−
322− enum ColumnListShape: Equatable {
323− case star
324− /// Plain column references, as the column names they resolve to.
325− case identifiers([String])
326− }
327−
328− /// Parses a SELECT list the column manager can regenerate without changing
329− /// what the query computes: `*`, or a comma-separated list of plain column
330− /// references. Anything else — aggregates, expressions, aliases
331− /// (`count(*) AS n`), qualified names — returns nil: rewriting it with `*` or
332− /// bare result keys would break the query, so the column manager then only
333− /// hides/reorders client-side.
334− nonisolated static func parseColumnList(_ list: Substring) -> ColumnListShape? {
335− let tokens = SQLTokenizer.tokenize(String(list)).filter { !$0.kind.isTrivia }
336− if tokens.count == 1, tokens[0].kind == .star { return .star }
337− guard tokens.count % 2 == 1 else { return nil }
338− var names: [String] = []
339− for (index, token) in tokens.enumerated() {
340− if index % 2 == 1 {
341− guard token.kind == .comma else { return nil }
342− continue
343− }
344− switch token.kind {
345− // DataFusion folds unquoted identifiers to lowercase.
346− case .identifier(let name): names.append(name.lowercased())
347− case .quotedIdentifier(let name): names.append(name)
348− default: return nil
349− }
350− }
351− return .identifiers(names)
352− }
353−
354− /// Inserts `newColumns` into `order`, placing each one that appears in
355− /// `reference` (a saved order) right after its nearest saved predecessor
356− /// that's already placed — or first if none is — and appending the rest.
357− /// Used to keep the positions of columns that are absent for a while.
358− nonisolated static func mergedColumnOrder(
359− _ order: [String], inserting newColumns: [String], reference: [String]
360− ) -> [String] {
361− var result = order
362− let referenceIndex = Dictionary(reference.enumerated().map { ($1, $0) }, uniquingKeysWith: { first, _ in first })
363− let pending = newColumns.filter { referenceIndex[$0] == nil }
364− let positioned = newColumns
365− .filter { referenceIndex[$0] != nil }
366− .sorted { referenceIndex[$0]! < referenceIndex[$1]! }
367− for column in positioned where !result.contains(column) {
368− let savedIndex = referenceIndex[column]!
369− let predecessor = reference[..<savedIndex].reversed().first { result.contains($0) }
370− if let predecessor, let position = result.firstIndex(of: predecessor) {
371− result.insert(column, at: position + 1)
372− } else {
373− result.insert(column, at: 0)
374− }
375− }
376− for column in pending where !result.contains(column) {
377− result.append(column)
378− }
379− return result
380− }
381−
382− // MARK: - Column Configuration Persistence
383−
384− struct ColumnConfiguration: Codable {
385− var order: [String]
386− var hidden: Set<String>
387− }
388−
389− private static func columnConfigKey(for stream: String) -> String {
390− "parseable_column_config_\(stream)"
391− }
392−
393− private func saveColumnConfig() {
394− guard let stream = currentStream else { return }
395− // Persist the full intended hidden set and order, not just the currently
396− // present columns (see `savedHiddenColumns` / `savedColumnOrder`).
397− let present = Set(columnOrder)
398− let order = Self.mergedColumnOrder(
399− columnOrder,
400− inserting: savedColumnOrder.filter { !present.contains($0) },
401− reference: savedColumnOrder
402− )
403− savedColumnOrder = order
404− let config = ColumnConfiguration(order: order, hidden: savedHiddenColumns.union(hiddenColumns))
405− if let data = try? JSONEncoder().encode(config) {
406− UserDefaults.standard.set(data, forKey: Self.columnConfigKey(for: stream))
407− }
408− }
409−
410− static func loadColumnConfig(for stream: String) -> ColumnConfiguration? {
411− guard let data = UserDefaults.standard.data(forKey: columnConfigKey(for: stream)) else { return nil }
412− return try? JSONDecoder().decode(ColumnConfiguration.self, from: data)
413− }
414−
415− func applyColumnConfig(extractedColumns: [String], stream: String?) {
416− columns = extractedColumns
417− currentStream = stream
418−
419− // Use pending config (from a saved query) if available,
420− // otherwise fall back to the per-stream default.
421− let config: ColumnConfiguration?
422− if let pending = pendingColumnConfig {
423− config = pending
424− pendingColumnConfig = nil
425− } else if let stream {
426− config = Self.loadColumnConfig(for: stream)
427− } else {
428− config = nil
429− }
430−
431− guard let config else {
432− columnOrder = extractedColumns
433− savedColumnOrder = []
434− hiddenColumns = []
435− savedHiddenColumns = []
436− return
437− }
438−
439− // Merge saved order with actual columns: keep saved order for columns
440− // that still exist, then append any new columns at the end
441− let extractedSet = Set(extractedColumns)
442− var merged = config.order.filter { extractedSet.contains($0) }
443− let mergedSet = Set(merged)
444− for col in extractedColumns where !mergedSet.contains(col) {
445− merged.append(col)
446− }
447− columnOrder = merged
448− savedColumnOrder = config.order
449− savedHiddenColumns = config.hidden
450− hiddenColumns = config.hidden.intersection(extractedSet)
451−
452− // The `visibleCount > 1` guard in toggleColumnVisibility only covers
453− // user toggles. A saved config can hide every currently-extracted column
454− // (e.g. the stream's schema shrank so only previously-hidden columns
455− // remain), which would render the results table with zero columns and
456− // leave `updateSQLColumns` early-returning on the empty case. Keep at
457− // least the first column visible. Mirrors LiveTailViewModel.updateColumns.
458− if !columnOrder.isEmpty, hiddenColumns.count >= columnOrder.count,
459− let firstColumn = columnOrder.first {
460− hiddenColumns.remove(firstColumn)
461− }
462− }
463−
464111 private(set) var filteredResults: [LogRecord] = []
465112
466− private func debouncedUpdateFilter() {
467− filterTask?.cancel()
468− if filterText.isEmpty {
469− updateFilteredResults()
470− return
471− }
472− filterTask = Task {
473− try? await Task.sleep(for: .milliseconds(150))
474− guard !Task.isCancelled else { return }
475− updateFilteredResults()
476− }
477− }
478−
479113 private func updateFilteredResults() {
480114 if filterText.isEmpty {
481115 filteredResults = results
482116 } else {
483− 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
491− 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)
117+ filteredResults = results.filter { record in
118+ record.values.contains { value in
119+ value.displayString.localizedCaseInsensitiveContains(filterText)
499120 }
500121 }
501122 }
@@ -540,115 +161,43 @@ final class QueryViewModel {
540161
541162 // Cancel any in-flight query
542163 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
550164
551165 isLoading = true
552166 errorMessage = nil
553− errorRange = nil
554167 resultsTruncated = false
555168 let startTime = CFAbsoluteTimeGetCurrent()
556169
557170 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− }
568171 do {
569172 try Task.checkCancellation()
570173 let queryResults = try await client.query(sql: sql, startTime: startDate, endTime: endDate)
571174 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 }
580175
581176 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
586177 queryDuration = CFAbsoluteTimeGetCurrent() - startTime
587178 resultCount = results.count
588179
589− // Detect if results were likely truncated. We know the cap for
590− // auto-generated queries; for user-entered SQL (which includes the
591− // default query `setDefaultQuery` writes into the editor — the common
592− // stream-selection path), parse a trailing `LIMIT n` so the indicator
593− // still fires when the row count lands exactly on the limit.
594− let effectiveLimit = usedAutoLimit ? limit : Self.trailingLimit(in: sql)
595− resultsTruncated = effectiveLimit.map { results.count == $0 } ?? false
180+ // Detect if results were likely truncated (only meaningful
181+ // for auto-generated queries where we control the LIMIT)
182+ resultsTruncated = usedAutoLimit && results.count == limit
596183
597− // Extract columns in a single pass, with priority fields first,
598− // then apply any saved column configuration for this stream
599− let extracted = extractColumns(from: results)
600− applyColumnConfig(extractedColumns: extracted, stream: stream)
601−
602− // Auto-hide columns that have no values in any row.
603− // Clear first so stale state from a previous query doesn't
604− // interfere with the visible-count check.
605− autoHiddenColumns = []
606− let empty = emptyColumns(in: results, columns: columnOrder)
607− let candidates = empty.subtracting(hiddenColumns)
608− let visibleCount = columnOrder.count - hiddenColumns.count
609− // Ensure at least one column remains visible
610− if candidates.count < visibleCount {
611− autoHiddenColumns = candidates
612− }
184+ // Extract columns in a single pass, with priority fields first
185+ columns = extractColumns(from: results)
613186
614187 // Record in history
615188 let entry = QueryHistoryEntry(sql: sql, resultCount: resultCount, duration: queryDuration ?? 0)
616189 addToHistory(entry)
617190 } catch is CancellationError {
618− // Silently ignore — cancelQuery() or a new executeQuery() handles state
619− return
191+ errorMessage = "Query cancelled"
192+ results = []
193+ columns = []
620194 } catch {
621− guard !Task.isCancelled else { return }
622− // Same supersession guard as the success path: a stale failed
623− // query must not clobber a newer query's state.
624− guard latestQueryStream == stream else { return }
625195 errorMessage = ParseableError.userFriendlyMessage(for: error)
626− if let serverError = error as? ParseableError,
627− case .serverError(_, let msg) = serverError,
628− let pos = SQLErrorPosition.parse(from: msg),
629− sqlQuery == sql {
630− // The server's line/column is relative to the *submitted* SQL.
631− // Only map it back into the editor when the editor still holds
632− // that same text — if the user edited the query while it was in
633− // flight, the position no longer corresponds to what's displayed
634− // and would underline an unrelated span. (`sqlQuery`'s didSet
635− // already cleared errorRange on that edit, so leaving it nil here
636− // keeps the editor unmarked.)
637− errorRange = SQLTokenizer.errorHighlightRange(
638− line: pos.line, column: pos.column, in: sql
639− )
640− }
641196 results = []
642197 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 = []
651198 }
199+
200+ isLoading = false
652201 }
653202 queryTask = task
654203 await task.value
@@ -660,28 +209,6 @@ final class QueryViewModel {
660209 queryTask = nil
661210 isLoading = false
662211 errorMessage = "Query cancelled"
663− errorRange = nil
664− }
665−
666− @MainActor
667− func loadSchema(client: ParseableClient?, stream: String?) async {
668− latestSchemaStream = stream
669− guard let client, let stream else {
670− schemaFields = []
671− return
672− }
673− do {
674− 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
680− } catch {
681− guard latestSchemaStream == stream else { return }
682− // Schema load is best-effort; don't surface errors
683− schemaFields = []
684− }
685212 }
686213
687214 /// Single-pass column extraction with priority ordering.
@@ -702,83 +229,39 @@ final class QueryViewModel {
702229 return ordered
703230 }
704231
705− /// Returns the set of columns that have no meaningful value in any record.
706− /// A value is considered empty if it is missing (nil), `.null`, or `.string("")`.
707− func emptyColumns(in records: [LogRecord], columns: [String]) -> Set<String> {
708− var empty = Set(columns)
709− for record in records {
710− 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) {
714− if let value = record[column], value != .null, value != .string("") {
715− empty.remove(column)
716− }
717− }
718− }
719− return empty
720− }
721−
722− /// Exports the on-screen rows as JSON. Uses `filteredResults` (not the full
723− /// `results`) so the output matches what the user sees with an active result
724− /// filter, mirroring the view's export path.
725232 func exportAsJSON() -> String {
726− let rows = filteredResults
727− guard !rows.isEmpty else { return "[]" }
233+ guard !results.isEmpty else { return "[]" }
728234 let encoder = JSONEncoder()
729235 encoder.outputFormatting = [.prettyPrinted, .sortedKeys]
730− if let data = try? encoder.encode(Self.projectRecords(rows, to: visibleColumns)),
236+ if let data = try? encoder.encode(results),
731237 let string = String(data: data, encoding: .utf8) {
732238 return string
733239 }
734240 return "[]"
735241 }
736242
737− /// Exports the on-screen rows as CSV. Uses `filteredResults` for the same
738− /// reason as `exportAsJSON`.
739243 func exportAsCSV() -> String {
740− Self.buildCSV(records: filteredResults, 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) } }
244+ Self.buildCSV(records: results, columns: columns)
752245 }
753246
754247 /// Builds a CSV string from records and columns. Safe to call from any thread.
755− nonisolated static func buildCSV(records: [LogRecord], columns: [String]) -> String {
248+ static func buildCSV(records: [LogRecord], columns: [String]) -> String {
756249 guard !records.isEmpty, !columns.isEmpty else { return "" }
757250
758− var csv = String()
759− csv.reserveCapacity(columns.count * 50 * (records.count + 1))
760−
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")
251+ var csv = columns.map { escapeCSV($0) }.joined(separator: ",") + "\n"
767252
768− // Rows
769253 for record in records {
770− for (i, column) in columns.enumerated() {
771− if i > 0 { csv.append(",") }
254+ let row = columns.map { column in
772255 let value = record[column]?.exportString ?? ""
773− csv.append(escapeCSV(value))
256+ return escapeCSV(value)
774257 }
775− csv.append("\n")
258+ csv += row.joined(separator: ",") + "\n"
776259 }
777260
778261 return csv
779262 }
780263
781− nonisolated private static func escapeCSV(_ value: String) -> String {
264+ private static func escapeCSV(_ value: String) -> String {
782265 // Check at the Unicode scalar level to correctly detect \r and \n
783266 // inside \r\n (CRLF) grapheme clusters, which Swift's String.contains
784267 // treats as a single Character that doesn't match "\r" or "\n" alone.
@@ -792,197 +275,24 @@ final class QueryViewModel {
792275 }
793276
794277 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−
812278 results = []
813279 columns = []
814− columnOrder = []
815− hiddenColumns = []
816− savedHiddenColumns = []
817− savedColumnOrder = []
818− autoHiddenColumns = []
819− currentStream = nil
820280 resultCount = 0
821281 queryDuration = nil
822282 selectedLogEntry = nil
823283 errorMessage = nil
824− errorRange = nil
825284 resultsTruncated = false
826− schemaFields = []
827− // Keep the stream trackers consistent with the cleared schema/results so
828− // a later reload guard never sees a non-nil stream paired with empty data.
829− latestSchemaStream = nil
830− latestQueryStream = nil
831− boundContext = nil
832285 }
833286
834− /// Modifies `sqlQuery` to add a column-value filter condition.
835− /// When `exclude` is true, the condition excludes the value; otherwise it includes only matching rows.
836− func addColumnFilter(column: String, value: JSONValue?, exclude: Bool) {
837− let escapedCol = Self.escapeSQLIdentifier(column)
838− let condition: String
839− if let value, value != .null {
840− let literal = value.sqlLiteral
841− // Exclude with `IS DISTINCT FROM`, not `<>`: under SQL's three-valued
842− // logic `NULL <> 'x'` is NULL, so `<>` also dropped every row where the
843− // column is missing/NULL — common for sparse fields — which "exclude
844− // this value" doesn't ask for (the live-tail exclude keeps them too).
845− condition = exclude
846− ? "\(escapedCol) IS DISTINCT FROM \(literal)"
847− : "\(escapedCol) = \(literal)"
848− } else {
849− condition = exclude ? "\(escapedCol) IS NOT NULL" : "\(escapedCol) IS NULL"
850− }
851−
852− let sql = sqlQuery.trimmingCharacters(in: .whitespacesAndNewlines)
853− if sql.isEmpty { return }
854−
855− // Locate the top-level WHERE and the first trailing clause using the
856− // tokenizer rather than a raw regex, so keywords inside quoted
857− // identifiers, string literals, comments, or subqueries are ignored.
858− let tokens = SQLTokenizer.tokenize(sql)
859− var depth = 0
860− // WHERE and the trailing clauses only follow the top-level FROM. Keywords
861− // before it belong to the SELECT list — e.g. a field named `offset` or
862− // `limit` (which the tokenizer reports as keywords) — and must not be
863− // mistaken for clause starts.
864− var fromSeen = false
865− var whereIndex: Int?
866− var tailIndex: Int?
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 {
960− // An empty WHERE body (e.g. a half-written `... WHERE` with no
961− // condition yet, or `WHERE` immediately followed by ORDER/GROUP/
962− // LIMIT) would otherwise produce `WHERE AND <condition>` — a
963− // dangling AND with no left operand. Drop the AND in that case.
964− let whereEnd = tokens[whereIndex].range.upperBound
965− sqlQuery = "\(sql[..<whereEnd]) \(condition)\(separated(sql[whereEnd...]))"
966− }
967− } else if let tailIndex {
968− let tailStart = tokens[tailIndex].range.lowerBound
969− sqlQuery = "\(sql[..<tailStart])WHERE \(condition) \(sql[tailStart...])"
970− } else {
971− let statementEnd = tokens[lastSignificant].range.upperBound
972− sqlQuery = "\(sql[..<statementEnd]) WHERE \(condition)\(separated(sql[statementEnd...]))"
973− }
974− }
975−
976− /// Sets the default query for the given stream, returning `true` if the
977− /// query text was replaced (i.e. the user hadn't customized it).
978− @discardableResult
979− func setDefaultQuery(stream: String, previousStream: String? = nil) -> Bool {
980− // Always replace when switching streams — the previous query (whether
981− // auto-generated or from a saved filter) belongs to the old stream.
982− // Only preserve the query when no stream change occurred (e.g. onAppear).
287+ func setDefaultQuery(stream: String, previousStream: String? = nil) {
288+ // Replace the query if it's empty or still matches the auto-generated
289+ // default for the previous stream. If the user edited the SQL, keep it.
983290 let shouldReplace: Bool
984− if sqlQuery.isEmpty || previousStream != nil {
291+ if sqlQuery.isEmpty {
985292 shouldReplace = true
293+ } else if let prev = previousStream {
294+ let prevPrefix = "SELECT * FROM \(Self.escapeSQLIdentifier(prev))"
295+ shouldReplace = sqlQuery.hasPrefix(prevPrefix)
986296 } else {
987297 shouldReplace = false
988298 }
@@ -990,39 +300,22 @@ final class QueryViewModel {
990300 if shouldReplace {
991301 sqlQuery = "SELECT * FROM \(Self.escapeSQLIdentifier(stream)) ORDER BY p_timestamp DESC LIMIT \(queryLimit)"
992302 }
993− return shouldReplace
994303 }
995304
996305 /// Escapes a SQL identifier by doubling internal double-quotes, then wrapping in double-quotes.
997− nonisolated static func escapeSQLIdentifier(_ identifier: String) -> String {
306+ static func escapeSQLIdentifier(_ identifier: String) -> String {
998307 "\"\(identifier.replacingOccurrences(of: "\"", with: "\"\""))\""
999308 }
1000309
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−
1014310 // MARK: - Query History
1015311
1016312 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()
1021313 // Deduplicate consecutive identical queries
1022314 if queryHistory.first?.sql == entry.sql { return }
1023315 queryHistory.insert(entry, at: 0)
1024316 if queryHistory.count > Self.maxHistory {
1025317 queryHistory = Array(queryHistory.prefix(Self.maxHistory))
318+ historyIsFull = true
1026319 }
1027320 Self.saveHistory(queryHistory)
1028321 }
ParseableViewer/Views/AboutView.swiftdeleted−36
@@ -1,36 +0,0 @@
1−import SwiftUI
2−
3−struct AboutView: View {
4− private let appVersion = Bundle.main.infoDictionary?["CFBundleShortVersionString"] as? String ?? "–"
5− private let buildNumber = Bundle.main.infoDictionary?["CFBundleVersion"] as? String ?? "–"
6− private let repoURL = URL(string: "https://github.com/ilyakooo0/parseable-macos")!
7− private let parseableURL = URL(string: "https://www.parseable.com")!
8−
9− var body: some View {
10− VStack(spacing: 16) {
11− Image(nsImage: NSApp.applicationIconImage)
12− .resizable()
13− .frame(width: 96, height: 96)
14−
15− Text("Parseable Viewer")
16− .font(.title)
17− .fontWeight(.bold)
18−
19− Text("Version \(appVersion) (\(buildNumber))")
20− .font(.callout)
21− .foregroundStyle(.secondary)
22−
23− Text("A native macOS log viewer for Parseable.")
24− .font(.body)
25− .multilineTextAlignment(.center)
26−
27− HStack(spacing: 16) {
28− Link("GitHub", destination: repoURL)
29− Link("parseable.com", destination: parseableURL)
30− }
31− .font(.callout)
32− }
33− .padding(32)
34− .frame(width: 320)
35− }
36−}
ParseableViewer/Views/AlertsView.swift+8−99
@@ -5,10 +5,6 @@ struct AlertsView: View {
55 @State private var alertConfig: AlertConfig?
66 @State private var isLoading = false
77 @State private var errorMessage: String?
8− /// Bumped by every load. SwiftUI cancels the previous `.task(id:)` load on a
9− /// stream switch, but that load still resumes (with a cancellation error) —
10− /// and must not touch `isLoading`, which now belongs to the newer load.
11− @State private var loadGeneration = 0
128
139 var body: some View {
1410 Group {
@@ -67,7 +63,7 @@ struct AlertsView: View {
6763 .frame(maxWidth: .infinity, maxHeight: .infinity)
6864 }
6965 }
70− .task(id: "\(appState.activeConnection?.id.uuidString ?? "")|\(stream)") {
66+ .task(id: stream) {
7167 await loadAlerts(stream: stream)
7268 }
7369 } else {
@@ -80,31 +76,13 @@ struct AlertsView: View {
8076
8177 private func loadAlerts(stream: String) async {
8278 guard let client = appState.client else { return }
83− let connID = appState.activeConnection?.id
84− loadGeneration &+= 1
85− let generation = loadGeneration
8679 isLoading = true
8780 errorMessage = nil
8881 alertConfig = nil
8982
9083 do {
91− let config = try await client.getAlerts(stream: stream)
92− // Superseded by a newer load: leave its spinner and state alone.
93− guard generation == loadGeneration else { return }
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
84+ alertConfig = try await client.getAlerts(stream: stream)
10485 } catch {
105− guard generation == loadGeneration else { return }
106− guard stream == appState.selectedStream,
107− connID == appState.activeConnection?.id else { isLoading = false; return }
10886 errorMessage = ParseableError.userFriendlyMessage(for: error)
10987 }
11088
@@ -119,28 +97,9 @@ struct AlertRowView: View {
11997 VStack(alignment: .leading, spacing: 6) {
12098 HStack {
12199 Image(systemName: "bell.fill")
122− .foregroundStyle(stateColor)
123− Text(alert.displayName)
100+ .foregroundStyle(.orange)
101+ Text(alert.name)
124102 .fontWeight(.medium)
125− Spacer()
126− if let severity = alert.severity {
127− Text(severity)
128− .font(.caption2)
129− .padding(.horizontal, 6)
130− .padding(.vertical, 2)
131− .background(severityColor.opacity(0.15))
132− .foregroundStyle(severityColor)
133− .clipShape(RoundedRectangle(cornerRadius: 4))
134− }
135− if let state = alert.state {
136− Text(state)
137− .font(.caption2)
138− .padding(.horizontal, 6)
139− .padding(.vertical, 2)
140− .background(stateColor.opacity(0.15))
141− .foregroundStyle(stateColor)
142− .clipShape(RoundedRectangle(cornerRadius: 4))
143− }
144103 }
145104
146105 if let message = alert.message {
@@ -149,40 +108,14 @@ struct AlertRowView: View {
149108 .foregroundStyle(.secondary)
150109 }
151110
152− HStack(spacing: 8) {
153− if let alertType = alert.alertType {
154− Label(alertType, systemImage: "gearshape")
155− .font(.caption2)
156− .padding(.horizontal, 6)
157− .padding(.vertical, 2)
158− .background(Color.blue.opacity(0.1))
159− .clipShape(RoundedRectangle(cornerRadius: 4))
160− }
161−
162− if let rule = alert.rule, let type = rule.type {
163− Label(type, systemImage: "gearshape")
164− .font(.caption2)
165− .padding(.horizontal, 6)
166− .padding(.vertical, 2)
167− .background(Color.blue.opacity(0.1))
168− .clipShape(RoundedRectangle(cornerRadius: 4))
169− }
170−
171− if let datasets = alert.datasets, !datasets.isEmpty {
172− Label(datasets.joined(separator: ", "), systemImage: "cylinder")
173− .font(.caption2)
174− .foregroundStyle(.secondary)
175− }
176− }
177−
178− if let tags = alert.tags, !tags.isEmpty {
111+ if let rule = alert.rule {
179112 HStack {
180− ForEach(tags, id: \.self) { tag in
181− Text(tag)
113+ if let type = rule.type {
114+ Label(type, systemImage: "gearshape")
182115 .font(.caption2)
183116 .padding(.horizontal, 6)
184117 .padding(.vertical, 2)
185− .background(Color.purple.opacity(0.1))
118+ .background(Color.blue.opacity(0.1))
186119 .clipShape(RoundedRectangle(cornerRadius: 4))
187120 }
188121 }
@@ -208,28 +141,4 @@ struct AlertRowView: View {
208141 }
209142 .padding(.vertical, 4)
210143 }
211−
212− private var stateColor: Color {
213− switch alert.state?.lowercased() {
214− case "triggered": return .red
215− case "disabled", "silenced": return .gray
216− // Parseable's default, healthy state (and 2.x's "resolved") — not a warning.
217− case "not-triggered", "resolved": return .green
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− }
224− }
225−
226− private var severityColor: Color {
227− switch alert.severity?.lowercased() {
228− case "critical": return .red
229− case "high": return .orange
230− case "medium": return .yellow
231− case "low": return .blue
232− default: return .secondary
233− }
234− }
235144}
ParseableViewer/Views/ConnectionSheet.swift+13−74
@@ -14,14 +14,6 @@ struct ConnectionSheet: View {
1414 @State private var isSaving = false
1515 @State private var testResult: TestResult?
1616 @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
2517
2618 enum TestResult {
2719 case success(String)
@@ -47,15 +39,12 @@ struct ConnectionSheet: View {
4739 TextField("Name:", text: $name)
4840 .textFieldStyle(.roundedBorder)
4941 .accessibilityLabel("Connection name")
50− .onChange(of: name) { _, _ in invalidateTestResult() }
5142
5243 TextField("Server URL:", text: $url)
5344 .textFieldStyle(.roundedBorder)
5445 .accessibilityLabel("Server URL")
5546 .onChange(of: url) { _, newValue in
5647 urlValidationError = Self.validateURL(newValue)
57− // A prior test result no longer reflects the edited fields.
58− invalidateTestResult()
5948 }
6049
6150 if let urlError = urlValidationError, !url.isEmpty {
@@ -67,12 +56,10 @@ struct ConnectionSheet: View {
6756 TextField("Username:", text: $username)
6857 .textFieldStyle(.roundedBorder)
6958 .accessibilityLabel("Username")
70− .onChange(of: username) { _, _ in invalidateTestResult() }
7159
7260 SecureField("Password:", text: $password)
7361 .textFieldStyle(.roundedBorder)
7462 .accessibilityLabel("Password")
75− .onChange(of: password) { _, _ in invalidateTestResult() }
7663 }
7764 .formStyle(.grouped)
7865 .padding()
@@ -119,22 +106,10 @@ struct ConnectionSheet: View {
119106 Button("Test Connection") {
120107 testConnection()
121108 }
122− .disabled(
123− url.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty
124− || username.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty
125− || isTesting
126− || urlValidationError != nil
127− )
109+ .disabled(url.isEmpty || username.isEmpty || isTesting || urlValidationError != nil)
128110 .accessibilityLabel("Test connection")
129111
130112 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− }
138113 password = ""
139114 dismiss()
140115 }
@@ -144,13 +119,7 @@ struct ConnectionSheet: View {
144119 saveAndConnect()
145120 }
146121 .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
153− )
122+ .disabled(name.isEmpty || url.isEmpty || username.isEmpty || isSaving || urlValidationError != nil)
154123 }
155124 .padding()
156125 }
@@ -162,47 +131,26 @@ struct ConnectionSheet: View {
162131 username = connection.username
163132 password = connection.password
164133 }
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)
169134 }
170135 }
171136
172− private func invalidateTestResult() {
173− testGeneration += 1
174− testResult = nil
175− }
176−
177137 private func testConnection() {
178138 isTesting = true
179139 testResult = nil
180− testGeneration += 1
181− let generation = testGeneration
182140
183141 Task {
184142 do {
185− let conn = ServerConnection(
186− name: name.trimmingCharacters(in: .whitespacesAndNewlines),
187− url: url.trimmingCharacters(in: .whitespacesAndNewlines),
188− username: username.trimmingCharacters(in: .whitespacesAndNewlines),
189− password: password
190− )
143+ let conn = ServerConnection(name: name, url: url, username: username, password: password)
191144 let client = try ParseableClient(connection: conn)
192145 try await client.checkHealth()
193− // The health endpoint doesn't check credentials; see
194− // AppState.verifyCredentials.
195− try await AppState.verifyCredentials(with: client)
196146 await MainActor.run {
197− isTesting = false
198− guard generation == testGeneration else { return }
199147 testResult = .success("Connection successful")
148+ isTesting = false
200149 }
201150 } catch {
202151 await MainActor.run {
203− isTesting = false
204− guard generation == testGeneration else { return }
205152 testResult = .failure(ParseableError.userFriendlyMessage(for: error))
153+ isTesting = false
206154 }
207155 }
208156 }
@@ -211,36 +159,27 @@ struct ConnectionSheet: View {
211159 private func saveAndConnect() {
212160 guard !isSaving else { return }
213161
214− let trimmedName = name.trimmingCharacters(in: .whitespacesAndNewlines)
215− let trimmedURL = url.trimmingCharacters(in: .whitespacesAndNewlines)
216− let trimmedUsername = username.trimmingCharacters(in: .whitespacesAndNewlines)
217−
218162 let conn: ServerConnection
219− if let existing = connection ?? createdConnection {
220− conn = ServerConnection(id: existing.id, name: trimmedName, url: trimmedURL, username: trimmedUsername, password: password)
163+ if let existing = connection {
164+ conn = ServerConnection(id: existing.id, name: name, url: url, username: username, password: password)
221165 appState.updateConnection(conn)
222166 } else {
223− conn = ServerConnection(name: trimmedName, url: trimmedURL, username: trimmedUsername, password: password)
167+ conn = ServerConnection(name: name, url: url, username: username, password: password)
224168 appState.addConnection(conn)
225− createdConnection = conn
226169 }
227170
228171 isSaving = true
229172 testResult = nil
230173
231174 Task {
232− let success = await appState.connect(to: conn)
175+ await appState.connect(to: conn)
233176 await MainActor.run {
234177 isSaving = false
235− if success {
178+ if appState.errorMessage != nil {
179+ testResult = .failure(appState.errorMessage ?? "Connection failed")
180+ } else {
236181 password = ""
237182 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
244183 }
245184 }
246185 }
@@ -251,7 +190,7 @@ struct ConnectionSheet: View {
251190 guard !trimmed.isEmpty else { return nil }
252191
253192 var normalized = trimmed
254− if !ServerConnection.hasURLScheme(normalized) {
193+ if !normalized.hasPrefix("http://") && !normalized.hasPrefix("https://") {
255194 normalized = "https://\(normalized)"
256195 }
257196
ParseableViewer/Views/ContentView.swift+39−75
@@ -10,7 +10,21 @@ struct ContentView: View {
1010 SidebarView()
1111 } detail: {
1212 if appState.isConnected {
13− MainContentView()
13+ if appState.selectedStream != nil {
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+ }
1428 } else {
1529 WelcomeView()
1630 }
@@ -19,10 +33,7 @@ struct ContentView: View {
1933 ConnectionSheet(connection: appState.editingConnection)
2034 }
2135 .alert("Error", isPresented: $appState.showError) {
22− Button("OK") {
23− appState.showError = false
24− appState.errorMessage = nil
25− }
36+ Button("OK") { appState.showError = false }
2637 } message: {
2738 Text(appState.errorMessage ?? "An unknown error occurred")
2839 }
@@ -31,25 +42,13 @@ struct ContentView: View {
3142
3243struct MainContentView: View {
3344 @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− }
4645
4746 var body: some View {
4847 @Bindable var appState = appState
4948
5049 VStack(spacing: 0) {
5150 // Tab bar
52− HStack(spacing: 4) {
51+ HStack(spacing: 0) {
5352 ForEach(AppState.AppTab.allCases) { tab in
5453 TabButton(
5554 title: tab.rawValue,
@@ -61,69 +60,34 @@ struct MainContentView: View {
6160 }
6261 Spacer()
6362 }
64− .padding(.horizontal, 10)
65− .padding(.vertical, 4)
63+ .padding(.horizontal, 8)
64+ .padding(.top, 4)
6665 .background(.bar)
6766
6867 Divider()
6968
70− // Content — only the active tab is instantiated. Stream-scoped tabs
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 {
77− case .query:
78− QueryView(viewModel: queryViewModel)
79− .id(AppState.AppTab.query)
80− case .liveTail:
81− LiveTailView()
82− .id(AppState.AppTab.liveTail)
83− case .streamInfo:
84− StreamDetailView()
85− .id(AppState.AppTab.streamInfo)
86− case .alerts:
87− AlertsView()
88− .id(AppState.AppTab.alerts)
89− case .users:
90− UsersView()
91− .id(AppState.AppTab.users)
92− case .serverInfo:
93− ServerInfoView()
94− .id(AppState.AppTab.serverInfo)
95− }
69+ // Content — only the active tab is instantiated
70+ switch appState.currentTab {
71+ case .query:
72+ QueryView()
73+ .id(AppState.AppTab.query)
74+ case .liveTail:
75+ LiveTailView()
76+ .id(AppState.AppTab.liveTail)
77+ case .streamInfo:
78+ StreamDetailView()
79+ .id(AppState.AppTab.streamInfo)
80+ case .alerts:
81+ AlertsView()
82+ .id(AppState.AppTab.alerts)
83+ case .users:
84+ UsersView()
85+ .id(AppState.AppTab.users)
86+ case .serverInfo:
87+ ServerInfoView()
88+ .id(AppState.AppTab.serverInfo)
9689 }
9790 }
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)
12791 }
12892}
12993
ParseableViewer/Views/LiveTailView.swift+44−369
@@ -3,23 +3,9 @@ import SwiftUI
33struct LiveTailView: View {
44 @Environment(AppState.self) private var appState
55 @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 selectedEntry: LiveTailViewModel.LiveTailEntry?
107 @State private var autoScroll = true
118 @State private var pulseOpacity: Double = 1.0
12− @State private var showColumnPopover = false
13− @State private var wrapText = false
14− @State private var sortColumn: String?
15− @State private var sortAscending = false
16− @State private var columnWidths: [String: CGFloat] = [:]
17− @State private var cachedSorted: [LiveTailViewModel.LiveTailEntry] = []
18− @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>?
239
2410 var body: some View {
2511 @Bindable var vm = viewModel
@@ -80,53 +66,16 @@ struct LiveTailView: View {
8066 .accessibilityLabel("Filter live tail entries")
8167 }
8268
83− Button {
84− wrapText.toggle()
85− } label: {
86− Image(systemName: wrapText ? "text.word.spacing" : "text.line.first.and.arrowtriangle.forward")
87− }
88− .help(wrapText ? "Disable text wrapping" : "Enable text wrapping")
89− .accessibilityLabel(wrapText ? "Disable text wrapping" : "Enable text wrapping")
90−
91− if !viewModel.columns.isEmpty {
92− Button {
93− showColumnPopover.toggle()
94− } label: {
95− HStack(spacing: 4) {
96− Image(systemName: "eye")
97− Text("Columns")
98− .font(.caption)
99− if !viewModel.hiddenColumns.isEmpty {
100− Text("(\(viewModel.visibleColumns.count)/\(viewModel.columns.count))")
101− .font(.caption)
102− .foregroundStyle(.secondary)
103− }
104− }
105− }
106− .accessibilityLabel("Manage column visibility and order")
107− .help("Show, hide, and reorder columns")
108− .popover(isPresented: $showColumnPopover, arrowEdge: .bottom) {
109− LiveTailColumnManagerView(viewModel: viewModel)
110− }
111− }
112−
11369 Toggle("Auto-scroll", isOn: $autoScroll)
11470 .toggleStyle(.checkbox)
11571
11672 Button {
11773 viewModel.clear()
118− selectedEntryID = nil
119− sortColumn = nil
120− columnWidths = [:]
121− cachedSorted = []
12274 } label: {
12375 Image(systemName: "trash")
12476 }
12577 .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)
78+ .keyboardShortcut(.delete, modifiers: .command)
13079 .accessibilityLabel("Clear all entries")
13180 }
13281 .padding(8)
@@ -139,21 +88,10 @@ struct LiveTailView: View {
13988 Circle()
14089 .fill(viewModel.isPaused ? .yellow : .green)
14190 .frame(width: 8, height: 8)
142− .opacity(pulseOpacity)
91+ .opacity(viewModel.isPaused ? 1.0 : pulseOpacity)
14392 .accessibilityLabel(viewModel.isPaused ? "Status: Paused" : "Status: Streaming")
144− // Drive the pulse with an explicit, re-triggerable animation.
145− // The previous one-shot `pulseOpacity = 0.3` only changed the
146− // value once, so the implicit `animation(value:)` never
147− // restarted after a pause/resume and the dot froze. Pausing
148− // settles the dot to solid; resuming restarts the pulse.
149− .onAppear { startPulsing() }
150− .onChange(of: viewModel.isPaused) { _, paused in
151− if paused {
152− withAnimation(.easeInOut(duration: 0.3)) { pulseOpacity = 1.0 }
153− } else {
154− startPulsing()
155− }
156− }
93+ .animation(.easeInOut(duration: 1.0).repeatForever(autoreverses: true), value: pulseOpacity)
94+ .onAppear { pulseOpacity = 0.3 }
15795 .onDisappear { pulseOpacity = 1.0 }
15896 Text(viewModel.isPaused ? "Paused" : "Streaming")
15997 .font(.caption)
@@ -182,7 +120,7 @@ struct LiveTailView: View {
182120 .font(.caption)
183121 .foregroundStyle(.secondary)
184122
185− if (!viewModel.filterText.isEmpty || !viewModel.columnFilters.isEmpty) && viewModel.displayedCount != viewModel.entryCount {
123+ if !viewModel.filterText.isEmpty && viewModel.displayedCount != viewModel.entryCount {
186124 Text("(of \(viewModel.entryCount) total)")
187125 .font(.caption)
188126 .foregroundStyle(.secondary)
@@ -194,335 +132,72 @@ struct LiveTailView: View {
194132
195133 Divider()
196134
197− // Active column filters
198− if !viewModel.columnFilters.isEmpty {
199− HStack(spacing: 4) {
200− Image(systemName: "line.3.horizontal.decrease.circle.fill")
201− .foregroundStyle(.secondary)
202− .font(.caption)
203−
204− ScrollView(.horizontal, showsIndicators: false) {
205− HStack(spacing: 4) {
206− ForEach(viewModel.columnFilters) { filter in
207− HStack(spacing: 2) {
208− Text(filter.displayLabel)
209− .font(.caption)
210− .lineLimit(1)
211− Button {
212− viewModel.removeColumnFilter(filter)
213− } label: {
214− Image(systemName: "xmark")
215− .font(.system(size: 8, weight: .bold))
216− }
217− .buttonStyle(.plain)
218− }
219− .padding(.horizontal, 6)
220− .padding(.vertical, 2)
221− .background(
222− RoundedRectangle(cornerRadius: 4)
223− .fill(filter.exclude
224− ? Color.red.opacity(0.15)
225− : Color.accentColor.opacity(0.15))
226− )
227− }
228− }
229− }
230−
231− Spacer()
232−
233− Button("Clear All") {
234− viewModel.clearColumnFilters()
135+ // Entries list
136+ ScrollViewReader { proxy in
137+ List(viewModel.filteredEntries, selection: Binding(
138+ get: { selectedEntry?.id },
139+ set: { id in
140+ selectedEntry = viewModel.filteredEntries.first { $0.id == id }
235141 }
236− .font(.caption)
237− .buttonStyle(.plain)
238− .foregroundStyle(.secondary)
142+ )) { entry in
143+ LiveTailEntryRow(entry: entry)
144+ .id(entry.id)
145+ .tag(entry.id)
239146 }
240− .padding(.horizontal, 8)
241− .padding(.vertical, 4)
242−
243− Divider()
244− }
245−
246− // Column table or empty state
247− if viewModel.visibleColumns.isEmpty {
248− VStack(spacing: 8) {
249− if !viewModel.isRunning {
250− Image(systemName: "antenna.radiowaves.left.and.right")
251− .font(.system(size: 32))
252− .foregroundStyle(.secondary)
253− Text("Start live tail to stream log entries")
254− .foregroundStyle(.secondary)
255− } else {
256− ProgressView()
257− .controlSize(.small)
258− Text("Waiting for entries...")
259− .foregroundStyle(.secondary)
147+ .listStyle(.plain)
148+ .font(.system(.caption, design: .monospaced))
149+ .onChange(of: viewModel.entries.count) { _, _ in
150+ guard autoScroll else { return }
151+ // When a filter is active, scroll to the last filtered entry;
152+ // when no filter, scroll to the absolute last entry.
153+ let target = viewModel.filterText.isEmpty
154+ ? viewModel.entries.last
155+ : viewModel.filteredEntries.last
156+ if let target {
157+ proxy.scrollTo(target.id, anchor: .bottom)
260158 }
261159 }
262− .frame(maxWidth: .infinity, maxHeight: .infinity)
263− } else {
264− liveTailTable
265160 }
266161 }
267− .frame(minWidth: 400)
268162
269163 // Detail pane
270− if let id = selectedEntryID,
271− let entry = cachedSorted.first(where: { $0.id == id }) {
164+ if let entry = selectedEntry {
272165 LogDetailView(record: entry.record)
273166 .frame(minWidth: 280, idealWidth: 320)
274167 }
275168 }
276169 .onDisappear {
277170 viewModel.stop()
278− liveTailSortTask?.cancel()
279171 }
280172 .onChange(of: appState.selectedStream) { _, _ in
281173 if viewModel.isRunning {
282174 viewModel.stop()
283175 }
284176 viewModel.clear()
285− selectedEntryID = nil
286− sortColumn = nil
287− columnWidths = [:]
288− cachedSorted = []
289− }
290− .onChange(of: viewModel.visibleColumns) { _, newColumns in
291− // Hiding the sort column leaves no header to show or change the sort,
292− // while rows stay ordered by the invisible value (and auto-scroll stays
293− // off). Drop it like LogTableView does; the sortColumn change rebuilds.
294− if let sortColumn, !newColumns.contains(sortColumn) {
295− self.sortColumn = nil
296− return
297− }
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− }
312− .onAppear {
313− rebuildSortedEntries()
314− }
315− }
316−
317− /// Starts (or restarts) the streaming-status pulse. Resetting `pulseOpacity`
318− /// to 1.0 before animating to 0.3 guarantees a value delta so the repeating
319− /// animation re-triggers even when called after a pause/resume cycle.
320− private func startPulsing() {
321− pulseOpacity = 1.0
322− withAnimation(.easeInOut(duration: 1.0).repeatForever(autoreverses: true)) {
323− pulseOpacity = 0.3
324− }
325− }
326−
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() {
340− ensureColumnWidths()
341− let entries = viewModel.cachedFilteredEntries
342− // Clear a selection whose entry no longer exists — filtered out by a new
343− // column/text filter, or aged out of the capped buffer during streaming.
344− // Without this the detail pane silently vanishes while the stale id lingers
345− // (LogTableView solves the same problem with reconcileSelection()).
346− if let id = selectedEntryID, !entries.contains(where: { $0.id == id }) {
347− selectedEntryID = nil
348− }
349− 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 {
369− cachedSorted = entries
370− return
371− }
372− let asc = sortAscending
373− liveTailSortTask = Task {
374− try? await Task.sleep(for: .milliseconds(150))
375− guard !Task.isCancelled else { return }
376− let sorted = await Task.detached(priority: .userInitiated) {
377− // `sorted(by:)` is not guaranteed stable and entry ids are random
378− // UUIDs, so break ties on arrival order (the buffer index) to stop
379− // equal-keyed rows from reshuffling on every poll.
380− entries.enumerated().sorted { a, b in
381− let aVal = a.element.record[col] ?? .null
382− let bVal = b.element.record[col] ?? .null
383− if aVal != bVal { return asc ? aVal < bVal : bVal < aVal }
384− return a.offset < b.offset
385− }.map(\.element)
386− }.value
387− guard !Task.isCancelled else { return }
388− cachedSorted = sorted
389− }
390− }
391−
392− private var liveTailTable: some View {
393− ScrollViewReader { proxy in
394− ScrollView([.horizontal, .vertical]) {
395− LazyVStack(alignment: .leading, spacing: 0, pinnedViews: [.sectionHeaders]) {
396− Section {
397− ForEach(Array(cachedSorted.enumerated()), id: \.element.id) { index, entry in
398− LogRowView(
399− record: entry.record,
400− columns: viewModel.visibleColumns,
401− columnWidths: columnWidths,
402− isSelected: selectedEntryID == entry.id,
403− isAlternate: index % 2 == 1,
404− severity: severityByID[entry.id] ?? .unknown,
405− severityColumns: severityColumnSet,
406− wrapText: wrapText,
407− onCellFilter: { column, value, exclude in
408− viewModel.addColumnFilter(column: column, value: value, exclude: exclude)
409− }
410− )
411− .onTapGesture {
412− if selectedEntryID == entry.id {
413− selectedEntryID = nil
414− } else {
415− selectedEntryID = entry.id
416− }
417− }
418− }
419− } header: {
420− LogHeaderView(
421− columns: viewModel.visibleColumns,
422− sortColumn: $sortColumn,
423− sortAscending: $sortAscending,
424− columnWidths: $columnWidths,
425− records: viewModel.cachedFilteredRecords,
426− onMoveColumn: { from, to in
427− viewModel.moveColumn(from, to: to)
428− },
429− onColumnFilter: { column, value, exclude in
430− viewModel.addColumnFilter(column: column, value: value, exclude: exclude)
431− }
432− )
433− }
434− }
435− }
436− .onChange(of: sortColumn) { _, _ in
437− rebuildSortedEntries()
438− }
439− .onChange(of: sortAscending) { _, _ in
440− rebuildSortedEntries()
441− }
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
448− guard autoScroll, sortColumn == nil else { return }
449− if let target = cachedSorted.last {
450− proxy.scrollTo(target.id, anchor: .bottom)
451− }
452− }
453177 }
454178 }
455179}
456180
457−// MARK: - Column Manager Popover
458−
459−struct LiveTailColumnManagerView: View {
460− let viewModel: LiveTailViewModel
181+struct LiveTailEntryRow: View {
182+ let entry: LiveTailViewModel.LiveTailEntry
461183
462184 var body: some View {
463− VStack(alignment: .leading, spacing: 0) {
464− HStack {
465− Text("Columns")
466− .font(.headline)
467− Spacer()
468− if !viewModel.hiddenColumns.isEmpty || viewModel.columnOrder != viewModel.columns {
469− Button("Reset") {
470− viewModel.resetColumnConfig()
471− }
472− .font(.caption)
473− }
185+ HStack(spacing: 8) {
186+ Text(entry.displayTimestamp)
187+ .foregroundStyle(.secondary)
188+ .frame(width: 90, alignment: .leading)
189+
190+ if let level = entry.record["level"] ?? entry.record["severity"] {
191+ Text(level.displayString)
192+ .fontWeight(.medium)
193+ .foregroundStyle(levelColor(for: level.displayString))
194+ .frame(width: 50, alignment: .leading)
474195 }
475− .padding(.horizontal, 12)
476− .padding(.top, 10)
477− .padding(.bottom, 6)
478−
479− Divider()
480−
481− List {
482− ForEach(viewModel.columnOrder, id: \.self) { column in
483− HStack {
484− Button {
485− viewModel.toggleColumnVisibility(column)
486− } label: {
487− Image(systemName: viewModel.hiddenColumns.contains(column)
488− ? "eye.slash" : "eye")
489− .foregroundStyle(viewModel.hiddenColumns.contains(column)
490− ? .secondary : .primary)
491− .frame(width: 20)
492− }
493− .buttonStyle(.plain)
494− .help(viewModel.hiddenColumns.contains(column) ? "Show column" : "Hide column")
495−
496− Text(column)
497− .font(.system(.body, design: .monospaced))
498− .lineLimit(1)
499− .foregroundStyle(viewModel.hiddenColumns.contains(column)
500− ? .secondary : .primary)
501−
502− Spacer()
503196
504− Image(systemName: "line.3.horizontal")
505− .foregroundStyle(.tertiary)
506− .font(.caption)
507− }
508− .padding(.vertical, 2)
509− }
510− .onMove { source, destination in
511− viewModel.moveColumn(from: source, to: destination)
512− }
513− }
514− .listStyle(.plain)
515−
516− if !viewModel.hiddenColumns.isEmpty {
517− Divider()
518− Button("Show All Columns") {
519− viewModel.showAllColumns()
520− }
521− .font(.caption)
522− .padding(.horizontal, 12)
523− .padding(.vertical, 8)
524− }
197+ Text(entry.summary)
198+ .lineLimit(1)
199+ .frame(maxWidth: .infinity, alignment: .leading)
200+ .help(entry.summary)
525201 }
526− .frame(width: 280, height: min(CGFloat(viewModel.columnOrder.count) * 32 + 80, 400))
527202 }
528203}
ParseableViewer/Views/LogDetailView.swift+23−47
@@ -4,7 +4,6 @@ struct LogDetailView: View {
44 let record: LogRecord
55 @State private var viewMode: ViewMode = .formatted
66 @State private var showCopyConfirmation = false
7− @State private var copyConfirmationTask: Task<Void, Never>?
87
98 enum ViewMode: String, CaseIterable {
109 case formatted = "Formatted"
@@ -50,12 +49,6 @@ struct LogDetailView: View {
5049 }
5150 }
5251 .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− }
5952 }
6053
6154 private func copyToClipboard() {
@@ -68,12 +61,8 @@ struct LogDetailView: View {
6861 guard success else { return }
6962
7063 showCopyConfirmation = true
71− // Cancel any in-flight timer so a rapid re-copy doesn't let an
72− // earlier task clear the checkmark while a newer copy is active.
73− copyConfirmationTask?.cancel()
74− copyConfirmationTask = Task {
64+ Task {
7565 try? await Task.sleep(for: .seconds(1.5))
76− guard !Task.isCancelled else { return }
7766 showCopyConfirmation = false
7867 }
7968 }
@@ -82,25 +71,24 @@ struct LogDetailView: View {
8271
8372struct FormattedRecordView: View {
8473 let record: LogRecord
85− let sortedKeys: [String]
8674
87− init(record: LogRecord) {
88− self.record = record
75+ var sortedKeys: [String] {
8976 let priority = ["p_timestamp", "level", "severity", "message", "msg"]
9077 var keys = record.keys.sorted()
9178 var result: [String] = []
79+
9280 for p in priority {
9381 if let idx = keys.firstIndex(of: p) {
9482 result.append(keys.remove(at: idx))
9583 }
9684 }
9785 result.append(contentsOf: keys)
98− self.sortedKeys = result
86+ return result
9987 }
10088
10189 var body: some View {
10290 VStack(alignment: .leading, spacing: 1) {
103− ForEach(Array(sortedKeys.enumerated()), id: \.element) { index, key in
91+ ForEach(sortedKeys, id: \.self) { key in
10492 HStack(alignment: .top, spacing: 8) {
10593 Text(key)
10694 .font(.system(.caption, design: .monospaced))
@@ -117,7 +105,8 @@ struct FormattedRecordView: View {
117105 .padding(.vertical, 3)
118106 .padding(.horizontal, 8)
119107 .background(
120− index % 2 == 1 ? Color.primary.opacity(0.03) : Color.clear
108+ sortedKeys.firstIndex(of: key).map { $0 % 2 == 1 }
109+ ?? false ? Color.primary.opacity(0.03) : Color.clear
121110 )
122111 }
123112 }
@@ -152,22 +141,19 @@ struct JSONValueView: View {
152141 case .bool(let v):
153142 Text(v ? "true" : "false")
154143 .font(.system(.caption, design: .monospaced))
155− .foregroundStyle(Color(nsColor: .systemOrange))
144+ .foregroundStyle(.purple)
156145 case .int(let v):
157146 Text(String(v))
158147 .font(.system(.caption, design: .monospaced))
159− .foregroundStyle(Color(nsColor: .systemPurple))
160− case .double:
161− // Use displayString so whole-number doubles render as "3" (not "3.0"),
162− // scientific notation is avoided, and inf/nan map to "null" — matching
163− // the table cells, CSV/JSON export, and the rest of the app.
164− Text(value.displayString)
148+ .foregroundStyle(.blue)
149+ case .double(let v):
150+ Text(String(v))
165151 .font(.system(.caption, design: .monospaced))
166− .foregroundStyle(Color(nsColor: .systemPurple))
152+ .foregroundStyle(.blue)
167153 case .string(let v):
168154 Text(v)
169155 .font(.system(.caption, design: .monospaced))
170− .foregroundStyle(Color(nsColor: .systemRed))
156+ .foregroundStyle(.primary)
171157 .textSelection(.enabled)
172158 case .array(let items):
173159 VStack(alignment: .leading, spacing: 2) {
@@ -199,27 +185,17 @@ struct JSONValueView: View {
199185
200186struct RawJSONView: View {
201187 let record: LogRecord
202− @State private var highlightedText: AttributedString?
188+
189+ var jsonString: String {
190+ let obj = JSONValue.object(record)
191+ return obj.prettyPrinted()
192+ }
203193
204194 var body: some View {
205− Group {
206− if let highlighted = highlightedText {
207− Text(highlighted)
208− } else {
209− Text(JSONValue.object(record).prettyPrinted())
210− .font(.system(.caption, design: .monospaced))
211− }
212− }
213− .textSelection(.enabled)
214− .padding(8)
215− .frame(maxWidth: .infinity, alignment: .leading)
216− .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()
222− highlightedText = JSONSyntaxHighlighter.highlight(json)
223− }
195+ Text(jsonString)
196+ .font(.system(.caption, design: .monospaced))
197+ .textSelection(.enabled)
198+ .padding(8)
199+ .frame(maxWidth: .infinity, alignment: .leading)
224200 }
225201}
ParseableViewer/Views/LogTableView.swift+49−562
@@ -1,5 +1,4 @@
11import SwiftUI
2−import UniformTypeIdentifiers
32
43/// Computes the display width needed for a text string using the given font.
54private func measureTextWidth(_ text: String, font: NSFont) -> CGFloat {
@@ -20,13 +19,7 @@ func idealColumnWidth(for column: String, records: [LogRecord]) -> CGFloat {
2019 let sampleCount = min(records.count, 200)
2120
2221 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 {
29− let i = s * records.count / sampleCount
22+ for i in 0..<sampleCount {
3023 let text = records[i][column]?.displayString ?? ""
3124 widest = max(widest, measureTextWidth(text, font: cellMeasureFont))
3225 }
@@ -54,179 +47,21 @@ func levelColor(for value: String) -> Color {
5447 }
5548}
5649
57−// MARK: - Severity Row Tinting
58−
59−/// Semantic severity levels for row background tinting.
60−enum SeverityLevel {
61− case fatal
62− case error
63− case warning
64− case info
65− case debug
66− case trace
67− case unknown
68−}
69−
70−/// Column names (lowercased) that commonly hold a severity / log-level value.
71−private let severityColumnNames: Set<String> = [
72− "level", "severity", "log_level", "loglevel", "log.level",
73− "p_level", "severity_text", "levelname", "log_severity",
74− "priority", "syslog_severity", "msg_severity", "verbosity",
75− "otel.severity", "severity_number",
76−]
77−
78−/// Maps a raw severity string to a ``SeverityLevel``.
79−func parseSeverity(from value: String) -> SeverityLevel {
80− let lower = value.trimmingCharacters(in: .whitespaces).lowercased()
81−
82− // Try well-known textual levels first.
83− switch lower {
84− // Fatal / Critical
85− case "fatal", "critical", "panic", "emerg", "emergency", "alert", "crit":
86− return .fatal
87− // Error
88− case "error", "err", "failure", "fail", "severe":
89− return .error
90− // Warning
91− case "warn", "warning", "caution":
92− return .warning
93− // Info / Notice
94− case "info", "information", "informational", "notice":
95− return .info
96− // Debug
97− case "debug", "dbg", "verbose":
98− return .debug
99− // Trace
100− case "trace", "finest", "finer", "fine", "all":
101− return .trace
102− default:
103− break
104− }
105−
106− // Try numeric syslog severity (RFC 5424): 0=Emergency … 7=Debug.
107− if let num = Int(lower) {
108− switch num {
109− case 0...1: return .fatal // Emergency, Alert
110− case 2: return .fatal // Critical
111− case 3: return .error // Error
112− case 4: return .warning // Warning
113− case 5: return .info // Notice
114− case 6: return .info // Informational
115− case 7: return .debug // Debug
116− default: break
117− }
118− }
119−
120− return .unknown
121−}
122−
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.
143−func extractSeverity(from record: LogRecord, severityColumns: Set<String>) -> 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 }
149− let str: String
150− switch value {
151− case .string(let s): str = s
152− 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
158− }
159− // OpenTelemetry severity columns use a 1–24 scale that collides with
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 }
165− continue
166− }
167− let level = parseSeverity(from: str)
168− if level != .unknown { return level }
169− }
170− return .unknown
171−}
172−
173−/// Returns a subtle background tint color for a severity level.
174−/// Info, debug, trace, and unknown levels return `nil` (no tint).
175−func severityRowTint(for level: SeverityLevel) -> Color? {
176− switch level {
177− case .fatal: return Color.red.opacity(0.12)
178− case .error: return Color.red.opacity(0.07)
179− case .warning: return Color.orange.opacity(0.07)
180− case .info, .debug, .trace, .unknown: return nil
181− }
182−}
183−
184−/// Builds the set of column names (preserving original casing) that are severity columns.
185−func buildSeverityColumnSet(columns: [String]) -> Set<String> {
186− var result = Set<String>()
187− for col in columns {
188− if severityColumnNames.contains(col.lowercased()) {
189− result.insert(col)
190− }
191− }
192− return result
193−}
194−
19550// MARK: - LogTableView
19651
197−struct IndexedRecord: Identifiable {
198− let id: Int
199− let record: LogRecord
200− let severity: SeverityLevel
201−}
202−
20352struct LogTableView: View {
20453 let records: [LogRecord]
20554 let columns: [String]
20655 @Binding var selectedRecord: LogRecord?
207− var isLoading: Bool = false
208− var wrapText: Bool = false
209− var onCellFilter: ((_ column: String, _ value: JSONValue?, _ exclude: Bool) -> Void)?
210− var onMoveColumn: ((String, String) -> Void)?
21156 @State private var sortColumn: String?
21257 @State private var sortAscending = false
21358 @State private var selectedIndex: Int?
214− @State private var cachedSorted: [IndexedRecord] = []
215− @State private var severityColumnSet: Set<String> = []
59+ @State private var cachedSorted: [LogRecord] = []
21660 @State private var sortTask: Task<Void, Never>?
217− @State private var widthTask: Task<Void, Never>?
21861 @State private var columnWidths: [String: CGFloat] = [:]
21962
22063 var body: some View {
221− if records.isEmpty && isLoading {
222− VStack(spacing: 8) {
223− ProgressView()
224− .controlSize(.large)
225− Text("Loading results...")
226− .foregroundStyle(.secondary)
227− }
228− .frame(maxWidth: .infinity, maxHeight: .infinity)
229− } else if records.isEmpty {
64+ if records.isEmpty {
23065 VStack(spacing: 8) {
23166 Image(systemName: "doc.text.magnifyingglass")
23267 .font(.system(size: 32))
@@ -240,26 +75,17 @@ struct LogTableView: View {
24075 ScrollView([.horizontal, .vertical]) {
24176 LazyVStack(alignment: .leading, spacing: 0, pinnedViews: [.sectionHeaders]) {
24277 Section {
243− ForEach(Array(cachedSorted.enumerated()), id: \.element.id) { index, item in
78+ ForEach(cachedSorted.indices, id: \.self) { index in
24479 LogRowView(
245− record: item.record,
80+ record: cachedSorted[index],
24681 columns: columns,
24782 columnWidths: columnWidths,
248− isSelected: selectedIndex == item.id,
249− isAlternate: index % 2 == 1,
250− severity: item.severity,
251− severityColumns: severityColumnSet,
252− wrapText: wrapText,
253− onCellFilter: onCellFilter
83+ isSelected: selectedIndex == index,
84+ isAlternate: index % 2 == 1
25485 )
25586 .onTapGesture {
256− if selectedIndex == item.id {
257− selectedIndex = nil
258− selectedRecord = nil
259− } else {
260− selectedIndex = item.id
261− selectedRecord = item.record
262− }
87+ selectedIndex = index
88+ selectedRecord = cachedSorted[index]
26389 }
26490 }
26591 } header: {
@@ -268,9 +94,7 @@ struct LogTableView: View {
26894 sortColumn: $sortColumn,
26995 sortAscending: $sortAscending,
27096 columnWidths: $columnWidths,
271− records: records,
272− onMoveColumn: onMoveColumn,
273− onColumnFilter: onCellFilter
97+ records: records
27498 )
27599 }
276100 }
@@ -282,163 +106,44 @@ struct LogTableView: View {
282106 .frame(minWidth: 300, idealWidth: 350)
283107 }
284108 }
285− .opacity(isLoading ? 0.5 : 1.0)
286− .overlay {
287− if isLoading {
288− ProgressView()
289− .controlSize(.large)
290− }
291− }
292− .onChange(of: records) { _, newRecords in
293− recomputeColumnWidths(columns: columns, records: newRecords)
109+ .onChange(of: records) { _, _ in
110+ columnWidths = computeColumnWidths(columns: columns, records: records)
294111 debouncedRebuildSort()
295112 }
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− }
315− }
316113 .onChange(of: sortColumn) { _, _ in debouncedRebuildSort() }
317114 .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− }
326115 .onAppear {
327− // This branch is unmounted while results are empty (e.g. across a
328− // stream switch, which clears them first), so `onChange(of: columns)`
329− // never sees the switch. Drop a sort column the new columns don't
330− // have — or hide — before sorting, or rows would be ordered by an
331− // invisible column with no header showing the sort.
332− if let sortColumn, !columns.contains(sortColumn) {
333− self.sortColumn = nil
334− }
335− recomputeColumnWidths(columns: columns, records: records)
116+ columnWidths = computeColumnWidths(columns: columns, records: records)
336117 rebuildSort()
337118 }
338119 }
339120 }
340121
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−
362122 private func debouncedRebuildSort() {
363123 sortTask?.cancel()
364124 sortTask = Task {
365125 try? await Task.sleep(for: .milliseconds(150))
366126 guard !Task.isCancelled else { return }
367− let recs = records
368− let col = sortColumn
369− let asc = sortAscending
370− let sevCols = buildSeverityColumnSet(columns: columns)
371− let indexed: [IndexedRecord] = 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
376− let aVal = a.element[col] ?? .null
377− let bVal = b.element[col] ?? .null
378− // `sorted(by:)` is not guaranteed stable, so break ties on
379− // the original index to keep equal-keyed rows in a fixed
380− // order across re-sorts (otherwise low-cardinality columns
381− // like `level`/`status` would reshuffle on every refresh).
382− if aVal != bVal { return asc ? aVal < bVal : bVal < aVal }
383− return a.offset < b.offset
384− }
385− } else {
386− sorted = enumerated
387− }
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 }
393− cachedSorted = indexed
394− severityColumnSet = sevCols
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,
411− let match = cachedSorted.first(where: { $0.record == selectedRecord }) {
412− selectedIndex = match.id
413− } else {
414− selectedIndex = nil
415− self.selectedRecord = nil
127+ rebuildSort()
416128 }
417129 }
418130
419131 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)]
426132 if let sortColumn {
427− sorted = enumerated.sorted { a, b in
428− let aVal = a.element[sortColumn] ?? .null
429− let bVal = b.element[sortColumn] ?? .null
430− // Stable tiebreak on original index — see the async path above.
431− if aVal != bVal { return sortAscending ? aVal < bVal : bVal < aVal }
432− return a.offset < b.offset
133+ cachedSorted = records.sorted { a, b in
134+ let aVal = a[sortColumn] ?? .null
135+ let bVal = b[sortColumn] ?? .null
136+ return sortAscending ? aVal < bVal : bVal < aVal
433137 }
434138 } else {
435− sorted = enumerated
139+ cachedSorted = records
436140 }
437− cachedSorted = sorted.map {
438− IndexedRecord(id: $0.offset, record: $0.element, severity: extractSeverity(from: $0.element, severityColumns: sevCols))
141+ if let selectedRecord,
142+ let idx = cachedSorted.firstIndex(where: { $0 == selectedRecord }) {
143+ selectedIndex = idx
144+ } else {
145+ selectedIndex = nil
439146 }
440− severityColumnSet = sevCols
441− reconcileSelection()
442147 }
443148}
444149
@@ -467,7 +172,7 @@ struct ColumnResizeHandle: View {
467172 }
468173 }
469174 .gesture(
470− DragGesture(minimumDistance: 1, coordinateSpace: .global)
175+ DragGesture(minimumDistance: 1)
471176 .onChanged { gesture in
472177 if initialWidth == nil {
473178 initialWidth = columnWidth
@@ -490,89 +195,32 @@ struct LogHeaderView: View {
490195 @Binding var sortAscending: Bool
491196 @Binding var columnWidths: [String: CGFloat]
492197 let records: [LogRecord]
493− var onMoveColumn: ((String, String) -> Void)?
494− var onColumnFilter: ((_ column: String, _ value: JSONValue?, _ exclude: Bool) -> Void)?
495−
496− @State private var draggedColumn: String?
497− @State private var dropTargetColumn: String?
498− /// `.contextMenu`'s builder runs during body evaluation, so the filter menus
499− /// compute their values while the view is being rendered. Writing an `@State`
500− /// dictionary from there is "modifying state during view update" (undefined
501− /// behavior, and it re-invalidates the header). The cache is therefore a plain
502− /// reference that isn't observed; `recordsGeneration` — bumped outside body
503− /// evaluation — is what invalidates it and triggers the re-render.
504− @State private var uniqueValuesCache = UniqueValuesCache()
505− @State private var recordsGeneration = 0
506−
507− private final class UniqueValuesCache {
508− var generation = -1
509− var values: [String: [JSONValue]] = [:]
510− }
511−
512− private static let maxFilterValues = 20
513−
514− 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] {
520− return cached
521− }
522− var seen = Set<JSONValue>()
523− var result: [JSONValue] = []
524− for record in records {
525− let value = record[column] ?? .null
526− if seen.insert(value).inserted {
527− result.append(value)
528− }
529− }
530− let sorted = result.sorted()
531− uniqueValuesCache.values[column] = sorted
532− return sorted
533− }
534−
535− private func dropIndicatorAlignment(for targetColumn: String) -> Alignment {
536− guard let dragged = draggedColumn,
537− let fromIndex = columns.firstIndex(of: dragged),
538− let toIndex = columns.firstIndex(of: targetColumn) else {
539− return .leading
540− }
541− return fromIndex < toIndex ? .trailing : .leading
542− }
543−
544− private func filterDisplayLabel(for value: JSONValue) -> String {
545− let str = value.displayString
546− if str.isEmpty { return "(empty)" }
547− if str.count > 50 { return String(str.prefix(50)) + "..." }
548− return str
549− }
550198
551199 var body: some View {
552200 HStack(spacing: 0) {
553201 ForEach(columns, id: \.self) { column in
554202 HStack(spacing: 0) {
555− HStack(spacing: 2) {
556− Text(column)
557− .font(.caption.weight(.semibold))
558− .lineLimit(1)
559− if sortColumn == column {
560− Image(systemName: sortAscending ? "chevron.up" : "chevron.down")
561− .font(.caption2)
562− }
563− }
564− .padding(.horizontal, 6)
565− .padding(.vertical, 4)
566− .contentShape(Rectangle())
567− .onTapGesture {
203+ Button {
568204 if sortColumn == column {
569205 sortAscending.toggle()
570206 } else {
571207 sortColumn = column
572208 sortAscending = true
573209 }
210+ } label: {
211+ HStack(spacing: 2) {
212+ Text(column)
213+ .font(.caption.weight(.semibold))
214+ .lineLimit(1)
215+ if sortColumn == column {
216+ Image(systemName: sortAscending ? "chevron.up" : "chevron.down")
217+ .font(.caption2)
218+ }
219+ }
574220 }
575− .accessibilityAddTraits(.isButton)
221+ .buttonStyle(.plain)
222+ .padding(.horizontal, 6)
223+ .padding(.vertical, 4)
576224 .accessibilityLabel(sortColumn == column
577225 ? "Sort by \(column), \(sortAscending ? "ascending" : "descending")"
578226 : "Sort by \(column)")
@@ -587,148 +235,18 @@ struct LogHeaderView: View {
587235 )
588236 }
589237 .frame(width: columnWidths[column] ?? 120, alignment: .leading)
590− .background {
591− if dropTargetColumn == column && draggedColumn != nil && draggedColumn != column {
592− Color.accentColor.opacity(0.15)
593− }
594− }
595− .overlay(alignment: dropIndicatorAlignment(for: column)) {
596− if dropTargetColumn == column && draggedColumn != nil && draggedColumn != column {
597− Rectangle()
598− .fill(Color.accentColor)
599− .frame(width: 2)
600− }
601− }
602− .onDrag {
603− draggedColumn = column
604− return NSItemProvider(object: column as NSString)
605− }
606− .onDrop(of: [UTType.plainText], delegate: ColumnDropDelegate(
607− column: column,
608− draggedColumn: $draggedColumn,
609− dropTargetColumn: $dropTargetColumn,
610− onMoveColumn: onMoveColumn
611− ))
612238 .contextMenu {
613− if let onMove = onMoveColumn {
614− if column != columns.first {
615− Button("Move Left") {
616− let idx = columns.firstIndex(of: column)!
617− onMove(column, columns[columns.index(before: idx)])
618− }
619− }
620− if column != columns.last {
621− Button("Move Right") {
622− let idx = columns.firstIndex(of: column)!
623− onMove(column, columns[columns.index(after: idx)])
624− }
625− }
626− Divider()
627− }
628239 Button("Auto-fit Column") {
629240 columnWidths[column] = idealColumnWidth(for: column, records: records)
630241 }
631242 Button("Auto-fit All Columns") {
632243 columnWidths = computeColumnWidths(columns: columns, records: records)
633244 }
634−
635− if let onFilter = onColumnFilter {
636− let values = uniqueValues(for: column)
637− if !values.isEmpty {
638− Divider()
639−
640− let displayValues = Array(values.prefix(Self.maxFilterValues))
641− let remaining = values.count - displayValues.count
642−
643− Menu("Filter by Value") {
644− ForEach(displayValues, id: \.self) { value in
645− Button(filterDisplayLabel(for: value)) {
646− onFilter(column, value == .null ? nil : value, false)
647− }
648− }
649− if remaining > 0 {
650− Divider()
651− Text("\(remaining) more values not shown")
652− }
653− }
654−
655− Menu("Exclude Value") {
656− ForEach(displayValues, id: \.self) { value in
657− Button(filterDisplayLabel(for: value)) {
658− onFilter(column, value == .null ? nil : value, true)
659− }
660− }
661− if remaining > 0 {
662− Divider()
663− Text("\(remaining) more values not shown")
664− }
665− }
666− }
667− }
668245 }
669246 }
670247 }
671248 .background(Color(nsColor: .controlBackgroundColor))
672249 .border(Color.secondary.opacity(0.2), width: 0.5)
673− .onChange(of: records) { _, _ in
674− recordsGeneration &+= 1
675− }
676− }
677−}
678−
679−// MARK: - ColumnDropDelegate
680−
681−private struct ColumnDropDelegate: DropDelegate {
682− let column: String
683− @Binding var draggedColumn: String?
684− @Binding var dropTargetColumn: String?
685− var onMoveColumn: ((String, String) -> Void)?
686−
687− func dropEntered(info: DropInfo) {
688− guard let dragged = draggedColumn, dragged != column else { return }
689− withAnimation(.easeInOut(duration: 0.2)) {
690− dropTargetColumn = column
691− }
692− }
693−
694− func dropExited(info: DropInfo) {
695− withAnimation(.easeInOut(duration: 0.2)) {
696− if dropTargetColumn == column {
697− dropTargetColumn = nil
698− }
699− }
700− }
701−
702− func dropUpdated(info: DropInfo) -> DropProposal? {
703− DropProposal(operation: .move)
704− }
705−
706− func performDrop(info: DropInfo) -> Bool {
707− guard let dragged = draggedColumn, dragged != column,
708− let provider = info.itemProviders(for: [UTType.plainText]).first else {
709− draggedColumn = nil
710− dropTargetColumn = nil
711− return false
712− }
713− draggedColumn = nil
714− dropTargetColumn = nil
715− // `draggedColumn` is only cleared by a drop on a header, so a header drag
716− // that was cancelled (or dropped elsewhere) leaves it set, and any later
717− // plain-text drag onto a header would move that stale column. Only move
718− // when the dropped payload really is the column header being dragged.
719− let target = column
720− let move = onMoveColumn
721− _ = provider.loadObject(ofClass: NSString.self) { object, _ in
722− guard let payload = object as? NSString, payload as String == dragged else { return }
723− DispatchQueue.main.async {
724− move?(dragged, target)
725− }
726− }
727− return true
728− }
729−
730− func validateDrop(info: DropInfo) -> Bool {
731− draggedColumn != nil && draggedColumn != column
732250 }
733251}
734252
@@ -740,28 +258,21 @@ struct LogRowView: View {
740258 let columnWidths: [String: CGFloat]
741259 let isSelected: Bool
742260 let isAlternate: Bool
743− var severity: SeverityLevel = .unknown
744− var severityColumns: Set<String> = []
745− var wrapText: Bool = false
746− var onCellFilter: ((_ column: String, _ value: JSONValue?, _ exclude: Bool) -> Void)?
747261
748262 var body: some View {
749− HStack(alignment: .top, spacing: 0) {
263+ HStack(spacing: 0) {
750264 ForEach(columns, id: \.self) { column in
751265 let value = record[column]
752266 Text(value?.displayString ?? "")
753267 .font(.system(.caption, design: .monospaced))
754− .lineLimit(wrapText ? nil : 1)
268+ .lineLimit(1)
755269 .padding(.horizontal, 6)
756270 .padding(.vertical, 3)
757− .frame(width: columnWidths[column] ?? 120, alignment: .topLeading)
271+ .frame(width: columnWidths[column] ?? 120, alignment: .leading)
758272 .foregroundStyle(colorForValue(column: column, value: value))
759273 .contextMenu {
760274 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 ?? ""
275+ let text = value?.displayString ?? ""
765276 NSPasteboard.general.clearContents()
766277 _ = NSPasteboard.general.setString(text, forType: .string)
767278 }
@@ -769,43 +280,19 @@ struct LogRowView: View {
769280 NSPasteboard.general.clearContents()
770281 _ = NSPasteboard.general.setString(column, forType: .string)
771282 }
772− if let onCellFilter {
773− Divider()
774− // Normalize a null cell to `nil` so this produces an
775− // IS NULL / IS NOT NULL filter, matching the header
776− // "Filter by Value" path. Passing `.null` straight
777− // through would build an `= null` value filter, which
778− // the live-tail in-memory matcher treats differently.
779− let filterValue: JSONValue? = value == .null ? nil : value
780− Button("Filter by This Value") {
781− onCellFilter(column, filterValue, false)
782− }
783− Button("Exclude This Value") {
784− onCellFilter(column, filterValue, true)
785− }
786− }
787− }
788− }
789− }
790− .background {
791− if isSelected {
792− Color.accentColor.opacity(0.2)
793− } else {
794− ZStack {
795− if isAlternate {
796− Color.primary.opacity(0.02)
797283 }
798− if let tint = severityRowTint(for: severity) {
799− tint
800− }
801− }
802284 }
803285 }
286+ .background(
287+ isSelected
288+ ? Color.accentColor.opacity(0.2)
289+ : (isAlternate ? Color.primary.opacity(0.02) : Color.clear)
290+ )
804291 }
805292
806293 private func colorForValue(column: String, value: JSONValue?) -> Color {
807294 guard let value else { return .secondary }
808− if severityColumns.contains(column) {
295+ if column == "level" || column == "severity" || column == "log_level" {
809296 return levelColor(for: value.displayString)
810297 }
811298 return .primary
ParseableViewer/Views/QueryView.swift+72−338
@@ -1,30 +1,12 @@
11import SwiftUI
22
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−
133struct QueryView: View {
144 @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
20− @State private var showSaveFilterSheet = false
21− @State private var saveFilterName = ""
22− @State private var isSavingFilter = false
23− @State private var saveFilterError: String?
5+ @State private var viewModel = QueryViewModel()
6+ @State private var showSaveQuerySheet = false
7+ @State private var saveQueryName = ""
248 @State private var exportError: String?
259 @State private var showExportError = false
26− @State private var showColumnPopover = false
27− @State private var wrapText = false
2810
2911 var body: some View {
3012 @Bindable var vm = viewModel
@@ -37,16 +19,7 @@ struct QueryView: View {
3719 TimeRangePicker(
3820 option: $vm.timeRangeOption,
3921 customStart: $vm.customStartDate,
40− customEnd: $vm.customEndDate,
41− onCommit: {
42− guard appState.selectedStream != nil else { return }
43− Task {
44− await viewModel.executeQuery(
45− client: appState.client,
46− stream: appState.selectedStream
47− )
48− }
49− }
22+ customEnd: $vm.customEndDate
5023 )
5124
5225 Spacer()
@@ -80,20 +53,21 @@ struct QueryView: View {
8053 }
8154
8255 Button {
83− showSaveFilterSheet = true
56+ showSaveQuerySheet = true
8457 } label: {
85− Text("Save view")
58+ Image(systemName: "bookmark")
8659 }
87− .help("Save filter")
60+ .help("Save query")
8861 .disabled(viewModel.sqlQuery.isEmpty)
89− .accessibilityLabel("Save filter")
62+ .accessibilityLabel("Save query")
9063
91− Button {
92− exportResults()
64+ Menu {
65+ Button("Export as JSON") { exportJSON() }
66+ Button("Export as CSV") { exportCSV() }
9367 } label: {
9468 Image(systemName: "square.and.arrow.up")
9569 }
96− .disabled(viewModel.filteredResults.isEmpty)
70+ .disabled(viewModel.results.isEmpty)
9771 .accessibilityLabel("Export results")
9872 .help("Export results")
9973
@@ -132,13 +106,12 @@ struct QueryView: View {
132106 .padding(8)
133107
134108 // SQL editor
135− SQLEditorView(
136− text: $vm.sqlQuery,
137− streamNames: appState.streams.map(\.name),
138− schemaFields: viewModel.schemaFields,
139− errorRange: viewModel.errorRange
140− )
109+ TextEditor(text: $vm.sqlQuery)
110+ .font(.system(.body, design: .monospaced))
141111 .frame(minHeight: 60, maxHeight: 120)
112+ .scrollContentBackground(.hidden)
113+ .padding(4)
114+ .background(Color(nsColor: .textBackgroundColor))
142115 .clipShape(RoundedRectangle(cornerRadius: 4))
143116 .overlay(
144117 RoundedRectangle(cornerRadius: 4)
@@ -209,36 +182,6 @@ struct QueryView: View {
209182 }
210183
211184 Spacer()
212−
213− Button {
214− wrapText.toggle()
215− } label: {
216− Image(systemName: wrapText ? "text.word.spacing" : "text.line.first.and.arrowtriangle.forward")
217− }
218− .help(wrapText ? "Disable text wrapping" : "Enable text wrapping")
219− .accessibilityLabel(wrapText ? "Disable text wrapping" : "Enable text wrapping")
220−
221− if !viewModel.columns.isEmpty {
222− Button {
223− showColumnPopover.toggle()
224− } label: {
225− HStack(spacing: 4) {
226− Image(systemName: "eye")
227− Text("Columns")
228− .font(.caption)
229− if !viewModel.hiddenColumns.isEmpty || !viewModel.autoHiddenColumns.isEmpty {
230− Text("(\(viewModel.visibleColumns.count)/\(viewModel.columns.count))")
231− .font(.caption)
232− .foregroundStyle(.secondary)
233− }
234− }
235− }
236− .accessibilityLabel("Manage column visibility and order")
237− .help("Show, hide, and reorder columns")
238− .popover(isPresented: $showColumnPopover, arrowEdge: .bottom) {
239− ColumnManagerView(viewModel: viewModel)
240− }
241− }
242185 }
243186 .padding(6)
244187 }
@@ -246,149 +189,31 @@ struct QueryView: View {
246189 // Results table
247190 LogTableView(
248191 records: viewModel.filteredResults,
249− columns: viewModel.visibleColumns,
250− selectedRecord: $vm.selectedLogEntry,
251− isLoading: viewModel.isLoading,
252− wrapText: wrapText,
253− onCellFilter: { column, value, exclude in
254− // Arrays/objects are List/Struct columns on the server; a
255− // `col = '<json>'` comparison fails to plan, and the failed
256− // run would clear the results the user is looking at.
257− if let value, !value.isScalar {
258− viewModel.errorMessage = "Filtering by an array or object value isn't supported in SQL queries."
259− return
260− }
261− // Results from a blank editor come from the auto-generated
262− // default query, which `addColumnFilter` can't see (it edits
263− // `sqlQuery`); put that query in the editor so the filter
264− // actually applies instead of silently re-running unfiltered.
265− if viewModel.sqlQuery.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty,
266− let stream = appState.selectedStream {
267− // (Passing a previous stream forces the replacement even
268− // when the blank editor holds only whitespace.)
269− viewModel.setDefaultQuery(stream: stream, previousStream: stream)
270− }
271− viewModel.addColumnFilter(column: column, value: value, exclude: exclude)
272− Task {
273− await viewModel.executeQuery(
274− client: appState.client,
275− stream: appState.selectedStream
276− )
277− }
278− },
279− onMoveColumn: { from, to in
280− viewModel.moveColumn(from, to: to)
281− }
192+ columns: viewModel.columns,
193+ selectedRecord: $vm.selectedLogEntry
282194 )
283195 }
284196 }
285− .onChange(of: QueryViewContext(stream: appState.selectedStream, pendingSQL: appState.pendingFilterSQL)) { old, new in
286− guard let stream = new.stream else {
287− viewModel.clearResults()
288− return
289− }
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 {
297− await viewModel.loadSchema(client: appState.client, stream: stream)
298− }
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 {
313− Task {
314− await viewModel.executeQuery(client: appState.client, stream: stream)
315− }
316− }
317− }
318− }
319− .onChange(of: viewModel.timeRangeOption) { _, newValue in
320− guard newValue != .custom, appState.selectedStream != nil else { return }
321− Task {
322− await viewModel.executeQuery(
323− client: appState.client,
324− stream: appState.selectedStream
325− )
197+ .onChange(of: appState.selectedStream) { oldValue, newValue in
198+ viewModel.clearResults()
199+ if let stream = newValue {
200+ viewModel.setDefaultQuery(stream: stream, previousStream: oldValue)
326201 }
327202 }
328203 .onAppear {
329204 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 {
351− Task {
352− await viewModel.loadSchema(client: appState.client, stream: stream)
353− }
354− }
355− // A saved filter may have set pendingFilterSQL before this view was
356− // mounted (e.g. the filter was launched from another tab). .onChange
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 {
364− await viewModel.executeQuery(
365− client: appState.client,
366− stream: stream
367− )
368− }
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− }
205+ viewModel.setDefaultQuery(stream: stream)
380206 }
381207 }
382− .onChange(of: appState.queryRefreshToken) { _, _ in
383− // Guard on a selected stream like every sibling query trigger in this
384− // view. Without it, a refresh (Cmd+R) with no stream selected and an
385− // empty editor surfaces a spurious "Select a stream..." error.
386− guard appState.selectedStream != nil else { return }
387− Task {
388− await viewModel.executeQuery(
389− client: appState.client,
390− stream: appState.selectedStream
391− )
208+ .onDisappear {
209+ if viewModel.isLoading {
210+ viewModel.cancelQuery()
211+ }
212+ }
213+ .onChange(of: appState.pendingSavedQuerySQL) { _, newSQL in
214+ if let sql = newSQL {
215+ viewModel.sqlQuery = sql
216+ appState.pendingSavedQuerySQL = nil
392217 }
393218 }
394219 .alert("Export Error", isPresented: $showExportError) {
@@ -396,42 +221,26 @@ struct QueryView: View {
396221 } message: {
397222 Text(exportError ?? "Unknown error")
398223 }
399− .sheet(isPresented: $showSaveFilterSheet) {
224+ .sheet(isPresented: $showSaveQuerySheet) {
400225 VStack(spacing: 16) {
401− Text("Save Filter")
226+ Text("Save Query")
402227 .font(.headline)
403− TextField("Filter name", text: $saveFilterName)
228+ TextField("Query name", text: $saveQueryName)
404229 .textFieldStyle(.roundedBorder)
405− if let error = saveFilterError {
406− Text(error)
407− .font(.caption)
408− .foregroundStyle(.red)
409− }
410230 HStack {
411− Button("Cancel") {
412− saveFilterError = nil
413− showSaveFilterSheet = false
414− }
231+ Button("Cancel") { showSaveQuerySheet = false }
415232 Spacer()
416233 Button("Save") {
417− isSavingFilter = true
418− saveFilterError = nil
419− Task {
420− do {
421− try await appState.saveFilter(
422− name: saveFilterName,
423− sql: viewModel.sqlQuery,
424− stream: appState.selectedStream ?? ""
425− )
426− saveFilterName = ""
427− showSaveFilterSheet = false
428− } catch {
429− saveFilterError = ParseableError.userFriendlyMessage(for: error)
430− }
431− isSavingFilter = false
432− }
234+ let query = SavedQuery(
235+ name: saveQueryName,
236+ sql: viewModel.sqlQuery,
237+ stream: appState.selectedStream ?? ""
238+ )
239+ appState.addSavedQuery(query)
240+ saveQueryName = ""
241+ showSaveQuerySheet = false
433242 }
434− .disabled(saveFilterName.isEmpty || isSavingFilter)
243+ .disabled(saveQueryName.isEmpty)
435244 .buttonStyle(.borderedProminent)
436245 }
437246 }
@@ -440,32 +249,36 @@ struct QueryView: View {
440249 }
441250 }
442251
443− private func exportResults() {
252+ private func exportJSON() {
253+ let results = viewModel.results
254+ saveToFile(type: "json") {
255+ let encoder = JSONEncoder()
256+ encoder.outputFormatting = [.prettyPrinted, .sortedKeys]
257+ do {
258+ let data = try encoder.encode(results)
259+ return String(data: data, encoding: .utf8) ?? "[]"
260+ } catch {
261+ return nil
262+ }
263+ }
264+ }
265+
266+ private func exportCSV() {
267+ let results = viewModel.results
268+ let columns = viewModel.columns
269+ saveToFile(type: "csv") {
270+ QueryViewModel.buildCSV(records: results, columns: columns)
271+ }
272+ }
273+
274+ private func saveToFile(type: String, generate: @Sendable @escaping () -> String?) {
444275 let panel = NSSavePanel()
445− panel.allowedContentTypes = [.json, .commaSeparatedText]
446− panel.nameFieldStringValue = "export.json"
276+ panel.allowedContentTypes = type == "json" ? [.json] : [.commaSeparatedText]
277+ panel.nameFieldStringValue = "export.\(type)"
447278 panel.begin { result in
448279 guard result == .OK, let url = panel.url else { return }
449− let isCSV = url.pathExtension.lowercased() == "csv"
450− // Export what's on screen: the table renders `filteredResults`, so a
451− // user who has typed a result filter expects the export to contain
452− // those rows, not the full unfiltered set.
453− let results = viewModel.filteredResults
454− let columns = viewModel.visibleColumns
455280 Task.detached(priority: .userInitiated) {
456− let content: String?
457− if isCSV {
458− content = QueryViewModel.buildCSV(records: results, columns: columns)
459− } else {
460− let encoder = JSONEncoder()
461− 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))
466− .flatMap { String(data: $0, encoding: .utf8) }
467− }
468− guard let content else {
281+ guard let content = generate() else {
469282 await MainActor.run {
470283 exportError = "Failed to encode data for export"
471284 showExportError = true
@@ -484,82 +297,3 @@ struct QueryView: View {
484297 }
485298 }
486299}
487−
488−// MARK: - Column Manager Popover
489−
490−struct ColumnManagerView: View {
491− let viewModel: QueryViewModel
492−
493− var body: some View {
494− VStack(alignment: .leading, spacing: 0) {
495− HStack {
496− Text("Columns")
497− .font(.headline)
498− Spacer()
499− if viewModel.hasHiddenColumns || viewModel.columnOrder != viewModel.columns {
500− Button("Reset") {
501− viewModel.resetColumnConfig()
502− }
503− .font(.caption)
504− }
505− }
506− .padding(.horizontal, 12)
507− .padding(.top, 10)
508− .padding(.bottom, 6)
509−
510− Divider()
511−
512− List {
513− ForEach(viewModel.columnOrder, id: \.self) { column in
514− let isHidden = viewModel.hiddenColumns.contains(column)
515− || viewModel.autoHiddenColumns.contains(column)
516− HStack {
517− Button {
518− viewModel.toggleColumnVisibility(column)
519− } label: {
520− Image(systemName: isHidden ? "eye.slash" : "eye")
521− .foregroundStyle(isHidden ? .secondary : .primary)
522− .frame(width: 20)
523− }
524− .buttonStyle(.plain)
525− .help(isHidden ? "Show column" : "Hide column")
526−
527− Text(column)
528− .font(.system(.body, design: .monospaced))
529− .lineLimit(1)
530− .foregroundStyle(isHidden ? .secondary : .primary)
531−
532− Spacer()
533−
534− if viewModel.autoHiddenColumns.contains(column) {
535− Text("empty")
536− .font(.caption2)
537− .foregroundStyle(.tertiary)
538− .padding(.trailing, 4)
539− }
540−
541− Image(systemName: "line.3.horizontal")
542− .foregroundStyle(.tertiary)
543− .font(.caption)
544− }
545− .padding(.vertical, 2)
546− }
547− .onMove { source, destination in
548− viewModel.moveColumn(from: source, to: destination)
549− }
550− }
551− .listStyle(.plain)
552−
553− if viewModel.hasHiddenColumns {
554− Divider()
555− Button("Show All Columns") {
556− viewModel.showAllColumns()
557− }
558− .font(.caption)
559− .padding(.horizontal, 12)
560− .padding(.vertical, 8)
561− }
562− }
563− .frame(width: 280, height: min(CGFloat(viewModel.columnOrder.count) * 32 + 80, 400))
564− }
565−}
ParseableViewer/Views/SQLAutoComplete.swiftdeleted−590
@@ -1,590 +0,0 @@
1−import AppKit
2−
3−// MARK: - Completion Item
4−
5−struct SQLCompletionItem: Hashable {
6− enum Kind: String {
7− case keyword
8− case function
9− case table
10− case column
11− }
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
17− let kind: Kind
18− let detail: String?
19− /// The bare text used for prefix/exact-match comparison against what the
20− /// user typed. For tables this is the unquoted stream name, since the typed
21− /// prefix never includes the surrounding quotes. This is NOT what gets
22− /// inserted — see `text`.
23− let matchText: String
24−
25− init(text: String, kind: Kind, detail: String? = nil, matchText: String? = nil) {
26− self.text = text
27− self.kind = kind
28− self.detail = detail
29− self.matchText = matchText ?? text
30− }
31−
32− var kindLabel: String {
33− switch kind {
34− case .keyword: return "K"
35− case .function: return "F"
36− case .table: return "T"
37− case .column: return "C"
38− }
39− }
40−
41− var kindColor: NSColor {
42− switch kind {
43− case .keyword: return .systemBlue
44− case .function: return .systemTeal
45− case .table: return .systemGreen
46− case .column: return .systemOrange
47− }
48− }
49−}
50−
51−// MARK: - Completion Context
52−
53−enum SQLCompletionContext {
54− case general
55− case tableRef
56− case columnRef
57− case afterOrder
58− case afterGroup
59−}
60−
61−// MARK: - Completion Provider
62−
63−enum SQLCompletionProvider {
64−
65− /// Returns matching completion items, the typed prefix, and the NSRange of
66− /// that prefix in the source text.
67− static func completions(
68− for text: String,
69− cursorPosition: Int,
70− streamNames: [String],
71− schemaFields: [SchemaField]
72− ) -> (items: [SQLCompletionItem], prefix: String, range: NSRange) {
73− let nsText = text as NSString
74− guard cursorPosition > 0, cursorPosition <= nsText.length else {
75− return ([], "", NSRange(location: 0, length: 0))
76− }
77−
78− // Find the start of the current word (letters, digits, underscore)
79− var wordStart = cursorPosition
80− while wordStart > 0 {
81− let ch = nsText.character(at: wordStart - 1)
82− if isWordCharacter(ch) {
83− wordStart -= 1
84− } else {
85− break
86− }
87− }
88−
89− // The matching prefix is only the text before the caret (what the user has
90− // typed so far), but the replacement range spans the whole word — scan
91− // forward past any trailing word characters so a mid-token completion
92− // replaces the entire token instead of duplicating its tail.
93− var wordEnd = cursorPosition
94− while wordEnd < nsText.length {
95− if isWordCharacter(nsText.character(at: wordEnd)) {
96− wordEnd += 1
97− } else {
98− break
99− }
100− }
101−
102− let prefix = nsText.substring(with: NSRange(location: wordStart, length: cursorPosition - wordStart))
103−
104− guard !prefix.isEmpty else {
105− return ([], "", NSRange(location: wordStart, length: wordEnd - wordStart))
106− }
107−
108− // Words typed inside a comment or a string literal aren't SQL, so offer
109− // nothing there. Otherwise the popup opens (e.g. `-- grouped by date`
110− // matches DATE) and the Return/Tab meant to end the line or keep typing is
111− // captured to accept a keyword, rewriting the comment or the literal value.
112− if isInsideCommentOrStringLiteral(text, offset: cursorPosition) {
113− return ([], prefix, NSRange(location: wordStart, length: wordEnd - wordStart))
114− }
115−
116− let context = determineContext(text: text, position: wordStart)
117−
118− // Table (and column) completions supply their own quoting. If the user has
119− // already typed quotes directly around the word (common for stream names
120− // with spaces, e.g. `FROM "my"`), absorb them into the replacement range so
121− // the inserted `"name"` replaces the stray quotes instead of producing
122− // `""name""`. Mirror `insertCompletion`, which absorbs leading and trailing
123− // quotes independently for any `.table`/`.column` item — so apply this
124− // wherever those completions are offered (`.tableRef`, `.columnRef` and
125− // `.general`).
126− var replaceStart = wordStart
127− var replaceEnd = wordEnd
128− let offersIdentifiers: Bool
129− switch context {
130− case .tableRef, .columnRef, .general: offersIdentifiers = true
131− case .afterOrder, .afterGroup: offersIdentifiers = false
132− }
133− if offersIdentifiers {
134− if wordStart > 0, nsText.character(at: wordStart - 1) == 0x22 {
135− replaceStart -= 1
136− }
137− if wordEnd < nsText.length, nsText.character(at: wordEnd) == 0x22 {
138− replaceEnd += 1
139− }
140− }
141− let range = NSRange(location: replaceStart, length: replaceEnd - replaceStart)
142− let uppercasePrefix = prefix.uppercased()
143−
144− var items: [SQLCompletionItem] = []
145−
146− switch context {
147− case .tableRef:
148− for name in streamNames.sorted() where name.uppercased().hasPrefix(uppercasePrefix) {
149− items.append(SQLCompletionItem(
150− text: "\"\(name)\"",
151− kind: .table,
152− matchText: name
153− ))
154− }
155−
156− case .columnRef:
157− for field in schemaFields.sorted(by: { $0.name < $1.name })
158− where field.name.uppercased().hasPrefix(uppercasePrefix) {
159− items.append(columnItem(for: field))
160− }
161− for fn in SQLSyntaxHighlighter.sortedFunctions where fn.hasPrefix(uppercasePrefix) {
162− items.append(SQLCompletionItem(text: fn, kind: .function))
163− }
164− for kw in SQLSyntaxHighlighter.sortedKeywords where kw.hasPrefix(uppercasePrefix) {
165− items.append(SQLCompletionItem(text: kw, kind: .keyword))
166− }
167−
168− case .afterOrder, .afterGroup:
169− if "BY".hasPrefix(uppercasePrefix) {
170− items.append(SQLCompletionItem(text: "BY", kind: .keyword))
171− }
172−
173− case .general:
174− for kw in SQLSyntaxHighlighter.sortedKeywords where kw.hasPrefix(uppercasePrefix) {
175− items.append(SQLCompletionItem(text: kw, kind: .keyword))
176− }
177− for fn in SQLSyntaxHighlighter.sortedFunctions where fn.hasPrefix(uppercasePrefix) {
178− items.append(SQLCompletionItem(text: fn, kind: .function))
179− }
180− for name in streamNames.sorted() where name.uppercased().hasPrefix(uppercasePrefix) {
181− items.append(SQLCompletionItem(
182− text: "\"\(name)\"",
183− kind: .table,
184− matchText: name
185− ))
186− }
187− for field in schemaFields.sorted(by: { $0.name < $1.name })
188− where field.name.uppercased().hasPrefix(uppercasePrefix) {
189− items.append(columnItem(for: field))
190− }
191− }
192−
193− // Don't show the popup when the only match is an exact hit. Compare the
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
205− if isExactHit { return ([], prefix, range) }
206− }
207−
208− return (items, prefix, range)
209− }
210−
211− /// Whether the UTF-16 `offset` falls inside a `--`/`/* */` comment or a
212− /// `'...'` string literal. The caret at the very end of an unterminated
213− /// literal/comment (or of a line comment) is still inside it.
214− static func isInsideCommentOrStringLiteral(_ text: String, offset: Int) -> Bool {
215− for token in SQLTokenizer.tokenize(text) {
216− let range = NSRange(token.range, in: text)
217− // Tokens are in order; one starting at or after the caret can't contain it.
218− if range.location >= offset { break }
219− let end = NSMaxRange(range)
220− let isClosed: Bool
221− switch token.kind {
222− case .lineComment:
223− isClosed = false
224− case .blockComment:
225− isClosed = range.length >= 4 && text[token.range].hasSuffix("*/")
226− case .stringLiteral:
227− // `''` escapes pair up, so a terminated literal has an even count.
228− let quotes = text[token.range].filter { $0 == "'" }.count
229− isClosed = range.length >= 2 && quotes % 2 == 0
230− default:
231− continue
232− }
233− if offset < end || (offset == end && !isClosed) { return true }
234− }
235− return false
236− }
237−
238− /// A column completion, double-quoted when the name wouldn't survive as a bare
239− /// identifier. DataFusion (Parseable's query engine) folds unquoted identifiers
240− /// to lowercase, so inserting a mixed-case field bare (`statusCode`) queries a
241− /// nonexistent `statuscode`; names with dots, spaces, or other punctuation
242− /// (e.g. OTel's `service.name`) wouldn't parse as one identifier at all. Plain
243− /// lowercase names stay bare so the common case reads naturally.
244− static func columnItem(for field: SchemaField) -> SQLCompletionItem {
245− SQLCompletionItem(
246− text: quotedIdentifierIfNeeded(field.name),
247− kind: .column,
248− detail: field.dataType,
249− matchText: field.name
250− )
251− }
252−
253− static func quotedIdentifierIfNeeded(_ name: String) -> String {
254− let isPlainLowercase = name.unicodeScalars.enumerated().allSatisfy { index, scalar in
255− switch scalar {
256− case "a"..."z", "_": return true
257− case "0"..."9": return index > 0
258− default: return false
259− }
260− }
261− // A bare keyword/function name (`order`, `user`, `limit`, `date`) would be
262− // parsed as SQL syntax rather than as the column, so quote those too.
263− let upper = name.uppercased()
264− let isReservedWord = SQLSyntaxHighlighter.keywords.contains(upper)
265− || SQLSyntaxHighlighter.functions.contains(upper)
266− if !name.isEmpty && isPlainLowercase && !isReservedWord { return name }
267− return QueryViewModel.escapeSQLIdentifier(name)
268− }
269−
270− // MARK: - Context Detection
271−
272− 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)
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)
289−
290− guard let lastToken = tokens.last?.uppercased() else {
291− return .general
292− }
293−
294− switch lastToken {
295− case "FROM", "JOIN", "INNER", "LEFT", "RIGHT", "FULL", "CROSS", "NATURAL", "INTO":
296− return .tableRef
297− case "SELECT", "WHERE", "AND", "OR", "ON", "HAVING", "SET", "BY",
298− "WHEN", "THEN", "ELSE", "CASE", "DISTINCT", "NOT", "BETWEEN", "LIKE", "IN", "IS":
299− return .columnRef
300− case "ORDER":
301− return .afterOrder
302− case "GROUP":
303− return .afterGroup
304− default:
305− // After a `,` or an opening `(` the caret continues an argument/column
306− // list (e.g. `SELECT COUNT(`, `WHERE status IN (`, `SELECT a, `), so
307− // scan back to the governing keyword to decide column vs table context.
308− if lastToken == "," || lastToken == "(" {
309− for token in tokens.reversed() {
310− let upper = token.uppercased()
311− if ["SELECT", "BY", "WHERE", "HAVING", "ON"].contains(upper) {
312− return .columnRef
313− }
314− if ["FROM", "JOIN"].contains(upper) {
315− return .tableRef
316− }
317− }
318− return .columnRef
319− }
320− return .general
321− }
322− }
323−
324− // MARK: - Simple Tokenizer
325−
326− private static func tokenize(_ text: String) -> [String] {
327− var tokens: [String] = []
328− var current = ""
329− var inSingleQuote = false
330− var inDoubleQuote = false
331−
332− for ch in text {
333− if ch == "'" && !inDoubleQuote {
334− inSingleQuote.toggle()
335− current.append(ch)
336− } else if ch == "\"" && !inSingleQuote {
337− inDoubleQuote.toggle()
338− current.append(ch)
339− } else if inSingleQuote || inDoubleQuote {
340− current.append(ch)
341− } else if ch == "," {
342− let trimmed = current.trimmingCharacters(in: .whitespaces)
343− if !trimmed.isEmpty { tokens.append(trimmed) }
344− tokens.append(",")
345− current = ""
346− } else if ch == "(" || ch == ")" {
347− let trimmed = current.trimmingCharacters(in: .whitespaces)
348− if !trimmed.isEmpty { tokens.append(trimmed) }
349− tokens.append(String(ch))
350− current = ""
351− } else if ch.isWhitespace {
352− let trimmed = current.trimmingCharacters(in: .whitespaces)
353− if !trimmed.isEmpty { tokens.append(trimmed) }
354− current = ""
355− } else {
356− current.append(ch)
357− }
358− }
359− // Do not include the in-progress word — it is the prefix we're completing
360− return tokens
361− }
362−
363− static func isWordCharacter(_ ch: unichar) -> Bool {
364− (ch >= 0x41 && ch <= 0x5A) || // A-Z
365− (ch >= 0x61 && ch <= 0x7A) || // a-z
366− (ch >= 0x30 && ch <= 0x39) || // 0-9
367− ch == 0x5F || // _
368− ch >= 0x80 // any non-ASCII code unit: Unicode letters
369− // and digits in identifiers (e.g. accented or CJK schema field names), plus
370− // both halves of a surrogate pair. SQL delimiters — whitespace, operators,
371− // quotes, parens, commas — are all ASCII (< 0x80), so this only sweeps in
372− // genuine identifier characters and won't run a word past a token boundary.
373− }
374−}
375−
376−// MARK: - Completion Popup (child window with table view)
377−
378−final class SQLCompletionPopup: NSObject, NSTableViewDataSource, NSTableViewDelegate {
379−
380− private let panel: NSPanel
381− private let tableView: NSTableView
382− private let scrollView: NSScrollView
383− private static let rowHeight: CGFloat = 22
384− private static let maxVisibleRows = 8
385− private static let panelWidth: CGFloat = 320
386−
387− private(set) var items: [SQLCompletionItem] = []
388− var selectedIndex: Int = 0
389− var onAccept: ((SQLCompletionItem) -> Void)?
390−
391− override init() {
392− panel = NSPanel(
393− contentRect: .zero,
394− styleMask: [.borderless, .nonactivatingPanel],
395− backing: .buffered,
396− defer: true
397− )
398− panel.isFloatingPanel = true
399− panel.hidesOnDeactivate = true
400− panel.hasShadow = true
401− panel.backgroundColor = .clear
402− panel.isOpaque = false
403− panel.level = .popUpMenu
404−
405− // Table view
406− tableView = NSTableView()
407− tableView.headerView = nil
408− tableView.rowHeight = Self.rowHeight
409− tableView.intercellSpacing = NSSize(width: 4, height: 0)
410− tableView.selectionHighlightStyle = .regular
411− tableView.allowsEmptySelection = false
412− tableView.gridStyleMask = []
413−
414− let kindCol = NSTableColumn(identifier: NSUserInterfaceItemIdentifier("kind"))
415− kindCol.width = 22
416− kindCol.minWidth = 22
417− kindCol.maxWidth = 22
418− tableView.addTableColumn(kindCol)
419−
420− let textCol = NSTableColumn(identifier: NSUserInterfaceItemIdentifier("text"))
421− textCol.width = Self.panelWidth - 30
422− tableView.addTableColumn(textCol)
423−
424− // Scroll view
425− scrollView = NSScrollView()
426− scrollView.documentView = tableView
427− scrollView.hasVerticalScroller = true
428− scrollView.hasHorizontalScroller = false
429− scrollView.autohidesScrollers = true
430− scrollView.drawsBackground = true
431− scrollView.backgroundColor = .windowBackgroundColor
432− scrollView.wantsLayer = true
433− scrollView.layer?.cornerRadius = 6
434− scrollView.layer?.borderColor = NSColor.separatorColor.cgColor
435− scrollView.layer?.borderWidth = 0.5
436−
437− panel.contentView = scrollView
438−
439− super.init()
440−
441− tableView.dataSource = self
442− tableView.delegate = self
443− tableView.target = self
444− tableView.doubleAction = #selector(doubleClicked)
445− }
446−
447− // MARK: - Public API
448−
449− var isVisible: Bool { panel.isVisible }
450−
451− func update(items: [SQLCompletionItem]) {
452− self.items = items
453− selectedIndex = 0
454− tableView.reloadData()
455−
456− if items.isEmpty {
457− hide()
458− return
459− }
460−
461− if !items.isEmpty {
462− tableView.selectRowIndexes(IndexSet(integer: 0), byExtendingSelection: false)
463− tableView.scrollRowToVisible(0)
464− }
465−
466− let rowCount = min(items.count, Self.maxVisibleRows)
467− let height = CGFloat(rowCount) * Self.rowHeight + 4
468− // Anchor by the top edge (where the caret sits), matching show()/reposition().
469− // The panel's origin is its bottom-left corner, so holding origin.y fixed
470− // while the height changes would move the top edge — growing the list would
471− // creep the popup upward over the caret. Pin the top edge instead.
472− let topEdge = panel.frame.origin.y + panel.frame.height
473− let frame = NSRect(x: panel.frame.origin.x, y: topEdge - height,
474− width: Self.panelWidth, height: height)
475− panel.setFrame(frame, display: true)
476− }
477−
478− func show(at screenPoint: NSPoint, in parentWindow: NSWindow) {
479− let rowCount = min(items.count, Self.maxVisibleRows)
480− let height = CGFloat(rowCount) * Self.rowHeight + 4
481− let frame = NSRect(x: screenPoint.x, y: screenPoint.y - height,
482− width: Self.panelWidth, height: height)
483− panel.setFrame(frame, display: true)
484− parentWindow.addChildWindow(panel, ordered: .above)
485− panel.orderFront(nil)
486− }
487−
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() {
500− panel.parent?.removeChildWindow(panel)
501− panel.orderOut(nil)
502− }
503−
504− func moveSelectionUp() {
505− guard !items.isEmpty else { return }
506− selectedIndex = max(0, selectedIndex - 1)
507− tableView.selectRowIndexes(IndexSet(integer: selectedIndex), byExtendingSelection: false)
508− tableView.scrollRowToVisible(selectedIndex)
509− }
510−
511− func moveSelectionDown() {
512− guard !items.isEmpty else { return }
513− selectedIndex = min(items.count - 1, selectedIndex + 1)
514− tableView.selectRowIndexes(IndexSet(integer: selectedIndex), byExtendingSelection: false)
515− tableView.scrollRowToVisible(selectedIndex)
516− }
517−
518− func acceptSelection() {
519− guard selectedIndex >= 0, selectedIndex < items.count else { return }
520− onAccept?(items[selectedIndex])
521− hide()
522− }
523−
524− // MARK: - NSTableViewDataSource
525−
526− func numberOfRows(in tableView: NSTableView) -> Int {
527− items.count
528− }
529−
530− // MARK: - NSTableViewDelegate
531−
532− func tableViewSelectionDidChange(_ notification: Notification) {
533− // A single click selects a row in the table directly. Keep `selectedIndex`
534− // — what Return/Tab accept and what the arrow keys move from — in sync,
535− // otherwise Return inserts the previously highlighted item instead of the
536− // one the user clicked.
537− let row = tableView.selectedRow
538− if row >= 0, row < items.count {
539− selectedIndex = row
540− }
541− }
542−
543− func tableView(_ tableView: NSTableView, viewFor tableColumn: NSTableColumn?, row: Int) -> NSView? {
544− guard row < items.count else { return nil }
545− let item = items[row]
546−
547− if tableColumn?.identifier.rawValue == "kind" {
548− let id = NSUserInterfaceItemIdentifier("KindCell")
549− let cell: NSTextField
550− if let reused = tableView.makeView(withIdentifier: id, owner: nil) as? NSTextField {
551− cell = reused
552− } else {
553− cell = NSTextField(labelWithString: "")
554− cell.identifier = id
555− cell.alignment = .center
556− cell.font = .monospacedSystemFont(ofSize: NSFont.smallSystemFontSize, weight: .bold)
557− }
558− cell.stringValue = item.kindLabel
559− cell.textColor = item.kindColor
560− return cell
561− }
562−
563− let id = NSUserInterfaceItemIdentifier("TextCell")
564− let cell: NSTextField
565− if let reused = tableView.makeView(withIdentifier: id, owner: nil) as? NSTextField {
566− cell = reused
567− } else {
568− cell = NSTextField(labelWithString: "")
569− cell.identifier = id
570− cell.font = .monospacedSystemFont(ofSize: NSFont.systemFontSize, weight: .regular)
571− cell.lineBreakMode = .byTruncatingTail
572− cell.maximumNumberOfLines = 1
573− }
574−
575− if let detail = item.detail {
576− cell.stringValue = "\(item.text) \(detail)"
577− } else {
578− cell.stringValue = item.text
579− }
580− cell.textColor = .labelColor
581− return cell
582− }
583−
584− @objc private func doubleClicked() {
585− let row = tableView.clickedRow
586− guard row >= 0, row < items.count else { return }
587− selectedIndex = row
588− acceptSelection()
589− }
590−}
ParseableViewer/Views/SQLEditorView.swiftdeleted−403
@@ -1,403 +0,0 @@
1−import AppKit
2−import SwiftUI
3−
4−/// A syntax-highlighted SQL editor backed by NSTextView with autocomplete support.
5−struct SQLEditorView: NSViewRepresentable {
6− @Binding var text: String
7− var streamNames: [String] = []
8− var schemaFields: [SchemaField] = []
9− var errorRange: NSRange?
10−
11− static let editorFont = NSFont.monospacedSystemFont(ofSize: NSFont.systemFontSize, weight: .regular)
12−
13− func makeNSView(context: Context) -> NSScrollView {
14− let scrollView = NSTextView.scrollableTextView()
15− guard let textView = scrollView.documentView as? NSTextView else { return scrollView }
16−
17− textView.font = Self.editorFont
18− textView.textColor = .labelColor
19− textView.backgroundColor = .textBackgroundColor
20− textView.drawsBackground = true
21− textView.isEditable = true
22− textView.isSelectable = true
23− textView.allowsUndo = true
24− textView.isAutomaticQuoteSubstitutionEnabled = false
25− textView.isAutomaticDashSubstitutionEnabled = false
26− textView.isAutomaticTextReplacementEnabled = false
27− textView.isAutomaticSpellingCorrectionEnabled = false
28− textView.isAutomaticTextCompletionEnabled = false
29− textView.isGrammarCheckingEnabled = false
30− textView.isContinuousSpellCheckingEnabled = false
31− textView.usesFindPanel = true
32− textView.textContainerInset = NSSize(width: 4, height: 4)
33− textView.isHorizontallyResizable = false
34− textView.isVerticallyResizable = true
35− textView.autoresizingMask = [.width]
36− textView.textContainer?.widthTracksTextView = true
37−
38− textView.delegate = context.coordinator
39−
40− textView.string = text
41− context.coordinator.applyHighlighting(to: textView)
42− context.coordinator.updateErrorHighlight(in: textView, errorRange: errorRange)
43−
44− scrollView.hasVerticalScroller = true
45− scrollView.hasHorizontalScroller = false
46− scrollView.drawsBackground = false
47−
48− return scrollView
49− }
50−
51− func updateNSView(_ nsView: NSScrollView, context: Context) {
52− guard let textView = nsView.documentView as? NSTextView else { return }
53−
54− context.coordinator.streamNames = streamNames
55− context.coordinator.schemaFields = schemaFields
56−
57− // Only update when text changed externally (not from user typing)
58− if textView.string != text && !context.coordinator.isEditing {
59− let selectedRanges = textView.selectedRanges
60− context.coordinator.replaceText(with: text, in: textView)
61− context.coordinator.applyHighlighting(to: textView)
62− // NSRange is UTF-16 based; compare against the NSString length, not
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 {
75− textView.selectedRanges = clampedRanges
76− }
77− }
78− context.coordinator.updateErrorHighlight(in: textView, errorRange: errorRange)
79− }
80−
81− func makeCoordinator() -> Coordinator {
82− Coordinator(text: $text, streamNames: streamNames, schemaFields: schemaFields)
83− }
84−
85− static func dismantleNSView(_ nsView: NSScrollView, coordinator: Coordinator) {
86− coordinator.cleanup()
87− }
88−
89− final class Coordinator: NSObject, NSTextViewDelegate {
90− var text: Binding<String>
91− var isEditing = false
92− var streamNames: [String]
93− var schemaFields: [SchemaField]
94−
95− /// Tracks the currently applied error underline so we can clear it.
96− private var lastAppliedErrorRange: NSRange?
97− /// The text content when the error was applied; used to detect stale ranges.
98− private var errorAppliedForText: String?
99−
100− private let completionPopup = SQLCompletionPopup()
101− /// Tracks whether we are inserting a completion so that `textDidChange`
102− /// doesn't re-trigger the popup for the replacement.
103− private var isInsertingCompletion = false
104− /// Set while `replaceText(with:in:)` pushes a model-side change (history
105− /// pick, saved filter, cell filter, column toggle…) into the text view, so
106− /// `textDidChange` doesn't echo it back into the binding or open the
107− /// completion popup for text the user didn't type.
108− private var isApplyingModelChange = false
109−
110− init(text: Binding<String>, streamNames: [String], schemaFields: [SchemaField]) {
111− self.text = text
112− self.streamNames = streamNames
113− self.schemaFields = schemaFields
114− super.init()
115−
116− completionPopup.onAccept = { [weak self] item in
117− self?.insertCompletion(item)
118− }
119− }
120−
121− // MARK: - NSTextViewDelegate
122−
123− func textDidChange(_ notification: Notification) {
124− guard let textView = notification.object as? NSTextView,
125− !isApplyingModelChange else { return }
126− isEditing = true
127− clearErrorHighlight(in: textView)
128− text.wrappedValue = textView.string
129− applyHighlighting(to: textView)
130− isEditing = false
131−
132− if !isInsertingCompletion {
133− updateCompletions(for: textView)
134− }
135− }
136−
137− /// Replaces the editor's contents with text set from outside the editor.
138− /// Assigning `textView.string` directly bypasses the text system's undo
139− /// registration and leaves the user's earlier typing on the undo stack with
140− /// ranges that belong to the old text — Cmd+Z would then edit an arbitrary
141− /// span of the new query (or raise on an out-of-range undo). Routing the
142− /// change through `shouldChangeText`/`didChangeText` records it as one
143− /// undoable step, so Cmd+Z restores the previous query instead.
144− func replaceText(with newText: String, in textView: NSTextView) {
145− let fullRange = NSRange(location: 0, length: (textView.string as NSString).length)
146− isApplyingModelChange = true
147− defer { isApplyingModelChange = false }
148− textView.breakUndoCoalescing()
149− if textView.shouldChangeText(in: fullRange, replacementString: newText) {
150− textView.replaceCharacters(in: fullRange, with: newText)
151− textView.didChangeText()
152− } else {
153− textView.string = newText
154− }
155− textView.breakUndoCoalescing()
156− }
157−
158− func textDidEndEditing(_ notification: Notification) {
159− // The popup is a separate child panel; without this it keeps floating
160− // over the results (and accepting double-clicks) after focus moves to
161− // another control.
162− completionPopup.hide()
163− }
164−
165− func textView(_ textView: NSTextView, doCommandBy commandSelector: Selector) -> Bool {
166− guard completionPopup.isVisible else { return false }
167−
168− switch commandSelector {
169− case #selector(NSResponder.moveDown(_:)):
170− completionPopup.moveSelectionDown()
171− return true
172− case #selector(NSResponder.moveUp(_:)):
173− completionPopup.moveSelectionUp()
174− return true
175− case #selector(NSResponder.insertTab(_:)):
176− completionPopup.acceptSelection()
177− return true
178− case #selector(NSResponder.insertNewline(_:)):
179− completionPopup.acceptSelection()
180− return true
181− case #selector(NSResponder.cancelOperation(_:)):
182− completionPopup.hide()
183− return true
184− default:
185− return false
186− }
187− }
188−
189− // MARK: - Completions
190−
191− 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
200− let result = SQLCompletionProvider.completions(
201− for: textView.string,
202− cursorPosition: cursorPosition,
203− streamNames: streamNames,
204− schemaFields: schemaFields
205− )
206−
207− guard !result.items.isEmpty else {
208− completionPopup.hide()
209− return
210− }
211−
212− completionPopup.update(items: result.items)
213−
214− if let window = textView.window {
215− let screenPoint = cursorScreenPoint(for: textView)
216− if completionPopup.isVisible {
217− completionPopup.reposition(at: screenPoint)
218− } else {
219− completionPopup.show(at: screenPoint, in: window)
220− }
221− }
222− }
223−
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) {
231− // Find the text view — walk up from the popup's parent window
232− guard let textView = findTextView() else { return }
233−
234− let cursorPosition = textView.selectedRange().location
235− let nsText = textView.string as NSString
236−
237− // Find the full word range to replace. Scan backward for the start and
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
241− while wordStart > 0 {
242− let ch = nsText.character(at: wordStart - 1)
243− if SQLCompletionProvider.isWordCharacter(ch) {
244− wordStart -= 1
245− } else {
246− break
247− }
248− }
249−
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)
276−
277− isInsertingCompletion = true
278− defer { isInsertingCompletion = false }
279−
280− if textView.shouldChangeText(in: replaceRange, replacementString: item.text) {
281− textView.replaceCharacters(in: replaceRange, with: item.text)
282− textView.didChangeText()
283− // Update the binding
284− isEditing = true
285− text.wrappedValue = textView.string
286− applyHighlighting(to: textView)
287− isEditing = false
288− }
289− }
290−
291− private weak var lastTextView: NSTextView?
292−
293− private func findTextView() -> NSTextView? {
294− lastTextView
295− }
296−
297− /// Called from textDidChange / doCommandBy to record the active text view.
298− func textDidBeginEditing(_ notification: Notification) {
299− if let tv = notification.object as? NSTextView {
300− lastTextView = tv
301− }
302− }
303−
304− func textViewDidChangeSelection(_ notification: Notification) {
305− if let tv = notification.object as? NSTextView {
306− lastTextView = tv
307− // Hide popup when cursor moves without typing (e.g. mouse click)
308− if !isEditing && !isInsertingCompletion {
309− completionPopup.hide()
310− }
311− }
312− }
313−
314− // MARK: - Error Highlighting
315−
316− func updateErrorHighlight(in textView: NSTextView, errorRange: NSRange?) {
317− guard let layoutManager = textView.layoutManager else { return }
318− let text = textView.string
319−
320− // If text changed since we last applied an error, clear stale highlight
321− if let appliedText = errorAppliedForText, appliedText != text {
322− clearErrorHighlight(in: textView)
323− }
324−
325− guard let range = errorRange else {
326− if lastAppliedErrorRange != nil {
327− clearErrorHighlight(in: textView)
328− }
329− return
330− }
331−
332− // Validate range is within bounds
333− let fullLength = (text as NSString).length
334− guard range.location + range.length <= fullLength else { return }
335−
336− // Don't re-apply the same highlight
337− if lastAppliedErrorRange == range && errorAppliedForText == text { return }
338−
339− // Clear any previous highlight first
340− if lastAppliedErrorRange != nil {
341− clearErrorHighlight(in: textView)
342− }
343−
344− layoutManager.addTemporaryAttribute(
345− .underlineStyle,
346− value: NSUnderlineStyle.thick.rawValue,
347− forCharacterRange: range
348− )
349− layoutManager.addTemporaryAttribute(
350− .underlineColor,
351− value: NSColor.systemRed,
352− forCharacterRange: range
353− )
354−
355− lastAppliedErrorRange = range
356− errorAppliedForText = text
357− }
358−
359− func clearErrorHighlight(in textView: NSTextView) {
360− guard let layoutManager = textView.layoutManager else { return }
361− let fullRange = NSRange(location: 0, length: (textView.string as NSString).length)
362− layoutManager.removeTemporaryAttribute(.underlineStyle, forCharacterRange: fullRange)
363− layoutManager.removeTemporaryAttribute(.underlineColor, forCharacterRange: fullRange)
364− lastAppliedErrorRange = nil
365− errorAppliedForText = nil
366− }
367−
368− // MARK: - Helpers
369−
370− func applyHighlighting(to textView: NSTextView) {
371− guard let textStorage = textView.textStorage else { return }
372− textStorage.beginEditing()
373− SQLSyntaxHighlighter.highlight(textView.string, in: textStorage, baseFont: SQLEditorView.editorFont)
374− textStorage.endEditing()
375− // Keep track of the text view for completion insertion
376− lastTextView = textView
377− }
378−
379− private func cursorScreenPoint(for textView: NSTextView) -> NSPoint {
380− guard let layoutManager = textView.layoutManager,
381− let container = textView.textContainer else {
382− // Fallback: bottom-left of the text view
383− let fallback = NSPoint(x: textView.frame.origin.x, y: textView.frame.maxY)
384− let windowPoint = textView.convert(fallback, to: nil)
385− return textView.window?.convertPoint(toScreen: windowPoint) ?? windowPoint
386− }
387− let cursorRange = textView.selectedRange()
388− let glyphRange = layoutManager.glyphRange(
389− forCharacterRange: NSRange(location: cursorRange.location, length: 0),
390− actualCharacterRange: nil
391− )
392− let rect = layoutManager.boundingRect(forGlyphRange: glyphRange, in: container)
393− // textContainerOrigin already accounts for textContainerInset
394− let origin = textView.textContainerOrigin
395− let viewPoint = NSPoint(
396− x: rect.origin.x + origin.x,
397− y: rect.maxY + origin.y + 2
398− )
399− let windowPoint = textView.convert(viewPoint, to: nil)
400− return textView.window?.convertPoint(toScreen: windowPoint) ?? windowPoint
401− }
402− }
403−}
ParseableViewer/Views/ServerInfoView.swift+17−71
@@ -3,15 +3,9 @@ import SwiftUI
33struct ServerInfoView: View {
44 @Environment(AppState.self) private var appState
55 @State private var about: ServerAbout?
6− // Start in the loading state: the `.task` always runs `loadInfo()` on appear, so
7− // initializing to `false` would paint the `else` branch's red "Unhealthy" dot for
8− // one frame before the check begins. Begin neutral ("Checking…") instead.
9− @State private var isLoading = true
6+ @State private var isLoading = false
107 @State private var isHealthy = false
118 @State private var errorMessage: String?
12− /// Bumped by every load so a superseded (cancelled) load can't clear the
13− /// newer load's spinner or overwrite its results.
14− @State private var loadGeneration = 0
159
1610 var body: some View {
1711 ScrollView {
@@ -22,7 +16,7 @@ struct ServerInfoView: View {
2216 .fontWeight(.semibold)
2317 Spacer()
2418 Button {
25− Task { await loadInfo(forceRefresh: true) }
19+ Task { await loadInfo() }
2620 } label: {
2721 Image(systemName: "arrow.clockwise")
2822 }
@@ -47,31 +41,18 @@ struct ServerInfoView: View {
4741 // Health status
4842 GroupBox("Health") {
4943 HStack {
50− // While the check is in flight, show a neutral state
51− // rather than the default `isHealthy == false`, which
52− // otherwise flashes a red "Unhealthy" dot on every
53− // connection switch before the result resolves.
54− if isLoading {
55− Circle()
56− .fill(.gray)
57− .frame(width: 12, height: 12)
58− .accessibilityLabel("Checking health")
59− Text("Checking…")
60− .fontWeight(.medium)
61− } else {
62− Circle()
63− .fill(isHealthy ? .green : .red)
64− .frame(width: 12, height: 12)
65− .accessibilityLabel(isHealthy ? "Healthy" : "Unhealthy")
66− Text(isHealthy ? "Healthy" : "Unhealthy")
67− .fontWeight(.medium)
68− }
44+ Circle()
45+ .fill(isHealthy ? .green : .red)
46+ .frame(width: 12, height: 12)
47+ .accessibilityLabel(isHealthy ? "Healthy" : "Unhealthy")
48+ Text(isHealthy ? "Healthy" : "Unhealthy")
49+ .fontWeight(.medium)
6950 Spacer()
7051 }
7152 .padding(8)
7253 }
7354
74− if let about {
55+ if let about = about ?? appState.serverAbout {
7556 GroupBox("Server Details") {
7657 VStack(alignment: .leading, spacing: 8) {
7758 InfoRow(label: "Version", value: about.version ?? "N/A")
@@ -142,67 +123,32 @@ struct ServerInfoView: View {
142123 }
143124 .padding()
144125 }
145− // Key on the active connection so switching (or reconnecting to a
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
126+ .task {
155127 await loadInfo()
156128 }
157129 }
158130
159− private func loadInfo(forceRefresh: Bool = false) async {
160− // Clear isLoading on the no-client path: since isLoading now starts `true`
161− // (to avoid a red "Unhealthy" flash before the first check), an early return
162− // without resetting it would wedge the spinner permanently.
163− guard let client = appState.client else { isLoading = false; return }
164− // `getAbout()` is cached for 60 s; the Refresh button must bypass that.
165− if forceRefresh {
166− await client.invalidateCache()
167− }
168− let connID = appState.activeConnection?.id
169− loadGeneration &+= 1
170− let generation = loadGeneration
131+ private func loadInfo() async {
132+ guard let client = appState.client else { return }
171133 isLoading = true
172134 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 }
176135
177136 async let healthCheck: Void = client.checkHealth()
178137 async let aboutResult = client.getAbout()
179138
180− let healthy: Bool
181139 do {
182140 try await healthCheck
183− healthy = true
141+ isHealthy = true
184142 } catch {
185− healthy = false
143+ isHealthy = false
186144 }
187− let aboutValue: ServerAbout?
188− let aboutError: String?
189145 do {
190− aboutValue = try await aboutResult
191− aboutError = nil
146+ about = try await aboutResult
192147 } catch {
193− aboutValue = nil
194− aboutError = ParseableError.userFriendlyMessage(for: error)
148+ about = nil
149+ errorMessage = ParseableError.userFriendlyMessage(for: error)
195150 }
196151
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
206152 isLoading = false
207153 }
208154}
ParseableViewer/Views/SettingsView.swift+2−21
@@ -6,13 +6,6 @@ struct SettingsView: View {
66 @AppStorage("maxQueryResults") private var maxQueryResults = 1000
77 @AppStorage("liveTailPollInterval") private var liveTailPollInterval = 2.0
88 @AppStorage("liveTailMaxEntries") private var liveTailMaxEntries = 5000
9− // The Settings scene is a separate window from ContentView. A .sheet presents
10− // over the window hosting the modifier, and binding both windows to the shared
11− // appState.showConnectionSheet would present two sheets at once. So the Settings
12− // window drives its own connection sheet with local state, fully independent of
13− // the main window's.
14− @State private var showConnectionSheet = false
15− @State private var sheetConnection: ServerConnection?
169 var body: some View {
1710 TabView {
1811 // General settings
@@ -83,20 +76,11 @@ struct SettingsView: View {
8376 .clipShape(RoundedRectangle(cornerRadius: 4))
8477 }
8578
86− Button {
87− sheetConnection = connection
88− showConnectionSheet = true
89− } label: {
90− Image(systemName: "pencil")
91− }
92− .help("Edit connection")
93−
9479 Button(role: .destructive) {
9580 appState.removeConnection(connection)
9681 } label: {
9782 Image(systemName: "trash")
9883 }
99− .help("Delete connection")
10084 }
10185 .padding(.vertical, 4)
10286 }
@@ -105,8 +89,8 @@ struct SettingsView: View {
10589 HStack {
10690 Spacer()
10791 Button("Add Connection...") {
108− sheetConnection = nil
109− showConnectionSheet = true
92+ appState.editingConnection = nil
93+ appState.showConnectionSheet = true
11094 }
11195 }
11296 .padding()
@@ -116,8 +100,5 @@ struct SettingsView: View {
116100 }
117101 }
118102 .frame(width: 500, height: 400)
119− .sheet(isPresented: $showConnectionSheet) {
120− ConnectionSheet(connection: sheetConnection)
121− }
122103 }
123104}
ParseableViewer/Views/SidebarView.swift+48−101
@@ -6,19 +6,11 @@ struct SidebarView: View {
66 @State private var newStreamName = ""
77 @State private var showDeleteConfirm = false
88 @State private var streamToDelete: String?
9− /// The connection `streamToDelete` was picked on. Stream names aren't unique
10− /// across servers, and the delete runs against whatever client is current
11− /// when the user confirms — so a server switch while the stats lookup was in
12− /// flight could otherwise delete a same-named stream on the *new* server.
13− @State private var streamToDeleteConnectionID: UUID?
14− /// The client the stream was picked with. Editing the active connection's URL
15− /// keeps its id but installs a new client for a possibly different server.
16− @State private var streamToDeleteClient: ParseableClient?
179 @State private var deleteStreamDetail: String?
1810
1911 /// Validates a stream name, returning an error message or nil if valid.
2012 static func validateStreamName(_ name: String) -> String? {
21− if name.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty {
13+ if name.trimmingCharacters(in: .whitespaces).isEmpty {
2214 return "Stream name cannot be empty."
2315 }
2416 if name.count > 255 {
@@ -66,21 +58,11 @@ struct SidebarView: View {
6658 }
6759 Divider()
6860 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
78− streamToDeleteConnectionID = targetConnectionID
79− streamToDeleteClient = appState.client
61+ streamToDelete = stream.name
8062 deleteStreamDetail = nil
8163 Task {
8264 if let client = appState.client,
83− let stats = try? await client.getStreamStats(stream: target) {
65+ let stats = try? await client.getStreamStats(stream: stream.name) {
8466 var parts: [String] = []
8567 if let count = stats.ingestion?.lifetime_count {
8668 parts.append("\(count) total records ingested")
@@ -88,25 +70,21 @@ struct SidebarView: View {
8870 if let size = stats.storage?.lifetime_size ?? stats.storage?.size {
8971 parts.append("\(size) storage used")
9072 }
91− if !parts.isEmpty, streamToDelete == target {
73+ if !parts.isEmpty {
9274 deleteStreamDetail = parts.joined(separator: ", ")
9375 }
9476 }
95− if streamToDelete == target,
96− appState.activeConnection?.id == targetConnectionID,
97− appState.client === streamToDeleteClient {
98− showDeleteConfirm = true
99− }
77+ showDeleteConfirm = true
10078 }
10179 }
10280 }
10381 }
10482 }
10583
106− if !appState.filters.isEmpty {
107− Section("Filters") {
108− ForEach(appState.filters) { filter in
109− FilterRow(filter: filter)
84+ if !appState.savedQueries.isEmpty {
85+ Section("Saved Queries") {
86+ ForEach(appState.savedQueries) { query in
87+ SavedQueryRow(query: query)
11088 }
11189 }
11290 }
@@ -177,11 +155,10 @@ struct SidebarView: View {
177155 .alert("Create Log Stream", isPresented: $showCreateStream) {
178156 TextField("Stream name", text: $newStreamName)
179157 Button("Create") {
180− let name = newStreamName.trimmingCharacters(in: .whitespacesAndNewlines)
181− guard !name.isEmpty else { newStreamName = ""; return }
158+ let name = newStreamName.trimmingCharacters(in: .whitespaces)
159+ guard !name.isEmpty else { return }
182160 if let error = Self.validateStreamName(name) {
183161 appState.showErrorMessage(error)
184− newStreamName = ""
185162 return
186163 }
187164 Task {
@@ -200,11 +177,6 @@ struct SidebarView: View {
200177 .alert("Delete Stream", isPresented: $showDeleteConfirm) {
201178 Button("Delete", role: .destructive) {
202179 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− }
208180 Task {
209181 do {
210182 try await appState.deleteStream(name: name)
@@ -227,7 +199,6 @@ struct SidebarView: View {
227199
228200struct ConnectionStatusView: View {
229201 @Environment(AppState.self) private var appState
230− static let addConnectionID = UUID(uuidString: "00000000-0000-0000-0000-000000000000")!
231202
232203 var body: some View {
233204 VStack(alignment: .leading, spacing: 4) {
@@ -237,35 +208,40 @@ struct ConnectionStatusView: View {
237208 .fill(appState.isConnected ? .green : .red)
238209 .frame(width: 8, height: 8)
239210 .accessibilityLabel(appState.isConnected ? "Connected" : "Disconnected")
240− Picker("Connection", selection: Binding<UUID>(
241− get: { connection.id },
242− set: { newID in
243− if newID == ConnectionStatusView.addConnectionID {
244− appState.editingConnection = nil
245− appState.showConnectionSheet = true
246− } else if newID != connection.id,
247− let conn = appState.connections.first(where: { $0.id == newID }) {
248− // Only reconnect when a *different* connection is
249− // picked. Re-selecting the active one would tear
250− // down and rebuild the client for no reason (a full
251− // health-check + stream reload, with a visible flicker).
211+ Text(connection.name)
212+ .font(.headline)
213+ .lineLimit(1)
214+ Spacer()
215+ Menu {
216+ ForEach(appState.connections) { conn in
217+ Button(conn.name) {
252218 Task { await appState.connect(to: conn) }
253219 }
254220 }
255− )) {
256− Section {
257− ForEach(appState.connections) { conn in
258− Text(conn.name).tag(conn.id)
259− }
221+ Divider()
222+ Button("Add Connection...") {
223+ appState.editingConnection = nil
224+ appState.showConnectionSheet = true
260225 }
261− Section {
262− Text("Add Connection...").tag(ConnectionStatusView.addConnectionID)
226+ Button("Edit Connection...") {
227+ appState.editingConnection = connection
228+ appState.showConnectionSheet = true
263229 }
230+ Divider()
231+ Button("Disconnect") {
232+ appState.disconnect()
233+ }
234+ } label: {
235+ Image(systemName: "ellipsis.circle")
264236 }
265− .labelsHidden()
266− .pickerStyle(.menu)
267− .accessibilityLabel("Connection selector")
237+ .menuStyle(.borderlessButton)
238+ .frame(width: 24)
239+ .accessibilityLabel("Connection options")
268240 }
241+ Text(connection.url)
242+ .font(.caption)
243+ .foregroundStyle(.secondary)
244+ .lineLimit(1)
269245 } else if appState.isConnecting {
270246 HStack {
271247 ProgressView()
@@ -298,51 +274,32 @@ struct StreamRowView: View {
298274 }
299275}
300276
301−struct FilterRow: View {
277+struct SavedQueryRow: View {
302278 @Environment(AppState.self) private var appState
303− let filter: ParseableFilter
304− @State private var isDeleting = false
279+ let query: SavedQuery
305280
306281 private var streamExists: Bool {
307− appState.streams.contains { $0.name == filter.streamName }
308− }
309−
310− private var isSQLFilter: Bool {
311− filter.query.filterType == "sql"
282+ appState.streams.contains { $0.name == query.stream }
312283 }
313284
314285 var body: some View {
315286 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
321− if let sql = filter.query.filterQuery {
322− appState.pendingFilterSQL = sql
323− }
287+ appState.selectedStream = query.stream
288+ appState.pendingSavedQuerySQL = query.sql
324289 appState.currentTab = .query
325290 } label: {
326291 HStack {
327292 Image(systemName: "bookmark")
328− .foregroundStyle(streamExists ? Color.secondary : Color.orange)
293+ .foregroundStyle(streamExists ? .orange : .secondary)
329294 VStack(alignment: .leading) {
330− Text(filter.filterName)
295+ Text(query.name)
331296 .lineLimit(1)
332297 HStack(spacing: 4) {
333− Text(filter.streamName)
334− if !isSQLFilter {
335− Text(filter.query.filterType)
336− .font(.caption2)
337− .padding(.horizontal, 4)
338− .padding(.vertical, 1)
339− .background(.quaternary)
340− .clipShape(RoundedRectangle(cornerRadius: 3))
341− }
298+ Text(query.stream)
342299 if !streamExists {
343300 Image(systemName: "exclamationmark.triangle.fill")
344301 .foregroundStyle(.orange)
345− .help("Stream \"\(filter.streamName)\" no longer exists")
302+ .help("Stream \"\(query.stream)\" no longer exists")
346303 }
347304 }
348305 .font(.caption2)
@@ -353,18 +310,8 @@ struct FilterRow: View {
353310 .buttonStyle(.plain)
354311 .contextMenu {
355312 Button("Delete", role: .destructive) {
356− guard !isDeleting else { return }
357− isDeleting = true
358− Task {
359− do {
360− try await appState.deleteFilter(filter)
361− } catch {
362− appState.showErrorMessage(ParseableError.userFriendlyMessage(for: error))
363− }
364− isDeleting = false
365− }
313+ appState.removeSavedQuery(query)
366314 }
367− .disabled(isDeleting)
368315 }
369316 }
370317}
ParseableViewer/Views/StreamDetailView.swift+17−56
@@ -8,9 +8,6 @@ struct StreamDetailView: View {
88 @State private var retention: [RetentionConfig] = []
99 @State private var isLoading = false
1010 @State private var errorMessage: String?
11− /// Bumped by every load. A superseded load (its `.task(id:)` cancelled by a
12− /// stream switch) still resumes, and must not clear the newer load's spinner.
13− @State private var loadGeneration = 0
1411
1512 var body: some View {
1613 Group {
@@ -23,12 +20,20 @@ struct StreamDetailView: View {
2320 Text(stream)
2421 .font(.title2)
2522 .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+ }
2631 }
2732
2833 Spacer()
2934
3035 Button {
31− Task { await loadData(stream: stream, forceRefresh: true) }
36+ Task { await loadData(stream: stream) }
3237 } label: {
3338 Image(systemName: "arrow.clockwise")
3439 }
@@ -47,7 +52,7 @@ struct StreamDetailView: View {
4752 Text(error)
4853 Spacer()
4954 Button("Retry") {
50− Task { await loadData(stream: stream, forceRefresh: true) }
55+ Task { await loadData(stream: stream) }
5156 }
5257 .buttonStyle(.bordered)
5358 .controlSize(.small)
@@ -160,11 +165,7 @@ struct StreamDetailView: View {
160165 }
161166 .padding()
162167 }
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) {
168169 await loadData(stream: stream)
169170 }
170171 } else {
@@ -177,22 +178,8 @@ struct StreamDetailView: View {
177178 }
178179 }
179180
180− private func loadData(stream: String, forceRefresh: Bool = false) async {
181+ private func loadData(stream: String) async {
181182 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
196183 isLoading = true
197184 errorMessage = nil
198185 // Clear previous stream's data so it isn't shown under the new header
@@ -209,43 +196,17 @@ struct StreamDetailView: View {
209196
210197 var failures: [String] = []
211198 var sawNotFound = false
212− var newSchema: StreamSchema?
213− var newStats: StreamStats?
214− var newInfo: StreamInfo?
215− var newRetention: [RetentionConfig] = []
216199
217200 func checkNotFound(_ error: Error) {
218201 if case .serverError(let code, _) = error as? ParseableError, code == 404 { sawNotFound = true }
219202 }
220203
221− do { newSchema = try await schemaFetch } catch { failures.append("schema"); checkNotFound(error) }
222− do { newStats = try await statsFetch } catch { failures.append("stats"); checkNotFound(error) }
223− do { newInfo = try await infoFetch } catch { failures.append("info"); checkNotFound(error) }
224− do { newRetention = try await retentionFetch } catch { 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
204+ do { schema = try await schemaFetch } catch { schema = nil; failures.append("schema"); checkNotFound(error) }
205+ do { stats = try await statsFetch } catch { stats = nil; failures.append("stats"); checkNotFound(error) }
206+ do { info = try await infoFetch } catch { info = nil; failures.append("info"); checkNotFound(error) }
207+ do { retention = try await retentionFetch } catch { retention = []; failures.append("retention"); checkNotFound(error) }
243208
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 {
249210 errorMessage = "Stream \"\(stream)\" was not found. It may have been deleted."
250211 } else if !failures.isEmpty {
251212 errorMessage = "Failed to load: \(failures.joined(separator: ", "))"
ParseableViewer/Views/SyntaxHighlighting.swiftdeleted−251
@@ -1,251 +0,0 @@
1−import AppKit
2−import SwiftUI
3−
4−// MARK: - SQL Syntax Highlighting
5−
6−enum SQLSyntaxHighlighter {
7−
8− static let keywords: Set<String> = [
9− "ADD", "ALL", "ALTER", "AND", "AS", "ASC",
10− "BETWEEN", "BY",
11− "CASE", "CAST", "CREATE", "CROSS", "CUBE", "CURRENT",
12− "DELETE", "DESC", "DISTINCT", "DROP",
13− "ELSE", "END", "EXCEPT", "EXISTS", "EXTRACT",
14− "FALSE", "FETCH", "FILTER", "FIRST", "FOLLOWING", "FOR", "FROM", "FULL",
15− "GROUP", "GROUPING",
16− "HAVING",
17− "IF", "IN", "INNER", "INSERT", "INTERSECT", "INTERVAL", "INTO", "IS",
18− "JOIN",
19− "LATERAL", "LEFT", "LIKE", "LIMIT",
20− "NATURAL", "NEXT", "NOT", "NULL",
21− "OFFSET", "ON", "ONLY", "OR", "ORDER", "OUTER", "OVER",
22− "PARTITION", "PERCENT", "PRECEDING",
23− "RANGE", "RECURSIVE", "RIGHT", "ROLLUP", "ROW", "ROWS",
24− "SELECT", "SET", "SETS",
25− "TABLE", "THEN", "TOP", "TRUE",
26− "UNBOUNDED", "UNION", "UPDATE", "USING",
27− "VALUES",
28− "WHEN", "WHERE", "WINDOW", "WITH",
29− ]
30−
31− static let functions: Set<String> = [
32− "ABS", "ARRAY_AGG", "AVG",
33− "CEIL", "COALESCE", "CONCAT", "COUNT",
34− "DATE", "DATE_TRUNC", "DENSE_RANK",
35− "FIRST_VALUE", "FLOOR",
36− "IIF", "IFNULL",
37− "JSON_EXTRACT", "JSON_VALUE",
38− "LAG", "LAST_VALUE", "LEAD", "LENGTH", "LOWER",
39− "MAX", "MIN",
40− "NOW", "NTH_VALUE", "NTILE", "NULLIF",
41− "RANK", "REPLACE", "ROUND", "ROW_NUMBER",
42− "STRING_AGG", "SUBSTRING", "SUM",
43− "TIME", "TIMESTAMP", "TO_CHAR", "TO_DATE", "TO_TIMESTAMP", "TRIM",
44− "UPPER",
45− ]
46−
47− // Pre-sorted arrays for completions
48− static let sortedKeywords: [String] = keywords.sorted()
49− static let sortedFunctions: [String] = functions.sorted()
50−
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(
61− pattern: "('[^']*(?:''[^']*)*')|(\"[^\"]*(?:\"\"[^\"]*)*\")|(--[^\\n]*|/\\*[\\s\\S]*?(?:\\*/|\\z))",
62− options: []
63− )
64− // Matches the same numeric forms as `SQLTokenizer.consumeNumber`: integers,
65− // decimals, leading-dot floats (`.5`), and exponents (`1e5`, `1.5e-3`). The
66− // lookbehind keeps digits that belong to an identifier (`col1`, `t.2`) from
67− // being highlighted as numbers.
68− private static let numberRegex = try! NSRegularExpression(pattern: "(?<![\\w.])(?:\\d+\\.?\\d*|\\.\\d+)(?:[eE][+-]?\\d+)?", options: [])
69− private static let keywordRegex: NSRegularExpression = {
70− let pattern = "\\b(?:" + sortedKeywords.joined(separator: "|") + ")\\b"
71− return try! NSRegularExpression(pattern: pattern, options: [.caseInsensitive])
72− }()
73− private static let functionRegex: NSRegularExpression = {
74− let pattern = "\\b(?:" + sortedFunctions.joined(separator: "|") + ")\\b"
75− return try! NSRegularExpression(pattern: pattern, options: [.caseInsensitive])
76− }()
77−
78− /// Applies SQL syntax highlighting to an NSTextStorage.
79− static func highlight(_ text: String, in textStorage: NSTextStorage, baseFont: NSFont) {
80− let fullRange = NSRange(location: 0, length: textStorage.length)
81− guard fullRange.length > 0 else { return }
82−
83− // Reset to default styling
84− textStorage.addAttributes([
85− .foregroundColor: NSColor.labelColor,
86− .font: baseFont,
87− ], range: fullRange)
88−
89− let nsText = text as NSString
90− var protectedRanges: [NSRange] = []
91−
92− // 1–3. Single-quoted strings, double-quoted identifiers, and comments, in
93− // one pass so none of them can start inside another (`'a--b'` stays a
94− // string, `-- don't` stays a comment).
95− let literalMatches = literalOrCommentRegex.matches(
96− in: text, range: NSRange(location: 0, length: nsText.length)
97− )
98− for match in literalMatches {
99− let isComment = match.range(at: 3).location != NSNotFound
100− textStorage.addAttribute(
101− .foregroundColor,
102− value: isComment ? NSColor.secondaryLabelColor : NSColor.systemRed,
103− range: match.range
104− )
105− protectedRanges.append(match.range)
106− }
107−
108− // 4. Numbers (outside protected ranges)
109− applyUnprotectedRegex(numberRegex, to: textStorage, text: nsText,
110− color: .systemPurple, protectedRanges: protectedRanges)
111−
112− // 5. Keywords (outside protected ranges)
113− applyUnprotectedKeywordRegex(keywordRegex, to: textStorage, text: nsText,
114− color: .systemBlue, bold: true, baseFont: baseFont,
115− protectedRanges: protectedRanges)
116−
117− // 6. Functions (outside protected ranges)
118− applyUnprotectedKeywordRegex(functionRegex, to: textStorage, text: nsText,
119− color: .systemTeal, bold: false, baseFont: baseFont,
120− protectedRanges: protectedRanges)
121− }
122−
123− private static func applyUnprotectedRegex(
124− _ regex: NSRegularExpression,
125− to textStorage: NSTextStorage,
126− text: NSString,
127− color: NSColor,
128− protectedRanges: [NSRange]
129− ) {
130− let matches = regex.matches(in: text as String, range: NSRange(location: 0, length: text.length))
131− for match in matches where !isProtected(match.range, by: protectedRanges) {
132− textStorage.addAttribute(.foregroundColor, value: color, range: match.range)
133− }
134− }
135−
136− private static func applyUnprotectedKeywordRegex(
137− _ regex: NSRegularExpression,
138− to textStorage: NSTextStorage,
139− text: NSString,
140− color: NSColor,
141− bold: Bool,
142− baseFont: NSFont,
143− protectedRanges: [NSRange]
144− ) {
145− let boldFont = NSFont.monospacedSystemFont(ofSize: baseFont.pointSize, weight: .bold)
146− let matches = regex.matches(in: text as String, range: NSRange(location: 0, length: text.length))
147− for match in matches where !isProtected(match.range, by: protectedRanges) {
148− textStorage.addAttribute(.foregroundColor, value: color, range: match.range)
149− if bold {
150− textStorage.addAttribute(.font, value: boldFont, range: match.range)
151− }
152− }
153− }
154−
155− private static func isProtected(_ range: NSRange, by protectedRanges: [NSRange]) -> Bool {
156− for protected in protectedRanges {
157− if range.location >= protected.location &&
158− NSMaxRange(range) <= NSMaxRange(protected) {
159− return true
160− }
161− }
162− return false
163− }
164−}
165−
166−// MARK: - JSON Syntax Highlighting
167−
168−enum JSONSyntaxHighlighter {
169−
170− // Pre-compiled regexes (created once, reused every call)
171− private static let stringRegex = try! NSRegularExpression(pattern: "\"(?:[^\"\\\\]|\\\\.)*\"", options: [])
172− private static let boolRegex = try! NSRegularExpression(pattern: "\\b(true|false)\\b", options: [])
173− private static let nullRegex = try! NSRegularExpression(pattern: "\\bnull\\b", options: [])
174− private static let numberRegex = try! NSRegularExpression(pattern: "-?\\b\\d+(?:\\.\\d+)?(?:[eE][+-]?\\d+)?\\b", options: [])
175−
176− /// Creates a syntax-highlighted AttributedString from a JSON string.
177− static func highlight(_ json: String, font: NSFont? = nil) -> AttributedString {
178− let resolvedFont = font ?? NSFont.monospacedSystemFont(ofSize: NSFont.smallSystemFontSize, weight: .regular)
179− let nsString = json as NSString
180− let fullRange = NSRange(location: 0, length: nsString.length)
181−
182− let attributed = NSMutableAttributedString(string: json, attributes: [
183− .font: resolvedFont,
184− .foregroundColor: NSColor.labelColor,
185− ])
186−
187− guard fullRange.length > 0 else { return AttributedString(attributed) }
188−
189− // Track string ranges to avoid coloring numbers/bools inside strings
190− var stringRanges: [NSRange] = []
191−
192− // 1. Double-quoted strings — distinguish keys from values
193− let matches = stringRegex.matches(in: json, range: fullRange)
194− for match in matches {
195− stringRanges.append(match.range)
196−
197− let isKey = isJSONKey(after: match.range, in: nsString)
198− let color: NSColor = isKey ? .systemCyan : .systemRed
199− attributed.addAttribute(.foregroundColor, value: color, range: match.range)
200− if isKey {
201− attributed.addAttribute(
202− .font,
203− value: NSFont.monospacedSystemFont(ofSize: resolvedFont.pointSize, weight: .medium),
204− range: match.range)
205− }
206− }
207−
208− // 2. Booleans (outside strings)
209− for match in boolRegex.matches(in: json, range: fullRange)
210− where !isInString(match.range, stringRanges: stringRanges) {
211− attributed.addAttribute(.foregroundColor, value: NSColor.systemOrange, range: match.range)
212− }
213−
214− // 3. Null (outside strings)
215− for match in nullRegex.matches(in: json, range: fullRange)
216− where !isInString(match.range, stringRanges: stringRanges) {
217− attributed.addAttribute(.foregroundColor, value: NSColor.secondaryLabelColor, range: match.range)
218− }
219−
220− // 4. Numbers (outside strings)
221− for match in numberRegex.matches(in: json, range: fullRange)
222− where !isInString(match.range, stringRanges: stringRanges) {
223− attributed.addAttribute(.foregroundColor, value: NSColor.systemPurple, range: match.range)
224− }
225−
226− return AttributedString(attributed)
227− }
228−
229− /// Checks whether a matched string token is a JSON key by scanning forward for a colon.
230− private static func isJSONKey(after range: NSRange, in text: NSString) -> Bool {
231− var idx = NSMaxRange(range)
232− while idx < text.length {
233− let ch = text.character(at: idx)
234− if ch == 0x20 || ch == 0x09 || ch == 0x0A || ch == 0x0D {
235− idx += 1
236− } else {
237− return ch == 0x3A // ':'
238− }
239− }
240− return false
241− }
242−
243− private static func isInString(_ range: NSRange, stringRanges: [NSRange]) -> Bool {
244− for sr in stringRanges {
245− if range.location >= sr.location && NSMaxRange(range) <= NSMaxRange(sr) {
246− return true
247− }
248− }
249− return false
250− }
251−}
ParseableViewer/Views/TimeRangePicker.swift+2−26
@@ -4,7 +4,6 @@ struct TimeRangePicker: View {
44 @Binding var option: QueryViewModel.TimeRangeOption
55 @Binding var customStart: Date
66 @Binding var customEnd: Date
7− var onCommit: (() -> Void)?
87 @State private var showCustomPicker = false
98
109 var body: some View {
@@ -31,27 +30,6 @@ struct TimeRangePicker: View {
3130 .font(.caption)
3231 }
3332 }
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− }
5533 .popover(isPresented: $showCustomPicker) {
5634 VStack(spacing: 12) {
5735 Text("Custom Time Range")
@@ -62,7 +40,7 @@ struct TimeRangePicker: View {
6240 DatePicker("End:", selection: $customEnd)
6341 .datePickerStyle(.field)
6442
65− if customEnd <= customStart {
43+ if customEnd < customStart {
6644 HStack(spacing: 4) {
6745 Image(systemName: "exclamationmark.triangle.fill")
6846 Text("End date must be after start date")
@@ -99,12 +77,10 @@ struct TimeRangePicker: View {
9977 Spacer()
10078
10179 Button("Done") {
102− // Just close — the `showCustomPicker` change handler is the
103− // single commit point for all close paths (Done + click-out).
10480 showCustomPicker = false
10581 }
10682 .buttonStyle(.borderedProminent)
107− .disabled(customEnd <= customStart)
83+ .disabled(customEnd < customStart)
10884 }
10985 }
11086 .padding()
ParseableViewer/Views/UsersView.swift+3−26
@@ -5,9 +5,6 @@ struct UsersView: View {
55 @State private var users: [UserInfo] = []
66 @State private var isLoading = false
77 @State private var errorMessage: String?
8− /// Bumped by every load so a superseded (cancelled) load can't clear the
9− /// newer load's spinner.
10− @State private var loadGeneration = 0
118
129 var body: some View {
1310 VStack(spacing: 0) {
@@ -75,42 +72,22 @@ struct UsersView: View {
7572 }
7673 }
7774 }
78− // Key on the active connection so switching (or reconnecting to a
79− // different) server reloads instead of showing the previous server's users.
80− .task(id: appState.activeConnection?.id) {
75+ .task {
8176 await loadUsers()
8277 }
8378 }
8479
8580 private func loadUsers() async {
8681 guard let client = appState.client else { return }
87− let connID = appState.activeConnection?.id
88− loadGeneration &+= 1
89− let generation = loadGeneration
9082 isLoading = true
9183 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 = []
9684
97− let loaded: [UserInfo]?
98− let loadError: String?
9985 do {
100− loaded = try await client.listUsers()
101− loadError = nil
86+ users = try await client.listUsers()
10287 } catch {
103− loaded = nil
104− loadError = ParseableError.userFriendlyMessage(for: error)
88+ errorMessage = ParseableError.userFriendlyMessage(for: error)
10589 }
10690
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
11491 isLoading = false
11592 }
11693}
ParseableViewerTests/ConnectionStoreTests.swift+80−71
@@ -2,25 +2,26 @@ import XCTest
22@testable import ParseableViewer
33
44final class ConnectionStoreTests: XCTestCase {
5− /// Whether the data-protection keychain is available in this environment.
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− }()
5+ private let testSuiteKey = "parseable_connections_test"
156
167 override func tearDown() {
178 super.tearDown()
18− KeychainService.deleteData(for: "parseable_connections_list")
19− KeychainService.deleteData(for: "parseable_active_connection_id")
9+ // Clean up any test data from UserDefaults
10+ UserDefaults.standard.removeObject(forKey: "parseable_connections")
11+ UserDefaults.standard.removeObject(forKey: "parseable_active_connection_id")
12+ UserDefaults.standard.removeObject(forKey: "parseable_saved_queries")
13+ }
14+
15+ // MARK: - Active connection ID
16+
17+ func testSaveAndLoadActiveConnectionID() {
18+ let id = UUID()
19+ ConnectionStore.saveActiveConnectionID(id)
20+ let loaded = ConnectionStore.loadActiveConnectionID()
21+ XCTAssertEqual(loaded, id)
2022 }
2123
22− func testSaveNilActiveConnectionID() throws {
23− try XCTSkipUnless(Self.keychainAvailable, "Data-protection keychain unavailable")
24+ func testSaveNilActiveConnectionID() {
2425 let id = UUID()
2526 ConnectionStore.saveActiveConnectionID(id)
2627 ConnectionStore.saveActiveConnectionID(nil)
@@ -29,7 +30,7 @@ final class ConnectionStoreTests: XCTestCase {
2930 }
3031
3132 func testLoadActiveConnectionIDWhenEmpty() {
32− KeychainService.deleteData(for: "parseable_active_connection_id")
33+ UserDefaults.standard.removeObject(forKey: "parseable_active_connection_id")
3334 let loaded = ConnectionStore.loadActiveConnectionID()
3435 XCTAssertNil(loaded)
3536 }
@@ -37,74 +38,35 @@ final class ConnectionStoreTests: XCTestCase {
3738 // MARK: - Connection persistence
3839
3940 func testLoadConnectionsWhenEmpty() {
40− KeychainService.deleteData(for: "parseable_connections_list")
41+ UserDefaults.standard.removeObject(forKey: "parseable_connections")
4142 let connections = ConnectionStore.loadConnections()
4243 XCTAssertTrue(connections.isEmpty)
4344 }
4445
45− func testSaveAndLoadActiveConnectionID() throws {
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(
56− name: "Prod",
46+ func testSaveAndLoadConnections() {
47+ let connection = ServerConnection(
48+ name: "Test Server",
5749 url: "https://logs.example.com",
5850 username: "admin",
59− password: "s3cret!"
51+ password: "secret123"
6052 )
61− ConnectionStore.saveConnections([conn])
62−
53+ ConnectionStore.saveConnections([connection])
6354 let loaded = ConnectionStore.loadConnections()
6455 XCTAssertEqual(loaded.count, 1)
65− XCTAssertEqual(loaded.first?.id, conn.id)
66− XCTAssertEqual(loaded.first?.name, "Prod")
67− XCTAssertEqual(loaded.first?.url, "https://logs.example.com")
68− XCTAssertEqual(loaded.first?.username, "admin")
69− XCTAssertEqual(loaded.first?.password, "s3cret!")
70−
71− // Clean up
72− KeychainService.deletePassword(for: conn.id)
56+ XCTAssertEqual(loaded[0].name, "Test Server")
57+ XCTAssertEqual(loaded[0].url, "https://logs.example.com")
58+ XCTAssertEqual(loaded[0].username, "admin")
7359 }
7460
75− func testRoundTripMultipleConnections() throws {
76− try XCTSkipUnless(Self.keychainAvailable, "Data-protection keychain unavailable")
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(
84− name: "Server B",
85− url: "https://b.example.com",
86− username: "user2",
87− password: "pass2"
88− )
89− ConnectionStore.saveConnections([conn1, conn2])
90−
61+ func testSaveMultipleConnections() {
62+ let c1 = ServerConnection(name: "Prod", url: "https://prod.example.com", username: "admin", password: "p1")
63+ let c2 = ServerConnection(name: "Dev", url: "https://dev.example.com", username: "dev", password: "p2")
64+ ConnectionStore.saveConnections([c1, c2])
9165 let loaded = ConnectionStore.loadConnections()
9266 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)
10467 }
10568
106− func testPasswordNotInKeychainConnectionData() throws {
107− try XCTSkipUnless(Self.keychainAvailable, "Data-protection keychain unavailable")
69+ func testPasswordNotInUserDefaults() {
10870 let connection = ServerConnection(
10971 name: "Test",
11072 url: "https://example.com",
@@ -113,11 +75,58 @@ final class ConnectionStoreTests: XCTestCase {
11375 )
11476 ConnectionStore.saveConnections([connection])
11577
116− // Verify password is NOT in the connections list data
117− if let data = KeychainService.loadData(for: "parseable_connections_list"),
78+ // Verify password is NOT in the UserDefaults data
79+ if let data = UserDefaults.standard.data(forKey: "parseable_connections"),
11880 let jsonString = String(data: data, encoding: .utf8) {
11981 XCTAssertFalse(jsonString.contains("supersecret"),
120− "Password should not be stored in connection list JSON")
82+ "Password should not be stored in UserDefaults JSON")
12183 }
12284 }
85+
86+ // MARK: - SavedQueryStore
87+
88+ func testSavedQueryStoreLoadEmpty() {
89+ UserDefaults.standard.removeObject(forKey: "parseable_saved_queries")
90+ let queries = SavedQueryStore.load()
91+ XCTAssertTrue(queries.isEmpty)
92+ }
93+
94+ func testSavedQueryStoreRoundTrip() {
95+ let query = SavedQuery(
96+ name: "Recent Errors",
97+ sql: "SELECT * FROM logs WHERE level = 'error' LIMIT 100",
98+ stream: "backend-logs"
99+ )
100+ SavedQueryStore.save([query])
101+ let loaded = SavedQueryStore.load()
102+ XCTAssertEqual(loaded.count, 1)
103+ XCTAssertEqual(loaded[0].name, "Recent Errors")
104+ XCTAssertEqual(loaded[0].sql, "SELECT * FROM logs WHERE level = 'error' LIMIT 100")
105+ XCTAssertEqual(loaded[0].stream, "backend-logs")
106+ }
107+
108+ func testSavedQueryStoreMultiple() {
109+ let q1 = SavedQuery(name: "Query 1", sql: "SELECT 1", stream: "s1")
110+ let q2 = SavedQuery(name: "Query 2", sql: "SELECT 2", stream: "s2")
111+ SavedQueryStore.save([q1, q2])
112+ let loaded = SavedQueryStore.load()
113+ XCTAssertEqual(loaded.count, 2)
114+ }
115+
116+ // MARK: - SavedQuery model
117+
118+ func testSavedQueryIdentifiable() {
119+ let q1 = SavedQuery(name: "A", sql: "SELECT 1", stream: "s")
120+ let q2 = SavedQuery(name: "B", sql: "SELECT 2", stream: "s")
121+ XCTAssertNotEqual(q1.id, q2.id)
122+ }
123+
124+ func testSavedQueryHashable() {
125+ let q1 = SavedQuery(name: "A", sql: "SELECT 1", stream: "s")
126+ var set = Set<SavedQuery>()
127+ set.insert(q1)
128+ XCTAssertEqual(set.count, 1)
129+ set.insert(q1) // Same query again
130+ XCTAssertEqual(set.count, 1)
131+ }
123132}
ParseableViewerTests/JSONValueTests.swift−53
@@ -75,24 +75,6 @@ final class JSONValueTests: XCTestCase {
7575 XCTAssertEqual(original, decoded)
7676 }
7777
78− func testEncodeNonFiniteDoubleAsNull() throws {
79− // A non-finite double must encode as JSON null rather than throwing — a
80− // single infinity/NaN otherwise aborts the whole JSONEncoder pass and
81− // collapses exportAsJSON() to "[]" (total silent data loss).
82− let encoder = JSONEncoder()
83− for value: Double in [.infinity, -.infinity, .nan] {
84− let data = try encoder.encode(JSONValue.double(value))
85− XCTAssertEqual(String(data: data, encoding: .utf8), "null")
86− }
87− }
88−
89− func testEncodeArrayWithNonFiniteDoubleDoesNotThrow() throws {
90− // Mirrors the export path: one non-finite value must not poison the rest.
91− let original: JSONValue = .array([.int(1), .double(.infinity), .string("ok")])
92− let data = try JSONEncoder().encode(original)
93− XCTAssertEqual(String(data: data, encoding: .utf8), "[1,null,\"ok\"]")
94− }
95−
9678 // MARK: - displayString
9779
9880 func testDisplayStringNull() {
@@ -273,41 +255,6 @@ final class JSONValueTests: XCTestCase {
273255 XCTAssertEqual(records[0]["level"], .string("error"))
274256 }
275257
276− // MARK: - sqlLiteral
277−
278− func testSqlLiteralNull() {
279− XCTAssertEqual(JSONValue.null.sqlLiteral, "NULL")
280− }
281−
282− func testSqlLiteralBool() {
283− XCTAssertEqual(JSONValue.bool(true).sqlLiteral, "true")
284− XCTAssertEqual(JSONValue.bool(false).sqlLiteral, "false")
285− }
286−
287− func testSqlLiteralInt() {
288− XCTAssertEqual(JSONValue.int(42).sqlLiteral, "42")
289− XCTAssertEqual(JSONValue.int(-7).sqlLiteral, "-7")
290− }
291−
292− func testSqlLiteralDouble() {
293− XCTAssertEqual(JSONValue.double(3.14).sqlLiteral, "3.14")
294− }
295−
296− func testSqlLiteralString() {
297− XCTAssertEqual(JSONValue.string("hello").sqlLiteral, "'hello'")
298− }
299−
300− func testSqlLiteralStringEscapesSingleQuotes() {
301− XCTAssertEqual(JSONValue.string("it's").sqlLiteral, "'it''s'")
302− }
303−
304− func testSqlLiteralArray() {
305− let arr = JSONValue.array([.int(1), .int(2)])
306− let literal = arr.sqlLiteral
307− XCTAssertTrue(literal.hasPrefix("'"))
308− XCTAssertTrue(literal.hasSuffix("'"))
309− }
310−
311258 // MARK: - LogRecord as typealias
312259
313260 func testLogRecordDecoding() throws {
ParseableViewerTests/LiveTailViewModelTests.swift+28−236
@@ -63,38 +63,43 @@ final class LiveTailViewModelTests: XCTestCase {
6363 // MARK: - Timestamp parsing
6464
6565 func testParseTimestampISO8601WithFractionalSeconds() {
66+ let vm = LiveTailViewModel()
6667 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)
6869 XCTAssertNotNil(date)
6970 }
7071
7172 func testParseTimestampISO8601Basic() {
73+ let vm = LiveTailViewModel()
7274 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)
7476 XCTAssertNotNil(date)
7577 }
7678
7779 func testParseTimestampFallbackFields() {
80+ let vm = LiveTailViewModel()
81+
7882 // "timestamp" field
7983 let r1: LogRecord = ["timestamp": .string("2024-06-15T10:30:45Z")]
80− XCTAssertNotNil(LiveTailViewModel.parseTimestamp(from: r1))
84+ XCTAssertNotNil(vm.parseTimestamp(from: r1))
8185
8286 // "time" field
8387 let r2: LogRecord = ["time": .string("2024-06-15T10:30:45Z")]
84− XCTAssertNotNil(LiveTailViewModel.parseTimestamp(from: r2))
88+ XCTAssertNotNil(vm.parseTimestamp(from: r2))
8589
8690 // "@timestamp" field
8791 let r3: LogRecord = ["@timestamp": .string("2024-06-15T10:30:45Z")]
88− XCTAssertNotNil(LiveTailViewModel.parseTimestamp(from: r3))
92+ XCTAssertNotNil(vm.parseTimestamp(from: r3))
8993 }
9094
9195 func testParseTimestampPriorityOrder() {
96+ let vm = LiveTailViewModel()
9297 // p_timestamp should take priority
9398 let record: LogRecord = [
9499 "p_timestamp": .string("2024-01-01T00:00:00Z"),
95100 "timestamp": .string("2024-12-31T23:59:59Z")
96101 ]
97− let date = LiveTailViewModel.parseTimestamp(from: record)
102+ let date = vm.parseTimestamp(from: record)
98103 XCTAssertNotNil(date)
99104
100105 let calendar = Calendar.current
@@ -104,57 +109,64 @@ final class LiveTailViewModelTests: XCTestCase {
104109 }
105110
106111 func testParseTimestampNoTimestampField() {
112+ let vm = LiveTailViewModel()
107113 let record: LogRecord = ["message": .string("no timestamp")]
108− let date = LiveTailViewModel.parseTimestamp(from: record)
114+ let date = vm.parseTimestamp(from: record)
109115 XCTAssertNil(date)
110116 }
111117
112118 func testParseTimestampNonStringValue() {
119+ let vm = LiveTailViewModel()
113120 let record: LogRecord = ["p_timestamp": .int(1718446245)]
114− let date = LiveTailViewModel.parseTimestamp(from: record)
121+ let date = vm.parseTimestamp(from: record)
115122 XCTAssertNil(date)
116123 }
117124
118125 func testParseTimestampInvalidString() {
126+ let vm = LiveTailViewModel()
119127 let record: LogRecord = ["p_timestamp": .string("not-a-date")]
120− let date = LiveTailViewModel.parseTimestamp(from: record)
128+ let date = vm.parseTimestamp(from: record)
121129 XCTAssertNil(date)
122130 }
123131
124132 // MARK: - Summary building
125133
126134 func testBuildSummaryWithLevelAndMessage() {
135+ let vm = LiveTailViewModel()
127136 let record: LogRecord = [
128137 "level": .string("info"),
129138 "message": .string("Server started")
130139 ]
131− let summary = LiveTailViewModel.buildSummary(from: record)
140+ let summary = vm.buildSummary(from: record)
132141 XCTAssertTrue(summary.contains("[info]"))
133142 XCTAssertTrue(summary.contains("Server started"))
134143 }
135144
136145 func testBuildSummaryAlternateFieldNames() {
146+ let vm = LiveTailViewModel()
137147 let record: LogRecord = [
138148 "severity": .string("warn"),
139149 "msg": .string("Disk almost full")
140150 ]
141− let summary = LiveTailViewModel.buildSummary(from: record)
151+ let summary = vm.buildSummary(from: record)
142152 XCTAssertTrue(summary.contains("[warn]"))
143153 XCTAssertTrue(summary.contains("Disk almost full"))
144154 }
145155
146156 func testBuildSummaryFallbackToScalarFields() {
157+ let vm = LiveTailViewModel()
147158 let record: LogRecord = [
148159 "status": .int(200),
149160 "path": .string("/api/health")
150161 ]
151− let summary = LiveTailViewModel.buildSummary(from: record)
162+ let summary = vm.buildSummary(from: record)
152163 XCTAssertFalse(summary.isEmpty)
153164 }
154165
155166 func testBuildSummaryEmptyRecord() {
167+ let vm = LiveTailViewModel()
156168 let record: LogRecord = [:]
157− let summary = LiveTailViewModel.buildSummary(from: record)
169+ let summary = vm.buildSummary(from: record)
158170 XCTAssertEqual(summary, "")
159171 }
160172
@@ -204,7 +216,7 @@ final class LiveTailViewModelTests: XCTestCase {
204216 func testFilteredEntriesEmptyFilter() {
205217 let vm = LiveTailViewModel()
206218 vm.filterText = ""
207− XCTAssertEqual(vm.cachedFilteredEntries.count, vm.entries.count)
219+ XCTAssertEqual(vm.filteredEntries.count, vm.entries.count)
208220 }
209221
210222 func testStopResetsRunningState() {
@@ -257,233 +269,13 @@ final class LiveTailViewModelTests: XCTestCase {
257269 }
258270
259271 func testBuildSummaryWithLogField() {
272+ let vm = LiveTailViewModel()
260273 let record: LogRecord = [
261274 "log_level": .string("debug"),
262275 "log": .string("Processing request")
263276 ]
264− let summary = LiveTailViewModel.buildSummary(from: record)
277+ let summary = vm.buildSummary(from: record)
265278 XCTAssertTrue(summary.contains("[debug]"))
266279 XCTAssertTrue(summary.contains("Processing request"))
267280 }
268−
269− // MARK: - Column Management
270−
271− func testInitialColumnState() {
272− let vm = LiveTailViewModel()
273− XCTAssertTrue(vm.columns.isEmpty)
274− XCTAssertTrue(vm.columnOrder.isEmpty)
275− XCTAssertTrue(vm.hiddenColumns.isEmpty)
276− XCTAssertTrue(vm.visibleColumns.isEmpty)
277− }
278−
279− func testVisibleColumnsExcludesHidden() {
280− let vm = LiveTailViewModel()
281− vm.columnOrder = ["a", "b", "c"]
282− vm.hiddenColumns = ["b"]
283− XCTAssertEqual(vm.visibleColumns, ["a", "c"])
284− }
285−
286− func testToggleColumnVisibilityHide() {
287− let vm = LiveTailViewModel()
288− vm.columns = ["a", "b", "c"]
289− vm.columnOrder = ["a", "b", "c"]
290− vm.hiddenColumns = []
291−
292− vm.toggleColumnVisibility("b")
293− XCTAssertTrue(vm.hiddenColumns.contains("b"))
294− XCTAssertEqual(vm.visibleColumns, ["a", "c"])
295− }
296−
297− func testToggleColumnVisibilityShow() {
298− let vm = LiveTailViewModel()
299− vm.columns = ["a", "b", "c"]
300− vm.columnOrder = ["a", "b", "c"]
301− vm.hiddenColumns = ["b"]
302−
303− vm.toggleColumnVisibility("b")
304− XCTAssertFalse(vm.hiddenColumns.contains("b"))
305− XCTAssertEqual(vm.visibleColumns, ["a", "b", "c"])
306− }
307−
308− func testCannotHideLastVisibleColumn() {
309− let vm = LiveTailViewModel()
310− vm.columns = ["a"]
311− vm.columnOrder = ["a"]
312− vm.hiddenColumns = []
313−
314− vm.toggleColumnVisibility("a")
315− // Should not hide the last visible column
316− XCTAssertFalse(vm.hiddenColumns.contains("a"))
317− XCTAssertEqual(vm.visibleColumns, ["a"])
318− }
319−
320− func testShowAllColumns() {
321− let vm = LiveTailViewModel()
322− vm.columns = ["a", "b", "c"]
323− vm.columnOrder = ["a", "b", "c"]
324− vm.hiddenColumns = ["a", "c"]
325−
326− vm.showAllColumns()
327− XCTAssertTrue(vm.hiddenColumns.isEmpty)
328− XCTAssertEqual(vm.visibleColumns, ["a", "b", "c"])
329− }
330−
331− func testMoveColumnByName() {
332− let vm = LiveTailViewModel()
333− vm.columns = ["a", "b", "c"]
334− vm.columnOrder = ["a", "b", "c"]
335−
336− vm.moveColumn("c", to: "a")
337− XCTAssertEqual(vm.columnOrder, ["c", "a", "b"])
338− }
339−
340− func testMoveColumnSamePositionIsNoOp() {
341− let vm = LiveTailViewModel()
342− vm.columns = ["a", "b", "c"]
343− vm.columnOrder = ["a", "b", "c"]
344−
345− vm.moveColumn("a", to: "a")
346− XCTAssertEqual(vm.columnOrder, ["a", "b", "c"])
347− }
348−
349− func testResetColumnConfig() {
350− let vm = LiveTailViewModel()
351− vm.columns = ["a", "b", "c"]
352− vm.columnOrder = ["c", "b", "a"]
353− vm.hiddenColumns = ["b"]
354−
355− vm.resetColumnConfig()
356− XCTAssertEqual(vm.columnOrder, ["a", "b", "c"])
357− XCTAssertTrue(vm.hiddenColumns.isEmpty)
358− }
359−
360− func testClearResetsColumns() {
361− let vm = LiveTailViewModel()
362− vm.columns = ["a", "b"]
363− vm.columnOrder = ["a", "b"]
364− vm.hiddenColumns = ["b"]
365−
366− vm.clear()
367− XCTAssertTrue(vm.columns.isEmpty)
368− XCTAssertTrue(vm.columnOrder.isEmpty)
369− XCTAssertTrue(vm.hiddenColumns.isEmpty)
370− }
371−
372− func testMoveColumnByIndexSet() {
373− let vm = LiveTailViewModel()
374− vm.columns = ["a", "b", "c"]
375− vm.columnOrder = ["a", "b", "c"]
376−
377− // Move "a" (index 0) to after "c" (index 3, past-the-end insertion)
378− vm.moveColumn(from: IndexSet(integer: 0), to: 3)
379− XCTAssertEqual(vm.columnOrder, ["b", "c", "a"])
380− }
381−
382− // MARK: - Column Filters
383−
384− func testAddColumnFilter() {
385− let vm = LiveTailViewModel()
386− vm.addColumnFilter(column: "level", value: .string("error"), exclude: false)
387− XCTAssertEqual(vm.columnFilters.count, 1)
388− XCTAssertEqual(vm.columnFilters[0].column, "level")
389− XCTAssertEqual(vm.columnFilters[0].value, .string("error"))
390− XCTAssertFalse(vm.columnFilters[0].exclude)
391− }
392−
393− func testAddColumnFilterExclude() {
394− let vm = LiveTailViewModel()
395− vm.addColumnFilter(column: "level", value: .string("debug"), exclude: true)
396− XCTAssertEqual(vm.columnFilters.count, 1)
397− XCTAssertTrue(vm.columnFilters[0].exclude)
398− }
399−
400− func testAddDuplicateFilterReplacesExisting() {
401− let vm = LiveTailViewModel()
402− vm.addColumnFilter(column: "level", value: .string("error"), exclude: false)
403− vm.addColumnFilter(column: "level", value: .string("error"), exclude: false)
404− XCTAssertEqual(vm.columnFilters.count, 1)
405− }
406−
407− func testAddFilterSameColumnIncludeReplaces() {
408− let vm = LiveTailViewModel()
409− vm.addColumnFilter(column: "level", value: .string("error"), exclude: false)
410− // A second include on the same column replaces the first: includes are
411− // AND-combined and a column can only equal one value, so stacking two
412− // includes would match zero rows and silently empty the tail.
413− vm.addColumnFilter(column: "level", value: .string("warn"), exclude: false)
414− XCTAssertEqual(vm.columnFilters.count, 1)
415− XCTAssertEqual(vm.columnFilters[0].value, .string("warn"))
416− }
417−
418− func testAddFilterSameColumnExcludesStack() {
419− let vm = LiveTailViewModel()
420− vm.addColumnFilter(column: "level", value: .string("debug"), exclude: true)
421− // Excludes on the same column legitimately stack: level ≠ debug AND
422− // level ≠ trace is meaningful, so both filters are retained.
423− vm.addColumnFilter(column: "level", value: .string("trace"), exclude: true)
424− XCTAssertEqual(vm.columnFilters.count, 2)
425− }
426−
427− func testRemoveColumnFilter() {
428− let vm = LiveTailViewModel()
429− vm.addColumnFilter(column: "level", value: .string("error"), exclude: false)
430− let filter = vm.columnFilters[0]
431− vm.removeColumnFilter(filter)
432− XCTAssertTrue(vm.columnFilters.isEmpty)
433− }
434−
435− func testClearColumnFilters() {
436− let vm = LiveTailViewModel()
437− vm.addColumnFilter(column: "level", value: .string("error"), exclude: false)
438− vm.addColumnFilter(column: "status", value: .int(500), exclude: true)
439− vm.clearColumnFilters()
440− XCTAssertTrue(vm.columnFilters.isEmpty)
441− }
442−
443− func testClearResetsColumnFilters() {
444− let vm = LiveTailViewModel()
445− vm.addColumnFilter(column: "level", value: .string("error"), exclude: false)
446− vm.clear()
447− XCTAssertTrue(vm.columnFilters.isEmpty)
448− }
449−
450− func testColumnFilterDisplayLabelInclude() {
451− let filter = LiveTailViewModel.ColumnFilter(column: "level", value: .string("error"), exclude: false)
452− XCTAssertEqual(filter.displayLabel, "level = error")
453− }
454−
455− func testColumnFilterDisplayLabelExclude() {
456− let filter = LiveTailViewModel.ColumnFilter(column: "level", value: .string("debug"), exclude: true)
457− XCTAssertEqual(filter.displayLabel, "level \u{2260} debug")
458− }
459−
460− func testColumnFilterDisplayLabelNull() {
461− let filter = LiveTailViewModel.ColumnFilter(column: "field", value: nil, exclude: false)
462− XCTAssertEqual(filter.displayLabel, "field = null")
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− }
489281}
ParseableViewerTests/LogStreamTests.swiftdeleted−74
@@ -1,74 +0,0 @@
1−import XCTest
2−@testable import ParseableViewer
3−
4−final class LogStreamTests: XCTestCase {
5− // MARK: - Comparable
6−
7− func testSortsByNameAlphabetically() {
8− let streams = [
9− LogStream(name: "zebra"),
10− LogStream(name: "alpha"),
11− LogStream(name: "middle"),
12− ]
13− let sorted = streams.sorted()
14− XCTAssertEqual(sorted.map(\.name), ["alpha", "middle", "zebra"])
15− }
16−
17− func testSortIsCaseInsensitive() {
18− let streams = [
19− LogStream(name: "Banana"),
20− LogStream(name: "apple"),
21− LogStream(name: "Cherry"),
22− ]
23− let sorted = streams.sorted()
24− XCTAssertEqual(sorted.map(\.name), ["apple", "Banana", "Cherry"])
25− }
26−
27− func testSortWithNumericSuffixes() {
28− // localizedStandardCompare sorts "stream2" before "stream10"
29− let streams = [
30− LogStream(name: "stream10"),
31− LogStream(name: "stream2"),
32− LogStream(name: "stream1"),
33− ]
34− let sorted = streams.sorted()
35− XCTAssertEqual(sorted.map(\.name), ["stream1", "stream2", "stream10"])
36− }
37−
38− func testSortStability() {
39− // Two streams with the same name should preserve their relative order
40− // (Swift's sorted() is stable since Swift 5)
41− let a = LogStream(name: "same")
42− let b = LogStream(name: "same")
43− let sorted = [a, b].sorted()
44− XCTAssertEqual(sorted.count, 2)
45− XCTAssertEqual(sorted[0].name, "same")
46− XCTAssertEqual(sorted[1].name, "same")
47− }
48−
49− func testSortEmptyArray() {
50− let streams: [LogStream] = []
51− let sorted = streams.sorted()
52− XCTAssertTrue(sorted.isEmpty)
53− }
54−
55− func testSortSingleElement() {
56− let streams = [LogStream(name: "only")]
57− let sorted = streams.sorted()
58− XCTAssertEqual(sorted.map(\.name), ["only"])
59− }
60−
61− // MARK: - Decoding
62−
63− func testDecodeFromObject() throws {
64− let json = Data(#"{"name": "test-stream"}"#.utf8)
65− let stream = try JSONDecoder().decode(LogStream.self, from: json)
66− XCTAssertEqual(stream.name, "test-stream")
67− }
68−
69− func testDecodeFromString() throws {
70− let json = Data(#""plain-string""#.utf8)
71− let stream = try JSONDecoder().decode(LogStream.self, from: json)
72− XCTAssertEqual(stream.name, "plain-string")
73− }
74−}
ParseableViewerTests/LogTableHelperTests.swift−148
@@ -82,152 +82,4 @@ final class LogTableHelperTests: XCTestCase {
8282 XCTAssertEqual(levelColor(for: "unknown"), .primary)
8383 XCTAssertEqual(levelColor(for: ""), .primary)
8484 }
85−
86− // MARK: - parseSeverity
87−
88− func testParseSeverityFatal() {
89− XCTAssertEqual(parseSeverity(from: "fatal"), .fatal)
90− XCTAssertEqual(parseSeverity(from: "CRITICAL"), .fatal)
91− XCTAssertEqual(parseSeverity(from: "panic"), .fatal)
92− XCTAssertEqual(parseSeverity(from: "emerg"), .fatal)
93− XCTAssertEqual(parseSeverity(from: "EMERGENCY"), .fatal)
94− XCTAssertEqual(parseSeverity(from: "alert"), .fatal)
95− XCTAssertEqual(parseSeverity(from: "crit"), .fatal)
96− }
97−
98− func testParseSeverityError() {
99− XCTAssertEqual(parseSeverity(from: "error"), .error)
100− XCTAssertEqual(parseSeverity(from: "ERROR"), .error)
101− XCTAssertEqual(parseSeverity(from: "err"), .error)
102− XCTAssertEqual(parseSeverity(from: "failure"), .error)
103− XCTAssertEqual(parseSeverity(from: "FAIL"), .error)
104− XCTAssertEqual(parseSeverity(from: "severe"), .error)
105− }
106−
107− func testParseSeverityWarning() {
108− XCTAssertEqual(parseSeverity(from: "warn"), .warning)
109− XCTAssertEqual(parseSeverity(from: "WARNING"), .warning)
110− XCTAssertEqual(parseSeverity(from: "caution"), .warning)
111− }
112−
113− func testParseSeverityInfo() {
114− XCTAssertEqual(parseSeverity(from: "info"), .info)
115− XCTAssertEqual(parseSeverity(from: "INFO"), .info)
116− XCTAssertEqual(parseSeverity(from: "information"), .info)
117− XCTAssertEqual(parseSeverity(from: "informational"), .info)
118− XCTAssertEqual(parseSeverity(from: "notice"), .info)
119− }
120−
121− func testParseSeverityDebug() {
122− XCTAssertEqual(parseSeverity(from: "debug"), .debug)
123− XCTAssertEqual(parseSeverity(from: "DEBUG"), .debug)
124− XCTAssertEqual(parseSeverity(from: "dbg"), .debug)
125− XCTAssertEqual(parseSeverity(from: "verbose"), .debug)
126− }
127−
128− func testParseSeverityTrace() {
129− XCTAssertEqual(parseSeverity(from: "trace"), .trace)
130− XCTAssertEqual(parseSeverity(from: "TRACE"), .trace)
131− XCTAssertEqual(parseSeverity(from: "finest"), .trace)
132− XCTAssertEqual(parseSeverity(from: "finer"), .trace)
133− XCTAssertEqual(parseSeverity(from: "fine"), .trace)
134− XCTAssertEqual(parseSeverity(from: "all"), .trace)
135− }
136−
137− func testParseSeverityNumericSyslog() {
138− // RFC 5424 severity codes
139− XCTAssertEqual(parseSeverity(from: "0"), .fatal) // Emergency
140− XCTAssertEqual(parseSeverity(from: "1"), .fatal) // Alert
141− XCTAssertEqual(parseSeverity(from: "2"), .fatal) // Critical
142− XCTAssertEqual(parseSeverity(from: "3"), .error) // Error
143− XCTAssertEqual(parseSeverity(from: "4"), .warning) // Warning
144− XCTAssertEqual(parseSeverity(from: "5"), .info) // Notice
145− XCTAssertEqual(parseSeverity(from: "6"), .info) // Informational
146− XCTAssertEqual(parseSeverity(from: "7"), .debug) // Debug
147− }
148−
149− func testParseSeverityUnknown() {
150− XCTAssertEqual(parseSeverity(from: ""), .unknown)
151− XCTAssertEqual(parseSeverity(from: "something"), .unknown)
152− XCTAssertEqual(parseSeverity(from: "99"), .unknown)
153− }
154−
155− func testParseSeverityWhitespace() {
156− XCTAssertEqual(parseSeverity(from: " error "), .error)
157− XCTAssertEqual(parseSeverity(from: " WARN "), .warning)
158− }
159−
160− // MARK: - extractSeverity
161−
162− func testExtractSeverityFromLevelColumn() {
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)
166− }
167−
168− func testExtractSeverityFromSeverityColumn() {
169− 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)
172− }
173−
174− func testExtractSeverityFromSeverityTextColumn() {
175− let record: LogRecord = ["severity_text": .string("FATAL")]
176− let sevCols = buildSeverityColumnSet(columns: Array(record.keys))
177− XCTAssertEqual(extractSeverity(from: record, severityColumns: sevCols), .fatal)
178− }
179−
180− func testExtractSeverityFromLogLevelColumn() {
181− let record: LogRecord = ["log_level": .string("info")]
182− let sevCols = buildSeverityColumnSet(columns: Array(record.keys))
183− XCTAssertEqual(extractSeverity(from: record, severityColumns: sevCols), .info)
184− }
185−
186− func testExtractSeverityFromNumericValue() {
187− let record: LogRecord = ["priority": .int(3)]
188− let sevCols = buildSeverityColumnSet(columns: Array(record.keys))
189− XCTAssertEqual(extractSeverity(from: record, severityColumns: sevCols), .error)
190− }
191−
192− func testExtractSeverityNoSeverityColumn() {
193− 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)
196− }
197−
198− func testExtractSeverityUnrecognizedValue() {
199− let record: LogRecord = ["level": .string("custom_level")]
200− let sevCols = buildSeverityColumnSet(columns: Array(record.keys))
201− XCTAssertEqual(extractSeverity(from: record, severityColumns: sevCols), .unknown)
202− }
203−
204− // MARK: - severityRowTint
205−
206− func testSeverityRowTintFatalReturnsColor() {
207− XCTAssertNotNil(severityRowTint(for: .fatal))
208− }
209−
210− func testSeverityRowTintErrorReturnsColor() {
211− XCTAssertNotNil(severityRowTint(for: .error))
212− }
213−
214− func testSeverityRowTintWarningReturnsColor() {
215− XCTAssertNotNil(severityRowTint(for: .warning))
216− }
217−
218− func testSeverityRowTintInfoReturnsNil() {
219− XCTAssertNil(severityRowTint(for: .info))
220− }
221−
222− func testSeverityRowTintDebugReturnsNil() {
223− XCTAssertNil(severityRowTint(for: .debug))
224− }
225−
226− func testSeverityRowTintTraceReturnsNil() {
227− XCTAssertNil(severityRowTint(for: .trace))
228− }
229−
230− func testSeverityRowTintUnknownReturnsNil() {
231− XCTAssertNil(severityRowTint(for: .unknown))
232− }
23385}
ParseableViewerTests/ParseableClientTests.swift−221
@@ -52,7 +52,6 @@ final class ParseableClientTests: XCTestCase {
5252 let config = try JSONDecoder().decode(AlertConfig.self, from: json)
5353 XCTAssertEqual(config.alerts?.count, 1)
5454 XCTAssertEqual(config.alerts?[0].name, "high-cpu")
55− XCTAssertEqual(config.alerts?[0].displayName, "high-cpu")
5655 XCTAssertEqual(config.version, "v1")
5756 }
5857
@@ -64,7 +63,6 @@ final class ParseableClientTests: XCTestCase {
6463 let config = try JSONDecoder().decode(AlertConfig.self, from: json)
6564 XCTAssertEqual(config.alerts?.count, 1)
6665 XCTAssertEqual(config.alerts?[0].name, "disk-full")
67− XCTAssertEqual(config.alerts?[0].displayName, "disk-full")
6866 XCTAssertNil(config.version)
6967 }
7068
@@ -74,42 +72,6 @@ final class ParseableClientTests: XCTestCase {
7472 XCTAssertNil(config.version)
7573 }
7674
77− func testAlertConfigModernAPIFormat() throws {
78− let json = """
79− [{"id": "01HQ3...", "title": "High CPU Alert", "severity": "Critical", "state": "Triggered", "alertType": "Threshold", "datasets": ["backend-logs"], "created": "2025-01-01T00:00:00Z"}]
80− """.data(using: .utf8)!
81−
82− let config = try JSONDecoder().decode(AlertConfig.self, from: json)
83− XCTAssertEqual(config.alerts?.count, 1)
84− let alert = try XCTUnwrap(config.alerts?.first)
85− XCTAssertEqual(alert.title, "High CPU Alert")
86− XCTAssertEqual(alert.displayName, "High CPU Alert")
87− XCTAssertEqual(alert.severity, "Critical")
88− XCTAssertEqual(alert.state, "Triggered")
89− XCTAssertEqual(alert.alertType, "Threshold")
90− XCTAssertEqual(alert.datasets, ["backend-logs"])
91− XCTAssertEqual(alert.alertId, "01HQ3...")
92− XCTAssertNil(alert.name)
93− }
94−
95− func testAlertRuleDisplayNamePrefersTitle() throws {
96− let json = """
97− {"title": "My Alert", "name": "my-alert"}
98− """.data(using: .utf8)!
99−
100− let alert = try JSONDecoder().decode(AlertRule.self, from: json)
101− XCTAssertEqual(alert.displayName, "My Alert")
102− }
103−
104− func testAlertRuleDisplayNameFallsBackToName() throws {
105− let json = """
106− {"name": "legacy-alert"}
107− """.data(using: .utf8)!
108−
109− let alert = try JSONDecoder().decode(AlertRule.self, from: json)
110− XCTAssertEqual(alert.displayName, "legacy-alert")
111− }
112−
11375 // MARK: - Retention config decoding
11476
11577 func testRetentionConfigArray() throws {
@@ -477,131 +439,6 @@ final class ParseableClientTests: XCTestCase {
477439 XCTAssertTrue(error?.lowercased().contains("empty") ?? false)
478440 }
479441
480− // MARK: - ParseableFilter decoding
481−
482− func testFilterFullResponse() throws {
483− let json = """
484− {
485− "filter_id": "abc-123",
486− "filter_name": "Error logs",
487− "stream_name": "backend",
488− "query": {"filter_type": "sql", "filter_query": "SELECT * FROM backend WHERE level = 'error'"},
489− "version": "v1",
490− "user_id": "admin",
491− "time_filter": {"start": "2024-01-01T00:00:00Z", "end": "2024-01-02T00:00:00Z"}
492− }
493− """.data(using: .utf8)!
494−
495− let filter = try JSONDecoder().decode(ParseableFilter.self, from: json)
496− XCTAssertEqual(filter.filterId, "abc-123")
497− XCTAssertEqual(filter.filterName, "Error logs")
498− XCTAssertEqual(filter.streamName, "backend")
499− XCTAssertEqual(filter.query.filterType, "sql")
500− XCTAssertEqual(filter.query.filterQuery, "SELECT * FROM backend WHERE level = 'error'")
501− XCTAssertEqual(filter.version, "v1")
502− XCTAssertEqual(filter.userId, "admin")
503− XCTAssertNotNil(filter.timeFilter)
504− }
505−
506− func testFilterMinimalFields() throws {
507− let json = """
508− {
509− "filter_name": "Quick query",
510− "stream_name": "logs",
511− "query": {"filter_type": "sql"}
512− }
513− """.data(using: .utf8)!
514−
515− let filter = try JSONDecoder().decode(ParseableFilter.self, from: json)
516− XCTAssertNil(filter.filterId)
517− XCTAssertEqual(filter.filterName, "Quick query")
518− XCTAssertEqual(filter.streamName, "logs")
519− XCTAssertEqual(filter.query.filterType, "sql")
520− XCTAssertNil(filter.query.filterQuery)
521− XCTAssertNil(filter.version)
522− XCTAssertNil(filter.userId)
523− XCTAssertNil(filter.timeFilter)
524− }
525−
526− func testFilterArrayDecoding() throws {
527− let json = """
528− [
529− {"filter_name": "A", "stream_name": "s1", "query": {"filter_type": "sql"}},
530− {"filter_name": "B", "stream_name": "s2", "query": {"filter_type": "filter"}}
531− ]
532− """.data(using: .utf8)!
533−
534− let filters = try JSONDecoder().decode([ParseableFilter].self, from: json)
535− XCTAssertEqual(filters.count, 2)
536− XCTAssertEqual(filters[0].filterName, "A")
537− XCTAssertEqual(filters[1].query.filterType, "filter")
538− }
539−
540− func testFilterEncodingForCreate() throws {
541− let filter = ParseableFilter(
542− filterName: "My filter",
543− streamName: "app-logs",
544− query: FilterQuery(filterType: "sql", filterQuery: "SELECT * FROM \"app-logs\"")
545− )
546− let data = try JSONEncoder().encode(filter)
547− let dict = try JSONSerialization.jsonObject(with: data) as! [String: Any]
548− XCTAssertEqual(dict["filter_name"] as? String, "My filter")
549− XCTAssertEqual(dict["stream_name"] as? String, "app-logs")
550− let query = dict["query"] as! [String: Any]
551− XCTAssertEqual(query["filter_type"] as? String, "sql")
552− XCTAssertEqual(query["filter_query"] as? String, "SELECT * FROM \"app-logs\"")
553− }
554−
555− func testFilterUnknownFieldsTolerated() throws {
556− let json = """
557− {
558− "filter_id": "xyz",
559− "filter_name": "Test",
560− "stream_name": "logs",
561− "query": {"filter_type": "sql", "filter_query": "SELECT 1", "some_new_field": true},
562− "brand_new_field": 42
563− }
564− """.data(using: .utf8)!
565−
566− let filter = try JSONDecoder().decode(ParseableFilter.self, from: json)
567− XCTAssertEqual(filter.filterId, "xyz")
568− XCTAssertEqual(filter.filterName, "Test")
569− }
570−
571− func testFilterQueryWithBuilder() throws {
572− let json = """
573− {
574− "filter_type": "filter",
575− "filter_query": null,
576− "filter_builder": {"rules": [{"field": "level", "operator": "=", "value": "error"}]}
577− }
578− """.data(using: .utf8)!
579−
580− let query = try JSONDecoder().decode(FilterQuery.self, from: json)
581− XCTAssertEqual(query.filterType, "filter")
582− XCTAssertNil(query.filterQuery)
583− XCTAssertNotNil(query.filterBuilder)
584− }
585−
586− func testFilterIdentifiableWithId() {
587− let filter = ParseableFilter(
588− filterId: "server-id-1",
589− filterName: "Test",
590− streamName: "logs",
591− query: FilterQuery(filterType: "sql")
592− )
593− XCTAssertEqual(filter.id, "server-id-1")
594− }
595−
596− func testFilterIdentifiableFallback() {
597− let filter = ParseableFilter(
598− filterName: "Test",
599− streamName: "logs",
600− query: FilterQuery(filterType: "sql")
601− )
602− XCTAssertEqual(filter.id, "logs:Test")
603− }
604−
605442 // MARK: - URL scheme validation
606443
607444 func testValidateURLAcceptsHTTPS() {
@@ -646,62 +483,4 @@ final class ParseableClientTests: XCTestCase {
646483 // Carriage returns in values should be wrapped in quotes per RFC 4180
647484 XCTAssertTrue(csv.contains("\"line1\r\nline2\""))
648485 }
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− }
707486}
ParseableViewerTests/QueryViewModelTests.swift+2−592
@@ -1,8 +1,6 @@
11import XCTest
22@testable import ParseableViewer
33
4−// QueryViewModel is @MainActor, so its tests must run on the main actor too.
5−@MainActor
64final class QueryViewModelTests: XCTestCase {
75 // MARK: - Column extraction
86
@@ -63,7 +61,7 @@ final class QueryViewModelTests: XCTestCase {
6361 XCTAssertEqual(vm.filteredResults.count, 2)
6462 }
6563
66− func testFilteredResultsMatchesContent() async {
64+ func testFilteredResultsMatchesContent() {
6765 let vm = QueryViewModel()
6866 vm.results = [
6967 ["message": .string("hello world")],
@@ -71,18 +69,16 @@ final class QueryViewModelTests: XCTestCase {
7169 ["message": .string("Hello there")]
7270 ]
7371 vm.filterText = "hello"
74− try? await Task.sleep(for: .milliseconds(200))
7572 XCTAssertEqual(vm.filteredResults.count, 2) // case-insensitive
7673 }
7774
78− func testFilteredResultsNoMatch() async {
75+ func testFilteredResultsNoMatch() {
7976 let vm = QueryViewModel()
8077 vm.results = [
8178 ["message": .string("hello")],
8279 ["message": .string("world")]
8380 ]
8481 vm.filterText = "xyz"
85− try? await Task.sleep(for: .milliseconds(200))
8682 XCTAssertEqual(vm.filteredResults.count, 0)
8783 }
8884
@@ -95,7 +91,6 @@ final class QueryViewModelTests: XCTestCase {
9591 ["name": .string("Bob"), "age": .int(25)]
9692 ]
9793 vm.columns = ["name", "age"]
98− vm.columnOrder = ["name", "age"]
9994 let csv = vm.exportAsCSV()
10095
10196 let lines = csv.components(separatedBy: "\n").filter { !$0.isEmpty }
@@ -111,7 +106,6 @@ final class QueryViewModelTests: XCTestCase {
111106 ["msg": .string("hello, world"), "note": .string("has \"quotes\"")]
112107 ]
113108 vm.columns = ["msg", "note"]
114− vm.columnOrder = ["msg", "note"]
115109 let csv = vm.exportAsCSV()
116110
117111 let lines = csv.components(separatedBy: "\n").filter { !$0.isEmpty }
@@ -140,19 +134,6 @@ final class QueryViewModelTests: XCTestCase {
140134 XCTAssertTrue(json.contains("\"value\""))
141135 }
142136
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−
156137 // MARK: - Time range
157138
158139 func testTimeRangeOptionsAllHaveValues() {
@@ -220,576 +201,5 @@ final class QueryViewModelTests: XCTestCase {
220201 vm.setDefaultQuery(stream: "stream\"name")
221202 XCTAssertTrue(vm.sqlQuery.contains("\"stream\"\"name\""))
222203 }
223−
224− // MARK: - Column filter
225−
226− func testAddColumnFilterIncludeStringValue() {
227− let vm = QueryViewModel()
228− vm.sqlQuery = "SELECT * FROM \"logs\" ORDER BY p_timestamp DESC LIMIT 1000"
229− vm.addColumnFilter(column: "level", value: .string("error"), exclude: false)
230− XCTAssertTrue(vm.sqlQuery.contains("WHERE \"level\" = 'error'"))
231− XCTAssertTrue(vm.sqlQuery.contains("ORDER BY"))
232− }
233−
234− func testAddColumnFilterExcludeStringValue() {
235− let vm = QueryViewModel()
236− vm.sqlQuery = "SELECT * FROM \"logs\" ORDER BY p_timestamp DESC LIMIT 1000"
237− vm.addColumnFilter(column: "level", value: .string("debug"), exclude: true)
238− XCTAssertTrue(vm.sqlQuery.contains("WHERE \"level\" IS DISTINCT FROM 'debug'"))
239− XCTAssertTrue(vm.sqlQuery.contains("ORDER BY"))
240− }
241−
242− func testAddColumnFilterNullValue() {
243− let vm = QueryViewModel()
244− vm.sqlQuery = "SELECT * FROM \"logs\" ORDER BY p_timestamp DESC"
245− vm.addColumnFilter(column: "tag", value: nil, exclude: false)
246− XCTAssertTrue(vm.sqlQuery.contains("WHERE \"tag\" IS NULL"))
247− }
248−
249− func testAddColumnFilterExcludeNullValue() {
250− let vm = QueryViewModel()
251− vm.sqlQuery = "SELECT * FROM \"logs\" ORDER BY p_timestamp DESC"
252− vm.addColumnFilter(column: "tag", value: .null, exclude: true)
253− XCTAssertTrue(vm.sqlQuery.contains("WHERE \"tag\" IS NOT NULL"))
254− }
255−
256− func testAddColumnFilterAppendsToExistingWhere() {
257− let vm = QueryViewModel()
258− vm.sqlQuery = "SELECT * FROM \"logs\" WHERE \"level\" = 'error' ORDER BY p_timestamp DESC"
259− vm.addColumnFilter(column: "host", value: .string("web-1"), exclude: false)
260− XCTAssertTrue(vm.sqlQuery.contains("AND \"host\" = 'web-1'"))
261− XCTAssertTrue(vm.sqlQuery.contains("WHERE \"level\" = 'error'"))
262− }
263−
264− func testAddColumnFilterParenthesizesExistingOr() {
265− // An existing top-level OR must be parenthesized before AND-ing the new
266− // condition so precedence is preserved.
267− let vm = QueryViewModel()
268− vm.sqlQuery = "SELECT * FROM \"logs\" WHERE \"a\" = 1 OR \"b\" = 2"
269− vm.addColumnFilter(column: "host", value: .string("web-1"), exclude: false)
270− XCTAssertTrue(vm.sqlQuery.contains("(\"a\" = 1 OR \"b\" = 2) AND \"host\" = 'web-1'"))
271− }
272−
273− func testAddColumnFilterIntValue() {
274− let vm = QueryViewModel()
275− vm.sqlQuery = "SELECT * FROM \"logs\""
276− vm.addColumnFilter(column: "status", value: .int(200), exclude: false)
277− XCTAssertTrue(vm.sqlQuery.contains("WHERE \"status\" = 200"))
278− }
279−
280− func testAddColumnFilterNoOpOnEmptyQuery() {
281− let vm = QueryViewModel()
282− vm.sqlQuery = ""
283− vm.addColumnFilter(column: "level", value: .string("info"), exclude: false)
284− XCTAssertEqual(vm.sqlQuery, "")
285− }
286−
287− func testAddColumnFilterQueryWithoutOrderBy() {
288− let vm = QueryViewModel()
289− vm.sqlQuery = "SELECT * FROM \"logs\""
290− vm.addColumnFilter(column: "level", value: .string("warn"), exclude: true)
291− XCTAssertTrue(vm.sqlQuery.contains("WHERE \"level\" IS DISTINCT FROM 'warn'"))
292− }
293−
294− func testAddColumnFilterEscapesSingleQuotesInValue() {
295− let vm = QueryViewModel()
296− vm.sqlQuery = "SELECT * FROM \"logs\""
297− vm.addColumnFilter(column: "msg", value: .string("it's a test"), exclude: false)
298− XCTAssertTrue(vm.sqlQuery.contains("'it''s a test'"))
299− }
300−
301− // MARK: - Column visibility
302−
303− func testVisibleColumnsExcludesHidden() {
304− let vm = QueryViewModel()
305− vm.columns = ["a", "b", "c"]
306− vm.columnOrder = ["a", "b", "c"]
307− vm.hiddenColumns = ["b"]
308− XCTAssertEqual(vm.visibleColumns, ["a", "c"])
309− }
310−
311− func testToggleColumnVisibilityHidesColumn() {
312− let vm = QueryViewModel()
313− vm.columns = ["a", "b", "c"]
314− vm.columnOrder = ["a", "b", "c"]
315− vm.toggleColumnVisibility("b")
316− XCTAssertTrue(vm.hiddenColumns.contains("b"))
317− XCTAssertEqual(vm.visibleColumns, ["a", "c"])
318− }
319−
320− func testToggleColumnVisibilityShowsColumn() {
321− let vm = QueryViewModel()
322− vm.columns = ["a", "b", "c"]
323− vm.columnOrder = ["a", "b", "c"]
324− vm.hiddenColumns = ["b"]
325− vm.toggleColumnVisibility("b")
326− XCTAssertFalse(vm.hiddenColumns.contains("b"))
327− XCTAssertEqual(vm.visibleColumns, ["a", "b", "c"])
328− }
329−
330− func testCannotHideAllColumns() {
331− let vm = QueryViewModel()
332− vm.columns = ["a", "b"]
333− vm.columnOrder = ["a", "b"]
334− vm.toggleColumnVisibility("a")
335− XCTAssertTrue(vm.hiddenColumns.contains("a"))
336− // Trying to hide the last visible column should be prevented
337− vm.toggleColumnVisibility("b")
338− XCTAssertFalse(vm.hiddenColumns.contains("b"))
339− XCTAssertEqual(vm.visibleColumns, ["b"])
340− }
341−
342− func testShowAllColumns() {
343− let vm = QueryViewModel()
344− vm.columns = ["a", "b", "c"]
345− vm.columnOrder = ["a", "b", "c"]
346− vm.hiddenColumns = ["a", "b"]
347− vm.showAllColumns()
348− XCTAssertTrue(vm.hiddenColumns.isEmpty)
349− XCTAssertEqual(vm.visibleColumns, ["a", "b", "c"])
350− }
351−
352− // MARK: - Column reordering
353−
354− func testMoveColumnByName() {
355− let vm = QueryViewModel()
356− vm.columns = ["a", "b", "c"]
357− vm.columnOrder = ["a", "b", "c"]
358− vm.moveColumn("c", to: "a")
359− XCTAssertEqual(vm.columnOrder, ["c", "a", "b"])
360− }
361−
362− func testMoveColumnRightwardLandsAfterTarget() {
363− // Rightward move (e.g. the "Move Right" context action, which passes the
364− // adjacent column as the target) must land the column *after* the target,
365− // not produce a no-op.
366− let vm = QueryViewModel()
367− vm.columns = ["a", "b", "c"]
368− vm.columnOrder = ["a", "b", "c"]
369− vm.moveColumn("b", to: "c")
370− XCTAssertEqual(vm.columnOrder, ["a", "c", "b"])
371− }
372−
373− func testMoveColumnLeftwardLandsBeforeTarget() {
374− let vm = QueryViewModel()
375− vm.columns = ["a", "b", "c"]
376− vm.columnOrder = ["a", "b", "c"]
377− vm.moveColumn("b", to: "a")
378− XCTAssertEqual(vm.columnOrder, ["b", "a", "c"])
379− }
380−
381− func testMoveColumnByIndexSet() {
382− let vm = QueryViewModel()
383− vm.columns = ["a", "b", "c"]
384− vm.columnOrder = ["a", "b", "c"]
385− vm.moveColumn(from: IndexSet(integer: 2), to: 0)
386− XCTAssertEqual(vm.columnOrder, ["c", "a", "b"])
387− }
388−
389− func testResetColumnConfig() {
390− let vm = QueryViewModel()
391− vm.columns = ["a", "b", "c"]
392− vm.columnOrder = ["c", "b", "a"]
393− vm.hiddenColumns = ["b"]
394− vm.resetColumnConfig()
395− XCTAssertEqual(vm.columnOrder, ["a", "b", "c"])
396− XCTAssertTrue(vm.hiddenColumns.isEmpty)
397− }
398−
399− func testVisibleColumnsRespectsOrder() {
400− let vm = QueryViewModel()
401− vm.columns = ["a", "b", "c"]
402− vm.columnOrder = ["c", "a", "b"]
403− vm.hiddenColumns = ["a"]
404− XCTAssertEqual(vm.visibleColumns, ["c", "b"])
405− }
406−
407− // MARK: - Column config persistence
408−
409− func testApplyColumnConfigWithNoSavedConfig() {
410− let vm = QueryViewModel()
411− vm.applyColumnConfig(extractedColumns: ["x", "y", "z"], stream: nil)
412− XCTAssertEqual(vm.columns, ["x", "y", "z"])
413− XCTAssertEqual(vm.columnOrder, ["x", "y", "z"])
414− XCTAssertTrue(vm.hiddenColumns.isEmpty)
415− }
416−
417− func testApplyColumnConfigMergesNewColumns() {
418− let vm = QueryViewModel()
419− // Save a config for "test_stream"
420− let config = QueryViewModel.ColumnConfiguration(order: ["b", "a"], hidden: ["a"])
421− if let data = try? JSONEncoder().encode(config) {
422− UserDefaults.standard.set(data, forKey: "parseable_column_config_test_stream")
423− }
424−
425− // Apply with new columns that include an extra column "c"
426− vm.applyColumnConfig(extractedColumns: ["a", "b", "c"], stream: "test_stream")
427− XCTAssertEqual(vm.columnOrder, ["b", "a", "c"]) // saved order preserved, new "c" appended
428− XCTAssertEqual(vm.hiddenColumns, ["a"]) // saved hidden preserved
429−
430− // Clean up
431− UserDefaults.standard.removeObject(forKey: "parseable_column_config_test_stream")
432− }
433−
434− func testApplyColumnConfigRemovesStaleSavedColumns() {
435− let vm = QueryViewModel()
436− // Save a config with a column that no longer exists
437− let config = QueryViewModel.ColumnConfiguration(order: ["old", "a", "b"], hidden: ["old"])
438− if let data = try? JSONEncoder().encode(config) {
439− UserDefaults.standard.set(data, forKey: "parseable_column_config_test_stream2")
440− }
441−
442− vm.applyColumnConfig(extractedColumns: ["a", "b"], stream: "test_stream2")
443− XCTAssertEqual(vm.columnOrder, ["a", "b"]) // "old" removed
444− XCTAssertTrue(vm.hiddenColumns.isEmpty) // "old" was hidden but no longer exists
445−
446− // Clean up
447− UserDefaults.standard.removeObject(forKey: "parseable_column_config_test_stream2")
448− }
449−
450− func testClearResultsResetsColumnState() {
451− let vm = QueryViewModel()
452− vm.columns = ["a", "b"]
453− vm.columnOrder = ["b", "a"]
454− vm.hiddenColumns = ["a"]
455− vm.clearResults()
456− XCTAssertTrue(vm.columns.isEmpty)
457− XCTAssertTrue(vm.columnOrder.isEmpty)
458− XCTAssertTrue(vm.hiddenColumns.isEmpty)
459− }
460−
461− func testCSVExportUsesVisibleColumns() {
462− let vm = QueryViewModel()
463− vm.results = [
464− ["name": .string("Alice"), "age": .int(30), "secret": .string("hidden")],
465− ]
466− vm.columns = ["name", "age", "secret"]
467− vm.columnOrder = ["name", "age", "secret"]
468− vm.hiddenColumns = ["secret"]
469− let csv = vm.exportAsCSV()
470−
471− let lines = csv.components(separatedBy: "\n").filter { !$0.isEmpty }
472− XCTAssertEqual(lines[0], "name,age")
473− XCTAssertFalse(csv.contains("secret"))
474− XCTAssertFalse(csv.contains("hidden"))
475− }
476−
477− // MARK: - Empty column detection
478−
479− func testEmptyColumnsDetectsAllNulls() {
480− let vm = QueryViewModel()
481− let records: [LogRecord] = [
482− ["a": .string("val"), "b": .null],
483− ["a": .string("val2"), "b": .null],
484− ]
485− let empty = vm.emptyColumns(in: records, columns: ["a", "b"])
486− XCTAssertEqual(empty, ["b"])
487− }
488−
489− func testEmptyColumnsDetectsMissingKeys() {
490− let vm = QueryViewModel()
491− let records: [LogRecord] = [
492− ["a": .string("val")],
493− ["a": .string("val2")],
494− ]
495− let empty = vm.emptyColumns(in: records, columns: ["a", "b"])
496− XCTAssertEqual(empty, ["b"])
497− }
498−
499− func testEmptyColumnsDetectsEmptyStrings() {
500− let vm = QueryViewModel()
501− let records: [LogRecord] = [
502− ["a": .string("val"), "b": .string("")],
503− ["a": .string("val2"), "b": .string("")],
504− ]
505− let empty = vm.emptyColumns(in: records, columns: ["a", "b"])
506− XCTAssertEqual(empty, ["b"])
507− }
508−
509− func testEmptyColumnsMixedNullAndMissing() {
510− let vm = QueryViewModel()
511− let records: [LogRecord] = [
512− ["a": .string("val"), "b": .null],
513− ["a": .string("val2")],
514− ["a": .string("val3"), "b": .string("")],
515− ]
516− let empty = vm.emptyColumns(in: records, columns: ["a", "b"])
517− XCTAssertEqual(empty, ["b"])
518− }
519−
520− func testEmptyColumnsNotEmptyIfAnyRecordHasValue() {
521− let vm = QueryViewModel()
522− let records: [LogRecord] = [
523− ["a": .string("val"), "b": .null],
524− ["a": .string("val2"), "b": .string("data")],
525− ]
526− let empty = vm.emptyColumns(in: records, columns: ["a", "b"])
527− XCTAssertTrue(empty.isEmpty)
528− }
529−
530− func testEmptyColumnsNoRecords() {
531− let vm = QueryViewModel()
532− let empty = vm.emptyColumns(in: [], columns: ["a", "b"])
533− XCTAssertEqual(empty, ["a", "b"])
534− }
535−
536− func testEmptyColumnsNoColumns() {
537− let vm = QueryViewModel()
538− let records: [LogRecord] = [["a": .string("val")]]
539− let empty = vm.emptyColumns(in: records, columns: [])
540− XCTAssertTrue(empty.isEmpty)
541− }
542−
543− func testAutoHiddenColumnsExcludedFromVisibleColumns() {
544− let vm = QueryViewModel()
545− vm.columns = ["a", "b", "c"]
546− vm.columnOrder = ["a", "b", "c"]
547− vm.autoHiddenColumns = ["b"]
548− XCTAssertEqual(vm.visibleColumns, ["a", "c"])
549− }
550−
551− func testToggleAutoHiddenColumnShowsIt() {
552− let vm = QueryViewModel()
553− vm.columns = ["a", "b", "c"]
554− vm.columnOrder = ["a", "b", "c"]
555− vm.autoHiddenColumns = ["b"]
556− vm.toggleColumnVisibility("b")
557− XCTAssertFalse(vm.autoHiddenColumns.contains("b"))
558− XCTAssertEqual(vm.visibleColumns, ["a", "b", "c"])
559− }
560−
561− func testShowAllColumnsClearsAutoHidden() {
562− let vm = QueryViewModel()
563− vm.columns = ["a", "b", "c"]
564− vm.columnOrder = ["a", "b", "c"]
565− vm.hiddenColumns = ["a"]
566− vm.autoHiddenColumns = ["b"]
567− vm.showAllColumns()
568− XCTAssertTrue(vm.hiddenColumns.isEmpty)
569− XCTAssertTrue(vm.autoHiddenColumns.isEmpty)
570− XCTAssertEqual(vm.visibleColumns, ["a", "b", "c"])
571− }
572−
573− func testResetColumnConfigClearsAutoHidden() {
574− let vm = QueryViewModel()
575− vm.columns = ["a", "b", "c"]
576− vm.columnOrder = ["c", "b", "a"]
577− vm.autoHiddenColumns = ["b"]
578− vm.resetColumnConfig()
579− XCTAssertEqual(vm.columnOrder, ["a", "b", "c"])
580− XCTAssertTrue(vm.autoHiddenColumns.isEmpty)
581− }
582−
583− func testClearResultsClearsAutoHidden() {
584− let vm = QueryViewModel()
585− vm.columns = ["a", "b"]
586− vm.columnOrder = ["a", "b"]
587− vm.autoHiddenColumns = ["b"]
588− vm.clearResults()
589− XCTAssertTrue(vm.autoHiddenColumns.isEmpty)
590− }
591−
592− // MARK: - Pending column config from saved queries
593−
594− func testApplyColumnConfigUsesPendingConfig() {
595− let vm = QueryViewModel()
596− vm.pendingColumnConfig = QueryViewModel.ColumnConfiguration(
597− order: ["c", "a", "b"],
598− hidden: ["b"]
599− )
600− vm.applyColumnConfig(extractedColumns: ["a", "b", "c"], stream: "test_stream")
601− XCTAssertEqual(vm.columnOrder, ["c", "a", "b"])
602− XCTAssertEqual(vm.hiddenColumns, ["b"])
603− XCTAssertNil(vm.pendingColumnConfig, "Pending config should be consumed")
604− }
605−
606− func testApplyColumnConfigPendingOverridesStreamDefault() {
607− let vm = QueryViewModel()
608− // Save a stream-level config
609− let streamConfig = QueryViewModel.ColumnConfiguration(order: ["a", "b", "c"], hidden: [])
610− if let data = try? JSONEncoder().encode(streamConfig) {
611− UserDefaults.standard.set(data, forKey: "parseable_column_config_pending_test_stream")
612− }
613−
614− // Set a different pending config (from saved query)
615− vm.pendingColumnConfig = QueryViewModel.ColumnConfiguration(
616− order: ["c", "b", "a"],
617− hidden: ["a"]
618− )
619− vm.applyColumnConfig(extractedColumns: ["a", "b", "c"], stream: "pending_test_stream")
620− XCTAssertEqual(vm.columnOrder, ["c", "b", "a"])
621− XCTAssertEqual(vm.hiddenColumns, ["a"])
622−
623− // Clean up
624− UserDefaults.standard.removeObject(forKey: "parseable_column_config_pending_test_stream")
625− }
626−
627− func testApplyColumnConfigPendingMergesNewColumns() {
628− let vm = QueryViewModel()
629− // Pending config references columns a and b, but results also have c
630− vm.pendingColumnConfig = QueryViewModel.ColumnConfiguration(
631− order: ["b", "a"],
632− hidden: []
633− )
634− vm.applyColumnConfig(extractedColumns: ["a", "b", "c"], stream: "test_stream")
635− XCTAssertEqual(vm.columnOrder, ["b", "a", "c"])
636− }
637−
638− func testApplyColumnConfigPendingRemovesStaleColumns() {
639− let vm = QueryViewModel()
640− // Pending config references "old" which no longer exists in results
641− vm.pendingColumnConfig = QueryViewModel.ColumnConfiguration(
642− order: ["old", "a", "b"],
643− hidden: ["old"]
644− )
645− vm.applyColumnConfig(extractedColumns: ["a", "b"], stream: "test_stream")
646− XCTAssertEqual(vm.columnOrder, ["a", "b"])
647− XCTAssertTrue(vm.hiddenColumns.isEmpty)
648− }
649−
650− func testCSVExportExcludesAutoHiddenColumns() {
651− let vm = QueryViewModel()
652− vm.results = [
653− ["name": .string("Alice"), "age": .int(30), "empty_col": .null],
654− ]
655− vm.columns = ["name", "age", "empty_col"]
656− vm.columnOrder = ["name", "age", "empty_col"]
657− vm.autoHiddenColumns = ["empty_col"]
658− let csv = vm.exportAsCSV()
659−
660− let lines = csv.components(separatedBy: "\n").filter { !$0.isEmpty }
661− XCTAssertEqual(lines[0], "name,age")
662− XCTAssertFalse(csv.contains("empty_col"))
663− }
664−
665− func testBoolZeroValuesNotTreatedAsEmpty() {
666− let vm = QueryViewModel()
667− let records: [LogRecord] = [
668− ["a": .bool(false)],
669− ["a": .int(0)],
670− ]
671− let empty = vm.emptyColumns(in: records, columns: ["a"])
672− XCTAssertTrue(empty.isEmpty)
673− }
674−
675− // MARK: - Column filter edge cases
676−
677− func testAddColumnFilterKeepsTrailingSemicolonLast() {
678− let vm = QueryViewModel()
679− vm.sqlQuery = "SELECT * FROM \"logs\";"
680− vm.addColumnFilter(column: "level", value: .string("error"), exclude: false)
681− XCTAssertEqual(vm.sqlQuery, "SELECT * FROM \"logs\" WHERE \"level\" = 'error';")
682− }
683−
684− func testAddColumnFilterDoesNotLandInTrailingComment() {
685− let vm = QueryViewModel()
686− vm.sqlQuery = "SELECT * FROM \"logs\" -- recent"
687− vm.addColumnFilter(column: "level", value: .string("error"), exclude: false)
688− XCTAssertEqual(vm.sqlQuery, "SELECT * FROM \"logs\" WHERE \"level\" = 'error' -- recent")
689− }
690−
691− func testAddColumnFilterKeepsLineBreakAfterWhereBodyComment() {
692− let vm = QueryViewModel()
693− vm.sqlQuery = "SELECT * FROM \"logs\"\nWHERE a = 1 -- note\nORDER BY p_timestamp DESC"
694− vm.addColumnFilter(column: "host", value: .string("web-1"), exclude: false)
695− XCTAssertEqual(
696− vm.sqlQuery,
697− "SELECT * FROM \"logs\"\nWHERE a = 1 AND \"host\" = 'web-1' -- note\nORDER BY p_timestamp DESC"
698− )
699− }
700−
701− func testAddColumnFilterWithTrailingClauseBeforeWhereDoesNotCrash() {
702− let vm = QueryViewModel()
703− vm.sqlQuery = "SELECT a FROM t1 GROUP BY a UNION ALL SELECT a FROM t2 WHERE b = 1"
704− vm.addColumnFilter(column: "host", value: .string("web-1"), exclude: false)
705− XCTAssertEqual(
706− vm.sqlQuery,
707− "SELECT a FROM t1 GROUP BY a UNION ALL SELECT a FROM t2 WHERE b = 1 AND \"host\" = 'web-1'"
708− )
709− }
710−
711− func testAddColumnFilterIgnoresKeywordNamedColumnsInSelectList() {
712− let vm = QueryViewModel()
713− vm.sqlQuery = "SELECT offset, message FROM \"kafka\" WHERE partition = 3"
714− vm.addColumnFilter(column: "host", value: .string("web-1"), exclude: false)
715− XCTAssertEqual(
716− vm.sqlQuery,
717− "SELECT offset, message FROM \"kafka\" WHERE partition = 3 AND \"host\" = 'web-1'"
718− )
719− }
720−
721− // MARK: - SELECT list rewriting
722−
723− func testUpdateSQLColumnsLeavesExpressionListsAlone() {
724− let vm = QueryViewModel()
725− let sql = "SELECT count(*) AS n, level FROM \"logs\" GROUP BY level"
726− vm.sqlQuery = sql
727− vm.columns = ["n", "level"]
728− vm.columnOrder = ["n", "level"]
729− vm.toggleColumnVisibility("n")
730− XCTAssertEqual(vm.sqlQuery, sql)
731− XCTAssertEqual(vm.visibleColumns, ["level"])
732− }
733−
734− func testUpdateSQLColumnsDoesNotBakeInAutoHiddenColumns() {
735− let vm = QueryViewModel()
736− vm.sqlQuery = "SELECT * FROM \"logs\""
737− vm.columns = ["a", "b", "c"]
738− vm.columnOrder = ["a", "b", "c"]
739− vm.autoHiddenColumns = ["c"]
740− vm.moveColumn("b", to: "a")
741− XCTAssertEqual(vm.sqlQuery, "SELECT * FROM \"logs\"")
742− }
743−
744− func testUpdateSQLColumnsKeepsHiddenColumnOutAfterNarrowedRerun() {
745− // Hiding `c` narrowed the SELECT list, so the re-run's results lack it; a
746− // later reorder must not switch back to `*` and fetch it again.
747− let vm = QueryViewModel()
748− vm.sqlQuery = "SELECT \"a\", \"b\" FROM \"logs\""
749− vm.pendingColumnConfig = QueryViewModel.ColumnConfiguration(order: ["a", "b", "c"], hidden: ["c"])
750− vm.applyColumnConfig(extractedColumns: ["a", "b"], stream: nil)
751− vm.moveColumn("b", to: "a")
752− XCTAssertEqual(vm.sqlQuery, "SELECT \"b\", \"a\" FROM \"logs\"")
753− XCTAssertTrue(vm.hasHiddenColumns)
754− vm.showAllColumns()
755− XCTAssertEqual(vm.sqlQuery, "SELECT * FROM \"logs\"")
756− }
757−
758− func testExcludeFilterKeepsRowsWhereColumnIsNull() {
759− let vm = QueryViewModel()
760− vm.sqlQuery = "SELECT * FROM \"logs\""
761− vm.addColumnFilter(column: "level", value: .string("debug"), exclude: true)
762− XCTAssertFalse(vm.sqlQuery.contains("<>"), "`<>` would also drop rows where level is NULL")
763− XCTAssertTrue(vm.sqlQuery.contains("\"level\" IS DISTINCT FROM 'debug'"))
764− }
765−
766− func testUpdateSQLColumnsKeepsListedColumnsMissingFromResults() {
767− // Parseable omits all-null fields from query JSON, so a listed column can
768− // be absent from `columns`; rewriting must neither refuse nor drop it.
769− let vm = QueryViewModel()
770− vm.sqlQuery = "SELECT \"a\", \"b\", \"c\", \"sparse\" FROM \"logs\""
771− vm.columns = ["a", "b", "c"]
772− vm.columnOrder = ["a", "b", "c"]
773− vm.toggleColumnVisibility("c")
774− XCTAssertEqual(vm.sqlQuery, "SELECT \"a\", \"b\", \"sparse\" FROM \"logs\"")
775− }
776−
777− func testReorderKeepsStarWhenSavedHiddenColumnIsAbsentAnyway() {
778− // Under `SELECT *` a saved-hidden column that's missing is missing for
779− // other reasons (sparse/removed); a plain reorder must keep `*`.
780− let vm = QueryViewModel()
781− vm.sqlQuery = "SELECT * FROM \"logs\""
782− vm.pendingColumnConfig = QueryViewModel.ColumnConfiguration(order: ["a", "b", "gone"], hidden: ["gone"])
783− vm.applyColumnConfig(extractedColumns: ["a", "b"], stream: nil)
784− vm.moveColumn("b", to: "a")
785− XCTAssertEqual(vm.sqlQuery, "SELECT * FROM \"logs\"")
786− }
787−
788− func testMergedColumnOrderKeepsSavedPositionForAbsentColumn() {
789− XCTAssertEqual(
790− QueryViewModel.mergedColumnOrder(["b", "a"], inserting: ["x"], reference: ["a", "x", "b"]),
791− ["b", "a", "x"]
792− )
793− }
794204}
795205
ParseableViewerTests/SQLAutoCompleteTests.swiftdeleted−337
@@ -1,337 +0,0 @@
1−import XCTest
2−@testable import ParseableViewer
3−
4−final class SQLAutoCompleteTests: XCTestCase {
5−
6− private let streams = ["access_logs", "error_logs", "metrics"]
7− private let fields = [
8− SchemaField(name: "p_timestamp", dataType: "Utf8"),
9− SchemaField(name: "level", dataType: "Utf8"),
10− SchemaField(name: "message", dataType: "Utf8"),
11− SchemaField(name: "host", dataType: "Utf8"),
12− SchemaField(name: "status_code", dataType: "Int64"),
13− ]
14−
15− // MARK: - Context detection
16−
17− func testContextAfterSelect() {
18− let ctx = SQLCompletionProvider.determineContext(text: "SELECT ", position: 7)
19− XCTAssertEqual(ctx, .columnRef)
20− }
21−
22− func testContextAfterFrom() {
23− let ctx = SQLCompletionProvider.determineContext(text: "SELECT * FROM ", position: 14)
24− XCTAssertEqual(ctx, .tableRef)
25− }
26−
27− func testContextAfterWhere() {
28− let ctx = SQLCompletionProvider.determineContext(text: "SELECT * FROM t WHERE ", position: 22)
29− XCTAssertEqual(ctx, .columnRef)
30− }
31−
32− func testContextAfterAnd() {
33− let ctx = SQLCompletionProvider.determineContext(text: "SELECT * FROM t WHERE x = 1 AND ", position: 32)
34− XCTAssertEqual(ctx, .columnRef)
35− }
36−
37− func testContextAfterJoin() {
38− let ctx = SQLCompletionProvider.determineContext(text: "SELECT * FROM a JOIN ", position: 21)
39− XCTAssertEqual(ctx, .tableRef)
40− }
41−
42− func testContextAfterOrder() {
43− let ctx = SQLCompletionProvider.determineContext(text: "SELECT * FROM t ORDER ", position: 22)
44− XCTAssertEqual(ctx, .afterOrder)
45− }
46−
47− func testContextAfterGroup() {
48− let ctx = SQLCompletionProvider.determineContext(text: "SELECT * FROM t GROUP ", position: 22)
49− XCTAssertEqual(ctx, .afterGroup)
50− }
51−
52− func testContextAfterCommaInSelect() {
53− let ctx = SQLCompletionProvider.determineContext(text: "SELECT a, ", position: 10)
54− XCTAssertEqual(ctx, .columnRef)
55− }
56−
57− func testContextAfterCommaInFrom() {
58− let ctx = SQLCompletionProvider.determineContext(text: "SELECT * FROM a, ", position: 17)
59− XCTAssertEqual(ctx, .tableRef)
60− }
61−
62− func testContextGeneral() {
63− let ctx = SQLCompletionProvider.determineContext(text: "", position: 0)
64− XCTAssertEqual(ctx, .general)
65− }
66−
67− // MARK: - Completions for keywords
68−
69− func testKeywordCompletionSelect() {
70− let result = SQLCompletionProvider.completions(
71− for: "SEL", cursorPosition: 3, streamNames: streams, schemaFields: fields
72− )
73− XCTAssertTrue(result.items.contains(where: { $0.text == "SELECT" }))
74− XCTAssertEqual(result.prefix, "SEL")
75− XCTAssertEqual(result.range, NSRange(location: 0, length: 3))
76− }
77−
78− func testKeywordCompletionCaseInsensitive() {
79− let result = SQLCompletionProvider.completions(
80− for: "sel", cursorPosition: 3, streamNames: streams, schemaFields: fields
81− )
82− XCTAssertTrue(result.items.contains(where: { $0.text == "SELECT" }))
83− }
84−
85− func testNoCompletionOnExactMatch() {
86− let result = SQLCompletionProvider.completions(
87− for: "SELECT", cursorPosition: 6, streamNames: [], schemaFields: []
88− )
89− // When the only match is exact, no items are returned
90− XCTAssertTrue(result.items.isEmpty)
91− }
92−
93− // MARK: - Table name completions
94−
95− func testTableCompletionAfterFrom() {
96− let result = SQLCompletionProvider.completions(
97− for: "SELECT * FROM ac", cursorPosition: 16, streamNames: streams, schemaFields: fields
98− )
99− XCTAssertTrue(result.items.contains(where: { $0.text == "\"access_logs\"" && $0.kind == .table }))
100− XCTAssertFalse(result.items.contains(where: { $0.text == "\"metrics\"" }))
101− }
102−
103− func testTableCompletionIncludesQuotes() {
104− let result = SQLCompletionProvider.completions(
105− for: "SELECT * FROM err", cursorPosition: 17, streamNames: streams, schemaFields: fields
106− )
107− let tableItem = result.items.first(where: { $0.kind == .table })
108− XCTAssertNotNil(tableItem)
109− XCTAssertEqual(tableItem?.text, "\"error_logs\"")
110− }
111−
112− // MARK: - Column completions
113−
114− func testColumnCompletionAfterWhere() {
115− let result = SQLCompletionProvider.completions(
116− for: "SELECT * FROM t WHERE lev", cursorPosition: 25,
117− streamNames: streams, schemaFields: fields
118− )
119− XCTAssertTrue(result.items.contains(where: { $0.text == "level" && $0.kind == .column }))
120− }
121−
122− func testColumnCompletionIncludesDetail() {
123− let result = SQLCompletionProvider.completions(
124− for: "SELECT * FROM t WHERE sta", cursorPosition: 25,
125− streamNames: streams, schemaFields: fields
126− )
127− let colItem = result.items.first(where: { $0.kind == .column })
128− XCTAssertNotNil(colItem)
129− XCTAssertEqual(colItem?.detail, "Int64")
130− }
131−
132− func testColumnCompletionAfterSelect() {
133− let result = SQLCompletionProvider.completions(
134− for: "SELECT mes", cursorPosition: 10,
135− streamNames: streams, schemaFields: fields
136− )
137− XCTAssertTrue(result.items.contains(where: { $0.text == "message" && $0.kind == .column }))
138− }
139−
140− // MARK: - Function completions
141−
142− func testFunctionCompletion() {
143− let result = SQLCompletionProvider.completions(
144− for: "SELECT COU", cursorPosition: 10,
145− streamNames: streams, schemaFields: fields
146− )
147− XCTAssertTrue(result.items.contains(where: { $0.text == "COUNT" && $0.kind == .function }))
148− }
149−
150− // MARK: - Empty prefix returns nothing
151−
152− func testEmptyPrefixNoCompletions() {
153− let result = SQLCompletionProvider.completions(
154− for: "SELECT ", cursorPosition: 7,
155− streamNames: streams, schemaFields: fields
156− )
157− XCTAssertTrue(result.items.isEmpty)
158− }
159−
160− // MARK: - Prefix range
161−
162− func testPrefixRangeInMiddle() {
163− let text = "SELECT * FROM acc WHERE"
164− let result = SQLCompletionProvider.completions(
165− for: text, cursorPosition: 17,
166− streamNames: streams, schemaFields: fields
167− )
168− // "acc" starts at position 14
169− XCTAssertEqual(result.range, NSRange(location: 14, length: 3))
170− XCTAssertEqual(result.prefix, "acc")
171− }
172−
173− // MARK: - ORDER BY / GROUP BY
174−
175− func testOrderByCompletion() {
176− let result = SQLCompletionProvider.completions(
177− for: "SELECT * FROM t ORDER B", cursorPosition: 23,
178− streamNames: streams, schemaFields: fields
179− )
180− XCTAssertTrue(result.items.contains(where: { $0.text == "BY" }))
181− }
182−
183− func testGroupByCompletion() {
184− let result = SQLCompletionProvider.completions(
185− for: "SELECT * FROM t GROUP B", cursorPosition: 23,
186− streamNames: streams, schemaFields: fields
187− )
188− XCTAssertTrue(result.items.contains(where: { $0.text == "BY" }))
189− }
190−
191− // MARK: - isWordCharacter
192−
193− func testWordCharacters() {
194− XCTAssertTrue(SQLCompletionProvider.isWordCharacter(0x41)) // A
195− XCTAssertTrue(SQLCompletionProvider.isWordCharacter(0x5A)) // Z
196− XCTAssertTrue(SQLCompletionProvider.isWordCharacter(0x61)) // a
197− XCTAssertTrue(SQLCompletionProvider.isWordCharacter(0x7A)) // z
198− XCTAssertTrue(SQLCompletionProvider.isWordCharacter(0x30)) // 0
199− XCTAssertTrue(SQLCompletionProvider.isWordCharacter(0x39)) // 9
200− XCTAssertTrue(SQLCompletionProvider.isWordCharacter(0x5F)) // _
201− XCTAssertFalse(SQLCompletionProvider.isWordCharacter(0x20)) // space
202− XCTAssertFalse(SQLCompletionProvider.isWordCharacter(0x2C)) // comma
203− XCTAssertFalse(SQLCompletionProvider.isWordCharacter(0x28)) // (
204− }
205−
206− // MARK: - Edge cases
207−
208− func testCursorAtZero() {
209− let result = SQLCompletionProvider.completions(
210− for: "SELECT", cursorPosition: 0,
211− streamNames: streams, schemaFields: fields
212− )
213− XCTAssertTrue(result.items.isEmpty)
214− }
215−
216− func testCursorBeyondEnd() {
217− let result = SQLCompletionProvider.completions(
218− for: "SEL", cursorPosition: 100,
219− streamNames: streams, schemaFields: fields
220− )
221− XCTAssertTrue(result.items.isEmpty)
222− }
223−
224− func testEmptyText() {
225− let result = SQLCompletionProvider.completions(
226− for: "", cursorPosition: 0,
227− streamNames: streams, schemaFields: fields
228− )
229− XCTAssertTrue(result.items.isEmpty)
230− }
231−
232− // MARK: - Mixed context completions
233−
234− func testGeneralContextIncludesAllKinds() {
235− // Typing "m" at the start should match keywords (no keywords start with m),
236− // functions, tables ("metrics"), and columns ("message")
237− let result = SQLCompletionProvider.completions(
238− for: "m", cursorPosition: 1,
239− streamNames: streams, schemaFields: fields
240− )
241− XCTAssertTrue(result.items.contains(where: { $0.kind == .function })) // MAX, MIN
242− XCTAssertTrue(result.items.contains(where: { $0.kind == .table })) // metrics
243− XCTAssertTrue(result.items.contains(where: { $0.kind == .column })) // message
244− }
245−
246− // MARK: - Completion item properties
247−
248− func testCompletionItemKindLabels() {
249− let kw = SQLCompletionItem(text: "SELECT", kind: .keyword)
250− XCTAssertEqual(kw.kindLabel, "K")
251−
252− let fn = SQLCompletionItem(text: "COUNT", kind: .function)
253− XCTAssertEqual(fn.kindLabel, "F")
254−
255− let tbl = SQLCompletionItem(text: "logs", kind: .table)
256− XCTAssertEqual(tbl.kindLabel, "T")
257−
258− let col = SQLCompletionItem(text: "level", kind: .column)
259− XCTAssertEqual(col.kindLabel, "C")
260− }
261−
262− // MARK: - Context with DISTINCT, NOT, BETWEEN, etc.
263−
264− func testContextAfterDistinct() {
265− let ctx = SQLCompletionProvider.determineContext(text: "SELECT DISTINCT ", position: 16)
266− XCTAssertEqual(ctx, .columnRef)
267− }
268−
269− func testContextAfterNot() {
270− let ctx = SQLCompletionProvider.determineContext(text: "SELECT * FROM t WHERE NOT ", position: 26)
271− XCTAssertEqual(ctx, .columnRef)
272− }
273−
274− func testContextAfterLeftJoin() {
275− let ctx = SQLCompletionProvider.determineContext(text: "SELECT * FROM a LEFT JOIN ", position: 26)
276− XCTAssertEqual(ctx, .tableRef)
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−}
335−
336−// Make SQLCompletionContext Equatable for assertions
337−extension SQLCompletionContext: Equatable {}
ParseableViewerTests/SQLTokenizerTests.swiftdeleted−366
@@ -1,366 +0,0 @@
1−import XCTest
2−@testable import ParseableViewer
3−
4−final class SQLTokenizerTests: XCTestCase {
5−
6− // MARK: - Tokenizer basics
7−
8− func testTokenizesSimpleSelect() {
9− let tokens = SQLTokenizer.tokenize("SELECT * FROM t")
10− let kinds = tokens.map(\.kind)
11− XCTAssertEqual(kinds, [
12− .keyword("SELECT"), .whitespace, .star, .whitespace,
13− .keyword("FROM"), .whitespace, .identifier("t"),
14− ])
15− }
16−
17− func testTokenizesQuotedIdentifier() {
18− let tokens = SQLTokenizer.tokenize(#"SELECT "my col" FROM t"#)
19− let kinds = tokens.map(\.kind)
20− XCTAssertEqual(kinds, [
21− .keyword("SELECT"), .whitespace, .quotedIdentifier("my col"),
22− .whitespace, .keyword("FROM"), .whitespace, .identifier("t"),
23− ])
24− }
25−
26− func testTokenizesDoubledQuoteEscape() {
27− let tokens = SQLTokenizer.tokenize(#"SELECT "a""b" FROM t"#)
28− let ids = tokens.compactMap { token -> String? in
29− if case .quotedIdentifier(let v) = token.kind { return v }
30− return nil
31− }
32− XCTAssertEqual(ids, [#"a"b"#])
33− }
34−
35− func testTokenizesStringLiteral() {
36− let tokens = SQLTokenizer.tokenize("SELECT 'hello' FROM t")
37− let strings = tokens.compactMap { token -> String? in
38− if case .stringLiteral(let v) = token.kind { return v }
39− return nil
40− }
41− XCTAssertEqual(strings, ["hello"])
42− }
43−
44− func testTokenizesEscapedStringLiteral() {
45− let tokens = SQLTokenizer.tokenize("SELECT 'it''s' FROM t")
46− let strings = tokens.compactMap { token -> String? in
47− if case .stringLiteral(let v) = token.kind { return v }
48− return nil
49− }
50− XCTAssertEqual(strings, ["it's"])
51− }
52−
53− func testTokenizesLineComment() {
54− let tokens = SQLTokenizer.tokenize("SELECT * -- get all\nFROM t")
55− let kinds = tokens.map(\.kind)
56− XCTAssertTrue(kinds.contains(.lineComment))
57− // FROM should still be tokenized as a keyword
58− XCTAssertTrue(kinds.contains(.keyword("FROM")))
59− }
60−
61− func testTokenizesBlockComment() {
62− let tokens = SQLTokenizer.tokenize("SELECT /* cols */ * FROM t")
63− let kinds = tokens.map(\.kind)
64− XCTAssertTrue(kinds.contains(.blockComment))
65− XCTAssertTrue(kinds.contains(.star))
66− }
67−
68− func testTokenizesNumber() {
69− let tokens = SQLTokenizer.tokenize("LIMIT 100")
70− let nums = tokens.compactMap { token -> String? in
71− if case .number(let v) = token.kind { return v }
72− return nil
73− }
74− XCTAssertEqual(nums, ["100"])
75− }
76−
77− func testTokenizesNumberWithExponent() {
78− let tokens = SQLTokenizer.tokenize("SELECT 1.5e-3")
79− let nums = tokens.compactMap { token -> String? in
80− if case .number(let v) = token.kind { return v }
81− return nil
82− }
83− XCTAssertEqual(nums, ["1.5e-3"])
84− }
85−
86− func testTokenizesOperators() {
87− let tokens = SQLTokenizer.tokenize("a <> b")
88− let others = tokens.compactMap { token -> String? in
89− if case .other(let v) = token.kind { return v }
90− return nil
91− }
92− XCTAssertEqual(others, ["<", ">"])
93− }
94−
95− func testKeywordsAreCaseInsensitive() {
96− let tokens = SQLTokenizer.tokenize("select distinct from where")
97− let keywords = tokens.compactMap { token -> String? in
98− if case .keyword(let v) = token.kind { return v }
99− return nil
100− }
101− // All stored uppercased
102− XCTAssertEqual(keywords, ["SELECT", "DISTINCT", "FROM", "WHERE"])
103− }
104−
105− func testNonKeywordIdentifier() {
106− let tokens = SQLTokenizer.tokenize("SELECT mycol FROM t")
107− let ids = tokens.compactMap { token -> String? in
108− if case .identifier(let v) = token.kind { return v }
109− return nil
110− }
111− XCTAssertEqual(ids, ["mycol", "t"])
112− }
113−
114− // MARK: - selectColumnListRange
115−
116− func testSimpleSelectStar() {
117− let sql = "SELECT * FROM table1"
118− let range = SQLTokenizer.selectColumnListRange(in: sql)
119− XCTAssertNotNil(range)
120− XCTAssertEqual(String(sql[range!]), "*")
121− }
122−
123− func testSimpleColumnList() {
124− let sql = "SELECT a, b, c FROM table1"
125− let range = SQLTokenizer.selectColumnListRange(in: sql)
126− XCTAssertNotNil(range)
127− XCTAssertEqual(String(sql[range!]), "a, b, c")
128− }
129−
130− func testSelectDistinct() {
131− let sql = "SELECT DISTINCT a, b FROM table1"
132− let range = SQLTokenizer.selectColumnListRange(in: sql)
133− XCTAssertNotNil(range)
134− XCTAssertEqual(String(sql[range!]), "a, b")
135− }
136−
137− func testCaseInsensitiveKeywords() {
138− let sql = "select a, b from table1"
139− let range = SQLTokenizer.selectColumnListRange(in: sql)
140− XCTAssertNotNil(range)
141− XCTAssertEqual(String(sql[range!]), "a, b")
142− }
143−
144− func testQuotedColumnNames() {
145− let sql = #"SELECT "my col", "other" FROM table1"#
146− let range = SQLTokenizer.selectColumnListRange(in: sql)
147− XCTAssertNotNil(range)
148− XCTAssertEqual(String(sql[range!]), #""my col", "other""#)
149− }
150−
151− func testSubqueryInSelect() {
152− let sql = "SELECT (SELECT count(*) FROM other), col FROM main"
153− let range = SQLTokenizer.selectColumnListRange(in: sql)
154− XCTAssertNotNil(range)
155− // Should capture everything up to the top-level FROM
156− XCTAssertEqual(String(sql[range!]), "(SELECT count(*) FROM other), col")
157− }
158−
159− func testStringLiteralContainingFROM() {
160− let sql = "SELECT 'from' AS label FROM table1"
161− let range = SQLTokenizer.selectColumnListRange(in: sql)
162− XCTAssertNotNil(range)
163− XCTAssertEqual(String(sql[range!]), "'from' AS label")
164− }
165−
166− func testFunctionCallsInSelect() {
167− let sql = "SELECT COALESCE(a, b), UPPER(c) FROM table1"
168− let range = SQLTokenizer.selectColumnListRange(in: sql)
169− XCTAssertNotNil(range)
170− XCTAssertEqual(String(sql[range!]), "COALESCE(a, b), UPPER(c)")
171− }
172−
173− func testNestedSubqueries() {
174− let sql = "SELECT (SELECT MAX(x) FROM (SELECT 1 AS x FROM dual)) FROM t"
175− let range = SQLTokenizer.selectColumnListRange(in: sql)
176− XCTAssertNotNil(range)
177− XCTAssertEqual(String(sql[range!]), "(SELECT MAX(x) FROM (SELECT 1 AS x FROM dual))")
178− }
179−
180− func testCommentBeforeFROM() {
181− let sql = "SELECT a, b /* columns */ FROM table1"
182− let range = SQLTokenizer.selectColumnListRange(in: sql)
183− XCTAssertNotNil(range)
184− // Column list should not include the trailing comment
185− XCTAssertEqual(String(sql[range!]), "a, b")
186− }
187−
188− func testLineCommentContainingFROM() {
189− let sql = "SELECT a -- FROM fake\n, b FROM table1"
190− let range = SQLTokenizer.selectColumnListRange(in: sql)
191− XCTAssertNotNil(range)
192− XCTAssertEqual(String(sql[range!]), "a -- FROM fake\n, b")
193− }
194−
195− func testNoFROMReturnsNil() {
196− let sql = "SELECT a, b, c"
197− let range = SQLTokenizer.selectColumnListRange(in: sql)
198− XCTAssertNil(range)
199− }
200−
201− func testNoSELECTReturnsNil() {
202− let sql = "INSERT INTO t VALUES (1, 2)"
203− let range = SQLTokenizer.selectColumnListRange(in: sql)
204− XCTAssertNil(range)
205− }
206−
207− func testEmptyStringReturnsNil() {
208− let range = SQLTokenizer.selectColumnListRange(in: "")
209− XCTAssertNil(range)
210− }
211−
212− func testLeadingWhitespace() {
213− let sql = " SELECT a, b FROM table1"
214− let range = SQLTokenizer.selectColumnListRange(in: sql)
215− XCTAssertNotNil(range)
216− XCTAssertEqual(String(sql[range!]), "a, b")
217− }
218−
219− func testFullQueryWithWhereOrderLimit() {
220− let sql = #"SELECT "p_timestamp", "msg" FROM "logs" WHERE level = 'error' ORDER BY "p_timestamp" DESC LIMIT 1000"#
221− let range = SQLTokenizer.selectColumnListRange(in: sql)
222− XCTAssertNotNil(range)
223− XCTAssertEqual(String(sql[range!]), #""p_timestamp", "msg""#)
224− }
225−
226− func testReplacementRoundTrip() {
227− let sql = "SELECT a, b, c FROM table1 ORDER BY a"
228− guard let range = SQLTokenizer.selectColumnListRange(in: sql) else {
229− XCTFail("Expected range")
230− return
231− }
232− var modified = sql
233− modified.replaceSubrange(range, with: #""x", "y""#)
234− XCTAssertEqual(modified, #"SELECT "x", "y" FROM table1 ORDER BY a"#)
235− }
236−
237− func testReplacementWithStar() {
238− let sql = #"SELECT "a", "b" FROM table1"#
239− guard let range = SQLTokenizer.selectColumnListRange(in: sql) else {
240− XCTFail("Expected range")
241− return
242− }
243− var modified = sql
244− modified.replaceSubrange(range, with: "*")
245− XCTAssertEqual(modified, "SELECT * FROM table1")
246− }
247−
248− func testQuotedIdentifierContainingFROM() {
249− let sql = #"SELECT "from" FROM table1"#
250− let range = SQLTokenizer.selectColumnListRange(in: sql)
251− XCTAssertNotNil(range)
252− XCTAssertEqual(String(sql[range!]), #""from""#)
253− }
254−
255− // MARK: - SQLErrorPosition.parse
256−
257− func testParseDataFusionError() {
258− let msg = "Expected: an expression, found: FROM at Line: 1, Column 15"
259− let pos = SQLErrorPosition.parse(from: msg)
260− XCTAssertEqual(pos, SQLErrorPosition(line: 1, column: 15))
261− }
262−
263− func testParseDataFusionErrorWithColonAfterColumn() {
264− let msg = "Error at Line: 2, Column: 10"
265− let pos = SQLErrorPosition.parse(from: msg)
266− XCTAssertEqual(pos, SQLErrorPosition(line: 2, column: 10))
267− }
268−
269− func testParseReturnsNilForNoPosition() {
270− let msg = "Some generic error without position info"
271− XCTAssertNil(SQLErrorPosition.parse(from: msg))
272− }
273−
274− func testParseReturnsNilForEmptyString() {
275− XCTAssertNil(SQLErrorPosition.parse(from: ""))
276− }
277−
278− // MARK: - characterOffset
279−
280− func testCharacterOffsetSingleLine() {
281− let sql = "SELECT * FROM t"
282− // Line 1, Column 8 → offset 7 (the '*')
283− XCTAssertEqual(SQLTokenizer.characterOffset(line: 1, column: 8, in: sql), 7)
284− }
285−
286− func testCharacterOffsetMultiLine() {
287− let sql = "SELECT *\nFROM t"
288− // Line 2, Column 1 → offset 9 (the 'F' of FROM)
289− XCTAssertEqual(SQLTokenizer.characterOffset(line: 2, column: 1, in: sql), 9)
290− }
291−
292− func testCharacterOffsetLineBeyondEnd() {
293− let sql = "SELECT *"
294− XCTAssertNil(SQLTokenizer.characterOffset(line: 3, column: 1, in: sql))
295− }
296−
297− func testCharacterOffsetColumnBeyondEnd() {
298− let sql = "SELECT *"
299− // Column 100 on a short string
300− XCTAssertNil(SQLTokenizer.characterOffset(line: 1, column: 100, in: sql))
301− }
302−
303− // MARK: - tokenRange
304−
305− func testTokenRangeAtKeyword() {
306− let sql = "SELEC * FROM t"
307− // Offset 0 is at the start of "SELEC" (which is an identifier, not a keyword)
308− let range = SQLTokenizer.tokenRange(atOffset: 0, in: sql)
309− XCTAssertNotNil(range)
310− XCTAssertEqual((sql as NSString).substring(with: range!), "SELEC")
311− }
312−
313− func testTokenRangeAtWhitespaceSnapsToNextToken() {
314− let sql = "SELECT * FROM t"
315− // Offset 6 is inside the whitespace between SELECT and *
316− let range = SQLTokenizer.tokenRange(atOffset: 7, in: sql)
317− XCTAssertNotNil(range)
318− XCTAssertEqual((sql as NSString).substring(with: range!), "*")
319− }
320−
321− func testTokenRangeAtIdentifier() {
322− let sql = "SELECT * FROM mytable"
323− // Offset 14 is at 'm' of "mytable"
324− let range = SQLTokenizer.tokenRange(atOffset: 14, in: sql)
325− XCTAssertNotNil(range)
326− XCTAssertEqual((sql as NSString).substring(with: range!), "mytable")
327− }
328−
329− // MARK: - errorHighlightRange (end-to-end)
330−
331− func testErrorHighlightRangeTypoInKeyword() {
332− let sql = "SELEC * FROM t"
333− // DataFusion would report Line: 1, Column 1 for SELEC
334− let range = SQLTokenizer.errorHighlightRange(line: 1, column: 1, in: sql)
335− XCTAssertNotNil(range)
336− XCTAssertEqual((sql as NSString).substring(with: range!), "SELEC")
337− }
338−
339− func testErrorHighlightRangeMultiLine() {
340− let sql = "SELECT *\nFRO t"
341− // Line 2, Column 1 → "FRO"
342− let range = SQLTokenizer.errorHighlightRange(line: 2, column: 1, in: sql)
343− XCTAssertNotNil(range)
344− XCTAssertEqual((sql as NSString).substring(with: range!), "FRO")
345− }
346−
347− func testErrorHighlightRangeInvalidPosition() {
348− let sql = "SELECT * FROM t"
349− XCTAssertNil(SQLTokenizer.errorHighlightRange(line: 99, column: 1, in: sql))
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−}
ParseableViewerTests/ServerConnectionTests.swift−10
@@ -77,14 +77,4 @@ final class ServerConnectionTests: XCTestCase {
7777 let conn = ServerConnection(name: "test", url: "https://", username: "admin", password: "pass")
7878 XCTAssertNil(conn.baseURL)
7979 }
80−
81− func testBaseURLAcceptsUppercaseScheme() {
82− let conn = ServerConnection(name: "t", url: "HTTP://10.0.0.5:8000", username: "u", password: "p")
83− XCTAssertEqual(conn.baseURL?.absoluteString, "http://10.0.0.5:8000")
84− }
85−
86− func testBaseURLRejectsNonHTTPScheme() {
87− let conn = ServerConnection(name: "t", url: "ftp://example.com", username: "u", password: "p")
88− XCTAssertNil(conn.baseURL)
89− }
9080}
ParseableViewerTests/SyntaxHighlightingTests.swiftdeleted−232
@@ -1,232 +0,0 @@
1−@testable import ParseableViewer
2−import XCTest
3−import AppKit
4−
5−final class SyntaxHighlightingTests: XCTestCase {
6−
7− // MARK: - Helpers
8−
9− private let font = NSFont.monospacedSystemFont(ofSize: 12, weight: .regular)
10−
11− private func sqlHighlight(_ sql: String) -> NSTextStorage {
12− let ts = NSTextStorage(string: sql)
13− SQLSyntaxHighlighter.highlight(sql, in: ts, baseFont: font)
14− return ts
15− }
16−
17− private func colorAt(_ ts: NSTextStorage, _ index: Int) -> NSColor? {
18− ts.attributes(at: index, effectiveRange: nil)[.foregroundColor] as? NSColor
19− }
20−
21− // MARK: - SQL: Keywords
22−
23− func testSQLKeywordsAreHighlighted() {
24− let ts = sqlHighlight("SELECT * FROM logs")
25− // SELECT (index 0) and FROM (index 9) should not be default label color
26− let selectColor = colorAt(ts, 0)
27− let logsColor = colorAt(ts, 14) // "logs" is plain identifier
28− XCTAssertNotEqual(selectColor, logsColor,
29− "Keyword 'SELECT' should be a different color than identifier 'logs'")
30−
31− let fromColor = colorAt(ts, 9)
32− XCTAssertEqual(selectColor, fromColor,
33− "'SELECT' and 'FROM' should share the same keyword color")
34− }
35−
36− func testSQLKeywordsAreCaseInsensitive() {
37− let ts = sqlHighlight("select * from logs")
38− let selectColor = colorAt(ts, 0)
39− let logsColor = colorAt(ts, 14)
40− XCTAssertNotEqual(selectColor, logsColor)
41− }
42−
43− func testSQLKeywordsAreBolded() {
44− let ts = sqlHighlight("SELECT x")
45− let attrs = ts.attributes(at: 0, effectiveRange: nil)
46− let usedFont = attrs[.font] as? NSFont
47− XCTAssertNotNil(usedFont)
48− // Bold font has weight >= .bold
49− let traits = usedFont!.fontDescriptor.symbolicTraits
50− XCTAssertTrue(traits.contains(.bold), "Keywords should be bold")
51− }
52−
53− // MARK: - SQL: Strings
54−
55− func testSQLSingleQuotedStringsHighlighted() {
56− let sql = "WHERE msg = 'hello'"
57− let ts = sqlHighlight(sql)
58− let stringStart = (sql as NSString).range(of: "'hello'").location
59− let stringColor = colorAt(ts, stringStart)
60− let whereColor = colorAt(ts, 0) // WHERE keyword
61− XCTAssertNotEqual(stringColor, whereColor,
62− "String literal should be a different color than keywords")
63− }
64−
65− func testSQLKeywordsInsideStringsAreNotHighlightedAsKeywords() {
66− let sql = "WHERE msg = 'SELECT FROM'"
67− let ts = sqlHighlight(sql)
68− let innerIdx = (sql as NSString).range(of: "'SELECT FROM'").location
69− let stringColor = colorAt(ts, innerIdx)
70− // The entire string should have the same color as its opening quote
71− let innerKeywordIdx = innerIdx + 1 // 'S' in SELECT inside string
72− let innerColor = colorAt(ts, innerKeywordIdx)
73− XCTAssertEqual(stringColor, innerColor,
74− "Keywords inside strings should be colored as string, not as keyword")
75− }
76−
77− // MARK: - SQL: Numbers
78−
79− func testSQLNumbersHighlighted() {
80− let sql = "LIMIT 100"
81− let ts = sqlHighlight(sql)
82− let numIdx = (sql as NSString).range(of: "100").location
83− let numColor = colorAt(ts, numIdx)
84− let keyColor = colorAt(ts, 0)
85− XCTAssertNotEqual(numColor, keyColor,
86− "Numbers should be a different color than keywords")
87− }
88−
89− // MARK: - SQL: Comments
90−
91− func testSQLLineComments() {
92− let sql = "SELECT * -- a comment\nFROM logs"
93− let ts = sqlHighlight(sql)
94− let commentIdx = (sql as NSString).range(of: "--").location
95− let commentColor = colorAt(ts, commentIdx)
96− let fromIdx = (sql as NSString).range(of: "FROM").location
97− let fromColor = colorAt(ts, fromIdx)
98− XCTAssertNotEqual(commentColor, fromColor,
99− "Comment color should differ from keyword color")
100− }
101−
102− func testSQLKeywordsInsideCommentsNotHighlighted() {
103− let sql = "-- SELECT FROM WHERE"
104− let ts = sqlHighlight(sql)
105− // All characters should share the comment color
106− let dashColor = colorAt(ts, 0)
107− let selectIdx = (sql as NSString).range(of: "SELECT").location
108− let selectColor = colorAt(ts, selectIdx)
109− XCTAssertEqual(dashColor, selectColor,
110− "Keywords inside comments should not be highlighted as keywords")
111− }
112−
113− // MARK: - SQL: Functions
114−
115− func testSQLFunctionsHighlighted() {
116− let sql = "SELECT COUNT(*) FROM logs"
117− let ts = sqlHighlight(sql)
118− let countIdx = (sql as NSString).range(of: "COUNT").location
119− let countColor = colorAt(ts, countIdx)
120− let selectColor = colorAt(ts, 0)
121− XCTAssertNotEqual(countColor, selectColor,
122− "Functions should be a different color than keywords")
123− }
124−
125− // MARK: - SQL: Edge Cases
126−
127− func testSQLEmptyString() {
128− let ts = NSTextStorage(string: "")
129− SQLSyntaxHighlighter.highlight("", in: ts, baseFont: font)
130− XCTAssertEqual(ts.length, 0)
131− }
132−
133− func testSQLDoubleQuotedIdentifiers() {
134− let sql = "SELECT \"my column\" FROM logs"
135− let ts = sqlHighlight(sql)
136− let quoteIdx = (sql as NSString).range(of: "\"my column\"").location
137− let quoteColor = colorAt(ts, quoteIdx)
138− let selectColor = colorAt(ts, 0)
139− XCTAssertNotEqual(quoteColor, selectColor,
140− "Double-quoted identifiers should differ from keywords")
141− }
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
154−
155− func testJSONHighlightProducesOutput() {
156− let json = "{\"key\": \"value\", \"n\": 42}"
157− let result = JSONSyntaxHighlighter.highlight(json)
158− XCTAssertFalse(result.characters.isEmpty)
159− }
160−
161− func testJSONHighlightEmptyString() {
162− let result = JSONSyntaxHighlighter.highlight("")
163− XCTAssertTrue(result.characters.isEmpty)
164− }
165−
166− func testJSONKeysAndValuesHaveDifferentColors() {
167− let json = "{\"name\": \"Alice\"}"
168− let result = JSONSyntaxHighlighter.highlight(json)
169− let ns = NSAttributedString(result)
170−
171− let keyIdx = (json as NSString).range(of: "\"name\"").location
172− let valueIdx = (json as NSString).range(of: "\"Alice\"").location
173−
174− let keyColor = ns.attributes(at: keyIdx, effectiveRange: nil)[.foregroundColor] as? NSColor
175− let valueColor = ns.attributes(at: valueIdx, effectiveRange: nil)[.foregroundColor] as? NSColor
176−
177− XCTAssertNotNil(keyColor)
178− XCTAssertNotNil(valueColor)
179− XCTAssertNotEqual(keyColor, valueColor,
180− "JSON keys and string values should have different colors")
181− }
182−
183− func testJSONBooleansHighlighted() {
184− let json = "{\"flag\": true}"
185− let result = JSONSyntaxHighlighter.highlight(json)
186− let ns = NSAttributedString(result)
187−
188− let boolIdx = (json as NSString).range(of: "true").location
189− let boolColor = ns.attributes(at: boolIdx, effectiveRange: nil)[.foregroundColor] as? NSColor
190− // Should not be default label color
191− XCTAssertNotEqual(boolColor, NSColor.labelColor,
192− "Booleans should be highlighted")
193− }
194−
195− func testJSONNullHighlighted() {
196− let json = "{\"x\": null}"
197− let result = JSONSyntaxHighlighter.highlight(json)
198− let ns = NSAttributedString(result)
199−
200− let nullIdx = (json as NSString).range(of: "null").location
201− let nullColor = ns.attributes(at: nullIdx, effectiveRange: nil)[.foregroundColor] as? NSColor
202− XCTAssertNotEqual(nullColor, NSColor.labelColor,
203− "Null should be highlighted")
204− }
205−
206− func testJSONNumbersHighlighted() {
207− let json = "{\"count\": 42}"
208− let result = JSONSyntaxHighlighter.highlight(json)
209− let ns = NSAttributedString(result)
210−
211− let numIdx = (json as NSString).range(of: "42").location
212− let numColor = ns.attributes(at: numIdx, effectiveRange: nil)[.foregroundColor] as? NSColor
213− XCTAssertNotEqual(numColor, NSColor.labelColor,
214− "Numbers should be highlighted")
215− }
216−
217− func testJSONNestedStructure() {
218− let json = """
219− {
220− "user": {
221− "name": "Bob",
222− "age": 30,
223− "active": true,
224− "address": null
225− }
226− }
227− """
228− // Should not crash on nested JSON
229− let result = JSONSyntaxHighlighter.highlight(json)
230− XCTAssertFalse(result.characters.isEmpty)
231− }
232−}
README.md+2−8
@@ -15,19 +15,13 @@ Browse log streams, run SQL queries, tail logs in real time, and inspect server
1515- **Alerts & Users** — View configured alert rules and user accounts on the connected server
1616- **Server Info** — Health status, version, deployment mode, storage backend, and update availability at a glance
1717
18−## Install
19−
20−```bash
21−brew install ilyakooo0/tap/parseable-viewer
22−```
23−
2418## Requirements
2519
2620- macOS 14.0+ (Sonoma)
2721- Xcode 16+
2822- [XcodeGen](https://github.com/yonaskolb/XcodeGen)
2923
30−## Build from source
24+## Build
3125
3226```bash
3327# Install XcodeGen (one-time)
@@ -143,7 +137,7 @@ ParseableViewer/
143137| `Cmd+R` | Refresh streams |
144138| `Cmd+Enter` | Run query |
145139| `Cmd+P` | Pause/resume live tail |
146−| `Cmd+K` | Clear live tail entries |
140+| `Cmd+Delete` | Clear live tail entries |
147141| `Cmd+Ctrl+S` | Toggle sidebar |
148142
149143## License
parseable-viewer.rbdeleted−19
@@ -1,19 +0,0 @@
1−cask "parseable-viewer" do
2− arch arm: "arm64", intel: "x86_64"
3−
4− version :latest
5− sha256 :no_check
6−
7− url "https://github.com/ilyakooo0/parseable-macos/releases/latest/download/ParseableViewer-#{arch}.zip"
8− name "Parseable Viewer"
9− desc "A macOS client for Parseable"
10− homepage "https://github.com/ilyakooo0/parseable-macos"
11−
12− app "Parseable Viewer.app"
13−
14− postflight do
15− system_command "/usr/bin/xattr",
16− args: ["-cr", "#{appdir}/Parseable Viewer.app"],
17− sudo: false
18− end
19−end
project.yml−10
@@ -37,15 +37,6 @@ targets:
3737 Release:
3838 SWIFT_OPTIMIZATION_LEVEL: "-O"
3939 SWIFT_COMPILATION_MODE: "wholemodule"
40− postBuildScripts:
41− - script: |
42− COMMIT_TIMESTAMP=$(TZ=UTC git log -1 --format='%cd' --date=format-local:'%Y.%m.%d.%H%M' 2>/dev/null)
43− if [ -n "$COMMIT_TIMESTAMP" ]; then
44− /usr/libexec/PlistBuddy -c "Set :CFBundleVersion ${COMMIT_TIMESTAMP}" "${TARGET_BUILD_DIR}/${INFOPLIST_PATH}"
45− /usr/libexec/PlistBuddy -c "Set :CFBundleShortVersionString ${COMMIT_TIMESTAMP}" "${TARGET_BUILD_DIR}/${INFOPLIST_PATH}"
46− fi
47− name: Set Version from Git Commit Timestamp
48− basedOnDependencyAnalysis: false
4940 entitlements:
5041 path: ParseableViewer/ParseableViewer.entitlements
5142
@@ -60,7 +51,6 @@ targets:
6051 base:
6152 SWIFT_VERSION: "5.9"
6253 MACOSX_DEPLOYMENT_TARGET: "14.0"
63− GENERATE_INFOPLIST_FILE: YES
6454 BUNDLE_LOADER: "$(BUILT_PRODUCTS_DIR)/Parseable Viewer.app/Contents/MacOS/Parseable Viewer"
6555 TEST_HOST: "$(BUNDLE_LOADER)"
6656 dependencies: