git.iko.soy/parseable-macos
on master help sign in
.github/workflows/build.yml+41−73
since splitsnapshots
unifiedsplit
@@ -3,14 +3,16 @@ name: Build and Release
3on:
3on:
4 push:
4 push:
5 branches: [master, main]
5 branches: [master, main]
6− tags:
7− - 'v*'
8 pull_request:
6 pull_request:
9 branches: [master, main]
7 branches: [master, main]
10
8
11jobs:
9jobs:
12 build:
10 build:
13 runs-on: macos-15
11 runs-on: macos-15
12+ permissions:
13+ id-token: write
14+ attestations: write
15+ contents: read
14 strategy:
16 strategy:
15 matrix:
17 matrix:
16 include:
18 include:
@@ -71,6 +73,11 @@ jobs:
71 exit 1
73 exit 1
72 fi
74 fi
73
75
76+ - name: Attest artifact
77+ uses: actions/attest-build-provenance@v2
78+ with:
79+ subject-path: ParseableViewer-${{ matrix.arch }}.zip
80+
74 - name: Upload artifact
81 - name: Upload artifact
75 uses: actions/upload-artifact@v4
82 uses: actions/upload-artifact@v4
76 with:
83 with:
@@ -80,6 +87,10 @@ jobs:
80 create-universal:
87 create-universal:
81 needs: build
88 needs: build
82 runs-on: macos-15
89 runs-on: macos-15
90+ permissions:
91+ id-token: write
92+ attestations: write
93+ contents: read
83 steps:
94 steps:
84 - name: Checkout
95 - name: Checkout
85 uses: actions/checkout@v4
96 uses: actions/checkout@v4
@@ -127,106 +138,63 @@ jobs:
127 cd universal_app
138 cd universal_app
128 zip -r -y "../ParseableViewer-universal.zip" "Parseable Viewer.app"
139 zip -r -y "../ParseableViewer-universal.zip" "Parseable Viewer.app"
129
140
141+ - name: Attest universal artifact
142+ uses: actions/attest-build-provenance@v2
143+ with:
144+ subject-path: ParseableViewer-universal.zip
145+
130 - name: Upload universal artifact
146 - name: Upload universal artifact
131 uses: actions/upload-artifact@v4
147 uses: actions/upload-artifact@v4
132 with:
148 with:
133 name: ParseableViewer-universal
149 name: ParseableViewer-universal
134 path: ParseableViewer-universal.zip
150 path: ParseableViewer-universal.zip
135
151
136− prerelease:
152+ release:
137 needs: create-universal
153 needs: create-universal
138 runs-on: macos-15
154 runs-on: macos-15
139 concurrency:
155 concurrency:
140 group: release
156 group: release
141 cancel-in-progress: false
157 cancel-in-progress: false
142− if: github.event_name == 'push' && !startsWith(github.ref, 'refs/tags/')
158+ if: github.event_name == 'push'
143 permissions:
159 permissions:
144 contents: write
160 contents: write
161+ id-token: write
162+ attestations: write
145 steps:
163 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+
146 - name: Download all artifacts
173 - name: Download all artifacts
147 uses: actions/download-artifact@v4
174 uses: actions/download-artifact@v4
148 with:
175 with:
149 path: artifacts
176 path: artifacts
150
177
151− - name: Create or update prerelease
178+ - name: Attest release artifacts
152− uses: softprops/action-gh-release@v2
179+ uses: actions/attest-build-provenance@v2
153 with:
180 with:
154− tag_name: latest
181+ subject-path: |
155− name: Latest Build
156− body: |
157− Automated prerelease build from commit ${{ github.sha }}.
158−
159− **Branch:** ${{ github.ref_name }}
160− **Commit:** [`${{ github.sha }}`](${{ github.server_url }}/${{ github.repository }}/commit/${{ github.sha }})
161− prerelease: true
162− make_latest: false
163− files: |
164 artifacts/ParseableViewer-arm64/ParseableViewer-arm64.zip
182 artifacts/ParseableViewer-arm64/ParseableViewer-arm64.zip
165 artifacts/ParseableViewer-x86_64/ParseableViewer-x86_64.zip
183 artifacts/ParseableViewer-x86_64/ParseableViewer-x86_64.zip
166 artifacts/ParseableViewer-universal/ParseableViewer-universal.zip
184 artifacts/ParseableViewer-universal/ParseableViewer-universal.zip
167− env:
168− GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
169
185
170− - name: Update Homebrew cask
171− env:
172− HOMEBREW_TAP_TOKEN: ${{ secrets.HOMEBREW_TAP_TOKEN }}
173− run: |
174− SHA_ARM64=$(shasum -a 256 artifacts/ParseableViewer-arm64/ParseableViewer-arm64.zip | awk '{print $1}')
175− SHA_X86_64=$(shasum -a 256 artifacts/ParseableViewer-x86_64/ParseableViewer-x86_64.zip | awk '{print $1}')
176− SHORT_SHA="${GITHUB_SHA::7}"
177−
178− cat > /tmp/parseable-viewer.rb << CASK
179− cask "parseable-viewer" do
180− arch arm: "arm64", intel: "x86_64"
181−
182− version "$SHORT_SHA"
183− sha256 arm: "$SHA_ARM64", intel: "$SHA_X86_64"
184−
185− url "https://github.com/ilyakooo0/parseable-macos/releases/download/latest/ParseableViewer-#{arch}.zip"
186− name "Parseable Viewer"
187− desc "A macOS client for Parseable"
188− homepage "https://github.com/ilyakooo0/parseable-macos"
189−
190− app "Parseable Viewer.app"
191−
192− postflight do
193− system_command "/usr/bin/xattr",
194− args: ["-cr", "#{appdir}/Parseable Viewer.app"],
195− sudo: false
196− end
197− end
198− CASK
199−
200− # Push updated cask to homebrew-tap
201− cd "$(mktemp -d)"
202− git clone "https://x-access-token:${HOMEBREW_TAP_TOKEN}@github.com/ilyakooo0/homebrew-tap.git" .
203− cp /tmp/parseable-viewer.rb Casks/parseable-viewer.rb
204− git config user.name "github-actions[bot]"
205− git config user.email "github-actions[bot]@users.noreply.github.com"
206− git add Casks/parseable-viewer.rb
207− git commit -m "Update parseable-viewer to ${SHORT_SHA}"
208− git push
209−
210− release:
186+ - name: Create release
211− needs: create-universal
212− runs-on: macos-15
213− if: startsWith(github.ref, 'refs/tags/v')
214− permissions:
215− contents: write
216− steps:
217− - name: Download all artifacts
218− uses: actions/download-artifact@v4
219− with:
220− path: artifacts
221−
222− - name: Create Draft Release
223 uses: softprops/action-gh-release@v2
187 uses: softprops/action-gh-release@v2
224 with:
188 with:
225− draft: true
189+ tag_name: ${{ steps.version.outputs.timestamp }}
226− generate_release_notes: true
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 }})
227 files: |
194 files: |
228 artifacts/ParseableViewer-arm64/ParseableViewer-arm64.zip
195 artifacts/ParseableViewer-arm64/ParseableViewer-arm64.zip
229 artifacts/ParseableViewer-x86_64/ParseableViewer-x86_64.zip
196 artifacts/ParseableViewer-x86_64/ParseableViewer-x86_64.zip
230 artifacts/ParseableViewer-universal/ParseableViewer-universal.zip
197 artifacts/ParseableViewer-universal/ParseableViewer-universal.zip
231 env:
198 env:
232 GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
199 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
31- **Concurrency**: Swift structured concurrency (`async/await`, `@MainActor`, strict `Sendable` conformance)
31- **Concurrency**: Swift structured concurrency (`async/await`, `@MainActor`, strict `Sendable` conformance)
32- **Networking**: `URLSession` async/await, no external dependencies
32- **Networking**: `URLSession` async/await, no external dependencies
33- **Project generation**: XcodeGen from `project.yml`
33- **Project generation**: XcodeGen from `project.yml`
34−- **Persistence**: `UserDefaults` for connections and saved queries; Keychain for passwords
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.
35
35
36### Source layout
36### Source layout
37
37
@@ -83,10 +83,10 @@ Live tail in Parseable uses gRPC Arrow Flight streaming. This app approximates i
83- Models use defensive `try?` decoding for optional fields to tolerate API version differences
83- Models use defensive `try?` decoding for optional fields to tolerate API version differences
84- Views follow the macOS pattern: `NavigationSplitView` sidebar + detail, with tab switching in the detail pane
84- Views follow the macOS pattern: `NavigationSplitView` sidebar + detail, with tab switching in the detail pane
85- All API calls are `async` and dispatched from views via `Task { }`
85- All API calls are `async` and dispatched from views via `Task { }`
86−- Passwords are stored in the Keychain, never in UserDefaults; `ServerConnection.password` is excluded from `Codable`
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.
87- Stream names are SQL-escaped via `escapeSQLIdentifier` (double-quote wrapping) and URL-encoded via `encodePathComponent` for API paths
87- Stream names are SQL-escaped via `escapeSQLIdentifier` (double-quote wrapping) and URL-encoded via `encodePathComponent` for API paths
88- Views clear stale data before loading new content (e.g., switching streams clears previous schema/stats)
88- Views clear stale data before loading new content (e.g., switching streams clears previous schema/stats)
89−- Entitlements: App Sandbox enabled with network.client for outbound connections
89+- Entitlements: none (App Sandbox is not enabled)
90
90
91## Common patterns
91## Common patterns
92
92
ParseableViewer.xcodeproj/project.pbxprojnew file+660
@@ -0,0 +1,660 @@
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.xcworkspacedatanew file+7
@@ -0,0 +1,7 @@
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.xcschemenew file+120
@@ -0,0 +1,120 @@
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+265−32
@@ -14,7 +14,11 @@ final class AppState {
14 private(set) var isNetworkAvailable = true
14 private(set) var isNetworkAvailable = true
15 private let networkMonitor = NWPathMonitor()
15 private let networkMonitor = NWPathMonitor()
16 private let networkMonitorQueue = DispatchQueue(label: "com.parseableviewer.network-monitor")
16 private let networkMonitorQueue = DispatchQueue(label: "com.parseableviewer.network-monitor")
17− private var connectTask: Task<Void, Never>?
17+ private var connectTask: Task<Bool, Never>?
18+ /// The connection `connectTask` is connecting to. `activeConnection` only
19+ /// switches once the new server answers, so this is what identifies an
20+ /// in-flight connect whose target the user deletes (see `removeConnection`).
21+ private var connectingConnectionID: UUID?
18
22
19 // MARK: - Stream State
23 // MARK: - Stream State
20 var streams: [LogStream] = []
24 var streams: [LogStream] = []
@@ -32,10 +36,11 @@ final class AppState {
32 var errorMessage: String?
36 var errorMessage: String?
33 var showError = false
37 var showError = false
34
38
35− // MARK: - Saved Queries
39+ // MARK: - Filters
36− var savedQueries: [SavedQuery] = []
40+ var filters: [ParseableFilter] = []
41+ var isLoadingFilters = false
37− /// Set by sidebar when user clicks a saved query; consumed by QueryView.
42+ /// Set by sidebar when user clicks a filter; consumed by QueryView.
38− var pendingSavedQuery: SavedQuery?
43+ var pendingFilterSQL: String?
39
44
40 // MARK: - Query Refresh
45 // MARK: - Query Refresh
41 /// Incremented by the Cmd+R shortcut; QueryView observes changes to re-execute the current query.
46 /// Incremented by the Cmd+R shortcut; QueryView observes changes to re-execute the current query.
@@ -61,6 +66,15 @@ final class AppState {
61 case .serverInfo: return "server.rack"
66 case .serverInfo: return "server.rack"
62 }
67 }
63 }
68 }
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+ }
64 }
78 }
65
79
66 var filteredStreams: [LogStream] {
80 var filteredStreams: [LogStream] {
@@ -76,7 +90,6 @@ final class AppState {
76
90
77 init() {
91 init() {
78 connections = ConnectionStore.loadConnections()
92 connections = ConnectionStore.loadConnections()
79− savedQueries = SavedQueryStore.load()
80
93
81 networkMonitor.pathUpdateHandler = { [weak self] path in
94 networkMonitor.pathUpdateHandler = { [weak self] path in
82 Task { @MainActor [weak self] in
95 Task { @MainActor [weak self] in
@@ -87,8 +100,9 @@ final class AppState {
87
100
88 if let activeID = ConnectionStore.loadActiveConnectionID(),
101 if let activeID = ConnectionStore.loadActiveConnectionID(),
89 let connection = connections.first(where: { $0.id == activeID }) {
102 let connection = connections.first(where: { $0.id == activeID }) {
103+ connectingConnectionID = connection.id
90− connectTask = Task { @MainActor in
104+ connectTask = Task { @MainActor [weak self] in
91− await connect(to: connection)
105+ await self?.performConnect(to: connection) ?? false
92 }
106 }
93 }
107 }
94 }
108 }
@@ -96,23 +110,71 @@ final class AppState {
96 // MARK: - Connection Management
110 // MARK: - Connection Management
97
111
98 @MainActor
112 @MainActor
113+ @discardableResult
99− func connect(to connection: ServerConnection) async {
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+ }
100− guard !isConnecting else { return }
136+ guard !isConnecting else { return false }
101
137
138+ // NWPathMonitor ignores loopback, so with no active network interface it
139+ // reports "unsatisfied" even though a local server (the common docker /
140+ // dev setup) is reachable. Don't block those; a genuinely unreachable
141+ // host still fails below with the same offline message.
102− if !isNetworkAvailable {
142+ if !isNetworkAvailable && !Self.isLoopback(connection.baseURL) {
103 self.errorMessage = "No internet connection. Check your network and try again."
143 self.errorMessage = "No internet connection. Check your network and try again."
104 self.showError = true
144 self.showError = true
105− return
145+ return false
106 }
146 }
107
147
108 isConnecting = true
148 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".
109 errorMessage = nil
154 errorMessage = nil
155+ showError = false
110
156
111 // When switching to a different server, clear stream-specific state
157 // When switching to a different server, clear stream-specific state
112 // so stale selections don't reference streams that don't exist.
158 // so stale selections don't reference streams that don't exist.
113 if activeConnection?.id != connection.id {
159 if activeConnection?.id != connection.id {
114 selectedStream = nil
160 selectedStream = nil
115 streamLoadError = nil
161 streamLoadError = nil
162+ // Drop the previous server's stream list and filters too — otherwise the
163+ // sidebar keeps rendering server A's streams/filters (with selection
164+ // already nilled) throughout the reconnect window. Same rationale as
165+ // `serverAbout` below; matches the failure path and `disconnect()`. Both
166+ // are repopulated once the new server's listStreams/listFilters respond.
167+ streams = []
168+ filters = []
169+ // Drop the previous server's cached `about` so views seeding from it
170+ // (ServerInfoView) don't paint the old server's details while the new
171+ // one loads. It's repopulated below once the new server responds.
172+ serverAbout = nil
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
116 }
178 }
117
179
118 do {
180 do {
@@ -120,6 +182,12 @@ final class AppState {
120
182
121 // Test connection — throws on non-200 or network failure
183 // Test connection — throws on non-200 or network failure
122 try await newClient.checkHealth()
184 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 }
123
191
124 self.client = newClient
192 self.client = newClient
125 self.activeConnection = connection
193 self.activeConnection = connection
@@ -127,18 +195,41 @@ final class AppState {
127
195
128 ConnectionStore.saveActiveConnectionID(connection.id)
196 ConnectionStore.saveActiveConnectionID(connection.id)
129
197
130− // Load server info and streams (best-effort; don't fail the connection)
198+ // Load server info, streams, and filters (best-effort; don't fail the connection)
131 async let about = newClient.getAbout()
199 async let about = newClient.getAbout()
132 async let streamList = newClient.listStreams()
200 async let streamList = newClient.listStreams()
201+ async let filterList = newClient.listFilters()
133
202
134− self.serverAbout = try? await about
203+ let aboutResult = try? await about
204+ let filterResult = (try? await filterList) ?? []
205+ let streamResult: Result<[LogStream], Error>
135 do {
206 do {
136− self.streams = try await streamList
207+ streamResult = .success(try await streamList)
137− self.streamLoadError = nil
138 } catch {
208 } catch {
209+ streamResult = .failure(error)
210+ }
211+
212+ // Re-check after the loads above: a disconnect could have landed
213+ // while awaiting, in which case committing these would show stale data.
214+ if Task.isCancelled { return false }
215+
216+ self.serverAbout = aboutResult
217+ self.filters = filterResult
218+ switch streamResult {
219+ case .success(let list):
220+ self.streams = list
221+ self.streamLoadError = nil
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):
139 self.streams = []
229 self.streams = []
140 self.streamLoadError = ParseableError.userFriendlyMessage(for: error)
230 self.streamLoadError = ParseableError.userFriendlyMessage(for: error)
141 }
231 }
232+ return true
142 } catch {
233 } catch {
143 // If the task was cancelled (e.g. user clicked Disconnect during
234 // If the task was cancelled (e.g. user clicked Disconnect during
144 // auto-reconnect), skip the error alert — disconnect() already
235 // auto-reconnect), skip the error alert — disconnect() already
@@ -149,10 +240,16 @@ final class AppState {
149 self.isConnected = false
240 self.isConnected = false
150 self.client = nil
241 self.client = nil
151 self.activeConnection = nil
242 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 = []
152 }
250 }
251+ return false
153 }
252 }
154−
155− isConnecting = false
156 }
253 }
157
254
158 @MainActor
255 @MainActor
@@ -165,17 +262,53 @@ final class AppState {
165 streams = []
262 streams = []
166 selectedStream = nil
263 selectedStream = nil
167 serverAbout = nil
264 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
168 ConnectionStore.saveActiveConnectionID(nil)
273 ConnectionStore.saveActiveConnectionID(nil)
169 }
274 }
170
275
171 @MainActor
276 @MainActor
172 func refreshStreams() async {
277 func refreshStreams() async {
173 guard let client else { return }
278 guard let client else { return }
279+ // Capture the connection this fetch belongs to. A disconnect during the
280+ // await would otherwise let the (now-stale) result overwrite the cleanup
281+ // disconnect() just performed (streams = [], client = nil, etc.), leaving
282+ // the disconnected UI showing the old server's streams and re-enabling
283+ // stream-scoped tabs. Mirrors the connID guard used in performConnect,
284+ // AlertsView.loadAlerts, UsersView.loadUsers, StreamDetailView.loadData,
285+ // and ServerInfoView.loadInfo.
286+ let connID = activeConnection?.id
174 isLoadingStreams = true
287 isLoadingStreams = true
175 streamLoadError = nil
288 streamLoadError = nil
176 do {
289 do {
177− streams = try await client.listStreams()
290+ let loaded = try await client.listStreams()
291+ // Drop results if the active connection changed while awaiting. Still
292+ // clear isLoadingStreams: this task set the spinner, so leaving it true
293+ // wedges the indicator and disables Refresh. Compare the client too:
294+ // editing the active connection's URL/credentials keeps its id but
295+ // installs a new client for a possibly different server.
296+ guard connID == activeConnection?.id, client === self.client else {
297+ isLoadingStreams = false
298+ return
299+ }
300+ streams = loaded
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+ }
178 } catch {
307 } catch {
308+ guard connID == activeConnection?.id, client === self.client else {
309+ isLoadingStreams = false
310+ return
311+ }
179 let message = ParseableError.userFriendlyMessage(for: error)
312 let message = ParseableError.userFriendlyMessage(for: error)
180 streamLoadError = message
313 streamLoadError = message
181 self.errorMessage = "Failed to load streams: \(message)"
314 self.errorMessage = "Failed to load streams: \(message)"
@@ -194,10 +327,45 @@ final class AppState {
194
327
195 @MainActor
328 @MainActor
196 func updateConnection(_ connection: ServerConnection) {
329 func updateConnection(_ connection: ServerConnection) {
330+ let previous = connections.first(where: { $0.id == connection.id })
197 if let index = connections.firstIndex(where: { $0.id == connection.id }) {
331 if let index = connections.firstIndex(where: { $0.id == connection.id }) {
198 connections[index] = connection
332 connections[index] = connection
199 ConnectionStore.saveConnections(connections)
333 ConnectionStore.saveConnections(connections)
200 }
334 }
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+ }
201 }
369 }
202
370
203 @MainActor
371 @MainActor
@@ -207,9 +375,49 @@ final class AppState {
207 ConnectionStore.saveConnections(connections)
375 ConnectionStore.saveConnections(connections)
208 if activeConnection?.id == connection.id {
376 if activeConnection?.id == connection.id {
209 disconnect()
377 disconnect()
378+ } else if connectingConnectionID == connection.id {
379+ // The removed connection is still being connected to (e.g. Cancel in the
380+ // connection sheet, or deleting it in Settings during the launch
381+ // reconnect). `activeConnection` doesn't point at it yet, so without
382+ // this the connect would carry on and install a client for a
383+ // connection that no longer exists (or tear down the current session
384+ // when it fails) after the user abandoned it.
385+ connectTask?.cancel()
386+ connectTask = nil
387+ connectingConnectionID = nil
388+ // Starting that connect already cleared the current server's stream
389+ // list and filters; reload them if we're still connected to it.
390+ if client != nil {
391+ Task { @MainActor [weak self] in
392+ await self?.refreshStreams()
393+ await self?.refreshFilters()
394+ }
395+ }
210 }
396 }
211 }
397 }
212
398
399+ /// `/api/v1/liveness` is unauthenticated on Parseable (it answers 200 to
400+ /// anyone, for load-balancer / k8s probes), so a health check alone accepts
401+ /// any username and password. Probe an authenticated endpoint and fail on
402+ /// 401. Other failures — e.g. a 403 for a role without the about permission —
403+ /// don't show the credentials are wrong, so they're ignored here. The client
404+ /// caches the response, so the follow-up `getAbout()` load reuses it.
405+ static func verifyCredentials(with client: ParseableClient) async throws {
406+ do {
407+ _ = try await client.getAbout()
408+ } catch ParseableError.unauthorized {
409+ throw ParseableError.unauthorized
410+ } catch {
411+ // Not an authentication failure; leave it to the best-effort loads.
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+
213 // MARK: - Stream Management
421 // MARK: - Stream Management
214
422
215 @MainActor
423 @MainActor
@@ -226,27 +434,52 @@ final class AppState {
226 if selectedStream == name {
434 if selectedStream == name {
227 selectedStream = nil
435 selectedStream = nil
228 }
436 }
229− // Remove saved queries that referenced the deleted stream
437+ await refreshFilters()
230− let orphaned = savedQueries.filter { $0.stream == name }
231− if !orphaned.isEmpty {
232− savedQueries.removeAll { $0.stream == name }
233− SavedQueryStore.save(savedQueries)
234− }
235 await refreshStreams()
438 await refreshStreams()
236 }
439 }
237
440
238− // MARK: - Saved Queries
441+ // MARK: - Filters
239
442
240 @MainActor
443 @MainActor
241− func addSavedQuery(_ query: SavedQuery) {
444+ func refreshFilters() async {
242− savedQueries.append(query)
445+ guard let client else { return }
243− SavedQueryStore.save(savedQueries)
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
244 }
459 }
245
460
246 @MainActor
461 @MainActor
247− func removeSavedQuery(_ query: SavedQuery) {
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+ }
248− savedQueries.removeAll { $0.id == query.id }
482+ filters.removeAll { $0.id == filter.id }
249− SavedQueryStore.save(savedQueries)
250 }
483 }
251
484
252 // MARK: - Error Handling
485 // MARK: - Error Handling
ParseableViewer/App/ParseableViewerApp.swift+33−1
@@ -4,6 +4,13 @@ import SwiftUI
4struct ParseableViewerApp: App {
4struct ParseableViewerApp: App {
5 @State private var appState = AppState()
5 @State private var appState = AppState()
6
6
7+ init() {
8+ // All window content shares one AppState (see the File > New group below),
9+ // so don't offer window tabs either: the tab bar's "+" would open a second
10+ // window just like File > New Window.
11+ NSWindow.allowsAutomaticWindowTabbing = false
12+ }
13+
7 var body: some Scene {
14 var body: some Scene {
8 WindowGroup {
15 WindowGroup {
9 ContentView()
16 ContentView()
@@ -13,7 +20,16 @@ struct ParseableViewerApp: App {
13 .windowStyle(.titleBar)
20 .windowStyle(.titleBar)
14 .defaultSize(width: 1200, height: 800)
21 .defaultSize(width: 1200, height: 800)
15 .commands {
22 .commands {
16− CommandGroup(after: .newItem) {
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) {
17 Button("New Connection...") {
33 Button("New Connection...") {
18 appState.editingConnection = nil
34 appState.editingConnection = nil
19 appState.showConnectionSheet = true
35 appState.showConnectionSheet = true
@@ -47,9 +63,25 @@ struct ParseableViewerApp: App {
47 }
63 }
48 }
64 }
49
65
66+ Window("About Parseable Viewer", id: "about") {
67+ AboutView()
68+ }
69+ .windowStyle(.hiddenTitleBar)
70+ .windowResizability(.contentSize)
71+
50 Settings {
72 Settings {
51 SettingsView()
73 SettingsView()
52 .environment(appState)
74 .environment(appState)
53 }
75 }
54 }
76 }
55}
77}
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,6 +24,11 @@
24 <string>public.app-category.developer-tools</string>
24 <string>public.app-category.developer-tools</string>
25 <key>LSMinimumSystemVersion</key>
25 <key>LSMinimumSystemVersion</key>
26 <string>$(MACOSX_DEPLOYMENT_TARGET)</string>
26 <string>$(MACOSX_DEPLOYMENT_TARGET)</string>
27+ <key>NSAppTransportSecurity</key>
28+ <dict>
29+ <key>NSAllowsArbitraryLoads</key>
30+ <true/>
31+ </dict>
27 <key>NSMainNibFile</key>
32 <key>NSMainNibFile</key>
28 <string></string>
33 <string></string>
29 <key>NSPrincipalClass</key>
34 <key>NSPrincipalClass</key>
ParseableViewer/Models/AlertModels.swift+27−1
@@ -45,10 +45,15 @@ struct AlertRule: Identifiable, Codable, Sendable {
45 let rule: AlertRuleSpec?
45 let rule: AlertRuleSpec?
46 let targets: [AlertTarget]?
46 let targets: [AlertTarget]?
47
47
48+ /// Stable fallback identity for alerts that carry no id/name/title.
49+ /// Generated once at init so `id` doesn't change on every access (which
50+ /// would make SwiftUI tear down and rebuild list rows continuously).
51+ private let localID = UUID()
52+
48 /// Display name, preferring the modern `title` field over legacy `name`.
53 /// Display name, preferring the modern `title` field over legacy `name`.
49 var displayName: String { title ?? name ?? "Unnamed Alert" }
54 var displayName: String { title ?? name ?? "Unnamed Alert" }
50
55
51− var id: String { alertId ?? name ?? title ?? UUID().uuidString }
56+ var id: String { alertId ?? name ?? title ?? localID.uuidString }
52
57
53 private enum CodingKeys: String, CodingKey {
58 private enum CodingKeys: String, CodingKey {
54 case alertId = "id"
59 case alertId = "id"
@@ -80,6 +85,27 @@ struct AlertRule: Identifiable, Codable, Sendable {
80struct AlertRuleSpec: Codable, Sendable {
85struct AlertRuleSpec: Codable, Sendable {
81 let type: String?
86 let type: String?
82 let config: String?
87 let config: String?
88+
89+ private enum CodingKeys: String, CodingKey {
90+ case type, config
91+ }
92+
93+ init(from decoder: Decoder) throws {
94+ let container = try decoder.container(keyedBy: CodingKeys.self)
95+ self.type = try? container.decode(String.self, forKey: .type)
96+ // The legacy per-stream rule is serialized as `{"type": …, "config": …}`
97+ // where only composite rules carry a string config; column rules carry an
98+ // object (`{"column": …, "operator": …, "value": …}`). Decoding that as a
99+ // String threw, which made the whole spec — and its type badge — vanish.
100+ // Keep non-string configs as compact JSON instead.
101+ if let string = try? container.decode(String.self, forKey: .config) {
102+ self.config = string
103+ } else if let value = try? container.decode(JSONValue.self, forKey: .config), value != .null {
104+ self.config = value.compactJSON
105+ } else {
106+ self.config = nil
107+ }
108+ }
83}
109}
84
110
85struct AlertTarget: Codable, Sendable {
111struct AlertTarget: Codable, Sendable {
ParseableViewer/Models/JSONValue.swift+224−23
@@ -37,7 +37,14 @@ enum JSONValue: Codable, Hashable, Sendable, Comparable {
37 case .null: try container.encodeNil()
37 case .null: try container.encodeNil()
38 case .bool(let v): try container.encode(v)
38 case .bool(let v): try container.encode(v)
39 case .int(let v): try container.encode(v)
39 case .int(let v): try container.encode(v)
40− case .double(let v): try container.encode(v)
40+ case .double(let v):
41+ // JSON has no representation for infinity/NaN, and JSONEncoder's
42+ // default `.throw` strategy would abort the *entire* encode — turning
43+ // a single non-finite value into a total export failure (`exportAsJSON`
44+ // collapses to "[]"). Encode it as null to match displayString,
45+ // compactJSON/jsonNumber, and the CSV export, which all render
46+ // non-finite doubles as "null".
47+ if v.isFinite { try container.encode(v) } else { try container.encodeNil() }
41 case .string(let v): try container.encode(v)
48 case .string(let v): try container.encode(v)
42 case .array(let v): try container.encode(v)
49 case .array(let v): try container.encode(v)
43 case .object(let v): try container.encode(v)
50 case .object(let v): try container.encode(v)
@@ -50,7 +57,14 @@ enum JSONValue: Codable, Hashable, Sendable, Comparable {
50 case .bool(let v): return v ? "true" : "false"
57 case .bool(let v): return v ? "true" : "false"
51 case .int(let v): return String(v)
58 case .int(let v): return String(v)
52 case .double(let v):
59 case .double(let v):
60+ // Non-finite doubles have no display (and no JSON) representation;
61+ // render them as "null" to match jsonNumber/exportString rather than
62+ // letting String(_:) emit "inf"/"-inf"/"nan" into the UI and CSV cells.
63+ if !v.isFinite { return "null" }
53 if v == v.rounded() && abs(v) < 1e15 {
64 if v == v.rounded() && abs(v) < 1e15 {
65+ // Normalize negative zero so it renders as "0", not "-0"
66+ // (`%.0f` preserves the sign bit of -0.0).
67+ if v == 0 { return "0" }
54 return String(format: "%.0f", v)
68 return String(format: "%.0f", v)
55 }
69 }
56 return String(v)
70 return String(v)
@@ -60,16 +74,31 @@ enum JSONValue: Codable, Hashable, Sendable, Comparable {
60 }
74 }
61 }
75 }
62
76
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+
63 /// Like `displayString` but serializes arrays/objects as compact JSON
96 /// Like `displayString` but serializes arrays/objects as compact JSON
64 /// instead of placeholders like "[3 items]". Used for CSV export.
97 /// instead of placeholders like "[3 items]". Used for CSV export.
65 var exportString: String {
98 var exportString: String {
66 switch self {
99 switch self {
67 case .array, .object:
100 case .array, .object:
68− if let data = try? JSONEncoder().encode(self),
69− let json = String(data: data, encoding: .utf8) {
70− return json
101+ return compactJSON
71− }
72− return displayString
73 default:
102 default:
74 return displayString
103 return displayString
75 }
104 }
@@ -81,7 +110,7 @@ enum JSONValue: Codable, Hashable, Sendable, Comparable {
81 case .null: return "NULL"
110 case .null: return "NULL"
82 case .bool(let v): return v ? "true" : "false"
111 case .bool(let v): return v ? "true" : "false"
83 case .int(let v): return String(v)
112 case .int(let v): return String(v)
84− case .double(let v): return String(v)
113+ case .double(let v): return v.isFinite ? String(v) : "NULL"
85 case .string(let v): return "'\(v.replacingOccurrences(of: "'", with: "''"))'"
114 case .string(let v): return "'\(v.replacingOccurrences(of: "'", with: "''"))'"
86 case .array, .object:
115 case .array, .object:
87 return "'\(exportString.replacingOccurrences(of: "'", with: "''"))'"
116 return "'\(exportString.replacingOccurrences(of: "'", with: "''"))'"
@@ -122,7 +151,7 @@ enum JSONValue: Codable, Hashable, Sendable, Comparable {
122 case .null: return "null"
151 case .null: return "null"
123 case .bool(let v): return v ? "true" : "false"
152 case .bool(let v): return v ? "true" : "false"
124 case .int(let v): return String(v)
153 case .int(let v): return String(v)
125− case .double(let v): return String(v)
154+ case .double(let v): return Self.jsonNumber(v)
126 case .string(let v): return "\"\(Self.escapeJSONString(v))\""
155 case .string(let v): return "\"\(Self.escapeJSONString(v))\""
127 case .array(let items):
156 case .array(let items):
128 if items.isEmpty { return "[]" }
157 if items.isEmpty { return "[]" }
@@ -145,43 +174,201 @@ enum JSONValue: Codable, Hashable, Sendable, Comparable {
145 /// Escapes special characters for valid JSON string output.
174 /// Escapes special characters for valid JSON string output.
146 static func escapeJSONString(_ s: String) -> String {
175 static func escapeJSONString(_ s: String) -> String {
147 var result = ""
176 var result = ""
148− result.reserveCapacity(s.count)
177+ result.unicodeScalars.reserveCapacity(s.unicodeScalars.count)
149− for char in s {
178+ for scalar in s.unicodeScalars {
150− switch char {
179+ switch scalar {
151 case "\"": result += "\\\""
180 case "\"": result += "\\\""
152 case "\\": result += "\\\\"
181 case "\\": result += "\\\\"
153 case "\n": result += "\\n"
182 case "\n": result += "\\n"
154 case "\r": result += "\\r"
183 case "\r": result += "\\r"
155 case "\t": result += "\\t"
184 case "\t": result += "\\t"
156 default:
185 default:
157− if char.asciiValue.map({ $0 < 0x20 }) == true {
186+ // Any control character (U+0000–U+001F), including those that
158− let code = char.asciiValue!
187+ // would otherwise combine into a grapheme cluster, must be
188+ // escaped to produce valid JSON.
189+ if scalar.value < 0x20 {
159− result += String(format: "\\u%04x", code)
190+ result += String(format: "\\u%04x", scalar.value)
160 } else {
191 } else {
161− result.append(char)
192+ result.unicodeScalars.append(scalar)
162 }
193 }
163 }
194 }
164 }
195 }
165 return result
196 return result
166 }
197 }
167
198
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+
168 /// Type-aware comparison: numbers compare numerically, strings
212 /// Type-aware comparison: numbers compare numerically, strings
169 /// lexicographically, nulls sort first, bools sort false < true.
213 /// lexicographically, nulls sort first, bools sort false < true.
170− /// Cross-type comparisons fall back to displayString.
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").
171 static func < (lhs: JSONValue, rhs: JSONValue) -> Bool {
218 static func < (lhs: JSONValue, rhs: JSONValue) -> Bool {
219+ if lhs.typeRank != rhs.typeRank { return lhs.typeRank < rhs.typeRank }
172 switch (lhs, rhs) {
220 switch (lhs, rhs) {
173 case (.null, .null): return false
221 case (.null, .null): return false
174− case (.null, _): return true
175− case (_, .null): return false
176 case (.bool(let a), .bool(let b)): return !a && b
222 case (.bool(let a), .bool(let b)): return !a && b
177 case (.int(let a), .int(let b)): return a < b
223 case (.int(let a), .int(let b)): return a < b
178− case (.double(let a), .double(let b)): return a < b
224+ case (.double(let a), .double(let b)): return doubleLessThan(a, b)
179− case (.int(let a), .double(let b)): return Double(a) < b
225+ case (.int(let a), .double(let b)): return intLessThanDouble(a, b)
180− case (.double(let a), .int(let b)): return a < Double(b)
226+ case (.double(let a), .int(let b)): return doubleLessThanInt(a, b)
181 case (.string(let a), .string(let b)):
227 case (.string(let a), .string(let b)):
228+ // Two distinct strings can fold to `.orderedSame` under
229+ // `localizedStandardCompare` (e.g. full-width vs half-width forms that
230+ // Swift's `==` still treats as different). Break that tie with the raw
231+ // `<` so order-equivalent values stay `==`, as `Comparable` requires —
232+ // the same fallback `LogStream.<` already applies.
182− return a.localizedStandardCompare(b) == .orderedAscending
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)
183 default:
239 default:
184− return lhs.displayString < rhs.displayString
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)
185 }
372 }
186 }
373 }
187}
374}
@@ -192,4 +379,18 @@ typealias LogRecord = [String: JSONValue]
192struct QueryResponse: Codable, Sendable {
379struct QueryResponse: Codable, Sendable {
193 let records: [LogRecord]
380 let records: [LogRecord]
194 let fields: [String]?
381 let fields: [String]?
382+
383+ enum CodingKeys: String, CodingKey {
384+ case records, fields
385+ }
386+
387+ init(from decoder: Decoder) throws {
388+ let container = try decoder.container(keyedBy: CodingKeys.self)
389+ // A wrapped response can legitimately omit `records` for a zero-row result
390+ // (e.g. `{"fields": [...]}`). Default to an empty array rather than letting
391+ // the synthesized decoder throw, which would surface as a spurious
392+ // "Unexpected query response format" error instead of an empty result set.
393+ records = try container.decodeIfPresent([LogRecord].self, forKey: .records) ?? []
394+ fields = try container.decodeIfPresent([String].self, forKey: .fields)
395+ }
195}
396}
ParseableViewer/Models/LogStream.swift+145−17
@@ -10,7 +10,14 @@ struct LogStream: Identifiable, Codable, Hashable, Sendable, Comparable {
10 }
10 }
11
11
12 static func < (lhs: LogStream, rhs: LogStream) -> Bool {
12 static func < (lhs: LogStream, rhs: LogStream) -> Bool {
13+ // Fall back to a raw comparison when the localized comparison reports
14+ // `.orderedSame` for two distinct names (e.g. Unicode-equivalent forms),
15+ // so `<` stays a strict total order consistent with synthesized `==`.
13− lhs.name.localizedStandardCompare(rhs.name) == .orderedAscending
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+ }
14 }
21 }
15
22
16 init(from decoder: Decoder) throws {
23 init(from decoder: Decoder) throws {
@@ -73,11 +80,111 @@ struct SchemaField: Identifiable, Codable, Hashable, Sendable {
73 if let typeStr = try? container.decode(String.self, forKey: .dataType) {
80 if let typeStr = try? container.decode(String.self, forKey: .dataType) {
74 self.dataType = typeStr
81 self.dataType = typeStr
75 } else if let typeObj = try? container.decode(JSONValue.self, forKey: .dataType) {
82 } else if let typeObj = try? container.decode(JSONValue.self, forKey: .dataType) {
76− self.dataType = typeObj.displayString
83+ self.dataType = Self.describeArrowType(typeObj)
77 } else {
84 } else {
78 self.dataType = "Unknown"
85 self.dataType = "Unknown"
79 }
86 }
80 }
87 }
88+
89+ /// Renders a serde-serialized Arrow `DataType`. Parameterized types are
90+ /// externally tagged objects — `p_timestamp`, present in every Parseable
91+ /// stream, arrives as `{"Timestamp": ["Millisecond", null]}` and a list as
92+ /// `{"List": {<field>}}` — which `displayString` collapsed to the placeholder
93+ /// "{1 fields}". Produce Arrow's own notation instead, e.g.
94+ /// `Timestamp(Millisecond, None)` / `List(Utf8)`.
95+ static func describeArrowType(_ value: JSONValue) -> String {
96+ switch value {
97+ case .null:
98+ return "None"
99+ case .string(let s):
100+ return s
101+ case .array(let items):
102+ return items.map { describeArrowType($0) }.joined(separator: ", ")
103+ case .object(let dict):
104+ // A nested `Field` (list/map element, struct member): show its type.
105+ if let fieldType = dict["data_type"] {
106+ return describeArrowType(fieldType)
107+ }
108+ if dict.count == 1, let entry = dict.first {
109+ return "\(entry.key)(\(describeArrowType(entry.value)))"
110+ }
111+ return value.compactJSON
112+ default:
113+ return value.displayString
114+ }
115+ }
116+}
117+
118+/// Formats a byte count that may arrive from the server as a fractional or
119+/// out-of-range Double. `Int64(_:)` from a `Double` is a *trapping* conversion —
120+/// it crashes on NaN, infinity, or values outside `Int64`'s range — so a valid
121+/// but huge JSON number like `1e30` would crash the whole decode. Guard first.
122+private func formatByteSize(_ value: Double) -> String? {
123+ guard value.isFinite, value >= 0, value < 9.223372036854776e18 else { return nil }
124+ return ByteCountFormatter.string(fromByteCount: Int64(value), countStyle: .file)
125+}
126+
127+private func formatByteSize(_ value: Int) -> String? {
128+ // A negative byte count isn't meaningful; `ByteCountFormatter` would render it
129+ // as a bogus "-1 KB"/"Zero KB" string, so drop it like the `Double` path does.
130+ guard value >= 0 else { return nil }
131+ return ByteCountFormatter.string(fromByteCount: Int64(value), countStyle: .file)
132+}
133+
134+/// Normalizes a byte count that arrived as a JSON *string*. A bare numeric string
135+/// (e.g. "1048576") is a raw byte count and is formatted like the numeric branches
136+/// so identical sizes render consistently ("1 MB") regardless of whether the server
137+/// quoted the value. Parseable 1.x — the versions that quote sizes — sends them as
138+/// "<n> Bytes" (e.g. "1048576 Bytes"), so that unit suffix is accepted too. Any
139+/// other already-formatted string (e.g. "1.2 GB") is passed through unchanged.
140+private func formatByteSize(_ value: String) -> String {
141+ var trimmed = value.trimmingCharacters(in: .whitespacesAndNewlines)
142+ if trimmed.lowercased().hasSuffix(" bytes") {
143+ trimmed = String(trimmed.dropLast(" bytes".count))
144+ }
145+ if let n = Int(trimmed) { return formatByteSize(n) ?? value }
146+ return value
147+}
148+
149+/// A string-backed coding key for fields whose name differs across Parseable
150+/// versions (kebab-case, snake_case, and camelCase spellings of the same field).
151+private struct FlexibleCodingKey: CodingKey {
152+ let stringValue: String
153+ var intValue: Int? { nil }
154+ init(stringValue: String) { self.stringValue = stringValue }
155+ init?(intValue: Int) { nil }
156+}
157+
158+private extension KeyedDecodingContainer where Key == FlexibleCodingKey {
159+ /// The first of `keys` that decodes as a string.
160+ func firstString(_ keys: String...) -> String? {
161+ for key in keys {
162+ if let value = try? decode(String.self, forKey: FlexibleCodingKey(stringValue: key)) {
163+ return value
164+ }
165+ }
166+ return nil
167+ }
168+
169+ /// The first of `keys` that decodes as a boolean — either a JSON bool or the
170+ /// strings "true"/"false" (Parseable serializes `static_schema_flag` as the
171+ /// string "true").
172+ func firstBool(_ keys: String...) -> Bool? {
173+ for key in keys {
174+ let codingKey = FlexibleCodingKey(stringValue: key)
175+ if let value = try? decode(Bool.self, forKey: codingKey) {
176+ return value
177+ }
178+ if let string = try? decode(String.self, forKey: codingKey) {
179+ switch string.lowercased() {
180+ case "true": return true
181+ case "false": return false
182+ default: continue
183+ }
184+ }
185+ }
186+ return nil
187+ }
81}
188}
82
189
83struct StreamStats: Codable, Sendable {
190struct StreamStats: Codable, Sendable {
@@ -100,16 +207,20 @@ struct StreamStats: Codable, Sendable {
100 self.lifetime_count = try? container.decode(Int.self, forKey: .lifetime_count)
207 self.lifetime_count = try? container.decode(Int.self, forKey: .lifetime_count)
101 // Handle size as string or number
208 // Handle size as string or number
102 if let s = try? container.decode(String.self, forKey: .size) {
209 if let s = try? container.decode(String.self, forKey: .size) {
103− self.size = s
210+ self.size = formatByteSize(s)
104 } else if let n = try? container.decode(Int.self, forKey: .size) {
211 } else if let n = try? container.decode(Int.self, forKey: .size) {
105− self.size = ByteCountFormatter.string(fromByteCount: Int64(n), countStyle: .file)
212+ self.size = formatByteSize(n)
213+ } else if let d = try? container.decode(Double.self, forKey: .size) {
214+ self.size = formatByteSize(d)
106 } else {
215 } else {
107 self.size = nil
216 self.size = nil
108 }
217 }
109 if let s = try? container.decode(String.self, forKey: .lifetime_size) {
218 if let s = try? container.decode(String.self, forKey: .lifetime_size) {
110− self.lifetime_size = s
219+ self.lifetime_size = formatByteSize(s)
111 } else if let n = try? container.decode(Int.self, forKey: .lifetime_size) {
220 } else if let n = try? container.decode(Int.self, forKey: .lifetime_size) {
112− self.lifetime_size = ByteCountFormatter.string(fromByteCount: Int64(n), countStyle: .file)
221+ self.lifetime_size = formatByteSize(n)
222+ } else if let d = try? container.decode(Double.self, forKey: .lifetime_size) {
223+ self.lifetime_size = formatByteSize(d)
113 } else {
224 } else {
114 self.lifetime_size = nil
225 self.lifetime_size = nil
115 }
226 }
@@ -127,18 +238,26 @@ struct StreamStats: Codable, Sendable {
127
238
128 init(from decoder: Decoder) throws {
239 init(from decoder: Decoder) throws {
129 let container = try decoder.container(keyedBy: CodingKeys.self)
240 let container = try decoder.container(keyedBy: CodingKeys.self)
130− self.type = try? container.decode(String.self, forKey: .type)
241+ // Parseable names this field `format` (e.g. "parquet") in every
242+ // version; `type` never appears, so "Storage Type" always read N/A.
243+ // Keep `type` as a fallback.
244+ self.type = (try? decoder.container(keyedBy: FlexibleCodingKey.self))?
245+ .firstString("format", "type")
131 if let s = try? container.decode(String.self, forKey: .size) {
246 if let s = try? container.decode(String.self, forKey: .size) {
132− self.size = s
247+ self.size = formatByteSize(s)
133 } else if let n = try? container.decode(Int.self, forKey: .size) {
248 } else if let n = try? container.decode(Int.self, forKey: .size) {
134− self.size = ByteCountFormatter.string(fromByteCount: Int64(n), countStyle: .file)
249+ self.size = formatByteSize(n)
250+ } else if let d = try? container.decode(Double.self, forKey: .size) {
251+ self.size = formatByteSize(d)
135 } else {
252 } else {
136 self.size = nil
253 self.size = nil
137 }
254 }
138 if let s = try? container.decode(String.self, forKey: .lifetime_size) {
255 if let s = try? container.decode(String.self, forKey: .lifetime_size) {
139− self.lifetime_size = s
256+ self.lifetime_size = formatByteSize(s)
140 } else if let n = try? container.decode(Int.self, forKey: .lifetime_size) {
257 } else if let n = try? container.decode(Int.self, forKey: .lifetime_size) {
141− self.lifetime_size = ByteCountFormatter.string(fromByteCount: Int64(n), countStyle: .file)
258+ self.lifetime_size = formatByteSize(n)
259+ } else if let d = try? container.decode(Double.self, forKey: .lifetime_size) {
260+ self.lifetime_size = formatByteSize(d)
142 } else {
261 } else {
143 self.lifetime_size = nil
262 self.lifetime_size = nil
144 }
263 }
@@ -166,11 +285,20 @@ struct StreamInfo: Codable, Sendable {
166 }
285 }
167
286
168 init(from decoder: Decoder) throws {
287 init(from decoder: Decoder) throws {
288+ // The /info key spellings vary by Parseable version: 1.x–2.x send
289+ // `created-at`/`first-event-at` with the other fields in snake_case
290+ // (`cache_enabled`, `time_partition`, `static_schema_flag`), while newer
291+ // servers send camelCase throughout (`createdAt`, `timePartition`, …).
292+ // The kebab-case `cache-enabled`/`time-partition`/`static-schema-flag`
293+ // decoded previously were never sent by any version, so those rows always
294+ // showed "N/A". The schema flag also arrives as the string "true".
169− let container = try decoder.container(keyedBy: CodingKeys.self)
295+ let container = try decoder.container(keyedBy: FlexibleCodingKey.self)
170− self.createdAt = try? container.decode(String.self, forKey: .createdAt)
296+ self.createdAt = container.firstString("created-at", "createdAt", "created_at")
171− self.firstEventAt = try? container.decode(String.self, forKey: .firstEventAt)
297+ self.firstEventAt = container.firstString("first-event-at", "firstEventAt", "first_event_at")
172− self.cacheEnabled = try? container.decode(Bool.self, forKey: .cacheEnabled)
298+ self.cacheEnabled = container.firstBool("cache_enabled", "cacheEnabled", "cache-enabled")
173− self.timePartition = try? container.decode(String.self, forKey: .timePartition)
299+ self.timePartition = container.firstString("time_partition", "timePartition", "time-partition")
174− self.staticSchemaFlag = try? container.decode(Bool.self, forKey: .staticSchemaFlag)
300+ self.staticSchemaFlag = container.firstBool(
301+ "static_schema_flag", "staticSchemaFlag", "static-schema-flag"
302+ )
175 }
303 }
176}
304}
ParseableViewer/Models/ParseableFilter.swiftnew file+79
@@ -0,0 +1,79 @@
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.swiftnew file+413
@@ -0,0 +1,413 @@
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+35−2
@@ -37,18 +37,51 @@ struct ServerConnection: Identifiable, Codable, Hashable, Sendable {
37 try container.encode(username, forKey: .username)
37 try container.encode(username, forKey: .username)
38 }
38 }
39
39
40+ // `password` is deliberately excluded from equality and hashing: it isn't
41+ // serialized (see CodingKeys) and is repopulated from the Keychain on
42+ // decode, so including it would make a decoded connection compare unequal
43+ // to the in-memory original whenever the Keychain read returns "" — e.g. in
44+ // Set membership or "has this connection changed?" checks.
45+ static func == (lhs: ServerConnection, rhs: ServerConnection) -> Bool {
46+ lhs.id == rhs.id
47+ && lhs.name == rhs.name
48+ && lhs.url == rhs.url
49+ && lhs.username == rhs.username
50+ }
51+
52+ func hash(into hasher: inout Hasher) {
53+ hasher.combine(id)
54+ }
55+
40 var baseURL: URL? {
56 var baseURL: URL? {
41 var urlString = url.trimmingCharacters(in: .whitespacesAndNewlines)
57 var urlString = url.trimmingCharacters(in: .whitespacesAndNewlines)
42 if urlString.isEmpty { return nil }
58 if urlString.isEmpty { return nil }
43− if !urlString.hasPrefix("http://") && !urlString.hasPrefix("https://") {
59+ if let schemeRange = Self.urlSchemeRange(in: urlString) {
60+ // Normalize the (case-insensitive) scheme so `HTTP://host` behaves
61+ // exactly like `http://host` everywhere downstream.
62+ urlString.replaceSubrange(schemeRange, with: urlString[schemeRange].lowercased())
63+ } else {
44 urlString = "https://\(urlString)"
64 urlString = "https://\(urlString)"
45 }
65 }
46 while urlString.hasSuffix("/") {
66 while urlString.hasSuffix("/") {
47 urlString.removeLast()
67 urlString.removeLast()
48 }
68 }
49− guard let parsed = URL(string: urlString), parsed.host != nil else {
69+ guard let parsed = URL(string: urlString), parsed.host != nil,
70+ parsed.scheme == "http" || parsed.scheme == "https" else {
50 return nil
71 return nil
51 }
72 }
52 return parsed
73 return parsed
53 }
74 }
75+
76+ /// Whether `string` already starts with a URL scheme (`http://`, `HTTPS://`,
77+ /// `ftp://`, …). Schemes are case-insensitive, so a case-sensitive
78+ /// `hasPrefix("http://")` check turned `HTTP://host` into
79+ /// `https://HTTP://host` — a URL whose host is "HTTP".
80+ static func hasURLScheme(_ string: String) -> Bool {
81+ urlSchemeRange(in: string) != nil
82+ }
83+
84+ private static func urlSchemeRange(in string: String) -> Range<String.Index>? {
85+ string.range(of: "^[A-Za-z][A-Za-z0-9+.-]*://", options: .regularExpression)
86+ }
54}
87}
ParseableViewer/Models/ServerInfo.swift+47−10
@@ -15,33 +15,59 @@ struct ServerAbout: Codable, Sendable {
15 let llmActive: Bool?
15 let llmActive: Bool?
16 let oidcActive: Bool?
16 let oidcActive: Bool?
17
17
18+ // Parseable's /about handler has always sent camelCase keys (`uiVersion`,
19+ // `deploymentId`, `grpcPort`), like the other fields here. Decoding only the
20+ // snake_case spellings — which the server never sends — left UI Version and
21+ // Deployment ID permanently "N/A" and hid the gRPC Port row. The snake_case
22+ // forms are kept as a fallback.
18 private enum CodingKeys: String, CodingKey {
23 private enum CodingKeys: String, CodingKey {
19 case version, commit, mode, staging, store, license
24 case version, commit, mode, staging, store, license
20− case uiVersion = "ui_version"
25+ case uiVersion
21− case deploymentId = "deployment_id"
26+ case deploymentId
22− case grpcPort = "grpc_port"
27+ case grpcPort
23 case updateAvailable
28 case updateAvailable
24 case latestVersion
29 case latestVersion
25 case llmActive
30 case llmActive
26 case oidcActive
31 case oidcActive
27 }
32 }
28
33
34+ private enum SnakeCaseKeys: String, CodingKey {
35+ case uiVersion = "ui_version"
36+ case deploymentId = "deployment_id"
37+ case grpcPort = "grpc_port"
38+ }
39+
29 init(from decoder: Decoder) throws {
40 init(from decoder: Decoder) throws {
30 let container = try decoder.container(keyedBy: CodingKeys.self)
41 let container = try decoder.container(keyedBy: CodingKeys.self)
42+ let snakeCase = try? decoder.container(keyedBy: SnakeCaseKeys.self)
31 self.version = try? container.decode(String.self, forKey: .version)
43 self.version = try? container.decode(String.self, forKey: .version)
32− self.uiVersion = try? container.decode(String.self, forKey: .uiVersion)
44+ self.uiVersion = (try? container.decode(String.self, forKey: .uiVersion))
45+ ?? (try? snakeCase?.decode(String.self, forKey: .uiVersion))
33 self.commit = try? container.decode(String.self, forKey: .commit)
46 self.commit = try? container.decode(String.self, forKey: .commit)
34− self.deploymentId = try? container.decode(String.self, forKey: .deploymentId)
47+ self.deploymentId = (try? container.decode(String.self, forKey: .deploymentId))
48+ ?? (try? snakeCase?.decode(String.self, forKey: .deploymentId))
35 self.mode = try? container.decode(String.self, forKey: .mode)
49 self.mode = try? container.decode(String.self, forKey: .mode)
36 self.staging = try? container.decode(String.self, forKey: .staging)
50 self.staging = try? container.decode(String.self, forKey: .staging)
37 self.store = try? container.decode(StoreInfo.self, forKey: .store)
51 self.store = try? container.decode(StoreInfo.self, forKey: .store)
38− self.license = try? container.decode(String.self, forKey: .license)
52+ self.license = (try? container.decode(String.self, forKey: .license))
53+ ?? (try? container.decode(JSONValue.self, forKey: .license)).flatMap(Self.describeLicense)
39− self.grpcPort = try? container.decode(Int.self, forKey: .grpcPort)
54+ self.grpcPort = (try? container.decode(Int.self, forKey: .grpcPort))
55+ ?? (try? snakeCase?.decode(Int.self, forKey: .grpcPort))
40 self.updateAvailable = try? container.decode(Bool.self, forKey: .updateAvailable)
56 self.updateAvailable = try? container.decode(Bool.self, forKey: .updateAvailable)
41 self.latestVersion = try? container.decode(String.self, forKey: .latestVersion)
57 self.latestVersion = try? container.decode(String.self, forKey: .latestVersion)
42 self.llmActive = try? container.decode(Bool.self, forKey: .llmActive)
58 self.llmActive = try? container.decode(Bool.self, forKey: .llmActive)
43 self.oidcActive = try? container.decode(Bool.self, forKey: .oidcActive)
59 self.oidcActive = try? container.decode(Bool.self, forKey: .oidcActive)
44 }
60 }
61+
62+ /// Current servers send `license` as an object (`{"name": "AGPL-3.0-only",
63+ /// "plan": "OSS", …}`) rather than a string, which decoded to nil ("N/A").
64+ private static func describeLicense(_ value: JSONValue) -> String? {
65+ guard case .object(let fields) = value, let name = fields["name"]?.stringValue else { return nil }
66+ if let plan = fields["plan"]?.stringValue, !plan.isEmpty {
67+ return "\(name) (\(plan))"
68+ }
69+ return name
70+ }
45}
71}
46
72
47struct StoreInfo: Codable, Sendable {
73struct StoreInfo: Codable, Sendable {
@@ -85,9 +111,20 @@ struct UserInfo: Identifiable, Codable, Sendable {
85 }
111 }
86
112
87 init(from decoder: Decoder) throws {
113 init(from decoder: Decoder) throws {
88− // Handle both {"id": "x", ...} and variations
114+ // Handle both {"id": "x", ...} and a bare "x" string element.
89− if let container = try? decoder.container(keyedBy: CodingKeys.self) {
115+ if let container = try? decoder.container(keyedBy: CodingKeys.self),
116+ container.contains(.id) {
117+ // Decode `id` defensively: the server may quote it as a string or
118+ // send it as a number/other JSON. Fall back to JSONValue.displayString
119+ // so a single odd entry doesn't fail the whole user list (matching the
120+ // file's otherwise-defensive decoding).
90− self.id = try container.decode(String.self, forKey: .id)
121+ if let stringID = try? container.decode(String.self, forKey: .id) {
122+ self.id = stringID
123+ } else if let valueID = try? container.decode(JSONValue.self, forKey: .id) {
124+ self.id = valueID.displayString
125+ } else {
126+ self.id = ""
127+ }
91 self.method = try? container.decode(String.self, forKey: .method)
128 self.method = try? container.decode(String.self, forKey: .method)
92 } else {
129 } else {
93 let container = try decoder.singleValueContainer()
130 let container = try decoder.singleValueContainer()
ParseableViewer/ParseableViewer.entitlements+1−6
@@ -1,10 +1,5 @@
1<?xml version="1.0" encoding="UTF-8"?>
1<?xml version="1.0" encoding="UTF-8"?>
2<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
2<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
3<plist version="1.0">
3<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>
10</plist>
5</plist>
ParseableViewer/Services/ConnectionStore.swift+19−50
@@ -1,26 +1,29 @@
1import Foundation
1import Foundation
2
2
3final class ConnectionStore {
3final class ConnectionStore {
4− private static let storageKey = "parseable_connections"
4+ private static let keychainConnectionsKey = "parseable_connections_list"
5− private static let activeConnectionKey = "parseable_active_connection_id"
5+ private static let keychainActiveIDKey = "parseable_active_connection_id"
6
6
7 static func loadConnections() -> [ServerConnection] {
7 static func loadConnections() -> [ServerConnection] {
8− guard let data = UserDefaults.standard.data(forKey: storageKey) else {
8+ guard let data = KeychainService.loadData(for: keychainConnectionsKey) else {
9 return []
9 return []
10 }
10 }
11 return (try? JSONDecoder().decode([ServerConnection].self, from: data)) ?? []
11 return (try? JSONDecoder().decode([ServerConnection].self, from: data)) ?? []
12 }
12 }
13
13
14 static func saveConnections(_ connections: [ServerConnection]) {
14 static func saveConnections(_ connections: [ServerConnection]) {
15− for connection in connections {
15+ for connection in connections where !connection.password.isEmpty {
16− if !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`.
17− KeychainService.savePassword(connection.password, for: connection.id)
23+ KeychainService.savePassword(connection.password, for: connection.id)
18− } else {
19− KeychainService.deletePassword(for: connection.id)
20− }
21 }
24 }
22 if let data = try? JSONEncoder().encode(connections) {
25 if let data = try? JSONEncoder().encode(connections) {
23− UserDefaults.standard.set(data, forKey: storageKey)
26+ KeychainService.saveData(data, for: keychainConnectionsKey)
24 }
27 }
25 }
28 }
26
29
@@ -29,52 +32,18 @@ final class ConnectionStore {
29 }
32 }
30
33
31 static func loadActiveConnectionID() -> UUID? {
34 static func loadActiveConnectionID() -> UUID? {
32− guard let string = UserDefaults.standard.string(forKey: activeConnectionKey) else {
35+ guard let data = KeychainService.loadData(for: keychainActiveIDKey),
36+ let string = String(data: data, encoding: .utf8) else {
33 return nil
37 return nil
34 }
38 }
35 return UUID(uuidString: string)
39 return UUID(uuidString: string)
36 }
40 }
37
41
38 static func saveActiveConnectionID(_ id: UUID?) {
42 static func saveActiveConnectionID(_ id: UUID?) {
39− UserDefaults.standard.set(id?.uuidString, forKey: activeConnectionKey)
43+ if let id, let data = id.uuidString.data(using: .utf8) {
40− }
44+ KeychainService.saveData(data, for: keychainActiveIDKey)
41−}
45+ } else {
42−
46+ KeychainService.deleteData(for: keychainActiveIDKey)
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)
56 }
47 }
57 }
48 }
58}
49}
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− var columnOrder: [String]?
67− var hiddenColumns: Set<String>?
68−
69− init(id: UUID = UUID(), name: String, sql: String, stream: String,
70− columnOrder: [String]? = nil, hiddenColumns: Set<String>? = nil,
71− createdAt: Date = Date()) {
72− self.id = id
73− self.name = name
74− self.sql = sql
75− self.stream = stream
76− self.columnOrder = columnOrder
77− self.hiddenColumns = hiddenColumns
78− self.createdAt = createdAt
79− }
80−}
ParseableViewer/Services/KeychainService.swift+76−26
@@ -8,32 +8,15 @@ enum KeychainService {
8
8
9 @discardableResult
9 @discardableResult
10 static func savePassword(_ password: String, for connectionID: UUID) -> Bool {
10 static func savePassword(_ password: String, for connectionID: UUID) -> Bool {
11− let account = connectionID.uuidString
12− deletePassword(for: connectionID)
13−
14 guard let data = password.data(using: .utf8) else { return false }
11 guard let data = password.data(using: .utf8) else { return false }
15− let query: [String: Any] = [
12+ return upsert(data, query: baseQuery(for: connectionID), label: connectionID.uuidString)
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
26 }
13 }
27
14
28 static func loadPassword(for connectionID: UUID) -> String? {
15 static func loadPassword(for connectionID: UUID) -> String? {
29 let account = connectionID.uuidString
16 let account = connectionID.uuidString
30− let query: [String: Any] = [
17+ var query = baseQuery(for: connectionID)
31− kSecClass as String: kSecClassGenericPassword,
32− kSecAttrService as String: service,
33− kSecAttrAccount as String: account,
34− kSecReturnData as String: true,
18+ query[kSecReturnData as String] = true
35− kSecMatchLimit as String: kSecMatchLimitOne
19+ query[kSecMatchLimit as String] = kSecMatchLimitOne
36− ]
37
20
38 var result: AnyObject?
21 var result: AnyObject?
39 let status = SecItemCopyMatching(query as CFDictionary, &result)
22 let status = SecItemCopyMatching(query as CFDictionary, &result)
@@ -47,15 +30,82 @@ enum KeychainService {
47 @discardableResult
30 @discardableResult
48 static func deletePassword(for connectionID: UUID) -> Bool {
31 static func deletePassword(for connectionID: UUID) -> Bool {
49 let account = connectionID.uuidString
32 let account = connectionID.uuidString
50− let query: [String: Any] = [
33+ let query = baseQuery(for: connectionID)
51− kSecClass as String: kSecClassGenericPassword,
52− kSecAttrService as String: service,
53− kSecAttrAccount as String: account
54− ]
55 let status = SecItemDelete(query as CFDictionary)
34 let status = SecItemDelete(query as CFDictionary)
56 if status != errSecSuccess && status != errSecItemNotFound {
35 if status != errSecSuccess && status != errSecItemNotFound {
57 logger.error("Failed to delete password for \(account): OSStatus \(status)")
36 logger.error("Failed to delete password for \(account): OSStatus \(status)")
58 }
37 }
59 return status == errSecSuccess || status == errSecItemNotFound
38 return status == errSecSuccess || status == errSecItemNotFound
60 }
39 }
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+ }
61}
111}
ParseableViewer/Services/ParseableClient.swift+319−47
@@ -51,6 +51,8 @@ enum ParseableError: LocalizedError {
51 return "Connection timed out. The server may be unreachable."
51 return "Connection timed out. The server may be unreachable."
52 case NSURLErrorSecureConnectionFailed, NSURLErrorServerCertificateUntrusted:
52 case NSURLErrorSecureConnectionFailed, NSURLErrorServerCertificateUntrusted:
53 return "SSL/TLS error. The server certificate may be invalid or untrusted."
53 return "SSL/TLS error. The server certificate may be invalid or untrusted."
54+ case NSURLErrorCancelled:
55+ return "Request cancelled"
54 default:
56 default:
55 return "Network error: \(error.localizedDescription)"
57 return "Network error: \(error.localizedDescription)"
56 }
58 }
@@ -59,27 +61,132 @@ enum ParseableError: LocalizedError {
59 }
61 }
60}
62}
61
63
64+private actor ResponseCache {
65+ private struct Entry: Sendable {
66+ let data: any Sendable
67+ let timestamp: Date
68+ }
69+
70+ private var cache: [String: Entry] = [:]
71+ private let ttl: TimeInterval = 60
72+
73+ func get<T: Sendable>(_ key: String, as type: T.Type) -> T? {
74+ guard let entry = cache[key],
75+ Date().timeIntervalSince(entry.timestamp) < ttl else {
76+ cache.removeValue(forKey: key)
77+ return nil
78+ }
79+ return entry.data as? T
80+ }
81+
82+ func set(_ key: String, value: some Sendable) {
83+ cache[key] = Entry(data: value, timestamp: Date())
84+ }
85+
86+ func invalidate() {
87+ cache.removeAll()
88+ }
89+}
90+
91+/// Strips the `Authorization` header on redirects that cross to a different
92+/// origin. By default `URLSession` re-sends the original request's headers —
93+/// including the `Basic` credentials this client attaches to every request —
94+/// to the redirect target. A server (or a malicious/misconfigured proxy)
95+/// answering with a 3xx to an unrelated host would otherwise harvest the user's
96+/// credentials. Same-origin redirects keep the header so normal auth still works.
97+///
98+/// Holds only an immutable `URL`, so it is safely `Sendable` despite the
99+/// NSObject base (which isn't formally `Sendable`).
100+private final class RedirectSanitizer: NSObject, URLSessionTaskDelegate, @unchecked Sendable {
101+ private let baseURL: URL
102+
103+ init(baseURL: URL) {
104+ self.baseURL = baseURL
105+ }
106+
107+ func urlSession(
108+ _ session: URLSession,
109+ task: URLSessionTask,
110+ willPerformHTTPRedirection response: HTTPURLResponse,
111+ newRequest request: URLRequest,
112+ completionHandler: @escaping (URLRequest?) -> Void
113+ ) {
114+ guard !RedirectSanitizer.sameOrigin(baseURL, request.url) else {
115+ completionHandler(request)
116+ return
117+ }
118+ var sanitized = request
119+ sanitized.setValue(nil, forHTTPHeaderField: "Authorization")
120+ completionHandler(sanitized)
121+ }
122+
123+ /// Origin equality per scheme/host/port, normalizing the implicit default
124+ /// port so `https://host` and `https://host:443` are treated as the same
125+ /// origin (and aren't stripped on a legitimate same-host redirect).
126+ static func sameOrigin(_ a: URL, _ b: URL?) -> Bool {
127+ guard let b else { return false }
128+ return a.scheme?.lowercased() == b.scheme?.lowercased()
129+ && a.host?.lowercased() == b.host?.lowercased()
130+ && effectivePort(a) == effectivePort(b)
131+ }
132+
133+ private static func effectivePort(_ url: URL) -> Int? {
134+ if let port = url.port { return port }
135+ switch url.scheme?.lowercased() {
136+ case "https": return 443
137+ case "http": return 80
138+ default: return nil
139+ }
140+ }
141+}
142+
62final class ParseableClient: Sendable {
143final class ParseableClient: Sendable {
63 let baseURL: URL
144 let baseURL: URL
64 let username: String
145 let username: String
65 let password: String
146 let password: String
66 private let session: URLSession
147 private let session: URLSession
67−
148+ private let authHeader: String
68− private static let isoFormatter: ISO8601DateFormatter = {
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 {
69 let f = ISO8601DateFormatter()
161 let f = ISO8601DateFormatter()
70 f.formatOptions = [.withInternetDateTime, .withFractionalSeconds]
162 f.formatOptions = [.withInternetDateTime, .withFractionalSeconds]
71 return f
163 return f
72− }()
164+ }
73
165
74 init(url: URL, username: String, password: String) {
166 init(url: URL, username: String, password: String) {
75 self.baseURL = url
167 self.baseURL = url
76 self.username = username
168 self.username = username
77 self.password = password
169 self.password = password
78
170
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+
79 let config = URLSessionConfiguration.default
178 let config = URLSessionConfiguration.default
80 config.timeoutIntervalForRequest = 30
179 config.timeoutIntervalForRequest = 30
81 config.timeoutIntervalForResource = 120
180 config.timeoutIntervalForResource = 120
181+ // Install a delegate that drops the Authorization header on cross-origin
182+ // redirects so Basic credentials can't be forwarded to an unrelated host.
183+ // The session retains the delegate until `finishTasksAndInvalidate()` in
184+ // deinit releases it, so there's no leak.
82− self.session = URLSession(configuration: config)
185+ self.session = URLSession(
186+ configuration: config,
187+ delegate: RedirectSanitizer(baseURL: url),
188+ delegateQueue: nil
189+ )
83 }
190 }
84
191
85 convenience init(connection: ServerConnection) throws {
192 convenience init(connection: ServerConnection) throws {
@@ -95,20 +202,19 @@ final class ParseableClient: Sendable {
95 session.finishTasksAndInvalidate()
202 session.finishTasksAndInvalidate()
96 }
203 }
97
204
98− private var authHeader: String {
99− let credentials = "\(username):\(password)"
100− guard let data = credentials.data(using: .utf8) else {
101− return "Basic "
102− }
103− return "Basic \(data.base64EncodedString())"
104− }
105−
106 private func buildRequest(method: String, path: String, body: Data? = nil, queryItems: [URLQueryItem]? = nil) throws -> URLRequest {
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.
107− var components = URLComponents(url: baseURL.appendingPathComponent(path), resolvingAgainstBaseURL: false)
210+ guard var components = URLComponents(url: baseURL, resolvingAgainstBaseURL: false) else {
211+ throw ParseableError.invalidURL
212+ }
213+ components.percentEncodedPath += path
108 if let queryItems {
214 if let queryItems {
109− components?.queryItems = queryItems
215+ components.queryItems = queryItems
110 }
216 }
111− guard let url = components?.url else {
217+ guard let url = components.url else {
112 throw ParseableError.invalidURL
218 throw ParseableError.invalidURL
113 }
219 }
114
220
@@ -136,7 +242,11 @@ final class ParseableClient: Sendable {
136 }
242 }
137
243
138 guard (200...299).contains(httpResponse.statusCode) else {
244 guard (200...299).contains(httpResponse.statusCode) else {
245+ // A proxy/load balancer can answer with a multi-KB HTML body; surfacing
246+ // it verbatim floods the error alert. Trim and cap to a readable prefix.
139− let message = String(data: data, encoding: .utf8) ?? "Unknown error"
247+ let raw = (String(data: data, encoding: .utf8) ?? "")
248+ .trimmingCharacters(in: .whitespacesAndNewlines)
249+ let message = raw.isEmpty ? "Unknown error" : String(raw.prefix(500))
140 throw ParseableError.serverError(httpResponse.statusCode, message)
250 throw ParseableError.serverError(httpResponse.statusCode, message)
141 }
251 }
142
252
@@ -154,21 +264,28 @@ final class ParseableClient: Sendable {
154 if httpResponse.statusCode == 401 {
264 if httpResponse.statusCode == 401 {
155 throw ParseableError.unauthorized
265 throw ParseableError.unauthorized
156 }
266 }
267+ // Accept any 2xx — a HEAD /liveness can legitimately return 204, etc.
157− guard httpResponse.statusCode == 200 else {
268+ guard (200...299).contains(httpResponse.statusCode) else {
158 throw ParseableError.serverError(httpResponse.statusCode, "Health check failed")
269 throw ParseableError.serverError(httpResponse.statusCode, "Health check failed")
159 }
270 }
160 }
271 }
161
272
162 func getAbout() async throws -> ServerAbout {
273 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+ }
163 let data = try await performRequest(method: "GET", path: "/api/v1/about")
278 let data = try await performRequest(method: "GET", path: "/api/v1/about")
164− return try JSONDecoder().decode(ServerAbout.self, from: data)
279+ let result = try Self.jsonDecoder.decode(ServerAbout.self, from: data)
280+ await cache.set(key, value: result)
281+ return result
165 }
282 }
166
283
167 // MARK: - Log Streams
284 // MARK: - Log Streams
168
285
169 func listStreams() async throws -> [LogStream] {
286 func listStreams() async throws -> [LogStream] {
170 let data = try await performRequest(method: "GET", path: "/api/v1/logstream")
287 let data = try await performRequest(method: "GET", path: "/api/v1/logstream")
171− return try JSONDecoder().decode([LogStream].self, from: data)
288+ return try Self.jsonDecoder.decode([LogStream].self, from: data)
172 }
289 }
173
290
174 private static func encodePathComponent(_ name: String) throws -> String {
291 private static func encodePathComponent(_ name: String) throws -> String {
@@ -184,38 +301,62 @@ final class ParseableClient: Sendable {
184 func createStream(name: String) async throws {
301 func createStream(name: String) async throws {
185 let encoded = try Self.encodePathComponent(name)
302 let encoded = try Self.encodePathComponent(name)
186 _ = try await performRequest(method: "PUT", path: "/api/v1/logstream/\(encoded)")
303 _ = 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()
187 }
307 }
188
308
189 func deleteStream(name: String) async throws {
309 func deleteStream(name: String) async throws {
190 let encoded = try Self.encodePathComponent(name)
310 let encoded = try Self.encodePathComponent(name)
191 _ = try await performRequest(method: "DELETE", path: "/api/v1/logstream/\(encoded)")
311 _ = 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()
192 }
314 }
193
315
194 func getStreamSchema(stream: String) async throws -> StreamSchema {
316 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+ }
195 let encoded = try Self.encodePathComponent(stream)
321 let encoded = try Self.encodePathComponent(stream)
196 let data = try await performRequest(method: "GET", path: "/api/v1/logstream/\(encoded)/schema")
322 let data = try await performRequest(method: "GET", path: "/api/v1/logstream/\(encoded)/schema")
197− return try JSONDecoder().decode(StreamSchema.self, from: data)
323+ let result = try Self.jsonDecoder.decode(StreamSchema.self, from: data)
324+ await cache.set(key, value: result)
325+ return result
198 }
326 }
199
327
200 func getStreamStats(stream: String) async throws -> StreamStats {
328 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+ }
201 let encoded = try Self.encodePathComponent(stream)
333 let encoded = try Self.encodePathComponent(stream)
202 let data = try await performRequest(method: "GET", path: "/api/v1/logstream/\(encoded)/stats")
334 let data = try await performRequest(method: "GET", path: "/api/v1/logstream/\(encoded)/stats")
203− return try JSONDecoder().decode(StreamStats.self, from: data)
335+ let result = try Self.jsonDecoder.decode(StreamStats.self, from: data)
336+ await cache.set(key, value: result)
337+ return result
204 }
338 }
205
339
206 func getStreamInfo(stream: String) async throws -> StreamInfo {
340 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+ }
207 let encoded = try Self.encodePathComponent(stream)
345 let encoded = try Self.encodePathComponent(stream)
208 let data = try await performRequest(method: "GET", path: "/api/v1/logstream/\(encoded)/info")
346 let data = try await performRequest(method: "GET", path: "/api/v1/logstream/\(encoded)/info")
209− return try JSONDecoder().decode(StreamInfo.self, from: data)
347+ let result = try Self.jsonDecoder.decode(StreamInfo.self, from: data)
348+ await cache.set(key, value: result)
349+ return result
210 }
350 }
211
351
212 // MARK: - Query
352 // MARK: - Query
213
353
214 func query(sql: String, startTime: Date, endTime: Date) async throws -> [LogRecord] {
354 func query(sql: String, startTime: Date, endTime: Date) async throws -> [LogRecord] {
355+ let formatter = Self.isoFormatter
215 let body: [String: Any] = [
356 let body: [String: Any] = [
216 "query": sql,
357 "query": sql,
217− "startTime": Self.isoFormatter.string(from: startTime),
358+ "startTime": formatter.string(from: startTime),
218− "endTime": Self.isoFormatter.string(from: endTime)
359+ "endTime": formatter.string(from: endTime)
219 ]
360 ]
220
361
221 let bodyData = try JSONSerialization.data(withJSONObject: body)
362 let bodyData = try JSONSerialization.data(withJSONObject: body)
@@ -225,36 +366,138 @@ final class ParseableClient: Sendable {
225 return []
366 return []
226 }
367 }
227
368
228− // Handle both response formats:
369+ // Peek at the first meaningful byte to choose the decoder path:
229− // 1. Wrapped: {"records": [...], "fields": [...]}
370+ // '{' → wrapped response, '[' → direct array. Skip a leading UTF-8 BOM
230− // 2. Direct array: [...]
371+ // (0xEF 0xBB 0xBF) and ASCII whitespace so a BOM-prefixed wrapped
231− if let wrapped = try? JSONDecoder().decode(QueryResponse.self, from: responseData) {
372+ // response isn't misrouted to the array decoder. (JSONDecoder itself
232− return wrapped.records
373+ // tolerates the BOM.)
374+ var sniff = responseData
375+ if sniff.starts(with: [0xEF, 0xBB, 0xBF]) {
376+ sniff = sniff.dropFirst(3)
233 }
377 }
234− do {
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 {
235− return try JSONDecoder().decode([LogRecord].self, from: responseData)
391+ return try Self.jsonDecoder.decode([LogRecord].self, from: responseData)
236− } catch {
392+ } catch {
237− throw ParseableError.decodingError("Unexpected query response format")
393+ throw ParseableError.decodingError("Unexpected query response format")
394+ }
238 }
395 }
239 }
396 }
240
397
241 // MARK: - Alerts
398 // MARK: - Alerts
242
399
243 func getAlerts(stream: String) async throws -> AlertConfig {
400 func getAlerts(stream: String) async throws -> AlertConfig {
244− // Try new API first; only fall back to legacy per-stream endpoint on 404
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+
245 do {
455 do {
246− let data = try await performRequest(method: "GET", path: "/api/v1/alerts")
456+ let (data, config) = try await modernAlerts()
247− return try JSONDecoder().decode(AlertConfig.self, from: data)
457+ // AlertConfig decodes leniently (every field via try?), so a 200 with
248− } catch let error as ParseableError {
458+ // an unexpected body yields an empty, non-throwing config rather than
249− if case .serverError(let code, _) = error, code == 404 {
459+ // a DecodingError — indistinguishable from a server that genuinely has
250− // New endpoint not available, try legacy below
460+ // no alerts. Only fall back to the legacy per-stream endpoint when the
251− } else {
461+ // body ISN'T a recognizable alerts container. Any top-level JSON array
252− throw error
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
253 }
499 }
254 }
500 }
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)
258 }
501 }
259
502
260 // MARK: - Retention
503 // MARK: - Retention
@@ -264,11 +507,17 @@ final class ParseableClient: Sendable {
264 let data = try await performRequest(method: "GET", path: "/api/v1/logstream/\(encoded)/retention")
507 let data = try await performRequest(method: "GET", path: "/api/v1/logstream/\(encoded)/retention")
265 if data.isEmpty { return [] }
508 if data.isEmpty { return [] }
266 // Handle both array and single object response
509 // Handle both array and single object response
267− if let array = try? JSONDecoder().decode([RetentionConfig].self, from: data) {
510+ if let array = try? Self.jsonDecoder.decode([RetentionConfig].self, from: data) {
268 return array
511 return array
269 }
512 }
270 do {
513 do {
271− let single = try JSONDecoder().decode(RetentionConfig.self, from: data)
514+ let single = try Self.jsonDecoder.decode(RetentionConfig.self, from: data)
515+ // RetentionConfig decodes every field with `try?`, so any JSON object
516+ // (including `{}` or an error body) decodes to an all-nil config. Treat
517+ // an empty config as "no retention" rather than a phantom policy entry.
518+ guard single.description != nil || single.duration != nil || single.action != nil else {
519+ return []
520+ }
272 return [single]
521 return [single]
273 } catch {
522 } catch {
274 throw ParseableError.decodingError("Unexpected retention response format")
523 throw ParseableError.decodingError("Unexpected retention response format")
@@ -279,6 +528,29 @@ final class ParseableClient: Sendable {
279
528
280 func listUsers() async throws -> [UserInfo] {
529 func listUsers() async throws -> [UserInfo] {
281 let data = try await performRequest(method: "GET", path: "/api/v1/user")
530 let data = try await performRequest(method: "GET", path: "/api/v1/user")
282− return try JSONDecoder().decode([UserInfo].self, from: data)
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()
283 }
555 }
284}
556}
ParseableViewer/ViewModels/LiveTailViewModel.swift+445−90
@@ -7,7 +7,9 @@ final class LiveTailViewModel {
7 var entries: [LiveTailEntry] = []
7 var entries: [LiveTailEntry] = []
8 var isRunning = false
8 var isRunning = false
9 var isPaused = false
9 var isPaused = false
10− var filterText = ""
10+ var filterText = "" {
11+ didSet { rebuildFilteredEntries() }
12+ }
11 var columnFilters: [ColumnFilter] = []
13 var columnFilters: [ColumnFilter] = []
12 var pollInterval: TimeInterval = 2.0
14 var pollInterval: TimeInterval = 2.0
13 var maxEntries = 5000
15 var maxEntries = 5000
@@ -19,34 +21,82 @@ final class LiveTailViewModel {
19 var columns: [String] = []
21 var columns: [String] = []
20 var columnOrder: [String] = []
22 var columnOrder: [String] = []
21 var hiddenColumns: Set<String> = []
23 var hiddenColumns: Set<String> = []
24+ /// The full persisted set of hidden columns, including ones not yet seen in
25+ /// this session. Live-tail columns are discovered incrementally across polls,
26+ /// so `hiddenColumns` (only the seen ones) can't be the source of truth — a
27+ /// column hidden last session would reappear visible when it shows up in a
28+ /// later poll. This retains the intent so it stays hidden when it appears.
29+ private var savedHiddenColumns: Set<String> = []
30+ /// The full persisted column order, including columns not yet seen this
31+ /// session — the order counterpart of `savedHiddenColumns`. A column that
32+ /// first shows up in a later poll is slotted back into its saved position
33+ /// (instead of being appended), and saving keeps unseen columns' positions
34+ /// rather than dropping them from the stored order.
35+ private var savedColumnOrder: [String] = []
22 private var currentStream: String?
36 private var currentStream: String?
23
37
24 // nonisolated(unsafe) so deinit can invalidate the timer.
38 // nonisolated(unsafe) so deinit can invalidate the timer.
25 // Only mutated from @MainActor methods (start/stop).
39 // Only mutated from @MainActor methods (start/stop).
26 nonisolated(unsafe) private var timer: Timer?
40 nonisolated(unsafe) private var timer: Timer?
27 private var lastTimestamp: Date?
41 private var lastTimestamp: Date?
42+ /// How many records with each content fingerprint have been delivered. A
43+ /// count rather than a set: Parseable stamps every event of one ingest request
44+ /// with the same millisecond `p_timestamp`, so genuinely repeated events (the
45+ /// same log line emitted three times) fingerprint identically. A set collapsed
46+ /// them into one row; counting keeps each occurrence while still suppressing
47+ /// the re-fetch of the same rows by the next overlapping poll window.
28− private var seenFingerprints: Set<String> = []
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> = []
29 private var consecutiveErrors = 0
59 private var consecutiveErrors = 0
30 private static let maxConsecutiveErrors = 5
60 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
31
70
32 deinit {
71 deinit {
33− timer?.invalidate()
72+ // Timer.invalidate() must run on the run loop the timer was scheduled on
73+ // (RunLoop.main, see start()). deinit is nonisolated and can run off the
74+ // main thread, so hop to the main queue rather than invalidating inline.
75+ // The scheduled timer only weakly references self, so deferring teardown
76+ // past deallocation is safe — at worst it fires once more as a no-op
77+ // before being invalidated. stop() remains the normal teardown path.
78+ if let timer {
79+ DispatchQueue.main.async { timer.invalidate() }
80+ }
34 }
81 }
35
82
36− // Cached formatters to avoid per-poll allocation
83+ // Cached formatters — nonisolated(unsafe) so static methods can use them from Task.detached
37− private static let displayFormatter: DateFormatter = {
84+ nonisolated(unsafe) private static let displayFormatter: DateFormatter = {
38 let f = DateFormatter()
85 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")
39 f.dateFormat = "HH:mm:ss.SSS"
89 f.dateFormat = "HH:mm:ss.SSS"
40 return f
90 return f
41 }()
91 }()
42
92
43− private static let isoFractional: ISO8601DateFormatter = {
93+ nonisolated(unsafe) private static let isoFractional: ISO8601DateFormatter = {
44 let f = ISO8601DateFormatter()
94 let f = ISO8601DateFormatter()
45 f.formatOptions = [.withInternetDateTime, .withFractionalSeconds]
95 f.formatOptions = [.withInternetDateTime, .withFractionalSeconds]
46 return f
96 return f
47 }()
97 }()
48
98
49− private static let isoBasic: ISO8601DateFormatter = {
99+ nonisolated(unsafe) private static let isoBasic: ISO8601DateFormatter = {
50 let f = ISO8601DateFormatter()
100 let f = ISO8601DateFormatter()
51 f.formatOptions = [.withInternetDateTime]
101 f.formatOptions = [.withInternetDateTime]
52 return f
102 return f
@@ -58,6 +108,7 @@ final class LiveTailViewModel {
58 let record: LogRecord
108 let record: LogRecord
59 let displayTimestamp: String
109 let displayTimestamp: String
60 let summary: String
110 let summary: String
111+ let fingerprint: String
61 }
112 }
62
113
63 struct ColumnFilter: Identifiable, Equatable, Sendable {
114 struct ColumnFilter: Identifiable, Equatable, Sendable {
@@ -73,50 +124,83 @@ final class LiveTailViewModel {
73 }
124 }
74 }
125 }
75
126
76− var filteredEntries: [LiveTailEntry] {
127+ private(set) var cachedFilteredEntries: [LiveTailEntry] = []
77− var result = entries
128+ var cachedFilteredRecords: [LogRecord] {
78−
129+ cachedFilteredEntries.map { $0.record }
79− // Apply column filters
130+ }
80− for filter in columnFilters {
131+ private(set) var filteredEntriesGeneration: Int = 0
81− result = result.filter { entry in
132+
82− let recordValue = entry.record[filter.column]
133+ private func rebuildFilteredEntries() {
83− let matches: Bool
134+ let hasColumnFilters = !columnFilters.isEmpty
84− if filter.value == nil || filter.value == .null {
135+ let hasTextFilter = !filterText.isEmpty
85− matches = recordValue == nil || recordValue == .null
136+ let activeFilters = columnFilters
86− } else {
137+ let text = filterText
87− matches = recordValue == filter.value
88− }
89− return filter.exclude ? !matches : matches
90− }
91− }
92
138
93− // Apply text filter
139+ let result: [LiveTailEntry]
94− if !filterText.isEmpty {
140+ if !hasColumnFilters && !hasTextFilter {
141+ result = entries
142+ } else {
95− result = result.filter { entry in
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 {
96− entry.summary.localizedCaseInsensitiveContains(filterText) ||
159+ if !entry.summary.localizedCaseInsensitiveContains(text) &&
97− entry.record.values.contains { $0.displayString.localizedCaseInsensitiveContains(filterText) }
160+ !entry.record.values.contains(where: { $0.displayString.localizedCaseInsensitiveContains(text) }) {
161+ return false
162+ }
163+ }
164+ return true
98 }
165 }
99 }
166 }
100
167
101− return result
168+ cachedFilteredEntries = result
169+ filteredEntriesGeneration += 1
102 }
170 }
103
171
104 func addColumnFilter(column: String, value: JSONValue?, exclude: Bool) {
172 func addColumnFilter(column: String, value: JSONValue?, exclude: Bool) {
105− // Remove any existing filter on the same column with the same value
173+ // Remove any existing filter on the same column+value regardless of its
174+ // exclude flag. Include (col = X) and exclude (col ≠ X) on the same value
175+ // are mutually exclusive; without dropping the `exclude` match too, toggling
176+ // include↔exclude stacks both and the AND'd filters match zero rows, so the
177+ // tail silently empties.
178+ columnFilters.removeAll { $0.column == column && $0.value == value }
179+ if !exclude {
180+ // Include filters are AND-combined in rebuildFilteredEntries, but a column
181+ // can only equal one value, so two includes on the same column with
182+ // different values (e.g. level = info then level = error) can never both
183+ // match and the tail would silently empty. A new include therefore
184+ // replaces any prior include on that column. (Excludes legitimately stack:
185+ // col ≠ a AND col ≠ b is meaningful, so they are left untouched here.)
106− columnFilters.removeAll { $0.column == column && $0.value == value && $0.exclude == exclude }
186+ columnFilters.removeAll { $0.column == column && !$0.exclude }
187+ }
107 columnFilters.append(ColumnFilter(column: column, value: value, exclude: exclude))
188 columnFilters.append(ColumnFilter(column: column, value: value, exclude: exclude))
189+ rebuildFilteredEntries()
108 }
190 }
109
191
110 func removeColumnFilter(_ filter: ColumnFilter) {
192 func removeColumnFilter(_ filter: ColumnFilter) {
111 columnFilters.removeAll { $0.id == filter.id }
193 columnFilters.removeAll { $0.id == filter.id }
194+ rebuildFilteredEntries()
112 }
195 }
113
196
114 func clearColumnFilters() {
197 func clearColumnFilters() {
115 columnFilters.removeAll()
198 columnFilters.removeAll()
199+ rebuildFilteredEntries()
116 }
200 }
117
201
118 var entryCount: Int { entries.count }
202 var entryCount: Int { entries.count }
119− var displayedCount: Int { filteredEntries.count }
203+ var displayedCount: Int { cachedFilteredEntries.count }
120
204
121 func start(client: ParseableClient?, stream: String?) {
205 func start(client: ParseableClient?, stream: String?) {
122 guard let client, let stream, !isRunning else { return }
206 guard let client, let stream, !isRunning else { return }
@@ -127,11 +211,19 @@ final class LiveTailViewModel {
127 let storedMax = UserDefaults.standard.integer(forKey: "liveTailMaxEntries")
211 let storedMax = UserDefaults.standard.integer(forKey: "liveTailMaxEntries")
128 if storedMax > 0 { maxEntries = storedMax }
212 if storedMax > 0 { maxEntries = storedMax }
129
213
214+ pollGeneration += 1
130 isRunning = true
215 isRunning = true
131 isPaused = false
216 isPaused = false
217+ // Reset the in-flight guard: if a poll was awaiting its network round-trip
218+ // when the tail was stopped, its `defer { isPolling = false }` only runs when
219+ // that request finally returns. Without clearing it here, the new timer's
220+ // first tick would hit `guard !isPolling` and be skipped for a whole interval.
221+ isPolling = false
132 errorMessage = nil
222 errorMessage = nil
133 entries = []
223 entries = []
134− seenFingerprints = []
224+ seenFingerprints = [:]
225+ seenFingerprintOrder = []
226+ allKnownKeys = []
135 droppedCount = 0
227 droppedCount = 0
136 consecutiveErrors = 0
228 consecutiveErrors = 0
137 lastPollTime = nil
229 lastPollTime = nil
@@ -139,18 +231,33 @@ final class LiveTailViewModel {
139 columns = []
231 columns = []
140 columnOrder = []
232 columnOrder = []
141 hiddenColumns = []
233 hiddenColumns = []
234+ savedHiddenColumns = []
235+ savedColumnOrder = []
142 columnFilters = []
236 columnFilters = []
237+ // Clear the text filter too. Leaving it set while clearing the column-filter
238+ // chips is inconsistent and would let a stale search from a previous stream
239+ // make the fresh tail come up appearing empty or partially filtered.
240+ filterText = ""
143 currentStream = stream
241 currentStream = stream
144−
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.
145− timer = Timer.scheduledTimer(withTimeInterval: pollInterval, repeats: true) { [weak self] _ in
249+ let pollTimer = Timer(timeInterval: pollInterval, repeats: true) { [weak self] _ in
146 Task { @MainActor [weak self] in
250 Task { @MainActor [weak self] in
147 guard let self, !self.isPaused else { return }
251 guard let self, !self.isPaused else { return }
148 await self.poll(client: client, stream: stream)
252 await self.poll(client: client, stream: stream)
149 }
253 }
150 }
254 }
255+ RunLoop.main.add(pollTimer, forMode: .common)
256+ timer = pollTimer
151 }
257 }
152
258
153 func stop() {
259 func stop() {
260+ pollGeneration += 1
154 timer?.invalidate()
261 timer?.invalidate()
155 timer = nil
262 timer = nil
156 isRunning = false
263 isRunning = false
@@ -162,13 +269,38 @@ final class LiveTailViewModel {
162 }
269 }
163
270
164 func clear() {
271 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
165 entries = []
277 entries = []
166− seenFingerprints = []
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 = []
167 droppedCount = 0
286 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
168 columns = []
294 columns = []
169 columnOrder = []
295 columnOrder = []
170 hiddenColumns = []
296 hiddenColumns = []
297+ savedHiddenColumns = []
298+ savedColumnOrder = []
171 columnFilters = []
299 columnFilters = []
300+ // Match start(): clear the text filter alongside the column-filter chips so
301+ // a clear()ed tail isn't immediately re-hidden by a lingering search string.
302+ filterText = ""
303+ rebuildFilteredEntries()
172 }
304 }
173
305
174 // MARK: - Column Management
306 // MARK: - Column Management
@@ -180,10 +312,12 @@ final class LiveTailViewModel {
180 func toggleColumnVisibility(_ column: String) {
312 func toggleColumnVisibility(_ column: String) {
181 if hiddenColumns.contains(column) {
313 if hiddenColumns.contains(column) {
182 hiddenColumns.remove(column)
314 hiddenColumns.remove(column)
315+ savedHiddenColumns.remove(column)
183 } else {
316 } else {
184 let visibleCount = columnOrder.count - hiddenColumns.count
317 let visibleCount = columnOrder.count - hiddenColumns.count
185 if visibleCount > 1 {
318 if visibleCount > 1 {
186 hiddenColumns.insert(column)
319 hiddenColumns.insert(column)
320+ savedHiddenColumns.insert(column)
187 }
321 }
188 }
322 }
189 saveColumnConfig()
323 saveColumnConfig()
@@ -191,6 +325,7 @@ final class LiveTailViewModel {
191
325
192 func showAllColumns() {
326 func showAllColumns() {
193 hiddenColumns.removeAll()
327 hiddenColumns.removeAll()
328+ savedHiddenColumns.removeAll()
194 saveColumnConfig()
329 saveColumnConfig()
195 }
330 }
196
331
@@ -204,13 +339,21 @@ final class LiveTailViewModel {
204 let toIndex = columnOrder.firstIndex(of: targetColumn),
339 let toIndex = columnOrder.firstIndex(of: targetColumn),
205 fromIndex != toIndex else { return }
340 fromIndex != toIndex else { return }
206 let item = columnOrder.remove(at: fromIndex)
341 let item = columnOrder.remove(at: fromIndex)
342+ // Match the drop-indicator semantics: a rightward move (fromIndex < toIndex)
343+ // lands the column *after* the target, a leftward move lands it *before*.
344+ // After removing an earlier element the target shifts down by one, so
345+ // inserting at `toIndex` lands after the target for rightward moves; for
346+ // leftward moves the target is unshifted and `toIndex` lands before it.
347+ let adjusted = toIndex
207− columnOrder.insert(item, at: toIndex)
348+ columnOrder.insert(item, at: adjusted)
208 saveColumnConfig()
349 saveColumnConfig()
209 }
350 }
210
351
211 func resetColumnConfig() {
352 func resetColumnConfig() {
212 columnOrder = columns
353 columnOrder = columns
354+ savedColumnOrder = []
213 hiddenColumns.removeAll()
355 hiddenColumns.removeAll()
356+ savedHiddenColumns.removeAll()
214 saveColumnConfig()
357 saveColumnConfig()
215 }
358 }
216
359
@@ -222,7 +365,16 @@ final class LiveTailViewModel {
222
365
223 private func saveColumnConfig() {
366 private func saveColumnConfig() {
224 guard let stream = currentStream else { return }
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
225− let config = QueryViewModel.ColumnConfiguration(order: columnOrder, hidden: hiddenColumns)
377+ let config = QueryViewModel.ColumnConfiguration(order: order, hidden: savedHiddenColumns)
226 if let data = try? JSONEncoder().encode(config) {
378 if let data = try? JSONEncoder().encode(config) {
227 UserDefaults.standard.set(data, forKey: Self.columnConfigKey(for: stream))
379 UserDefaults.standard.set(data, forKey: Self.columnConfigKey(for: stream))
228 }
380 }
@@ -235,26 +387,21 @@ final class LiveTailViewModel {
235
387
236 // MARK: - Column Extraction
388 // MARK: - Column Extraction
237
389
238− private func extractColumns(from records: [LogRecord]) -> [String] {
390+ private func extractColumnsFromKeys(_ keys: Set<String>) -> [String] {
239 let priorityFields = ["p_timestamp", "p_tags", "p_metadata", "level", "severity", "message", "msg"]
391 let priorityFields = ["p_timestamp", "p_tags", "p_metadata", "level", "severity", "message", "msg"]
240− var allKeys = Set<String>()
392+ var remaining = keys
241− for record in records {
242− allKeys.formUnion(record.keys)
243− }
244−
245 var ordered: [String] = []
393 var ordered: [String] = []
246 for field in priorityFields {
394 for field in priorityFields {
247− if allKeys.remove(field) != nil {
395+ if remaining.remove(field) != nil {
248 ordered.append(field)
396 ordered.append(field)
249 }
397 }
250 }
398 }
251− ordered.append(contentsOf: allKeys.sorted())
399+ ordered.append(contentsOf: remaining.sorted())
252 return ordered
400 return ordered
253 }
401 }
254
402
255 private func updateColumns(stream: String) {
403 private func updateColumns(stream: String) {
256− let allRecords = entries.map { $0.record }
404+ let extracted = extractColumnsFromKeys(allKnownKeys)
257− let extracted = extractColumns(from: allRecords)
258
405
259 if columns.isEmpty {
406 if columns.isEmpty {
260 // First time — apply saved config
407 // First time — apply saved config
@@ -269,9 +416,13 @@ final class LiveTailViewModel {
269 merged.append(col)
416 merged.append(col)
270 }
417 }
271 columnOrder = merged
418 columnOrder = merged
419+ savedColumnOrder = config.order
420+ savedHiddenColumns = config.hidden
272 hiddenColumns = config.hidden.intersection(extractedSet)
421 hiddenColumns = config.hidden.intersection(extractedSet)
273 } else {
422 } else {
274 columnOrder = extracted
423 columnOrder = extracted
424+ savedColumnOrder = []
425+ savedHiddenColumns = []
275 hiddenColumns = []
426 hiddenColumns = []
276 }
427 }
277 } else {
428 } else {
@@ -283,12 +434,38 @@ final class LiveTailViewModel {
283 }
434 }
284 if !newCols.isEmpty {
435 if !newCols.isEmpty {
285 columns.append(contentsOf: newCols)
436 columns.append(contentsOf: newCols)
286− columnOrder.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) })
287 }
441 }
288 }
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+ }
289 }
453 }
290
454
291 private func poll(client: ParseableClient, stream: String) async {
455 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+
292 let now = Date()
469 let now = Date()
293 // The Parseable server truncates query time-ranges to minute
470 // The Parseable server truncates query time-ranges to minute
294 // boundaries, so a narrow window (< 60 s) can collapse to a
471 // boundaries, so a narrow window (< 60 s) can collapse to a
@@ -298,27 +475,59 @@ final class LiveTailViewModel {
298 let idealStart = lastTimestamp ?? now.addingTimeInterval(-30)
475 let idealStart = lastTimestamp ?? now.addingTimeInterval(-30)
299 let safeStart = now.addingTimeInterval(-90)
476 let safeStart = now.addingTimeInterval(-90)
300 let queryStart = min(idealStart, safeStart)
477 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.
301− let sql = "SELECT * FROM \(QueryViewModel.escapeSQLIdentifier(stream)) ORDER BY p_timestamp DESC LIMIT 200"
482+ let sql = "SELECT * FROM \(QueryViewModel.escapeSQLIdentifier(stream)) ORDER BY p_timestamp DESC LIMIT 1000"
302
483
303 do {
484 do {
304 let records = try await client.query(sql: sql, startTime: queryStart, endTime: now)
485 let records = try await client.query(sql: sql, startTime: queryStart, endTime: now)
305
486
306− var newEntries: [LiveTailEntry] = []
487+ // A stop()/clear()/start during the network round-trip supersedes this
307−
488+ // poll; don't write stale results to a torn-down or cleared tail. Also
308− for record in records {
489+ // bail if the user paused mid-flight.
309− let fp = Self.fingerprint(for: record)
490+ guard isRunning, !isPaused, generation == pollGeneration else { return }
491+
492+ // Process records off the main actor using a snapshot of seen fingerprints
310− guard !seenFingerprints.contains(fp) else { continue }
493+ let seenSnapshot = seenFingerprints
311− seenFingerprints.insert(fp)
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
312
517
313− let timestamp = parseTimestamp(from: record) ?? now
518+ // Re-check after the detached processing hop: a stop/clear could have
314− let summary = buildSummary(from: record)
519+ // landed while we were off the main actor.
520+ guard isRunning, !isPaused, generation == pollGeneration else { return }
315
521
522+ // Authoritative dedup on main actor
316− newEntries.append(LiveTailEntry(
523+ var newEntries: [LiveTailEntry] = []
317− timestamp: timestamp,
524+ for candidate in candidateEntries
318− record: record,
525+ where candidate.occurrence > (seenFingerprints[candidate.entry.fingerprint] ?? 0) {
319− displayTimestamp: Self.displayFormatter.string(from: timestamp),
526+ newEntries.append(candidate.entry)
320− summary: summary
527+ }
321− ))
528+ for (fp, count) in batchCounts where count > (seenFingerprints[fp] ?? 0) {
529+ if seenFingerprints[fp] == nil { seenFingerprintOrder.append(fp) }
530+ seenFingerprints[fp] = count
322 }
531 }
323
532
324 if !newEntries.isEmpty {
533 if !newEntries.isEmpty {
@@ -327,14 +536,57 @@ final class LiveTailViewModel {
327 if entries.count > maxEntries {
536 if entries.count > maxEntries {
328 let excess = entries.count - maxEntries
537 let excess = entries.count - maxEntries
329 droppedCount += excess
538 droppedCount += excess
330− entries.removeFirst(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+ }
331
553
332− // Rebuild fingerprint set from remaining entries to bound memory
554+ // Bound the seen-set on every poll, not only when the buffer
333− // and ensure dropped entries can be re-detected if they reappear.
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
334− seenFingerprints = Set(entries.map { Self.fingerprint(for: $0.record) })
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+ }
335 }
574 }
336
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 {
337− updateColumns(stream: stream)
586+ updateColumns(stream: stream)
587+ }
588+
589+ rebuildFilteredEntries()
338 }
590 }
339
591
340 lastTimestamp = now
592 lastTimestamp = now
@@ -342,6 +594,10 @@ final class LiveTailViewModel {
342 errorMessage = nil
594 errorMessage = nil
343 consecutiveErrors = 0
595 consecutiveErrors = 0
344 } catch {
596 } 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 }
345 consecutiveErrors += 1
601 consecutiveErrors += 1
346 if consecutiveErrors >= Self.maxConsecutiveErrors {
602 if consecutiveErrors >= Self.maxConsecutiveErrors {
347 errorMessage = "\(ParseableError.userFriendlyMessage(for: error)) — stopped after \(consecutiveErrors) consecutive failures"
603 errorMessage = "\(ParseableError.userFriendlyMessage(for: error)) — stopped after \(consecutiveErrors) consecutive failures"
@@ -352,43 +608,140 @@ final class LiveTailViewModel {
352 }
608 }
353 }
609 }
354
610
355− /// Deterministic content-based fingerprint using sorted key-value pairs and FNV-1a.
611+ /// Deterministic content-based fingerprint using XOR with golden-ratio mixing.
612+ /// Order-independent: O(k) instead of O(k log k) per record.
356− static func fingerprint(for record: LogRecord) -> String {
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 {
357− var h0: UInt64 = 14695981039346656037
617+ var kh0: UInt64 = 14695981039346656037
358− var h1: UInt64 = 14695981039346656037 &* 31
618+ var kh1: UInt64 = 14695981039346656037 &* 31
359− for key in record.keys.sorted() {
360 for byte in key.utf8 {
619 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 {
361 h0 = (h0 ^ UInt64(byte)) &* 1099511628211
689 h0 = (h0 ^ UInt64(byte)) &* 1099511628211
362 h1 = (h1 ^ UInt64(byte)) &* 6700417
690 h1 = (h1 ^ UInt64(byte)) &* 6700417
363 }
691 }
692+ case .array(let arr):
364− h0 = (h0 ^ 0) &* 1099511628211 // separator
693+ h0 = (h0 ^ 0x05) &* 1099511628211
365− if let val = record[key] {
694+ h1 = (h1 ^ 0x05) &* 6700417
366− // Use exportString so nested objects/arrays contribute their
695+ for element in arr {
367− // actual content to the hash, not just their field count.
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
368− for byte in val.exportString.utf8 {
708+ for byte in key.utf8 {
369− h0 = (h0 ^ UInt64(byte)) &* 1099511628211
709+ kh0 = (kh0 ^ UInt64(byte)) &* 1099511628211
370− h1 = (h1 ^ UInt64(byte)) &* 6700417
710+ kh1 = (kh1 ^ UInt64(byte)) &* 6700417
371 }
711 }
712+ kh0 = (kh0 ^ 0xFD) &* 1099511628211 // key-value separator
713+ hashJSONValue(value, h0: &kh0, h1: &kh1)
714+ xor0 ^= kh0 &* 0x9E3779B97F4A7C15
715+ xor1 ^= kh1 &* 0x9E3779B97F4A7C15
372 }
716 }
373− h1 = (h1 ^ 0xFF) &* 6700417 // field separator
717+ h0 = h0 &+ xor0
718+ h1 = h1 &+ xor1
374 }
719 }
375− return String(h0, radix: 36) + String(h1, radix: 36)
376 }
720 }
377
721
378− func parseTimestamp(from record: LogRecord) -> Date? {
722+ nonisolated static func parseTimestamp(from record: LogRecord) -> Date? {
379 guard let value = record["p_timestamp"] ?? record["timestamp"] ?? record["time"] ?? record["@timestamp"] else {
723 guard let value = record["p_timestamp"] ?? record["timestamp"] ?? record["time"] ?? record["@timestamp"] else {
380 return nil
724 return nil
381 }
725 }
382 if case .string(let str) = value {
726 if case .string(let str) = value {
383− if let date = Self.isoFractional.date(from: str) {
727+ if let date = isoFractional.date(from: str) ?? isoBasic.date(from: str) {
384 return date
728 return date
385 }
729 }
730+ // Parseable stores `p_timestamp` as an Arrow timestamp without a
731+ // timezone, and the query API serializes it as a naive UTC string with
732+ // no offset (e.g. "2024-01-11T09:08:34.290"). `.withInternetDateTime`
733+ // requires a zone designator, so such values never parsed: every entry
734+ // fell back to the poll time, which also scrambled the tail's order
735+ // (each poll's DESC-ordered batch was appended newest-first). Retry as
736+ // UTC; a string that already carries an offset won't parse with an
737+ // extra "Z", so this can't reinterpret zoned timestamps.
738+ let utc = str + "Z"
386− return Self.isoBasic.date(from: str)
739+ return isoFractional.date(from: utc) ?? isoBasic.date(from: utc)
387 }
740 }
388 return nil
741 return nil
389 }
742 }
390
743
391− func buildSummary(from record: LogRecord) -> String {
744+ nonisolated static func buildSummary(from record: LogRecord) -> String {
392 var parts: [String] = []
745 var parts: [String] = []
393
746
394 if let level = record["level"] ?? record["severity"] ?? record["log_level"] {
747 if let level = record["level"] ?? record["severity"] ?? record["log_level"] {
@@ -400,14 +753,16 @@ final class LiveTailViewModel {
400 }
753 }
401
754
402 if parts.isEmpty {
755 if parts.isEmpty {
756+ // Filter to scalars BEFORE taking the prefix — otherwise three leading
757+ // non-scalar fields (alphabetically) would yield an empty summary even
758+ // when scalar fields exist later in the record.
403 let scalarFields = record
759 let scalarFields = record
404 .filter { $0.key != "p_timestamp" && $0.key != "p_tags" && $0.key != "p_metadata" }
760 .filter { $0.key != "p_timestamp" && $0.key != "p_tags" && $0.key != "p_metadata" }
761+ .filter { $0.value.isScalar }
405 .sorted { $0.key < $1.key }
762 .sorted { $0.key < $1.key }
406 .prefix(3)
763 .prefix(3)
407 for (key, value) in scalarFields {
764 for (key, value) in scalarFields {
408− if value.isScalar {
409− parts.append("\(key)=\(value.displayString)")
765+ parts.append("\(key)=\(value.displayString)")
410− }
411 }
766 }
412 }
767 }
413
768
ParseableViewer/ViewModels/QueryViewModel.swift+555−53
@@ -2,33 +2,120 @@ import Foundation
2import SwiftUI
2import SwiftUI
3
3
4@Observable
4@Observable
5+@MainActor
5final class QueryViewModel {
6final class QueryViewModel {
6− var sqlQuery = ""
7+ var sqlQuery = "" {
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+ }
7 var results: [LogRecord] = [] {
15 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
8− didSet { updateFilteredResults() }
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+ }
9 }
53 }
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
10 var columns: [String] = []
59 var columns: [String] = []
11 var columnOrder: [String] = []
60 var columnOrder: [String] = []
12 var hiddenColumns: Set<String> = []
61 var hiddenColumns: Set<String> = []
62+ /// The full persisted set of hidden columns for the current stream, including
63+ /// ones absent from the current result. Hiding a column drops it from the
64+ /// SELECT list, so it's missing from the very next result and `hiddenColumns`
65+ /// (only the columns present) forgets it — the next save would then erase the
66+ /// preference. This keeps the intent, mirroring LiveTailViewModel.
67+ private(set) var savedHiddenColumns: Set<String> = []
68+ /// The persisted column order including columns absent from the current
69+ /// result (the order counterpart of `savedHiddenColumns`), so saving doesn't
70+ /// drop a hidden column's position and it returns to its place when shown.
71+ private var savedColumnOrder: [String] = []
13 var autoHiddenColumns: Set<String> = []
72 var autoHiddenColumns: Set<String> = []
14 /// Column config from a saved query, applied on next query execution.
73 /// Column config from a saved query, applied on next query execution.
15 var pendingColumnConfig: ColumnConfiguration?
74 var pendingColumnConfig: ColumnConfiguration?
75+
76+ /// The connection and stream the editor text and results belong to. The view
77+ /// model outlives QueryView (switching tabs keeps the query), so the view
78+ /// compares this on appear to catch a stream/server change made while another
79+ /// tab was showing.
80+ struct BoundContext: Equatable {
81+ let connectionID: UUID?
82+ let stream: String
83+ }
84+ var boundContext: BoundContext?
16 private var currentStream: String?
85 private var currentStream: String?
17 var isLoading = false
86 var isLoading = false
18 var errorMessage: String?
87 var errorMessage: String?
88+ var errorRange: NSRange?
19 var resultCount = 0
89 var resultCount = 0
20 var queryDuration: TimeInterval?
90 var queryDuration: TimeInterval?
21 var selectedLogEntry: LogRecord?
91 var selectedLogEntry: LogRecord?
22 var filterText = "" {
92 var filterText = "" {
23− didSet { updateFilteredResults() }
93+ didSet { debouncedUpdateFilter() }
24 }
94 }
95+ private var filterTask: Task<Void, Never>?
25 var resultsTruncated = false
96 var resultsTruncated = false
26
97
27 // Schema fields for autocomplete
98 // Schema fields for autocomplete
28 var schemaFields: [SchemaField] = []
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?
29
108
30 // Query task for cancellation
109 // Query task for cancellation
31 private var queryTask: Task<Void, Never>?
110 private var queryTask: Task<Void, Never>?
111+ /// Bumped at the start of every `executeQuery`. The task captures this value
112+ /// and only clears `isLoading` on exit if it's still the active query — so a
113+ /// superseded query's late cleanup doesn't drop the loading indicator that
114+ /// the newer query set. Without this, `isLoading = false` had to live in
115+ /// every caller of `cancel()` (cancelQuery, clearResults, superseding
116+ /// executeQuery) and a future cancel path that forgot to set it would wedge
117+ /// the spinner indefinitely.
118+ private var queryGeneration = 0
32
119
33 // Time range
120 // Time range
34 var timeRangeOption: TimeRangeOption = .last1Hour
121 var timeRangeOption: TimeRangeOption = .last1Hour
@@ -37,7 +124,10 @@ final class QueryViewModel {
37
124
38 // Query history
125 // Query history
39 var queryHistory: [QueryHistoryEntry] = []
126 var queryHistory: [QueryHistoryEntry] = []
127+ // Derived from the (always-trimmed) history so it stays correct after a
128+ // restored full history is loaded at launch, not just after an in-session
129+ // overflow. `addToHistory` trims to `maxHistory`, so count never exceeds it.
40− var historyIsFull = false
130+ var historyIsFull: Bool { queryHistory.count >= Self.maxHistory }
41 private static let maxHistory = 50
131 private static let maxHistory = 50
42
132
43 struct QueryHistoryEntry: Identifiable, Codable, Sendable {
133 struct QueryHistoryEntry: Identifiable, Codable, Sendable {
@@ -122,9 +212,16 @@ final class QueryViewModel {
122 return columnOrder.filter { !allHidden.contains($0) }
212 return columnOrder.filter { !allHidden.contains($0) }
123 }
213 }
124
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+
125 func toggleColumnVisibility(_ column: String) {
221 func toggleColumnVisibility(_ column: String) {
126 if hiddenColumns.contains(column) {
222 if hiddenColumns.contains(column) {
127 hiddenColumns.remove(column)
223 hiddenColumns.remove(column)
224+ savedHiddenColumns.remove(column)
128 } else if autoHiddenColumns.contains(column) {
225 } else if autoHiddenColumns.contains(column) {
129 autoHiddenColumns.remove(column)
226 autoHiddenColumns.remove(column)
130 } else {
227 } else {
@@ -133,20 +230,25 @@ final class QueryViewModel {
133 let visibleCount = columnOrder.count - allHidden.count
230 let visibleCount = columnOrder.count - allHidden.count
134 if visibleCount > 1 {
231 if visibleCount > 1 {
135 hiddenColumns.insert(column)
232 hiddenColumns.insert(column)
233+ savedHiddenColumns.insert(column)
136 }
234 }
137 }
235 }
138 saveColumnConfig()
236 saveColumnConfig()
237+ updateSQLColumns()
139 }
238 }
140
239
141 func showAllColumns() {
240 func showAllColumns() {
142 hiddenColumns.removeAll()
241 hiddenColumns.removeAll()
242+ savedHiddenColumns.removeAll()
143 autoHiddenColumns.removeAll()
243 autoHiddenColumns.removeAll()
144 saveColumnConfig()
244 saveColumnConfig()
245+ updateSQLColumns()
145 }
246 }
146
247
147 func moveColumn(from source: IndexSet, to destination: Int) {
248 func moveColumn(from source: IndexSet, to destination: Int) {
148 columnOrder.move(fromOffsets: source, toOffset: destination)
249 columnOrder.move(fromOffsets: source, toOffset: destination)
149 saveColumnConfig()
250 saveColumnConfig()
251+ updateSQLColumns()
150 }
252 }
151
253
152 func moveColumn(_ column: String, to targetColumn: String) {
254 func moveColumn(_ column: String, to targetColumn: String) {
@@ -154,15 +256,127 @@ final class QueryViewModel {
154 let toIndex = columnOrder.firstIndex(of: targetColumn),
256 let toIndex = columnOrder.firstIndex(of: targetColumn),
155 fromIndex != toIndex else { return }
257 fromIndex != toIndex else { return }
156 let item = columnOrder.remove(at: fromIndex)
258 let item = columnOrder.remove(at: fromIndex)
259+ // Match the drop-indicator semantics: a rightward move (fromIndex < toIndex)
260+ // lands the column *after* the target, a leftward move lands it *before*.
261+ // After removing an earlier element the target shifts down by one, so
262+ // inserting at `toIndex` lands after the target for rightward moves; for
263+ // leftward moves the target is unshifted and `toIndex` lands before it.
264+ let adjusted = toIndex
157− columnOrder.insert(item, at: toIndex)
265+ columnOrder.insert(item, at: adjusted)
158 saveColumnConfig()
266 saveColumnConfig()
267+ updateSQLColumns()
159 }
268 }
160
269
161 func resetColumnConfig() {
270 func resetColumnConfig() {
162 columnOrder = columns
271 columnOrder = columns
272+ savedColumnOrder = []
163 hiddenColumns.removeAll()
273 hiddenColumns.removeAll()
274+ savedHiddenColumns.removeAll()
164 autoHiddenColumns.removeAll()
275 autoHiddenColumns.removeAll()
165 saveColumnConfig()
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
166 }
380 }
167
381
168 // MARK: - Column Configuration Persistence
382 // MARK: - Column Configuration Persistence
@@ -178,7 +392,16 @@ final class QueryViewModel {
178
392
179 private func saveColumnConfig() {
393 private func saveColumnConfig() {
180 guard let stream = currentStream else { return }
394 guard let stream = currentStream else { return }
395+ // Persist the full intended hidden set and order, not just the currently
396+ // present columns (see `savedHiddenColumns` / `savedColumnOrder`).
397+ let present = Set(columnOrder)
398+ let order = Self.mergedColumnOrder(
399+ columnOrder,
400+ inserting: savedColumnOrder.filter { !present.contains($0) },
401+ reference: savedColumnOrder
402+ )
403+ savedColumnOrder = order
181− let config = ColumnConfiguration(order: columnOrder, hidden: hiddenColumns)
404+ let config = ColumnConfiguration(order: order, hidden: savedHiddenColumns.union(hiddenColumns))
182 if let data = try? JSONEncoder().encode(config) {
405 if let data = try? JSONEncoder().encode(config) {
183 UserDefaults.standard.set(data, forKey: Self.columnConfigKey(for: stream))
406 UserDefaults.standard.set(data, forKey: Self.columnConfigKey(for: stream))
184 }
407 }
@@ -207,7 +430,9 @@ final class QueryViewModel {
207
430
208 guard let config else {
431 guard let config else {
209 columnOrder = extractedColumns
432 columnOrder = extractedColumns
433+ savedColumnOrder = []
210 hiddenColumns = []
434 hiddenColumns = []
435+ savedHiddenColumns = []
211 return
436 return
212 }
437 }
213
438
@@ -220,18 +445,57 @@ final class QueryViewModel {
220 merged.append(col)
445 merged.append(col)
221 }
446 }
222 columnOrder = merged
447 columnOrder = merged
448+ savedColumnOrder = config.order
449+ savedHiddenColumns = config.hidden
223 hiddenColumns = config.hidden.intersection(extractedSet)
450 hiddenColumns = config.hidden.intersection(extractedSet)
451+
452+ // The `visibleCount > 1` guard in toggleColumnVisibility only covers
453+ // user toggles. A saved config can hide every currently-extracted column
454+ // (e.g. the stream's schema shrank so only previously-hidden columns
455+ // remain), which would render the results table with zero columns and
456+ // leave `updateSQLColumns` early-returning on the empty case. Keep at
457+ // least the first column visible. Mirrors LiveTailViewModel.updateColumns.
458+ if !columnOrder.isEmpty, hiddenColumns.count >= columnOrder.count,
459+ let firstColumn = columnOrder.first {
460+ hiddenColumns.remove(firstColumn)
461+ }
224 }
462 }
225
463
226 private(set) var filteredResults: [LogRecord] = []
464 private(set) var filteredResults: [LogRecord] = []
227
465
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+
228 private func updateFilteredResults() {
479 private func updateFilteredResults() {
229 if filterText.isEmpty {
480 if filterText.isEmpty {
230 filteredResults = results
481 filteredResults = results
231 } else {
482 } 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 {
232− filteredResults = results.filter { record in
494+ filteredResults = results.filter { record in
233− record.values.contains { value in
495+ record.values
496+ .map { $0.displayString }
497+ .joined(separator: " ")
234− value.displayString.localizedCaseInsensitiveContains(filterText)
498+ .localizedCaseInsensitiveContains(text)
235 }
499 }
236 }
500 }
237 }
501 }
@@ -276,25 +540,59 @@ final class QueryViewModel {
276
540
277 // Cancel any in-flight query
541 // Cancel any in-flight query
278 queryTask?.cancel()
542 queryTask?.cancel()
543+ // Bump the generation so the in-flight task's cleanup can detect that
544+ // it has been superseded and skip its `isLoading = false` (which would
545+ // otherwise race with the `isLoading = true` below and briefly drop
546+ // the loading indicator during this query).
547+ queryGeneration &+= 1
548+ let generation = queryGeneration
549+ latestQueryStream = stream
279
550
280 isLoading = true
551 isLoading = true
281 errorMessage = nil
552 errorMessage = nil
553+ errorRange = nil
282 resultsTruncated = false
554 resultsTruncated = false
283 let startTime = CFAbsoluteTimeGetCurrent()
555 let startTime = CFAbsoluteTimeGetCurrent()
284
556
285 let task = Task {
557 let task = Task {
558+ // Always clear the spinner on exit when this is still the active
559+ // query. The defer runs on every path (success, error, and the
560+ // cancellation cases that previously returned before reaching the
561+ // trailing `isLoading = false`), so the spinner can never wedge
562+ // even if a new cancel path is added without resetting it.
563+ defer {
564+ if generation == queryGeneration {
565+ isLoading = false
566+ }
567+ }
286 do {
568 do {
287 try Task.checkCancellation()
569 try Task.checkCancellation()
288 let queryResults = try await client.query(sql: sql, startTime: startDate, endTime: endDate)
570 let queryResults = try await client.query(sql: sql, startTime: startDate, endTime: endDate)
289 try Task.checkCancellation()
571 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 }
290
580
291 results = queryResults
581 results = queryResults
582+ // Drop any prior row selection: the new result set reassigns row
583+ // identities, so a retained selectedLogEntry would either dangle or
584+ // value-match an unrelated row, leaving a stale detail inspector.
585+ selectedLogEntry = nil
292 queryDuration = CFAbsoluteTimeGetCurrent() - startTime
586 queryDuration = CFAbsoluteTimeGetCurrent() - startTime
293 resultCount = results.count
587 resultCount = results.count
294
588
295− // Detect if results were likely truncated (only meaningful
589+ // Detect if results were likely truncated. We know the cap for
296− // for auto-generated queries where we control the LIMIT)
590+ // auto-generated queries; for user-entered SQL (which includes the
591+ // default query `setDefaultQuery` writes into the editor — the common
592+ // stream-selection path), parse a trailing `LIMIT n` so the indicator
593+ // still fires when the row count lands exactly on the limit.
594+ let effectiveLimit = usedAutoLimit ? limit : Self.trailingLimit(in: sql)
297− resultsTruncated = usedAutoLimit && results.count == limit
595+ resultsTruncated = effectiveLimit.map { results.count == $0 } ?? false
298
596
299 // Extract columns in a single pass, with priority fields first,
597 // Extract columns in a single pass, with priority fields first,
300 // then apply any saved column configuration for this stream
598 // then apply any saved column configuration for this stream
@@ -317,16 +615,40 @@ final class QueryViewModel {
317 let entry = QueryHistoryEntry(sql: sql, resultCount: resultCount, duration: queryDuration ?? 0)
615 let entry = QueryHistoryEntry(sql: sql, resultCount: resultCount, duration: queryDuration ?? 0)
318 addToHistory(entry)
616 addToHistory(entry)
319 } catch is CancellationError {
617 } catch is CancellationError {
320− errorMessage = "Query cancelled"
618+ // Silently ignore — cancelQuery() or a new executeQuery() handles state
321− results = []
619+ return
322− columns = []
323 } catch {
620 } 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 }
324 errorMessage = ParseableError.userFriendlyMessage(for: error)
625 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+ }
325 results = []
641 results = []
326 columns = []
642 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 = []
327 }
651 }
328−
329− isLoading = false
330 }
652 }
331 queryTask = task
653 queryTask = task
332 await task.value
654 await task.value
@@ -338,18 +660,25 @@ final class QueryViewModel {
338 queryTask = nil
660 queryTask = nil
339 isLoading = false
661 isLoading = false
340 errorMessage = "Query cancelled"
662 errorMessage = "Query cancelled"
663+ errorRange = nil
341 }
664 }
342
665
343 @MainActor
666 @MainActor
344 func loadSchema(client: ParseableClient?, stream: String?) async {
667 func loadSchema(client: ParseableClient?, stream: String?) async {
668+ latestSchemaStream = stream
345 guard let client, let stream else {
669 guard let client, let stream else {
346 schemaFields = []
670 schemaFields = []
347 return
671 return
348 }
672 }
349 do {
673 do {
350 let schema = try await client.getStreamSchema(stream: stream)
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 }
351 schemaFields = schema.fields
679 schemaFields = schema.fields
352 } catch {
680 } catch {
681+ guard latestSchemaStream == stream else { return }
353 // Schema load is best-effort; don't surface errors
682 // Schema load is best-effort; don't surface errors
354 schemaFields = []
683 schemaFields = []
355 }
684 }
@@ -379,7 +708,9 @@ final class QueryViewModel {
379 var empty = Set(columns)
708 var empty = Set(columns)
380 for record in records {
709 for record in records {
381 if empty.isEmpty { break }
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.
382− for column in empty {
713+ for column in Array(empty) {
383 if let value = record[column], value != .null, value != .string("") {
714 if let value = record[column], value != .null, value != .string("") {
384 empty.remove(column)
715 empty.remove(column)
385 }
716 }
@@ -388,39 +719,66 @@ final class QueryViewModel {
388 return empty
719 return empty
389 }
720 }
390
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.
391 func exportAsJSON() -> String {
725 func exportAsJSON() -> String {
726+ let rows = filteredResults
392− guard !results.isEmpty else { return "[]" }
727+ guard !rows.isEmpty else { return "[]" }
393 let encoder = JSONEncoder()
728 let encoder = JSONEncoder()
394 encoder.outputFormatting = [.prettyPrinted, .sortedKeys]
729 encoder.outputFormatting = [.prettyPrinted, .sortedKeys]
395− if let data = try? encoder.encode(results),
730+ if let data = try? encoder.encode(Self.projectRecords(rows, to: visibleColumns)),
396 let string = String(data: data, encoding: .utf8) {
731 let string = String(data: data, encoding: .utf8) {
397 return string
732 return string
398 }
733 }
399 return "[]"
734 return "[]"
400 }
735 }
401
736
737+ /// Exports the on-screen rows as CSV. Uses `filteredResults` for the same
738+ /// reason as `exportAsJSON`.
402 func exportAsCSV() -> String {
739 func exportAsCSV() -> String {
403− Self.buildCSV(records: results, columns: visibleColumns)
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) } }
404 }
752 }
405
753
406 /// Builds a CSV string from records and columns. Safe to call from any thread.
754 /// Builds a CSV string from records and columns. Safe to call from any thread.
407− static func buildCSV(records: [LogRecord], columns: [String]) -> String {
755+ nonisolated static func buildCSV(records: [LogRecord], columns: [String]) -> String {
408 guard !records.isEmpty, !columns.isEmpty else { return "" }
756 guard !records.isEmpty, !columns.isEmpty else { return "" }
409
757
410− var csv = columns.map { escapeCSV($0) }.joined(separator: ",") + "\n"
758+ var csv = String()
759+ csv.reserveCapacity(columns.count * 50 * (records.count + 1))
411
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")
767+
768+ // Rows
412 for record in records {
769 for record in records {
413− let row = columns.map { column in
770+ for (i, column) in columns.enumerated() {
771+ if i > 0 { csv.append(",") }
414 let value = record[column]?.exportString ?? ""
772 let value = record[column]?.exportString ?? ""
415− return escapeCSV(value)
773+ csv.append(escapeCSV(value))
416 }
774 }
417− csv += row.joined(separator: ",") + "\n"
775+ csv.append("\n")
418 }
776 }
419
777
420 return csv
778 return csv
421 }
779 }
422
780
423− private static func escapeCSV(_ value: String) -> String {
781+ nonisolated private static func escapeCSV(_ value: String) -> String {
424 // Check at the Unicode scalar level to correctly detect \r and \n
782 // Check at the Unicode scalar level to correctly detect \r and \n
425 // inside \r\n (CRLF) grapheme clusters, which Swift's String.contains
783 // inside \r\n (CRLF) grapheme clusters, which Swift's String.contains
426 // treats as a single Character that doesn't match "\r" or "\n" alone.
784 // treats as a single Character that doesn't match "\r" or "\n" alone.
@@ -434,18 +792,43 @@ final class QueryViewModel {
434 }
792 }
435
793
436 func clearResults() {
794 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+
437 results = []
812 results = []
438 columns = []
813 columns = []
439 columnOrder = []
814 columnOrder = []
440 hiddenColumns = []
815 hiddenColumns = []
816+ savedHiddenColumns = []
817+ savedColumnOrder = []
441 autoHiddenColumns = []
818 autoHiddenColumns = []
442 currentStream = nil
819 currentStream = nil
443 resultCount = 0
820 resultCount = 0
444 queryDuration = nil
821 queryDuration = nil
445 selectedLogEntry = nil
822 selectedLogEntry = nil
446 errorMessage = nil
823 errorMessage = nil
824+ errorRange = nil
447 resultsTruncated = false
825 resultsTruncated = false
448 schemaFields = []
826 schemaFields = []
827+ // Keep the stream trackers consistent with the cleared schema/results so
828+ // a later reload guard never sees a non-nil stream paired with empty data.
829+ latestSchemaStream = nil
830+ latestQueryStream = nil
831+ boundContext = nil
449 }
832 }
450
833
451 /// Modifies `sqlQuery` to add a column-value filter condition.
834 /// Modifies `sqlQuery` to add a column-value filter condition.
@@ -455,48 +838,151 @@ final class QueryViewModel {
455 let condition: String
838 let condition: String
456 if let value, value != .null {
839 if let value, value != .null {
457 let literal = value.sqlLiteral
840 let literal = value.sqlLiteral
841+ // Exclude with `IS DISTINCT FROM`, not `<>`: under SQL's three-valued
842+ // logic `NULL <> 'x'` is NULL, so `<>` also dropped every row where the
843+ // column is missing/NULL — common for sparse fields — which "exclude
844+ // this value" doesn't ask for (the live-tail exclude keeps them too).
845+ condition = exclude
846+ ? "\(escapedCol) IS DISTINCT FROM \(literal)"
458− condition = exclude ? "\(escapedCol) <> \(literal)" : "\(escapedCol) = \(literal)"
847+ : "\(escapedCol) = \(literal)"
459 } else {
848 } else {
460 condition = exclude ? "\(escapedCol) IS NOT NULL" : "\(escapedCol) IS NULL"
849 condition = exclude ? "\(escapedCol) IS NOT NULL" : "\(escapedCol) IS NULL"
461 }
850 }
462
851
463− var sql = sqlQuery.trimmingCharacters(in: .whitespacesAndNewlines)
852+ let sql = sqlQuery.trimmingCharacters(in: .whitespacesAndNewlines)
464 if sql.isEmpty { return }
853 if sql.isEmpty { return }
465
854
466− let wherePattern = #"(?i)\bWHERE\b"#
855+ // Locate the top-level WHERE and the first trailing clause using the
467− let tailPattern = #"(?i)\b(ORDER\s+BY|GROUP\s+BY|HAVING|LIMIT)\b"#
856+ // tokenizer rather than a raw regex, so keywords inside quoted
468−
857+ // identifiers, string literals, comments, or subqueries are ignored.
469− if let whereRange = sql.range(of: wherePattern, options: .regularExpression) {
858+ let tokens = SQLTokenizer.tokenize(sql)
470− let afterWhere = whereRange.upperBound
859+ var depth = 0
471− let searchRange = afterWhere..<sql.endIndex
860+ // WHERE and the trailing clauses only follow the top-level FROM. Keywords
472− if let tailRange = sql.range(of: tailPattern, options: .regularExpression, range: searchRange) {
861+ // before it belong to the SELECT list — e.g. a field named `offset` or
473− sql.insert(contentsOf: "AND \(condition) ", at: tailRange.lowerBound)
862+ // `limit` (which the tokenizer reports as keywords) — and must not be
474− } else {
863+ // mistaken for clause starts.
475− sql += " AND \(condition)"
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
476 }
897 }
477− } else {
898+ }
478− if let tailRange = sql.range(of: tailPattern, options: .regularExpression) {
899+ var lastSignificant: Int?
479− sql.insert(contentsOf: "WHERE \(condition) ", at: tailRange.lowerBound)
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...]))"
480 } else {
959 } else {
481− sql += " WHERE \(condition)"
960+ // An empty WHERE body (e.g. a half-written `... WHERE` with no
961+ // condition yet, or `WHERE` immediately followed by ORDER/GROUP/
962+ // LIMIT) would otherwise produce `WHERE AND <condition>` — a
963+ // dangling AND with no left operand. Drop the AND in that case.
964+ let whereEnd = tokens[whereIndex].range.upperBound
965+ sqlQuery = "\(sql[..<whereEnd]) \(condition)\(separated(sql[whereEnd...]))"
482 }
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...]))"
483 }
973 }
484−
485− sqlQuery = sql
486 }
974 }
487
975
488 /// Sets the default query for the given stream, returning `true` if the
976 /// Sets the default query for the given stream, returning `true` if the
489 /// query text was replaced (i.e. the user hadn't customized it).
977 /// query text was replaced (i.e. the user hadn't customized it).
490 @discardableResult
978 @discardableResult
491 func setDefaultQuery(stream: String, previousStream: String? = nil) -> Bool {
979 func setDefaultQuery(stream: String, previousStream: String? = nil) -> Bool {
492− // Replace the query if it's empty or still matches the auto-generated
980+ // Always replace when switching streams — the previous query (whether
493− // default for the previous stream. If the user edited the SQL, keep it.
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).
494 let shouldReplace: Bool
983 let shouldReplace: Bool
495− if sqlQuery.isEmpty {
984+ if sqlQuery.isEmpty || previousStream != nil {
496 shouldReplace = true
985 shouldReplace = true
497− } else if let prev = previousStream {
498− let prevPrefix = "SELECT * FROM \(Self.escapeSQLIdentifier(prev))"
499− shouldReplace = sqlQuery.hasPrefix(prevPrefix)
500 } else {
986 } else {
501 shouldReplace = false
987 shouldReplace = false
502 }
988 }
@@ -508,19 +994,35 @@ final class QueryViewModel {
508 }
994 }
509
995
510 /// Escapes a SQL identifier by doubling internal double-quotes, then wrapping in double-quotes.
996 /// Escapes a SQL identifier by doubling internal double-quotes, then wrapping in double-quotes.
511− static func escapeSQLIdentifier(_ identifier: String) -> String {
997+ nonisolated static func escapeSQLIdentifier(_ identifier: String) -> String {
512 "\"\(identifier.replacingOccurrences(of: "\"", with: "\"\""))\""
998 "\"\(identifier.replacingOccurrences(of: "\"", with: "\"\""))\""
513 }
999 }
514
1000
1001+ /// Extracts the row cap from a trailing `LIMIT n` clause (tolerating a trailing
1002+ /// semicolon/whitespace), used to detect when a result set was truncated.
1003+ /// Returns nil when there's no recognizable trailing `LIMIT` — e.g. when the
1004+ /// query uses `LIMIT n OFFSET m` — so we never report a false truncation.
1005+ nonisolated static func trailingLimit(in sql: String) -> Int? {
1006+ let pattern = #"(?i)\bLIMIT\s+(\d+)\s*;?\s*$"#
1007+ guard let regex = try? NSRegularExpression(pattern: pattern) else { return nil }
1008+ let range = NSRange(sql.startIndex..<sql.endIndex, in: sql)
1009+ guard let match = regex.firstMatch(in: sql, range: range),
1010+ let numberRange = Range(match.range(at: 1), in: sql) else { return nil }
1011+ return Int(sql[numberRange])
1012+ }
1013+
515 // MARK: - Query History
1014 // MARK: - Query History
516
1015
517 private func addToHistory(_ entry: QueryHistoryEntry) {
1016 private func addToHistory(_ entry: QueryHistoryEntry) {
1017+ // Start from what's persisted, not this instance's copy: a model created
1018+ // earlier (e.g. one whose query finished after a reconnect replaced it)
1019+ // would otherwise overwrite newer entries saved by the current one.
1020+ queryHistory = Self.loadHistory()
518 // Deduplicate consecutive identical queries
1021 // Deduplicate consecutive identical queries
519 if queryHistory.first?.sql == entry.sql { return }
1022 if queryHistory.first?.sql == entry.sql { return }
520 queryHistory.insert(entry, at: 0)
1023 queryHistory.insert(entry, at: 0)
521 if queryHistory.count > Self.maxHistory {
1024 if queryHistory.count > Self.maxHistory {
522 queryHistory = Array(queryHistory.prefix(Self.maxHistory))
1025 queryHistory = Array(queryHistory.prefix(Self.maxHistory))
523− historyIsFull = true
524 }
1026 }
525 Self.saveHistory(queryHistory)
1027 Self.saveHistory(queryHistory)
526 }
1028 }
ParseableViewer/Views/AboutView.swiftnew file+36
@@ -0,0 +1,36 @@
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+32−4
@@ -5,6 +5,10 @@ struct AlertsView: View {
5 @State private var alertConfig: AlertConfig?
5 @State private var alertConfig: AlertConfig?
6 @State private var isLoading = false
6 @State private var isLoading = false
7 @State private var errorMessage: String?
7 @State private var errorMessage: String?
8+ /// Bumped by every load. SwiftUI cancels the previous `.task(id:)` load on a
9+ /// stream switch, but that load still resumes (with a cancellation error) —
10+ /// and must not touch `isLoading`, which now belongs to the newer load.
11+ @State private var loadGeneration = 0
8
12
9 var body: some View {
13 var body: some View {
10 Group {
14 Group {
@@ -63,7 +67,7 @@ struct AlertsView: View {
63 .frame(maxWidth: .infinity, maxHeight: .infinity)
67 .frame(maxWidth: .infinity, maxHeight: .infinity)
64 }
68 }
65 }
69 }
66− .task(id: stream) {
70+ .task(id: "\(appState.activeConnection?.id.uuidString ?? "")|\(stream)") {
67 await loadAlerts(stream: stream)
71 await loadAlerts(stream: stream)
68 }
72 }
69 } else {
73 } else {
@@ -76,13 +80,31 @@ struct AlertsView: View {
76
80
77 private func loadAlerts(stream: String) async {
81 private func loadAlerts(stream: String) async {
78 guard let client = appState.client else { return }
82 guard let client = appState.client else { return }
83+ let connID = appState.activeConnection?.id
84+ loadGeneration &+= 1
85+ let generation = loadGeneration
79 isLoading = true
86 isLoading = true
80 errorMessage = nil
87 errorMessage = nil
81 alertConfig = nil
88 alertConfig = nil
82
89
83 do {
90 do {
84− alertConfig = try await client.getAlerts(stream: stream)
91+ let config = try await client.getAlerts(stream: stream)
92+ // Superseded by a newer load: leave its spinner and state alone.
93+ guard generation == loadGeneration else { return }
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
85 } catch {
104 } catch {
105+ guard generation == loadGeneration else { return }
106+ guard stream == appState.selectedStream,
107+ connID == appState.activeConnection?.id else { isLoading = false; return }
86 errorMessage = ParseableError.userFriendlyMessage(for: error)
108 errorMessage = ParseableError.userFriendlyMessage(for: error)
87 }
109 }
88
110
@@ -190,8 +212,14 @@ struct AlertRowView: View {
190 private var stateColor: Color {
212 private var stateColor: Color {
191 switch alert.state?.lowercased() {
213 switch alert.state?.lowercased() {
192 case "triggered": return .red
214 case "triggered": return .red
193− case "disabled": return .gray
215+ case "disabled", "silenced": return .gray
194− default: return .orange
216+ // Parseable's default, healthy state (and 2.x's "resolved") — not a warning.
217+ case "not-triggered", "resolved": return .green
218+ // A reported-but-unrecognized state is a genuine "needs attention" signal,
219+ // so tint it orange; a missing state is simply unknown and stays neutral so
220+ // the bell icon isn't painted as a warning for every stateless alert.
221+ case .some: return .orange
222+ case nil: return .secondary
195 }
223 }
196 }
224 }
197
225
ParseableViewer/Views/ConnectionSheet.swift+74−13
@@ -14,6 +14,14 @@ struct ConnectionSheet: View {
14 @State private var isSaving = false
14 @State private var isSaving = false
15 @State private var testResult: TestResult?
15 @State private var testResult: TestResult?
16 @State private var urlValidationError: String?
16 @State private var urlValidationError: String?
17+ /// For a new connection, holds the connection created on the first save so a
18+ /// retry (after a failed connect) updates it in place rather than appending a
19+ /// duplicate.
20+ @State private var createdConnection: ServerConnection?
21+ /// Bumped on every field edit and every test run, so an in-flight Test
22+ /// Connection whose fields have since changed doesn't post its (now stale)
23+ /// success/failure under values that were never tested.
24+ @State private var testGeneration = 0
17
25
18 enum TestResult {
26 enum TestResult {
19 case success(String)
27 case success(String)
@@ -39,12 +47,15 @@ struct ConnectionSheet: View {
39 TextField("Name:", text: $name)
47 TextField("Name:", text: $name)
40 .textFieldStyle(.roundedBorder)
48 .textFieldStyle(.roundedBorder)
41 .accessibilityLabel("Connection name")
49 .accessibilityLabel("Connection name")
50+ .onChange(of: name) { _, _ in invalidateTestResult() }
42
51
43 TextField("Server URL:", text: $url)
52 TextField("Server URL:", text: $url)
44 .textFieldStyle(.roundedBorder)
53 .textFieldStyle(.roundedBorder)
45 .accessibilityLabel("Server URL")
54 .accessibilityLabel("Server URL")
46 .onChange(of: url) { _, newValue in
55 .onChange(of: url) { _, newValue in
47 urlValidationError = Self.validateURL(newValue)
56 urlValidationError = Self.validateURL(newValue)
57+ // A prior test result no longer reflects the edited fields.
58+ invalidateTestResult()
48 }
59 }
49
60
50 if let urlError = urlValidationError, !url.isEmpty {
61 if let urlError = urlValidationError, !url.isEmpty {
@@ -56,10 +67,12 @@ struct ConnectionSheet: View {
56 TextField("Username:", text: $username)
67 TextField("Username:", text: $username)
57 .textFieldStyle(.roundedBorder)
68 .textFieldStyle(.roundedBorder)
58 .accessibilityLabel("Username")
69 .accessibilityLabel("Username")
70+ .onChange(of: username) { _, _ in invalidateTestResult() }
59
71
60 SecureField("Password:", text: $password)
72 SecureField("Password:", text: $password)
61 .textFieldStyle(.roundedBorder)
73 .textFieldStyle(.roundedBorder)
62 .accessibilityLabel("Password")
74 .accessibilityLabel("Password")
75+ .onChange(of: password) { _, _ in invalidateTestResult() }
63 }
76 }
64 .formStyle(.grouped)
77 .formStyle(.grouped)
65 .padding()
78 .padding()
@@ -106,10 +119,22 @@ struct ConnectionSheet: View {
106 Button("Test Connection") {
119 Button("Test Connection") {
107 testConnection()
120 testConnection()
108 }
121 }
122+ .disabled(
123+ url.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty
124+ || username.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty
125+ || isTesting
109− .disabled(url.isEmpty || username.isEmpty || isTesting || urlValidationError != nil)
126+ || urlValidationError != nil
127+ )
110 .accessibilityLabel("Test connection")
128 .accessibilityLabel("Test connection")
111
129
112 Button("Cancel") {
130 Button("Cancel") {
131+ // A new connection is persisted on the first save so a retry
132+ // updates it in place. If the user abandons it via Cancel
133+ // (e.g. after a failed connect), remove that never-connected
134+ // entry so it doesn't linger in the sidebar.
135+ if let created = createdConnection {
136+ appState.removeConnection(created)
137+ }
113 password = ""
138 password = ""
114 dismiss()
139 dismiss()
115 }
140 }
@@ -119,7 +144,13 @@ struct ConnectionSheet: View {
119 saveAndConnect()
144 saveAndConnect()
120 }
145 }
121 .keyboardShortcut(.defaultAction)
146 .keyboardShortcut(.defaultAction)
147+ .disabled(
148+ name.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty
149+ || url.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty
150+ || username.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty
151+ || isSaving
122− .disabled(name.isEmpty || url.isEmpty || username.isEmpty || isSaving || urlValidationError != nil)
152+ || urlValidationError != nil
153+ )
123 }
154 }
124 .padding()
155 .padding()
125 }
156 }
@@ -131,26 +162,47 @@ struct ConnectionSheet: View {
131 username = connection.username
162 username = connection.username
132 password = connection.password
163 password = connection.password
133 }
164 }
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)
134 }
169 }
135 }
170 }
136
171
172+ private func invalidateTestResult() {
173+ testGeneration += 1
174+ testResult = nil
175+ }
176+
137 private func testConnection() {
177 private func testConnection() {
138 isTesting = true
178 isTesting = true
139 testResult = nil
179 testResult = nil
180+ testGeneration += 1
181+ let generation = testGeneration
140
182
141 Task {
183 Task {
142 do {
184 do {
143− let conn = ServerConnection(name: name, url: url, username: username, password: password)
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+ )
144 let client = try ParseableClient(connection: conn)
191 let client = try ParseableClient(connection: conn)
145 try await client.checkHealth()
192 try await client.checkHealth()
193+ // The health endpoint doesn't check credentials; see
194+ // AppState.verifyCredentials.
195+ try await AppState.verifyCredentials(with: client)
146 await MainActor.run {
196 await MainActor.run {
147− testResult = .success("Connection successful")
148 isTesting = false
197 isTesting = false
198+ guard generation == testGeneration else { return }
199+ testResult = .success("Connection successful")
149 }
200 }
150 } catch {
201 } catch {
151 await MainActor.run {
202 await MainActor.run {
152− testResult = .failure(ParseableError.userFriendlyMessage(for: error))
153 isTesting = false
203 isTesting = false
204+ guard generation == testGeneration else { return }
205+ testResult = .failure(ParseableError.userFriendlyMessage(for: error))
154 }
206 }
155 }
207 }
156 }
208 }
@@ -159,27 +211,36 @@ struct ConnectionSheet: View {
159 private func saveAndConnect() {
211 private func saveAndConnect() {
160 guard !isSaving else { return }
212 guard !isSaving else { return }
161
213
214+ let trimmedName = name.trimmingCharacters(in: .whitespacesAndNewlines)
215+ let trimmedURL = url.trimmingCharacters(in: .whitespacesAndNewlines)
216+ let trimmedUsername = username.trimmingCharacters(in: .whitespacesAndNewlines)
217+
162 let conn: ServerConnection
218 let conn: ServerConnection
163− if let existing = connection {
219+ if let existing = connection ?? createdConnection {
164− conn = ServerConnection(id: existing.id, name: name, url: url, username: username, password: password)
220+ conn = ServerConnection(id: existing.id, name: trimmedName, url: trimmedURL, username: trimmedUsername, password: password)
165 appState.updateConnection(conn)
221 appState.updateConnection(conn)
166 } else {
222 } else {
167− conn = ServerConnection(name: name, url: url, username: username, password: password)
223+ conn = ServerConnection(name: trimmedName, url: trimmedURL, username: trimmedUsername, password: password)
168 appState.addConnection(conn)
224 appState.addConnection(conn)
225+ createdConnection = conn
169 }
226 }
170
227
171 isSaving = true
228 isSaving = true
172 testResult = nil
229 testResult = nil
173
230
174 Task {
231 Task {
175− await appState.connect(to: conn)
232+ let success = await appState.connect(to: conn)
176 await MainActor.run {
233 await MainActor.run {
177 isSaving = false
234 isSaving = false
178− if appState.errorMessage != nil {
235+ if success {
179− testResult = .failure(appState.errorMessage ?? "Connection failed")
180− } else {
181 password = ""
236 password = ""
182 dismiss()
237 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
183 }
244 }
184 }
245 }
185 }
246 }
@@ -190,7 +251,7 @@ struct ConnectionSheet: View {
190 guard !trimmed.isEmpty else { return nil }
251 guard !trimmed.isEmpty else { return nil }
191
252
192 var normalized = trimmed
253 var normalized = trimmed
193− if !normalized.hasPrefix("http://") && !normalized.hasPrefix("https://") {
254+ if !ServerConnection.hasURLScheme(normalized) {
194 normalized = "https://\(normalized)"
255 normalized = "https://\(normalized)"
195 }
256 }
196
257
ParseableViewer/Views/ContentView.swift+75−39
@@ -10,21 +10,7 @@ struct ContentView: View {
10 SidebarView()
10 SidebarView()
11 } detail: {
11 } detail: {
12 if appState.isConnected {
12 if appState.isConnected {
13− if appState.selectedStream != nil {
14− MainContentView()
13+ 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− }
28 } else {
14 } else {
29 WelcomeView()
15 WelcomeView()
30 }
16 }
@@ -33,7 +19,10 @@ struct ContentView: View {
33 ConnectionSheet(connection: appState.editingConnection)
19 ConnectionSheet(connection: appState.editingConnection)
34 }
20 }
35 .alert("Error", isPresented: $appState.showError) {
21 .alert("Error", isPresented: $appState.showError) {
22+ Button("OK") {
36− Button("OK") { appState.showError = false }
23+ appState.showError = false
24+ appState.errorMessage = nil
25+ }
37 } message: {
26 } message: {
38 Text(appState.errorMessage ?? "An unknown error occurred")
27 Text(appState.errorMessage ?? "An unknown error occurred")
39 }
28 }
@@ -42,13 +31,25 @@ struct ContentView: View {
42
31
43struct MainContentView: View {
32struct MainContentView: View {
44 @Environment(AppState.self) private var appState
33 @Environment(AppState.self) private var appState
34+ /// Kept here rather than inside QueryView so the Query tab's editor text,
35+ /// results and time range survive switching to another tab and back (only
36+ /// the active tab's view is instantiated). Dropped on disconnect, when this
37+ /// view goes away.
38+ @State private var queryViewModel = QueryViewModel()
39+
40+ /// Which server the Query tab's state belongs to. Editing the active
41+ /// connection's URL or user keeps its id (so QueryView's own stream/connection
42+ /// check can't see it), and a rename shouldn't wipe the user's query.
43+ private var serverIdentity: String? {
44+ appState.activeConnection.map { "\($0.id.uuidString)|\($0.url)|\($0.username)" }
45+ }
45
46
46 var body: some View {
47 var body: some View {
47 @Bindable var appState = appState
48 @Bindable var appState = appState
48
49
49 VStack(spacing: 0) {
50 VStack(spacing: 0) {
50 // Tab bar
51 // Tab bar
51− HStack(spacing: 0) {
52+ HStack(spacing: 4) {
52 ForEach(AppState.AppTab.allCases) { tab in
53 ForEach(AppState.AppTab.allCases) { tab in
53 TabButton(
54 TabButton(
54 title: tab.rawValue,
55 title: tab.rawValue,
@@ -60,34 +61,69 @@ struct MainContentView: View {
60 }
61 }
61 Spacer()
62 Spacer()
62 }
63 }
63− .padding(.horizontal, 8)
64+ .padding(.horizontal, 10)
64− .padding(.top, 4)
65+ .padding(.vertical, 4)
65 .background(.bar)
66 .background(.bar)
66
67
67 Divider()
68 Divider()
68
69
69− // Content — only the active tab is instantiated
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 {
70− switch appState.currentTab {
76+ switch appState.currentTab {
71− case .query:
77+ case .query:
72− QueryView()
78+ QueryView(viewModel: queryViewModel)
73− .id(AppState.AppTab.query)
79+ .id(AppState.AppTab.query)
74− case .liveTail:
80+ case .liveTail:
75− LiveTailView()
81+ LiveTailView()
76− .id(AppState.AppTab.liveTail)
82+ .id(AppState.AppTab.liveTail)
77− case .streamInfo:
83+ case .streamInfo:
78− StreamDetailView()
84+ StreamDetailView()
79− .id(AppState.AppTab.streamInfo)
85+ .id(AppState.AppTab.streamInfo)
80− case .alerts:
86+ case .alerts:
81− AlertsView()
87+ AlertsView()
82− .id(AppState.AppTab.alerts)
88+ .id(AppState.AppTab.alerts)
83− case .users:
89+ case .users:
84− UsersView()
90+ UsersView()
85− .id(AppState.AppTab.users)
91+ .id(AppState.AppTab.users)
86− case .serverInfo:
92+ case .serverInfo:
87− ServerInfoView()
93+ ServerInfoView()
88− .id(AppState.AppTab.serverInfo)
94+ .id(AppState.AppTab.serverInfo)
95+ }
89 }
96 }
90 }
97 }
98+ .onChange(of: serverIdentity) { _, _ in
99+ // The Query tab's model outlives stream and server changes made while
100+ // it isn't on screen. On a new server, drop the old one's results and
101+ // query (cancelling one still in flight) so the tab starts fresh.
102+ queryViewModel.clearResults()
103+ queryViewModel.sqlQuery = ""
104+ queryViewModel.filterText = ""
105+ }
106+ .onDisappear {
107+ // Disconnecting tears this view (and the model) down; don't leave an
108+ // in-flight query running against the server the user just left.
109+ queryViewModel.clearResults()
110+ }
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)
91 }
127 }
92}
128}
93
129
ParseableViewer/Views/LiveTailView.swift+162−37
@@ -3,13 +3,23 @@ import SwiftUI
3struct LiveTailView: View {
3struct LiveTailView: View {
4 @Environment(AppState.self) private var appState
4 @Environment(AppState.self) private var appState
5 @State private var viewModel = LiveTailViewModel()
5 @State private var viewModel = LiveTailViewModel()
6+ /// Selection is tracked by entry id, not by record value: live-tail rows
7+ /// frequently share identical content (repeated log lines), so comparing by
8+ /// record value would select/toggle every matching row at once.
6− @State private var selectedRecord: LogRecord?
9+ @State private var selectedEntryID: UUID?
7 @State private var autoScroll = true
10 @State private var autoScroll = true
8 @State private var pulseOpacity: Double = 1.0
11 @State private var pulseOpacity: Double = 1.0
9 @State private var showColumnPopover = false
12 @State private var showColumnPopover = false
13+ @State private var wrapText = false
10 @State private var sortColumn: String?
14 @State private var sortColumn: String?
11 @State private var sortAscending = false
15 @State private var sortAscending = false
12 @State private var columnWidths: [String: CGFloat] = [:]
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>?
13
23
14 var body: some View {
24 var body: some View {
15 @Bindable var vm = viewModel
25 @Bindable var vm = viewModel
@@ -70,6 +80,14 @@ struct LiveTailView: View {
70 .accessibilityLabel("Filter live tail entries")
80 .accessibilityLabel("Filter live tail entries")
71 }
81 }
72
82
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+
73 if !viewModel.columns.isEmpty {
91 if !viewModel.columns.isEmpty {
74 Button {
92 Button {
75 showColumnPopover.toggle()
93 showColumnPopover.toggle()
@@ -97,14 +115,18 @@ struct LiveTailView: View {
97
115
98 Button {
116 Button {
99 viewModel.clear()
117 viewModel.clear()
100− selectedRecord = nil
118+ selectedEntryID = nil
101 sortColumn = nil
119 sortColumn = nil
102 columnWidths = [:]
120 columnWidths = [:]
121+ cachedSorted = []
103 } label: {
122 } label: {
104 Image(systemName: "trash")
123 Image(systemName: "trash")
105 }
124 }
106 .help("Clear entries")
125 .help("Clear entries")
126+ // ⌘K, not ⌘⌫: command-key equivalents are dispatched before the
127+ // focused text field sees the keystroke, so ⌘⌫ (delete to start
128+ // of line) in the Filter field would wipe the whole buffer.
107− .keyboardShortcut(.delete, modifiers: .command)
129+ .keyboardShortcut("k", modifiers: .command)
108 .accessibilityLabel("Clear all entries")
130 .accessibilityLabel("Clear all entries")
109 }
131 }
110 .padding(8)
132 .padding(8)
@@ -117,10 +139,21 @@ struct LiveTailView: View {
117 Circle()
139 Circle()
118 .fill(viewModel.isPaused ? .yellow : .green)
140 .fill(viewModel.isPaused ? .yellow : .green)
119 .frame(width: 8, height: 8)
141 .frame(width: 8, height: 8)
120− .opacity(viewModel.isPaused ? 1.0 : pulseOpacity)
142+ .opacity(pulseOpacity)
121 .accessibilityLabel(viewModel.isPaused ? "Status: Paused" : "Status: Streaming")
143 .accessibilityLabel(viewModel.isPaused ? "Status: Paused" : "Status: Streaming")
122− .animation(.easeInOut(duration: 1.0).repeatForever(autoreverses: true), value: pulseOpacity)
144+ // Drive the pulse with an explicit, re-triggerable animation.
123− .onAppear { pulseOpacity = 0.3 }
145+ // The previous one-shot `pulseOpacity = 0.3` only changed the
146+ // value once, so the implicit `animation(value:)` never
147+ // restarted after a pause/resume and the dot froze. Pausing
148+ // settles the dot to solid; resuming restarts the pulse.
149+ .onAppear { startPulsing() }
150+ .onChange(of: viewModel.isPaused) { _, paused in
151+ if paused {
152+ withAnimation(.easeInOut(duration: 0.3)) { pulseOpacity = 1.0 }
153+ } else {
154+ startPulsing()
155+ }
156+ }
124 .onDisappear { pulseOpacity = 1.0 }
157 .onDisappear { pulseOpacity = 1.0 }
125 Text(viewModel.isPaused ? "Paused" : "Streaming")
158 Text(viewModel.isPaused ? "Paused" : "Streaming")
126 .font(.caption)
159 .font(.caption)
@@ -234,39 +267,125 @@ struct LiveTailView: View {
234 .frame(minWidth: 400)
267 .frame(minWidth: 400)
235
268
236 // Detail pane
269 // Detail pane
237− if let record = selectedRecord {
270+ if let id = selectedEntryID,
271+ let entry = cachedSorted.first(where: { $0.id == id }) {
238− LogDetailView(record: record)
272+ LogDetailView(record: entry.record)
239 .frame(minWidth: 280, idealWidth: 320)
273 .frame(minWidth: 280, idealWidth: 320)
240 }
274 }
241 }
275 }
242 .onDisappear {
276 .onDisappear {
243 viewModel.stop()
277 viewModel.stop()
278+ liveTailSortTask?.cancel()
244 }
279 }
245 .onChange(of: appState.selectedStream) { _, _ in
280 .onChange(of: appState.selectedStream) { _, _ in
246 if viewModel.isRunning {
281 if viewModel.isRunning {
247 viewModel.stop()
282 viewModel.stop()
248 }
283 }
249 viewModel.clear()
284 viewModel.clear()
250− selectedRecord = nil
285+ selectedEntryID = nil
251 sortColumn = nil
286 sortColumn = nil
252 columnWidths = [:]
287 columnWidths = [:]
288+ cachedSorted = []
253 }
289 }
254 .onChange(of: viewModel.visibleColumns) { _, newColumns in
290 .onChange(of: viewModel.visibleColumns) { _, newColumns in
255− // Compute widths for any newly visible columns
291+ // Hiding the sort column leaves no header to show or change the sort,
256− let records = viewModel.filteredEntries.map { $0.record }
292+ // while rows stay ordered by the invisible value (and auto-scroll stays
257− for col in newColumns where columnWidths[col] == nil {
293+ // off). Drop it like LogTableView does; the sortColumn change rebuilds.
258− columnWidths[col] = idealColumnWidth(for: col, records: records)
294+ if let sortColumn, !newColumns.contains(sortColumn) {
295+ self.sortColumn = nil
296+ return
259 }
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()
260 }
314 }
261 }
315 }
262
316
263− private var sortedEntries: [LiveTailViewModel.LiveTailEntry] {
317+ /// Starts (or restarts) the streaming-status pulse. Resetting `pulseOpacity`
264− let entries = viewModel.filteredEntries
318+ /// to 1.0 before animating to 0.3 guarantees a value delta so the repeating
265− guard let sortColumn else { return entries }
319+ /// animation re-triggers even when called after a pause/resume cycle.
266− return entries.sorted { a, b in
320+ private func startPulsing() {
267− let aVal = a.record[sortColumn] ?? .null
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
268− let bVal = b.record[sortColumn] ?? .null
368+ guard let col = sortColumn else {
369+ cachedSorted = entries
370+ return
371+ }
269− return sortAscending ? aVal < bVal : bVal < aVal
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
270 }
389 }
271 }
390 }
272
391
@@ -275,24 +394,25 @@ struct LiveTailView: View {
275 ScrollView([.horizontal, .vertical]) {
394 ScrollView([.horizontal, .vertical]) {
276 LazyVStack(alignment: .leading, spacing: 0, pinnedViews: [.sectionHeaders]) {
395 LazyVStack(alignment: .leading, spacing: 0, pinnedViews: [.sectionHeaders]) {
277 Section {
396 Section {
278− let sorted = sortedEntries
397+ ForEach(Array(cachedSorted.enumerated()), id: \.element.id) { index, entry in
279− ForEach(sorted.indices, id: \.self) { index in
280 LogRowView(
398 LogRowView(
281− record: sorted[index].record,
399+ record: entry.record,
282 columns: viewModel.visibleColumns,
400 columns: viewModel.visibleColumns,
283 columnWidths: columnWidths,
401 columnWidths: columnWidths,
284− isSelected: selectedRecord == sorted[index].record,
402+ isSelected: selectedEntryID == entry.id,
285 isAlternate: index % 2 == 1,
403 isAlternate: index % 2 == 1,
404+ severity: severityByID[entry.id] ?? .unknown,
405+ severityColumns: severityColumnSet,
406+ wrapText: wrapText,
286 onCellFilter: { column, value, exclude in
407 onCellFilter: { column, value, exclude in
287 viewModel.addColumnFilter(column: column, value: value, exclude: exclude)
408 viewModel.addColumnFilter(column: column, value: value, exclude: exclude)
288 }
409 }
289 )
410 )
290− .id(sorted[index].id)
291 .onTapGesture {
411 .onTapGesture {
292− if selectedRecord == sorted[index].record {
412+ if selectedEntryID == entry.id {
293− selectedRecord = nil
413+ selectedEntryID = nil
294 } else {
414 } else {
295− selectedRecord = sorted[index].record
415+ selectedEntryID = entry.id
296 }
416 }
297 }
417 }
298 }
418 }
@@ -302,7 +422,7 @@ struct LiveTailView: View {
302 sortColumn: $sortColumn,
422 sortColumn: $sortColumn,
303 sortAscending: $sortAscending,
423 sortAscending: $sortAscending,
304 columnWidths: $columnWidths,
424 columnWidths: $columnWidths,
305− records: viewModel.filteredEntries.map { $0.record },
425+ records: viewModel.cachedFilteredRecords,
306 onMoveColumn: { from, to in
426 onMoveColumn: { from, to in
307 viewModel.moveColumn(from, to: to)
427 viewModel.moveColumn(from, to: to)
308 },
428 },
@@ -313,15 +433,20 @@ struct LiveTailView: View {
313 }
433 }
314 }
434 }
315 }
435 }
316− .onChange(of: viewModel.entries.count) { _, _ in
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
317 guard autoScroll, sortColumn == nil else { return }
448 guard autoScroll, sortColumn == nil else { return }
318− // When a filter is active, scroll to the last filtered entry;
319− // when no filter, scroll to the absolute last entry.
320− let hasFilters = !viewModel.filterText.isEmpty || !viewModel.columnFilters.isEmpty
321− let target = hasFilters
322− ? viewModel.filteredEntries.last
323− : viewModel.entries.last
324− if let target {
449+ if let target = cachedSorted.last {
325 proxy.scrollTo(target.id, anchor: .bottom)
450 proxy.scrollTo(target.id, anchor: .bottom)
326 }
451 }
327 }
452 }
ParseableViewer/Views/LogDetailView.swift+43−14
@@ -4,6 +4,7 @@ struct LogDetailView: View {
4 let record: LogRecord
4 let record: LogRecord
5 @State private var viewMode: ViewMode = .formatted
5 @State private var viewMode: ViewMode = .formatted
6 @State private var showCopyConfirmation = false
6 @State private var showCopyConfirmation = false
7+ @State private var copyConfirmationTask: Task<Void, Never>?
7
8
8 enum ViewMode: String, CaseIterable {
9 enum ViewMode: String, CaseIterable {
9 case formatted = "Formatted"
10 case formatted = "Formatted"
@@ -49,6 +50,12 @@ struct LogDetailView: View {
49 }
50 }
50 }
51 }
51 .background(Color(nsColor: .controlBackgroundColor))
52 .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+ }
52 }
59 }
53
60
54 private func copyToClipboard() {
61 private func copyToClipboard() {
@@ -61,8 +68,12 @@ struct LogDetailView: View {
61 guard success else { return }
68 guard success else { return }
62
69
63 showCopyConfirmation = true
70 showCopyConfirmation = true
64− Task {
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 {
65 try? await Task.sleep(for: .seconds(1.5))
75 try? await Task.sleep(for: .seconds(1.5))
76+ guard !Task.isCancelled else { return }
66 showCopyConfirmation = false
77 showCopyConfirmation = false
67 }
78 }
68 }
79 }
@@ -71,24 +82,25 @@ struct LogDetailView: View {
71
82
72struct FormattedRecordView: View {
83struct FormattedRecordView: View {
73 let record: LogRecord
84 let record: LogRecord
85+ let sortedKeys: [String]
74
86
75− var sortedKeys: [String] {
87+ init(record: LogRecord) {
88+ self.record = record
76 let priority = ["p_timestamp", "level", "severity", "message", "msg"]
89 let priority = ["p_timestamp", "level", "severity", "message", "msg"]
77 var keys = record.keys.sorted()
90 var keys = record.keys.sorted()
78 var result: [String] = []
91 var result: [String] = []
79−
80 for p in priority {
92 for p in priority {
81 if let idx = keys.firstIndex(of: p) {
93 if let idx = keys.firstIndex(of: p) {
82 result.append(keys.remove(at: idx))
94 result.append(keys.remove(at: idx))
83 }
95 }
84 }
96 }
85 result.append(contentsOf: keys)
97 result.append(contentsOf: keys)
86− return result
98+ self.sortedKeys = result
87 }
99 }
88
100
89 var body: some View {
101 var body: some View {
90 VStack(alignment: .leading, spacing: 1) {
102 VStack(alignment: .leading, spacing: 1) {
91− ForEach(sortedKeys, id: \.self) { key in
103+ ForEach(Array(sortedKeys.enumerated()), id: \.element) { index, key in
92 HStack(alignment: .top, spacing: 8) {
104 HStack(alignment: .top, spacing: 8) {
93 Text(key)
105 Text(key)
94 .font(.system(.caption, design: .monospaced))
106 .font(.system(.caption, design: .monospaced))
@@ -105,8 +117,7 @@ struct FormattedRecordView: View {
105 .padding(.vertical, 3)
117 .padding(.vertical, 3)
106 .padding(.horizontal, 8)
118 .padding(.horizontal, 8)
107 .background(
119 .background(
108− sortedKeys.firstIndex(of: key).map { $0 % 2 == 1 }
109− ?? false ? Color.primary.opacity(0.03) : Color.clear
120+ index % 2 == 1 ? Color.primary.opacity(0.03) : Color.clear
110 )
121 )
111 }
122 }
112 }
123 }
@@ -146,8 +157,11 @@ struct JSONValueView: View {
146 Text(String(v))
157 Text(String(v))
147 .font(.system(.caption, design: .monospaced))
158 .font(.system(.caption, design: .monospaced))
148 .foregroundStyle(Color(nsColor: .systemPurple))
159 .foregroundStyle(Color(nsColor: .systemPurple))
149− case .double(let v):
160+ case .double:
150− Text(String(v))
161+ // Use displayString so whole-number doubles render as "3" (not "3.0"),
162+ // scientific notation is avoided, and inf/nan map to "null" — matching
163+ // the table cells, CSV/JSON export, and the rest of the app.
164+ Text(value.displayString)
151 .font(.system(.caption, design: .monospaced))
165 .font(.system(.caption, design: .monospaced))
152 .foregroundStyle(Color(nsColor: .systemPurple))
166 .foregroundStyle(Color(nsColor: .systemPurple))
153 case .string(let v):
167 case .string(let v):
@@ -185,12 +199,27 @@ struct JSONValueView: View {
185
199
186struct RawJSONView: View {
200struct RawJSONView: View {
187 let record: LogRecord
201 let record: LogRecord
202+ @State private var highlightedText: AttributedString?
188
203
189 var body: some View {
204 var body: some View {
205+ Group {
206+ if let highlighted = highlightedText {
207+ Text(highlighted)
208+ } else {
190− let json = JSONValue.object(record).prettyPrinted()
209+ Text(JSONValue.object(record).prettyPrinted())
191− Text(JSONSyntaxHighlighter.highlight(json))
210+ .font(.system(.caption, design: .monospaced))
211+ }
212+ }
192− .textSelection(.enabled)
213+ .textSelection(.enabled)
193− .padding(8)
214+ .padding(8)
194− .frame(maxWidth: .infinity, alignment: .leading)
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 }
224 }
196}
225}
ParseableViewer/Views/LogTableView.swift+289−63
@@ -20,7 +20,13 @@ func idealColumnWidth(for column: String, records: [LogRecord]) -> CGFloat {
20 let sampleCount = min(records.count, 200)
20 let sampleCount = min(records.count, 200)
21
21
22 var widest = measureTextWidth(column, font: headerMeasureFont)
22 var widest = measureTextWidth(column, font: headerMeasureFont)
23+ // Sample evenly across the whole result set, not just the first N rows —
24+ // otherwise a wide value that sorts beyond row N is never measured and the
25+ // column auto-fits too narrow after sorting. Map each of the `sampleCount`
26+ // samples to a spread-out index so we hit exactly `sampleCount` distinct rows
27+ // (a stride with an integer step would skip rows yet still undersample).
23− for i in 0..<sampleCount {
28+ for s in 0..<sampleCount {
29+ let i = s * records.count / sampleCount
24 let text = records[i][column]?.displayString ?? ""
30 let text = records[i][column]?.displayString ?? ""
25 widest = max(widest, measureTextWidth(text, font: cellMeasureFont))
31 widest = max(widest, measureTextWidth(text, font: cellMeasureFont))
26 }
32 }
@@ -114,19 +120,52 @@ func parseSeverity(from value: String) -> SeverityLevel {
114 return .unknown
120 return .unknown
115}
121}
116
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+
117−/// Extracts the severity level from a log record by checking common column names.
142+/// Extracts the severity level from a log record by checking only known severity columns.
118−func extractSeverity(from record: LogRecord) -> SeverityLevel {
143+func extractSeverity(from record: LogRecord, severityColumns: Set<String>) -> SeverityLevel {
119− for (key, value) in record {
144+ // Iterate in a deterministic (sorted) order so that, when a record carries
120− if severityColumnNames.contains(key.lowercased()) {
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 }
121− let str: String
149+ let str: String
122− switch value {
150+ switch value {
123− case .string(let s): str = s
151+ case .string(let s): str = s
124− case .int(let i): str = String(i)
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)
125− default: continue
157+ default: continue
126− }
158+ }
127− let level = parseSeverity(from: str)
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)
128 if level != .unknown { return level }
164 if level != .unknown { return level }
165+ continue
129 }
166 }
167+ let level = parseSeverity(from: str)
168+ if level != .unknown { return level }
130 }
169 }
131 return .unknown
170 return .unknown
132}
171}
@@ -142,20 +181,40 @@ func severityRowTint(for level: SeverityLevel) -> Color? {
142 }
181 }
143}
182}
144
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+
145// MARK: - LogTableView
195// MARK: - LogTableView
146
196
197+struct IndexedRecord: Identifiable {
198+ let id: Int
199+ let record: LogRecord
200+ let severity: SeverityLevel
201+}
202+
147struct LogTableView: View {
203struct LogTableView: View {
148 let records: [LogRecord]
204 let records: [LogRecord]
149 let columns: [String]
205 let columns: [String]
150 @Binding var selectedRecord: LogRecord?
206 @Binding var selectedRecord: LogRecord?
151 var isLoading: Bool = false
207 var isLoading: Bool = false
208+ var wrapText: Bool = false
152 var onCellFilter: ((_ column: String, _ value: JSONValue?, _ exclude: Bool) -> Void)?
209 var onCellFilter: ((_ column: String, _ value: JSONValue?, _ exclude: Bool) -> Void)?
153 var onMoveColumn: ((String, String) -> Void)?
210 var onMoveColumn: ((String, String) -> Void)?
154 @State private var sortColumn: String?
211 @State private var sortColumn: String?
155 @State private var sortAscending = false
212 @State private var sortAscending = false
156 @State private var selectedIndex: Int?
213 @State private var selectedIndex: Int?
157− @State private var cachedSorted: [LogRecord] = []
214+ @State private var cachedSorted: [IndexedRecord] = []
215+ @State private var severityColumnSet: Set<String> = []
158 @State private var sortTask: Task<Void, Never>?
216 @State private var sortTask: Task<Void, Never>?
217+ @State private var widthTask: Task<Void, Never>?
159 @State private var columnWidths: [String: CGFloat] = [:]
218 @State private var columnWidths: [String: CGFloat] = [:]
160
219
161 var body: some View {
220 var body: some View {
@@ -181,22 +240,25 @@ struct LogTableView: View {
181 ScrollView([.horizontal, .vertical]) {
240 ScrollView([.horizontal, .vertical]) {
182 LazyVStack(alignment: .leading, spacing: 0, pinnedViews: [.sectionHeaders]) {
241 LazyVStack(alignment: .leading, spacing: 0, pinnedViews: [.sectionHeaders]) {
183 Section {
242 Section {
184− ForEach(cachedSorted.indices, id: \.self) { index in
243+ ForEach(Array(cachedSorted.enumerated()), id: \.element.id) { index, item in
185 LogRowView(
244 LogRowView(
186− record: cachedSorted[index],
245+ record: item.record,
187 columns: columns,
246 columns: columns,
188 columnWidths: columnWidths,
247 columnWidths: columnWidths,
189− isSelected: selectedIndex == index,
248+ isSelected: selectedIndex == item.id,
190 isAlternate: index % 2 == 1,
249 isAlternate: index % 2 == 1,
250+ severity: item.severity,
251+ severityColumns: severityColumnSet,
252+ wrapText: wrapText,
191 onCellFilter: onCellFilter
253 onCellFilter: onCellFilter
192 )
254 )
193 .onTapGesture {
255 .onTapGesture {
194− if selectedIndex == index {
256+ if selectedIndex == item.id {
195 selectedIndex = nil
257 selectedIndex = nil
196 selectedRecord = nil
258 selectedRecord = nil
197 } else {
259 } else {
198− selectedIndex = index
260+ selectedIndex = item.id
199− selectedRecord = cachedSorted[index]
261+ selectedRecord = item.record
200 }
262 }
201 }
263 }
202 }
264 }
@@ -227,44 +289,156 @@ struct LogTableView: View {
227 .controlSize(.large)
289 .controlSize(.large)
228 }
290 }
229 }
291 }
230− .onChange(of: records) { _, _ in
292+ .onChange(of: records) { _, newRecords in
231− columnWidths = computeColumnWidths(columns: columns, records: records)
293+ recomputeColumnWidths(columns: columns, records: newRecords)
232 debouncedRebuildSort()
294 debouncedRebuildSort()
233 }
295 }
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+ }
234 .onChange(of: sortColumn) { _, _ in debouncedRebuildSort() }
316 .onChange(of: sortColumn) { _, _ in debouncedRebuildSort() }
235 .onChange(of: sortAscending) { _, _ in debouncedRebuildSort() }
317 .onChange(of: sortAscending) { _, _ in debouncedRebuildSort() }
318+ .onChange(of: selectedRecord) { _, newValue in
319+ // The owner can drop the selection on its own (QueryViewModel
320+ // clears it after every query). When the new result set equals the
321+ // old one, `onChange(of: records)` doesn't fire, so nothing would
322+ // reconcile the row highlight: the row stayed highlighted with no
323+ // detail pane, and the next click on it merely "deselected" it.
324+ if newValue == nil { selectedIndex = nil }
325+ }
236 .onAppear {
326 .onAppear {
327+ // This branch is unmounted while results are empty (e.g. across a
328+ // stream switch, which clears them first), so `onChange(of: columns)`
329+ // never sees the switch. Drop a sort column the new columns don't
330+ // have — or hide — before sorting, or rows would be ordered by an
331+ // invisible column with no header showing the sort.
332+ if let sortColumn, !columns.contains(sortColumn) {
333+ self.sortColumn = nil
334+ }
237− columnWidths = computeColumnWidths(columns: columns, records: records)
335+ recomputeColumnWidths(columns: columns, records: records)
238 rebuildSort()
336 rebuildSort()
239 }
337 }
240 }
338 }
241 }
339 }
242
340
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+
243 private func debouncedRebuildSort() {
362 private func debouncedRebuildSort() {
244 sortTask?.cancel()
363 sortTask?.cancel()
245 sortTask = Task {
364 sortTask = Task {
246 try? await Task.sleep(for: .milliseconds(150))
365 try? await Task.sleep(for: .milliseconds(150))
247 guard !Task.isCancelled else { return }
366 guard !Task.isCancelled else { return }
248− rebuildSort()
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
249 }
416 }
250 }
417 }
251
418
252 private func rebuildSort() {
419 private func rebuildSort() {
420+ // A debounced rebuild scheduled before the table unmounted must not land
421+ // after (and overwrite) this synchronous one.
422+ sortTask?.cancel()
423+ let sevCols = buildSeverityColumnSet(columns: columns)
424+ let enumerated = Array(records.enumerated())
425+ let sorted: [(offset: Int, element: LogRecord)]
253 if let sortColumn {
426 if let sortColumn {
254− cachedSorted = records.sorted { a, b in
427+ sorted = enumerated.sorted { a, b in
255− let aVal = a[sortColumn] ?? .null
428+ let aVal = a.element[sortColumn] ?? .null
256− let bVal = b[sortColumn] ?? .null
429+ let bVal = b.element[sortColumn] ?? .null
430+ // Stable tiebreak on original index — see the async path above.
257− return sortAscending ? aVal < bVal : bVal < aVal
431+ if aVal != bVal { return sortAscending ? aVal < bVal : bVal < aVal }
432+ return a.offset < b.offset
258 }
433 }
259 } else {
434 } else {
260− cachedSorted = records
435+ sorted = enumerated
261 }
436 }
262− if let selectedRecord,
437+ cachedSorted = sorted.map {
263− let idx = cachedSorted.firstIndex(where: { $0 == selectedRecord }) {
438+ IndexedRecord(id: $0.offset, record: $0.element, severity: extractSeverity(from: $0.element, severityColumns: sevCols))
264− selectedIndex = idx
265− } else {
266− selectedIndex = nil
267 }
439 }
440+ severityColumnSet = sevCols
441+ reconcileSelection()
268 }
442 }
269}
443}
270
444
@@ -293,7 +467,7 @@ struct ColumnResizeHandle: View {
293 }
467 }
294 }
468 }
295 .gesture(
469 .gesture(
296− DragGesture(minimumDistance: 1)
470+ DragGesture(minimumDistance: 1, coordinateSpace: .global)
297 .onChanged { gesture in
471 .onChanged { gesture in
298 if initialWidth == nil {
472 if initialWidth == nil {
299 initialWidth = columnWidth
473 initialWidth = columnWidth
@@ -321,10 +495,30 @@ struct LogHeaderView: View {
321
495
322 @State private var draggedColumn: String?
496 @State private var draggedColumn: String?
323 @State private var dropTargetColumn: String?
497 @State private var dropTargetColumn: String?
498+ /// `.contextMenu`'s builder runs during body evaluation, so the filter menus
499+ /// compute their values while the view is being rendered. Writing an `@State`
500+ /// dictionary from there is "modifying state during view update" (undefined
501+ /// behavior, and it re-invalidates the header). The cache is therefore a plain
502+ /// reference that isn't observed; `recordsGeneration` — bumped outside body
503+ /// evaluation — is what invalidates it and triggers the re-render.
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+ }
324
511
325 private static let maxFilterValues = 20
512 private static let maxFilterValues = 20
326
513
327 private func uniqueValues(for column: String) -> [JSONValue] {
514 private func uniqueValues(for column: String) -> [JSONValue] {
515+ if uniqueValuesCache.generation != recordsGeneration {
516+ uniqueValuesCache.generation = recordsGeneration
517+ uniqueValuesCache.values = [:]
518+ }
519+ if let cached = uniqueValuesCache.values[column] {
520+ return cached
521+ }
328 var seen = Set<JSONValue>()
522 var seen = Set<JSONValue>()
329 var result: [JSONValue] = []
523 var result: [JSONValue] = []
330 for record in records {
524 for record in records {
@@ -333,7 +527,9 @@ struct LogHeaderView: View {
333 result.append(value)
527 result.append(value)
334 }
528 }
335 }
529 }
336− return result.sorted()
530+ let sorted = result.sorted()
531+ uniqueValuesCache.values[column] = sorted
532+ return sorted
337 }
533 }
338
534
339 private func dropIndicatorAlignment(for targetColumn: String) -> Alignment {
535 private func dropIndicatorAlignment(for targetColumn: String) -> Alignment {
@@ -356,27 +552,27 @@ struct LogHeaderView: View {
356 HStack(spacing: 0) {
552 HStack(spacing: 0) {
357 ForEach(columns, id: \.self) { column in
553 ForEach(columns, id: \.self) { column in
358 HStack(spacing: 0) {
554 HStack(spacing: 0) {
359− Button {
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 {
360 if sortColumn == column {
568 if sortColumn == column {
361 sortAscending.toggle()
569 sortAscending.toggle()
362 } else {
570 } else {
363 sortColumn = column
571 sortColumn = column
364 sortAscending = true
572 sortAscending = true
365 }
573 }
366− } label: {
367− HStack(spacing: 2) {
368− Text(column)
369− .font(.caption.weight(.semibold))
370− .lineLimit(1)
371− if sortColumn == column {
372− Image(systemName: sortAscending ? "chevron.up" : "chevron.down")
373− .font(.caption2)
374− }
375− }
376 }
574 }
377− .buttonStyle(.plain)
575+ .accessibilityAddTraits(.isButton)
378− .padding(.horizontal, 6)
379− .padding(.vertical, 4)
380 .accessibilityLabel(sortColumn == column
576 .accessibilityLabel(sortColumn == column
381 ? "Sort by \(column), \(sortAscending ? "ascending" : "descending")"
577 ? "Sort by \(column), \(sortAscending ? "ascending" : "descending")"
382 : "Sort by \(column)")
578 : "Sort by \(column)")
@@ -474,6 +670,9 @@ struct LogHeaderView: View {
474 }
670 }
475 .background(Color(nsColor: .controlBackgroundColor))
671 .background(Color(nsColor: .controlBackgroundColor))
476 .border(Color.secondary.opacity(0.2), width: 0.5)
672 .border(Color.secondary.opacity(0.2), width: 0.5)
673+ .onChange(of: records) { _, _ in
674+ recordsGeneration &+= 1
675+ }
477 }
676 }
478}
677}
479
678
@@ -505,14 +704,26 @@ private struct ColumnDropDelegate: DropDelegate {
505 }
704 }
506
705
507 func performDrop(info: DropInfo) -> Bool {
706 func performDrop(info: DropInfo) -> Bool {
508− guard let dragged = draggedColumn, dragged != column else {
707+ guard let dragged = draggedColumn, dragged != column,
708+ let provider = info.itemProviders(for: [UTType.plainText]).first else {
509 draggedColumn = nil
709 draggedColumn = nil
510 dropTargetColumn = nil
710 dropTargetColumn = nil
511 return false
711 return false
512 }
712 }
513− onMoveColumn?(dragged, column)
514 draggedColumn = nil
713 draggedColumn = nil
515 dropTargetColumn = nil
714 dropTargetColumn = nil
715+ // `draggedColumn` is only cleared by a drop on a header, so a header drag
716+ // that was cancelled (or dropped elsewhere) leaves it set, and any later
717+ // plain-text drag onto a header would move that stale column. Only move
718+ // when the dropped payload really is the column header being dragged.
719+ let target = column
720+ let move = onMoveColumn
721+ _ = provider.loadObject(ofClass: NSString.self) { object, _ in
722+ guard let payload = object as? NSString, payload as String == dragged else { return }
723+ DispatchQueue.main.async {
724+ move?(dragged, target)
725+ }
726+ }
516 return true
727 return true
517 }
728 }
518
729
@@ -529,22 +740,28 @@ struct LogRowView: View {
529 let columnWidths: [String: CGFloat]
740 let columnWidths: [String: CGFloat]
530 let isSelected: Bool
741 let isSelected: Bool
531 let isAlternate: Bool
742 let isAlternate: Bool
743+ var severity: SeverityLevel = .unknown
744+ var severityColumns: Set<String> = []
745+ var wrapText: Bool = false
532 var onCellFilter: ((_ column: String, _ value: JSONValue?, _ exclude: Bool) -> Void)?
746 var onCellFilter: ((_ column: String, _ value: JSONValue?, _ exclude: Bool) -> Void)?
533
747
534 var body: some View {
748 var body: some View {
535− HStack(spacing: 0) {
749+ HStack(alignment: .top, spacing: 0) {
536 ForEach(columns, id: \.self) { column in
750 ForEach(columns, id: \.self) { column in
537 let value = record[column]
751 let value = record[column]
538 Text(value?.displayString ?? "")
752 Text(value?.displayString ?? "")
539 .font(.system(.caption, design: .monospaced))
753 .font(.system(.caption, design: .monospaced))
540− .lineLimit(1)
754+ .lineLimit(wrapText ? nil : 1)
541 .padding(.horizontal, 6)
755 .padding(.horizontal, 6)
542 .padding(.vertical, 3)
756 .padding(.vertical, 3)
543− .frame(width: columnWidths[column] ?? 120, alignment: .leading)
757+ .frame(width: columnWidths[column] ?? 120, alignment: .topLeading)
544 .foregroundStyle(colorForValue(column: column, value: value))
758 .foregroundStyle(colorForValue(column: column, value: value))
545 .contextMenu {
759 .contextMenu {
546 Button("Copy Value") {
760 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.
547− let text = value?.displayString ?? ""
764+ let text = value?.exportString ?? ""
548 NSPasteboard.general.clearContents()
765 NSPasteboard.general.clearContents()
549 _ = NSPasteboard.general.setString(text, forType: .string)
766 _ = NSPasteboard.general.setString(text, forType: .string)
550 }
767 }
@@ -554,11 +771,17 @@ struct LogRowView: View {
554 }
771 }
555 if let onCellFilter {
772 if let onCellFilter {
556 Divider()
773 Divider()
774+ // Normalize a null cell to `nil` so this produces an
775+ // IS NULL / IS NOT NULL filter, matching the header
776+ // "Filter by Value" path. Passing `.null` straight
777+ // through would build an `= null` value filter, which
778+ // the live-tail in-memory matcher treats differently.
779+ let filterValue: JSONValue? = value == .null ? nil : value
557 Button("Filter by This Value") {
780 Button("Filter by This Value") {
558− onCellFilter(column, value, false)
781+ onCellFilter(column, filterValue, false)
559 }
782 }
560 Button("Exclude This Value") {
783 Button("Exclude This Value") {
561− onCellFilter(column, value, true)
784+ onCellFilter(column, filterValue, true)
562 }
785 }
563 }
786 }
564 }
787 }
@@ -567,19 +790,22 @@ struct LogRowView: View {
567 .background {
790 .background {
568 if isSelected {
791 if isSelected {
569 Color.accentColor.opacity(0.2)
792 Color.accentColor.opacity(0.2)
570− } else if let tint = severityRowTint(for: extractSeverity(from: record)) {
571− tint
572− } else if isAlternate {
573− Color.primary.opacity(0.02)
574 } else {
793 } else {
575− Color.clear
794+ ZStack {
795+ if isAlternate {
796+ Color.primary.opacity(0.02)
797+ }
798+ if let tint = severityRowTint(for: severity) {
799+ tint
800+ }
801+ }
576 }
802 }
577 }
803 }
578 }
804 }
579
805
580 private func colorForValue(column: String, value: JSONValue?) -> Color {
806 private func colorForValue(column: String, value: JSONValue?) -> Color {
581 guard let value else { return .secondary }
807 guard let value else { return .secondary }
582− if severityColumnNames.contains(column.lowercased()) {
808+ if severityColumns.contains(column) {
583 return levelColor(for: value.displayString)
809 return levelColor(for: value.displayString)
584 }
810 }
585 return .primary
811 return .primary
ParseableViewer/Views/QueryView.swift+159−57
@@ -1,13 +1,30 @@
1import SwiftUI
1import SwiftUI
2
2
3+/// Composite of the inputs that decide which query QueryView should run. Watched
4+/// by a single `.onChange` so a stream switch and a pending saved-filter (which
5+/// arrive in the same update) are handled together in one pass — two separate
6+/// `.onChange` handlers would race and could clobber the filter with the default
7+/// query depending on their firing order.
8+private struct QueryViewContext: Equatable {
9+ let stream: String?
10+ let pendingSQL: String?
11+}
12+
3struct QueryView: View {
13struct QueryView: View {
4 @Environment(AppState.self) private var appState
14 @Environment(AppState.self) private var appState
15+ /// Owned by MainContentView, not by this view: only the active tab is
16+ /// mounted, so a view-local @State model was rebuilt on every tab switch —
17+ /// discarding the user's query, results, time range and filter and re-running
18+ /// the default query when they came back.
5− @State private var viewModel = QueryViewModel()
19+ let viewModel: QueryViewModel
6− @State private var showSaveQuerySheet = false
20+ @State private var showSaveFilterSheet = false
7− @State private var saveQueryName = ""
21+ @State private var saveFilterName = ""
22+ @State private var isSavingFilter = false
23+ @State private var saveFilterError: String?
8 @State private var exportError: String?
24 @State private var exportError: String?
9 @State private var showExportError = false
25 @State private var showExportError = false
10 @State private var showColumnPopover = false
26 @State private var showColumnPopover = false
27+ @State private var wrapText = false
11
28
12 var body: some View {
29 var body: some View {
13 @Bindable var vm = viewModel
30 @Bindable var vm = viewModel
@@ -63,20 +80,20 @@ struct QueryView: View {
63 }
80 }
64
81
65 Button {
82 Button {
66− showSaveQuerySheet = true
83+ showSaveFilterSheet = true
67 } label: {
84 } label: {
68 Text("Save view")
85 Text("Save view")
69 }
86 }
70− .help("Save query")
87+ .help("Save filter")
71 .disabled(viewModel.sqlQuery.isEmpty)
88 .disabled(viewModel.sqlQuery.isEmpty)
72− .accessibilityLabel("Save view")
89+ .accessibilityLabel("Save filter")
73
90
74 Button {
91 Button {
75 exportResults()
92 exportResults()
76 } label: {
93 } label: {
77 Image(systemName: "square.and.arrow.up")
94 Image(systemName: "square.and.arrow.up")
78 }
95 }
79− .disabled(viewModel.results.isEmpty)
96+ .disabled(viewModel.filteredResults.isEmpty)
80 .accessibilityLabel("Export results")
97 .accessibilityLabel("Export results")
81 .help("Export results")
98 .help("Export results")
82
99
@@ -118,7 +135,8 @@ struct QueryView: View {
118 SQLEditorView(
135 SQLEditorView(
119 text: $vm.sqlQuery,
136 text: $vm.sqlQuery,
120 streamNames: appState.streams.map(\.name),
137 streamNames: appState.streams.map(\.name),
121− schemaFields: viewModel.schemaFields
138+ schemaFields: viewModel.schemaFields,
139+ errorRange: viewModel.errorRange
122 )
140 )
123 .frame(minHeight: 60, maxHeight: 120)
141 .frame(minHeight: 60, maxHeight: 120)
124 .clipShape(RoundedRectangle(cornerRadius: 4))
142 .clipShape(RoundedRectangle(cornerRadius: 4))
@@ -192,6 +210,14 @@ struct QueryView: View {
192
210
193 Spacer()
211 Spacer()
194
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+
195 if !viewModel.columns.isEmpty {
221 if !viewModel.columns.isEmpty {
196 Button {
222 Button {
197 showColumnPopover.toggle()
223 showColumnPopover.toggle()
@@ -223,7 +249,25 @@ struct QueryView: View {
223 columns: viewModel.visibleColumns,
249 columns: viewModel.visibleColumns,
224 selectedRecord: $vm.selectedLogEntry,
250 selectedRecord: $vm.selectedLogEntry,
225 isLoading: viewModel.isLoading,
251 isLoading: viewModel.isLoading,
252+ wrapText: wrapText,
226 onCellFilter: { column, value, exclude in
253 onCellFilter: { column, value, exclude in
254+ // Arrays/objects are List/Struct columns on the server; a
255+ // `col = '<json>'` comparison fails to plan, and the failed
256+ // run would clear the results the user is looking at.
257+ if let value, !value.isScalar {
258+ viewModel.errorMessage = "Filtering by an array or object value isn't supported in SQL queries."
259+ return
260+ }
261+ // Results from a blank editor come from the auto-generated
262+ // default query, which `addColumnFilter` can't see (it edits
263+ // `sqlQuery`); put that query in the editor so the filter
264+ // actually applies instead of silently re-running unfiltered.
265+ if viewModel.sqlQuery.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty,
266+ let stream = appState.selectedStream {
267+ // (Passing a previous stream forces the replacement even
268+ // when the blank editor holds only whitespace.)
269+ viewModel.setDefaultQuery(stream: stream, previousStream: stream)
270+ }
227 viewModel.addColumnFilter(column: column, value: value, exclude: exclude)
271 viewModel.addColumnFilter(column: column, value: value, exclude: exclude)
228 Task {
272 Task {
229 await viewModel.executeQuery(
273 await viewModel.executeQuery(
@@ -238,19 +282,36 @@ struct QueryView: View {
238 )
282 )
239 }
283 }
240 }
284 }
241− .onChange(of: appState.selectedStream) { oldValue, newValue in
285+ .onChange(of: QueryViewContext(stream: appState.selectedStream, pendingSQL: appState.pendingFilterSQL)) { old, new in
286+ guard let stream = new.stream else {
242− viewModel.clearResults()
287+ viewModel.clearResults()
243− if let stream = newValue {
288+ return
244− let didSetDefault = viewModel.setDefaultQuery(stream: stream, previousStream: oldValue)
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+ )
245 Task {
296 Task {
246 await viewModel.loadSchema(client: appState.client, stream: stream)
297 await viewModel.loadSchema(client: appState.client, stream: stream)
247 }
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)
248 if didSetDefault {
312 if didSetDefault {
249 Task {
313 Task {
250− await viewModel.executeQuery(
314+ await viewModel.executeQuery(client: appState.client, stream: stream)
251− client: appState.client,
252− stream: stream
253− )
254 }
315 }
255 }
316 }
256 }
317 }
@@ -266,28 +327,63 @@ struct QueryView: View {
266 }
327 }
267 .onAppear {
328 .onAppear {
268 if let stream = appState.selectedStream {
329 if let stream = appState.selectedStream {
330+ // The view model outlives this view. If the stream (or server)
331+ // changed while another tab was showing, the state it kept belongs
332+ // to the old one — reset it exactly like a stream switch would.
333+ let context = QueryViewModel.BoundContext(
334+ connectionID: appState.activeConnection?.id, stream: stream
335+ )
336+ var previousStream: String?
337+ if let bound = viewModel.boundContext, bound != context {
338+ previousStream = bound.stream
339+ viewModel.clearResults()
340+ // A freshly built model (what this view used to get on every
341+ // mount) had no results filter; don't let an old one hide rows.
342+ viewModel.filterText = ""
343+ }
344+ viewModel.boundContext = context
345+ // Reload the schema when it's missing OR when the loaded schema
346+ // belongs to a different stream than the current one — otherwise a
347+ // viewModel that survives with another stream's schema would keep
348+ // autocomplete pointed at the wrong stream's fields until the next
349+ // stream switch.
269− if viewModel.schemaFields.isEmpty {
350+ if viewModel.schemaFields.isEmpty || viewModel.latestSchemaStream != stream {
270 Task {
351 Task {
271 await viewModel.loadSchema(client: appState.client, stream: stream)
352 await viewModel.loadSchema(client: appState.client, stream: stream)
272 }
353 }
273 }
354 }
274− let didSetDefault = viewModel.setDefaultQuery(stream: stream)
355+ // A saved filter may have set pendingFilterSQL before this view was
275− if didSetDefault && viewModel.results.isEmpty {
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
276 Task {
363 Task {
277 await viewModel.executeQuery(
364 await viewModel.executeQuery(
278 client: appState.client,
365 client: appState.client,
279 stream: stream
366 stream: stream
280 )
367 )
281 }
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+ }
282 }
379 }
283 }
380 }
284 }
381 }
285− .onDisappear {
286− if viewModel.isLoading {
287− viewModel.cancelQuery()
288− }
289− }
290 .onChange(of: appState.queryRefreshToken) { _, _ in
382 .onChange(of: appState.queryRefreshToken) { _, _ in
383+ // Guard on a selected stream like every sibling query trigger in this
384+ // view. Without it, a refresh (Cmd+R) with no stream selected and an
385+ // empty editor surfaces a spurious "Select a stream..." error.
386+ guard appState.selectedStream != nil else { return }
291 Task {
387 Task {
292 await viewModel.executeQuery(
388 await viewModel.executeQuery(
293 client: appState.client,
389 client: appState.client,
@@ -295,47 +391,47 @@ struct QueryView: View {
295 )
391 )
296 }
392 }
297 }
393 }
298− .onChange(of: appState.pendingSavedQuery) { _, newQuery in
299− if let query = newQuery {
300− viewModel.sqlQuery = query.sql
301− if let order = query.columnOrder {
302− viewModel.pendingColumnConfig = QueryViewModel.ColumnConfiguration(
303− order: order,
304− hidden: query.hiddenColumns ?? []
305− )
306− }
307− appState.pendingSavedQuery = nil
308− }
309− }
310 .alert("Export Error", isPresented: $showExportError) {
394 .alert("Export Error", isPresented: $showExportError) {
311 Button("OK") {}
395 Button("OK") {}
312 } message: {
396 } message: {
313 Text(exportError ?? "Unknown error")
397 Text(exportError ?? "Unknown error")
314 }
398 }
315− .sheet(isPresented: $showSaveQuerySheet) {
399+ .sheet(isPresented: $showSaveFilterSheet) {
316 VStack(spacing: 16) {
400 VStack(spacing: 16) {
317− Text("Save Query")
401+ Text("Save Filter")
318 .font(.headline)
402 .font(.headline)
319− TextField("Query name", text: $saveQueryName)
403+ TextField("Filter name", text: $saveFilterName)
320 .textFieldStyle(.roundedBorder)
404 .textFieldStyle(.roundedBorder)
405+ if let error = saveFilterError {
406+ Text(error)
407+ .font(.caption)
408+ .foregroundStyle(.red)
409+ }
321 HStack {
410 HStack {
322− Button("Cancel") { showSaveQuerySheet = false }
411+ Button("Cancel") {
412+ saveFilterError = nil
413+ showSaveFilterSheet = false
414+ }
323 Spacer()
415 Spacer()
324 Button("Save") {
416 Button("Save") {
325− let colOrder = viewModel.columnOrder.isEmpty ? nil : viewModel.columnOrder
417+ isSavingFilter = true
326− let hidden = viewModel.hiddenColumns.isEmpty ? nil : viewModel.hiddenColumns
418+ saveFilterError = nil
327− let query = SavedQuery(
419+ Task {
328− name: saveQueryName,
420+ do {
421+ try await appState.saveFilter(
422+ name: saveFilterName,
329− sql: viewModel.sqlQuery,
423+ sql: viewModel.sqlQuery,
330− stream: appState.selectedStream ?? "",
424+ stream: appState.selectedStream ?? ""
331− columnOrder: colOrder,
425+ )
332− hiddenColumns: hidden
426+ saveFilterName = ""
333− )
427+ showSaveFilterSheet = false
334− appState.addSavedQuery(query)
428+ } catch {
335− saveQueryName = ""
429+ saveFilterError = ParseableError.userFriendlyMessage(for: error)
336− showSaveQuerySheet = false
430+ }
431+ isSavingFilter = false
432+ }
337 }
433 }
338− .disabled(saveQueryName.isEmpty)
434+ .disabled(saveFilterName.isEmpty || isSavingFilter)
339 .buttonStyle(.borderedProminent)
435 .buttonStyle(.borderedProminent)
340 }
436 }
341 }
437 }
@@ -351,7 +447,10 @@ struct QueryView: View {
351 panel.begin { result in
447 panel.begin { result in
352 guard result == .OK, let url = panel.url else { return }
448 guard result == .OK, let url = panel.url else { return }
353 let isCSV = url.pathExtension.lowercased() == "csv"
449 let isCSV = url.pathExtension.lowercased() == "csv"
450+ // Export what's on screen: the table renders `filteredResults`, so a
451+ // user who has typed a result filter expects the export to contain
452+ // those rows, not the full unfiltered set.
354− let results = viewModel.results
453+ let results = viewModel.filteredResults
355 let columns = viewModel.visibleColumns
454 let columns = viewModel.visibleColumns
356 Task.detached(priority: .userInitiated) {
455 Task.detached(priority: .userInitiated) {
357 let content: String?
456 let content: String?
@@ -360,7 +459,10 @@ struct QueryView: View {
360 } else {
459 } else {
361 let encoder = JSONEncoder()
460 let encoder = JSONEncoder()
362 encoder.outputFormatting = [.prettyPrinted, .sortedKeys]
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)
363− content = (try? encoder.encode(results))
465+ content = (try? encoder.encode(projected))
364 .flatMap { String(data: $0, encoding: .utf8) }
466 .flatMap { String(data: $0, encoding: .utf8) }
365 }
467 }
366 guard let content else {
468 guard let content else {
@@ -394,7 +496,7 @@ struct ColumnManagerView: View {
394 Text("Columns")
496 Text("Columns")
395 .font(.headline)
497 .font(.headline)
396 Spacer()
498 Spacer()
397− if !viewModel.hiddenColumns.isEmpty || !viewModel.autoHiddenColumns.isEmpty || viewModel.columnOrder != viewModel.columns {
499+ if viewModel.hasHiddenColumns || viewModel.columnOrder != viewModel.columns {
398 Button("Reset") {
500 Button("Reset") {
399 viewModel.resetColumnConfig()
501 viewModel.resetColumnConfig()
400 }
502 }
@@ -448,7 +550,7 @@ struct ColumnManagerView: View {
448 }
550 }
449 .listStyle(.plain)
551 .listStyle(.plain)
450
552
451− if !viewModel.hiddenColumns.isEmpty || !viewModel.autoHiddenColumns.isEmpty {
553+ if viewModel.hasHiddenColumns {
452 Divider()
554 Divider()
453 Button("Show All Columns") {
555 Button("Show All Columns") {
454 viewModel.showAllColumns()
556 viewModel.showAllColumns()
ParseableViewer/Views/SQLAutoComplete.swift+190−22
@@ -10,18 +10,23 @@ struct SQLCompletionItem: Hashable {
10 case column
10 case column
11 }
11 }
12
12
13+ /// The text inserted into the editor when the completion is accepted. For
14+ /// tables this is the quoted identifier (`"name"`) so the resulting SQL is
15+ /// valid even when the stream name contains spaces or other special chars.
13 let text: String
16 let text: String
14 let kind: Kind
17 let kind: Kind
15 let detail: String?
18 let detail: String?
16− /// The raw text to insert (without quoting). For tables this equals the
19+ /// The bare text used for prefix/exact-match comparison against what the
17− /// unquoted stream name so prefix matching works correctly.
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`.
18− let insertText: String
23+ let matchText: String
19
24
20− init(text: String, kind: Kind, detail: String? = nil, insertText: String? = nil) {
25+ init(text: String, kind: Kind, detail: String? = nil, matchText: String? = nil) {
21 self.text = text
26 self.text = text
22 self.kind = kind
27 self.kind = kind
23 self.detail = detail
28 self.detail = detail
24− self.insertText = insertText ?? text
29+ self.matchText = matchText ?? text
25 }
30 }
26
31
27 var kindLabel: String {
32 var kindLabel: String {
@@ -81,14 +86,59 @@ enum SQLCompletionProvider {
81 }
86 }
82 }
87 }
83
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+
84 let prefix = nsText.substring(with: NSRange(location: wordStart, length: cursorPosition - wordStart))
102 let prefix = nsText.substring(with: NSRange(location: wordStart, length: cursorPosition - wordStart))
85− let range = NSRange(location: wordStart, length: cursorPosition - wordStart)
86
103
87 guard !prefix.isEmpty else {
104 guard !prefix.isEmpty else {
88− return ([], "", range)
105+ return ([], "", NSRange(location: wordStart, length: wordEnd - wordStart))
106+ }
107+
108+ // Words typed inside a comment or a string literal aren't SQL, so offer
109+ // nothing there. Otherwise the popup opens (e.g. `-- grouped by date`
110+ // matches DATE) and the Return/Tab meant to end the line or keep typing is
111+ // captured to accept a keyword, rewriting the comment or the literal value.
112+ if isInsideCommentOrStringLiteral(text, offset: cursorPosition) {
113+ return ([], prefix, NSRange(location: wordStart, length: wordEnd - wordStart))
89 }
114 }
90
115
91 let context = determineContext(text: text, position: wordStart)
116 let context = determineContext(text: text, position: wordStart)
117+
118+ // Table (and column) completions supply their own quoting. If the user has
119+ // already typed quotes directly around the word (common for stream names
120+ // with spaces, e.g. `FROM "my"`), absorb them into the replacement range so
121+ // the inserted `"name"` replaces the stray quotes instead of producing
122+ // `""name""`. Mirror `insertCompletion`, which absorbs leading and trailing
123+ // quotes independently for any `.table`/`.column` item — so apply this
124+ // wherever those completions are offered (`.tableRef`, `.columnRef` and
125+ // `.general`).
126+ var replaceStart = wordStart
127+ var replaceEnd = wordEnd
128+ let offersIdentifiers: Bool
129+ switch context {
130+ case .tableRef, .columnRef, .general: offersIdentifiers = true
131+ case .afterOrder, .afterGroup: offersIdentifiers = false
132+ }
133+ if offersIdentifiers {
134+ if wordStart > 0, nsText.character(at: wordStart - 1) == 0x22 {
135+ replaceStart -= 1
136+ }
137+ if wordEnd < nsText.length, nsText.character(at: wordEnd) == 0x22 {
138+ replaceEnd += 1
139+ }
140+ }
141+ let range = NSRange(location: replaceStart, length: replaceEnd - replaceStart)
92 let uppercasePrefix = prefix.uppercased()
142 let uppercasePrefix = prefix.uppercased()
93
143
94 var items: [SQLCompletionItem] = []
144 var items: [SQLCompletionItem] = []
@@ -99,19 +149,19 @@ enum SQLCompletionProvider {
99 items.append(SQLCompletionItem(
149 items.append(SQLCompletionItem(
100 text: "\"\(name)\"",
150 text: "\"\(name)\"",
101 kind: .table,
151 kind: .table,
102− insertText: name
152+ matchText: name
103 ))
153 ))
104 }
154 }
105
155
106 case .columnRef:
156 case .columnRef:
107 for field in schemaFields.sorted(by: { $0.name < $1.name })
157 for field in schemaFields.sorted(by: { $0.name < $1.name })
108 where field.name.uppercased().hasPrefix(uppercasePrefix) {
158 where field.name.uppercased().hasPrefix(uppercasePrefix) {
109− items.append(SQLCompletionItem(text: field.name, kind: .column, detail: field.dataType))
159+ items.append(columnItem(for: field))
110 }
160 }
111− for fn in SQLSyntaxHighlighter.functions.sorted() where fn.hasPrefix(uppercasePrefix) {
161+ for fn in SQLSyntaxHighlighter.sortedFunctions where fn.hasPrefix(uppercasePrefix) {
112 items.append(SQLCompletionItem(text: fn, kind: .function))
162 items.append(SQLCompletionItem(text: fn, kind: .function))
113 }
163 }
114− for kw in SQLSyntaxHighlighter.keywords.sorted() where kw.hasPrefix(uppercasePrefix) {
164+ for kw in SQLSyntaxHighlighter.sortedKeywords where kw.hasPrefix(uppercasePrefix) {
115 items.append(SQLCompletionItem(text: kw, kind: .keyword))
165 items.append(SQLCompletionItem(text: kw, kind: .keyword))
116 }
166 }
117
167
@@ -121,37 +171,120 @@ enum SQLCompletionProvider {
121 }
171 }
122
172
123 case .general:
173 case .general:
124− for kw in SQLSyntaxHighlighter.keywords.sorted() where kw.hasPrefix(uppercasePrefix) {
174+ for kw in SQLSyntaxHighlighter.sortedKeywords where kw.hasPrefix(uppercasePrefix) {
125 items.append(SQLCompletionItem(text: kw, kind: .keyword))
175 items.append(SQLCompletionItem(text: kw, kind: .keyword))
126 }
176 }
127− for fn in SQLSyntaxHighlighter.functions.sorted() where fn.hasPrefix(uppercasePrefix) {
177+ for fn in SQLSyntaxHighlighter.sortedFunctions where fn.hasPrefix(uppercasePrefix) {
128 items.append(SQLCompletionItem(text: fn, kind: .function))
178 items.append(SQLCompletionItem(text: fn, kind: .function))
129 }
179 }
130 for name in streamNames.sorted() where name.uppercased().hasPrefix(uppercasePrefix) {
180 for name in streamNames.sorted() where name.uppercased().hasPrefix(uppercasePrefix) {
131 items.append(SQLCompletionItem(
181 items.append(SQLCompletionItem(
132 text: "\"\(name)\"",
182 text: "\"\(name)\"",
133 kind: .table,
183 kind: .table,
134− insertText: name
184+ matchText: name
135 ))
185 ))
136 }
186 }
137 for field in schemaFields.sorted(by: { $0.name < $1.name })
187 for field in schemaFields.sorted(by: { $0.name < $1.name })
138 where field.name.uppercased().hasPrefix(uppercasePrefix) {
188 where field.name.uppercased().hasPrefix(uppercasePrefix) {
139− items.append(SQLCompletionItem(text: field.name, kind: .column, detail: field.dataType))
189+ items.append(columnItem(for: field))
140 }
190 }
141 }
191 }
142
192
143− // Don't show the popup when the only match is an exact hit
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
144− if items.count == 1 && items[0].text.uppercased() == uppercasePrefix {
204+ : only.matchText.uppercased() == uppercasePrefix
145− return ([], prefix, range)
205+ if isExactHit { return ([], prefix, range) }
146 }
206 }
147
207
148 return (items, prefix, range)
208 return (items, prefix, range)
149 }
209 }
150
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+
151 // MARK: - Context Detection
270 // MARK: - Context Detection
152
271
153 static func determineContext(text: String, position: Int) -> SQLCompletionContext {
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.
154− let before = String(text.prefix(position))
276+ let before = (text as NSString).substring(to: position)
277+
278+ // A qualifier dot directly before the caret (`t.co|`, `"stream".le|`) means
279+ // we're completing the column half of a table-qualified reference. The word
280+ // scan that produced `position` stops at the dot (it's not a word character),
281+ // while the tokenizer keeps the dot attached to the preceding token — so
282+ // without this the last token is `"t."`, which matches no keyword and falls
283+ // through to `.general`, mixing keywords/tables into a column position.
284+ if before.hasSuffix(".") {
285+ return .columnRef
286+ }
287+
155 let tokens = tokenize(before)
288 let tokens = tokenize(before)
156
289
157 guard let lastToken = tokens.last?.uppercased() else {
290 guard let lastToken = tokens.last?.uppercased() else {
@@ -169,7 +302,10 @@ enum SQLCompletionProvider {
169 case "GROUP":
302 case "GROUP":
170 return .afterGroup
303 return .afterGroup
171 default:
304 default:
305+ // After a `,` or an opening `(` the caret continues an argument/column
306+ // list (e.g. `SELECT COUNT(`, `WHERE status IN (`, `SELECT a, `), so
307+ // scan back to the governing keyword to decide column vs table context.
172− if lastToken == "," {
308+ if lastToken == "," || lastToken == "(" {
173 for token in tokens.reversed() {
309 for token in tokens.reversed() {
174 let upper = token.uppercased()
310 let upper = token.uppercased()
175 if ["SELECT", "BY", "WHERE", "HAVING", "ON"].contains(upper) {
311 if ["SELECT", "BY", "WHERE", "HAVING", "ON"].contains(upper) {
@@ -228,7 +364,12 @@ enum SQLCompletionProvider {
228 (ch >= 0x41 && ch <= 0x5A) || // A-Z
364 (ch >= 0x41 && ch <= 0x5A) || // A-Z
229 (ch >= 0x61 && ch <= 0x7A) || // a-z
365 (ch >= 0x61 && ch <= 0x7A) || // a-z
230 (ch >= 0x30 && ch <= 0x39) || // 0-9
366 (ch >= 0x30 && ch <= 0x39) || // 0-9
231− ch == 0x5F // _
367+ ch == 0x5F || // _
368+ ch >= 0x80 // any non-ASCII code unit: Unicode letters
369+ // and digits in identifiers (e.g. accented or CJK schema field names), plus
370+ // both halves of a surrogate pair. SQL delimiters — whitespace, operators,
371+ // quotes, parens, commas — are all ASCII (< 0x80), so this only sweeps in
372+ // genuine identifier characters and won't run a word past a token boundary.
232 }
373 }
233}
374}
234
375
@@ -324,7 +465,12 @@ final class SQLCompletionPopup: NSObject, NSTableViewDataSource, NSTableViewDele
324
465
325 let rowCount = min(items.count, Self.maxVisibleRows)
466 let rowCount = min(items.count, Self.maxVisibleRows)
326 let height = CGFloat(rowCount) * Self.rowHeight + 4
467 let height = CGFloat(rowCount) * Self.rowHeight + 4
468+ // Anchor by the top edge (where the caret sits), matching show()/reposition().
469+ // The panel's origin is its bottom-left corner, so holding origin.y fixed
470+ // while the height changes would move the top edge — growing the list would
471+ // creep the popup upward over the caret. Pin the top edge instead.
472+ let topEdge = panel.frame.origin.y + panel.frame.height
327− let frame = NSRect(x: panel.frame.origin.x, y: panel.frame.origin.y,
473+ let frame = NSRect(x: panel.frame.origin.x, y: topEdge - height,
328 width: Self.panelWidth, height: height)
474 width: Self.panelWidth, height: height)
329 panel.setFrame(frame, display: true)
475 panel.setFrame(frame, display: true)
330 }
476 }
@@ -339,6 +485,17 @@ final class SQLCompletionPopup: NSObject, NSTableViewDataSource, NSTableViewDele
339 panel.orderFront(nil)
485 panel.orderFront(nil)
340 }
486 }
341
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+
342 func hide() {
499 func hide() {
343 panel.parent?.removeChildWindow(panel)
500 panel.parent?.removeChildWindow(panel)
344 panel.orderOut(nil)
501 panel.orderOut(nil)
@@ -372,6 +529,17 @@ final class SQLCompletionPopup: NSObject, NSTableViewDataSource, NSTableViewDele
372
529
373 // MARK: - NSTableViewDelegate
530 // MARK: - NSTableViewDelegate
374
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+
375 func tableView(_ tableView: NSTableView, viewFor tableColumn: NSTableColumn?, row: Int) -> NSView? {
543 func tableView(_ tableView: NSTableView, viewFor tableColumn: NSTableColumn?, row: Int) -> NSView? {
376 guard row < items.count else { return nil }
544 guard row < items.count else { return nil }
377 let item = items[row]
545 let item = items[row]
ParseableViewer/Views/SQLEditorView.swift+165−9
@@ -6,6 +6,7 @@ struct SQLEditorView: NSViewRepresentable {
6 @Binding var text: String
6 @Binding var text: String
7 var streamNames: [String] = []
7 var streamNames: [String] = []
8 var schemaFields: [SchemaField] = []
8 var schemaFields: [SchemaField] = []
9+ var errorRange: NSRange?
9
10
10 static let editorFont = NSFont.monospacedSystemFont(ofSize: NSFont.systemFontSize, weight: .regular)
11 static let editorFont = NSFont.monospacedSystemFont(ofSize: NSFont.systemFontSize, weight: .regular)
11
12
@@ -38,6 +39,7 @@ struct SQLEditorView: NSViewRepresentable {
38
39
39 textView.string = text
40 textView.string = text
40 context.coordinator.applyHighlighting(to: textView)
41 context.coordinator.applyHighlighting(to: textView)
42+ context.coordinator.updateErrorHighlight(in: textView, errorRange: errorRange)
41
43
42 scrollView.hasVerticalScroller = true
44 scrollView.hasVerticalScroller = true
43 scrollView.hasHorizontalScroller = false
45 scrollView.hasHorizontalScroller = false
@@ -55,29 +57,55 @@ struct SQLEditorView: NSViewRepresentable {
55 // Only update when text changed externally (not from user typing)
57 // Only update when text changed externally (not from user typing)
56 if textView.string != text && !context.coordinator.isEditing {
58 if textView.string != text && !context.coordinator.isEditing {
57 let selectedRanges = textView.selectedRanges
59 let selectedRanges = textView.selectedRanges
58− textView.string = text
60+ context.coordinator.replaceText(with: text, in: textView)
59 context.coordinator.applyHighlighting(to: textView)
61 context.coordinator.applyHighlighting(to: textView)
60− let validRanges = selectedRanges.filter { NSMaxRange($0.rangeValue) <= textView.string.count }
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+ }
61− if !validRanges.isEmpty {
74+ if !clampedRanges.isEmpty {
62− textView.selectedRanges = validRanges
75+ textView.selectedRanges = clampedRanges
63 }
76 }
64 }
77 }
78+ context.coordinator.updateErrorHighlight(in: textView, errorRange: errorRange)
65 }
79 }
66
80
67 func makeCoordinator() -> Coordinator {
81 func makeCoordinator() -> Coordinator {
68 Coordinator(text: $text, streamNames: streamNames, schemaFields: schemaFields)
82 Coordinator(text: $text, streamNames: streamNames, schemaFields: schemaFields)
69 }
83 }
70
84
85+ static func dismantleNSView(_ nsView: NSScrollView, coordinator: Coordinator) {
86+ coordinator.cleanup()
87+ }
88+
71 final class Coordinator: NSObject, NSTextViewDelegate {
89 final class Coordinator: NSObject, NSTextViewDelegate {
72 var text: Binding<String>
90 var text: Binding<String>
73 var isEditing = false
91 var isEditing = false
74 var streamNames: [String]
92 var streamNames: [String]
75 var schemaFields: [SchemaField]
93 var schemaFields: [SchemaField]
76
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+
77 private let completionPopup = SQLCompletionPopup()
100 private let completionPopup = SQLCompletionPopup()
78 /// Tracks whether we are inserting a completion so that `textDidChange`
101 /// Tracks whether we are inserting a completion so that `textDidChange`
79 /// doesn't re-trigger the popup for the replacement.
102 /// doesn't re-trigger the popup for the replacement.
80 private var isInsertingCompletion = false
103 private var isInsertingCompletion = false
104+ /// Set while `replaceText(with:in:)` pushes a model-side change (history
105+ /// pick, saved filter, cell filter, column toggle…) into the text view, so
106+ /// `textDidChange` doesn't echo it back into the binding or open the
107+ /// completion popup for text the user didn't type.
108+ private var isApplyingModelChange = false
81
109
82 init(text: Binding<String>, streamNames: [String], schemaFields: [SchemaField]) {
110 init(text: Binding<String>, streamNames: [String], schemaFields: [SchemaField]) {
83 self.text = text
111 self.text = text
@@ -93,8 +121,10 @@ struct SQLEditorView: NSViewRepresentable {
93 // MARK: - NSTextViewDelegate
121 // MARK: - NSTextViewDelegate
94
122
95 func textDidChange(_ notification: Notification) {
123 func textDidChange(_ notification: Notification) {
96− guard let textView = notification.object as? NSTextView else { return }
124+ guard let textView = notification.object as? NSTextView,
125+ !isApplyingModelChange else { return }
97 isEditing = true
126 isEditing = true
127+ clearErrorHighlight(in: textView)
98 text.wrappedValue = textView.string
128 text.wrappedValue = textView.string
99 applyHighlighting(to: textView)
129 applyHighlighting(to: textView)
100 isEditing = false
130 isEditing = false
@@ -104,6 +134,34 @@ struct SQLEditorView: NSViewRepresentable {
104 }
134 }
105 }
135 }
106
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+
107 func textView(_ textView: NSTextView, doCommandBy commandSelector: Selector) -> Bool {
165 func textView(_ textView: NSTextView, doCommandBy commandSelector: Selector) -> Bool {
108 guard completionPopup.isVisible else { return false }
166 guard completionPopup.isVisible else { return false }
109
167
@@ -131,6 +189,13 @@ struct SQLEditorView: NSViewRepresentable {
131 // MARK: - Completions
189 // MARK: - Completions
132
190
133 private func updateCompletions(for textView: NSTextView) {
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+
134 let cursorPosition = textView.selectedRange().location
199 let cursorPosition = textView.selectedRange().location
135 let result = SQLCompletionProvider.completions(
200 let result = SQLCompletionProvider.completions(
136 for: textView.string,
201 for: textView.string,
@@ -146,12 +211,22 @@ struct SQLEditorView: NSViewRepresentable {
146
211
147 completionPopup.update(items: result.items)
212 completionPopup.update(items: result.items)
148
213
149− if !completionPopup.isVisible, let window = textView.window {
214+ if let window = textView.window {
150 let screenPoint = cursorScreenPoint(for: textView)
215 let screenPoint = cursorScreenPoint(for: textView)
216+ if completionPopup.isVisible {
217+ completionPopup.reposition(at: screenPoint)
218+ } else {
151− completionPopup.show(at: screenPoint, in: window)
219+ completionPopup.show(at: screenPoint, in: window)
220+ }
152 }
221 }
153 }
222 }
154
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+
155 private func insertCompletion(_ item: SQLCompletionItem) {
230 private func insertCompletion(_ item: SQLCompletionItem) {
156 // Find the text view — walk up from the popup's parent window
231 // Find the text view — walk up from the popup's parent window
157 guard let textView = findTextView() else { return }
232 guard let textView = findTextView() else { return }
@@ -159,7 +234,9 @@ struct SQLEditorView: NSViewRepresentable {
159 let cursorPosition = textView.selectedRange().location
234 let cursorPosition = textView.selectedRange().location
160 let nsText = textView.string as NSString
235 let nsText = textView.string as NSString
161
236
162− // Find the prefix range to replace
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").
163 var wordStart = cursorPosition
240 var wordStart = cursorPosition
164 while wordStart > 0 {
241 while wordStart > 0 {
165 let ch = nsText.character(at: wordStart - 1)
242 let ch = nsText.character(at: wordStart - 1)
@@ -170,7 +247,32 @@ struct SQLEditorView: NSViewRepresentable {
170 }
247 }
171 }
248 }
172
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+
173− let replaceRange = NSRange(location: wordStart, length: cursorPosition - wordStart)
275+ let replaceRange = NSRange(location: wordStart, length: wordEnd - wordStart)
174
276
175 isInsertingCompletion = true
277 isInsertingCompletion = true
176 defer { isInsertingCompletion = false }
278 defer { isInsertingCompletion = false }
@@ -209,6 +311,60 @@ struct SQLEditorView: NSViewRepresentable {
209 }
311 }
210 }
312 }
211
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+
212 // MARK: - Helpers
368 // MARK: - Helpers
213
369
214 func applyHighlighting(to textView: NSTextView) {
370 func applyHighlighting(to textView: NSTextView) {
ParseableViewer/Views/ServerInfoView.swift+71−17
@@ -3,9 +3,15 @@ import SwiftUI
3struct ServerInfoView: View {
3struct ServerInfoView: View {
4 @Environment(AppState.self) private var appState
4 @Environment(AppState.self) private var appState
5 @State private var about: ServerAbout?
5 @State private var about: ServerAbout?
6+ // Start in the loading state: the `.task` always runs `loadInfo()` on appear, so
7+ // initializing to `false` would paint the `else` branch's red "Unhealthy" dot for
8+ // one frame before the check begins. Begin neutral ("Checking…") instead.
6− @State private var isLoading = false
9+ @State private var isLoading = true
7 @State private var isHealthy = false
10 @State private var isHealthy = false
8 @State private var errorMessage: String?
11 @State private var errorMessage: String?
12+ /// Bumped by every load so a superseded (cancelled) load can't clear the
13+ /// newer load's spinner or overwrite its results.
14+ @State private var loadGeneration = 0
9
15
10 var body: some View {
16 var body: some View {
11 ScrollView {
17 ScrollView {
@@ -16,7 +22,7 @@ struct ServerInfoView: View {
16 .fontWeight(.semibold)
22 .fontWeight(.semibold)
17 Spacer()
23 Spacer()
18 Button {
24 Button {
19− Task { await loadInfo() }
25+ Task { await loadInfo(forceRefresh: true) }
20 } label: {
26 } label: {
21 Image(systemName: "arrow.clockwise")
27 Image(systemName: "arrow.clockwise")
22 }
28 }
@@ -41,18 +47,31 @@ struct ServerInfoView: View {
41 // Health status
47 // Health status
42 GroupBox("Health") {
48 GroupBox("Health") {
43 HStack {
49 HStack {
50+ // While the check is in flight, show a neutral state
51+ // rather than the default `isHealthy == false`, which
52+ // otherwise flashes a red "Unhealthy" dot on every
53+ // connection switch before the result resolves.
54+ if isLoading {
44− Circle()
55+ Circle()
56+ .fill(.gray)
57+ .frame(width: 12, height: 12)
58+ .accessibilityLabel("Checking health")
59+ Text("Checking…")
60+ .fontWeight(.medium)
61+ } else {
62+ Circle()
45− .fill(isHealthy ? .green : .red)
63+ .fill(isHealthy ? .green : .red)
46− .frame(width: 12, height: 12)
64+ .frame(width: 12, height: 12)
47− .accessibilityLabel(isHealthy ? "Healthy" : "Unhealthy")
65+ .accessibilityLabel(isHealthy ? "Healthy" : "Unhealthy")
48− Text(isHealthy ? "Healthy" : "Unhealthy")
66+ Text(isHealthy ? "Healthy" : "Unhealthy")
49− .fontWeight(.medium)
67+ .fontWeight(.medium)
68+ }
50 Spacer()
69 Spacer()
51 }
70 }
52 .padding(8)
71 .padding(8)
53 }
72 }
54
73
55− if let about = about ?? appState.serverAbout {
74+ if let about {
56 GroupBox("Server Details") {
75 GroupBox("Server Details") {
57 VStack(alignment: .leading, spacing: 8) {
76 VStack(alignment: .leading, spacing: 8) {
58 InfoRow(label: "Version", value: about.version ?? "N/A")
77 InfoRow(label: "Version", value: about.version ?? "N/A")
@@ -123,32 +142,67 @@ struct ServerInfoView: View {
123 }
142 }
124 .padding()
143 .padding()
125 }
144 }
126− .task {
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
127 await loadInfo()
155 await loadInfo()
128 }
156 }
129 }
157 }
130
158
131− private func loadInfo() async {
159+ private func loadInfo(forceRefresh: Bool = false) async {
160+ // Clear isLoading on the no-client path: since isLoading now starts `true`
161+ // (to avoid a red "Unhealthy" flash before the first check), an early return
162+ // without resetting it would wedge the spinner permanently.
132− guard let client = appState.client else { return }
163+ guard let client = appState.client else { isLoading = false; return }
164+ // `getAbout()` is cached for 60 s; the Refresh button must bypass that.
165+ if forceRefresh {
166+ await client.invalidateCache()
167+ }
168+ let connID = appState.activeConnection?.id
169+ loadGeneration &+= 1
170+ let generation = loadGeneration
133 isLoading = true
171 isLoading = true
134 errorMessage = nil
172 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 }
135
176
136 async let healthCheck: Void = client.checkHealth()
177 async let healthCheck: Void = client.checkHealth()
137 async let aboutResult = client.getAbout()
178 async let aboutResult = client.getAbout()
138
179
180+ let healthy: Bool
139 do {
181 do {
140 try await healthCheck
182 try await healthCheck
141− isHealthy = true
183+ healthy = true
142 } catch {
184 } catch {
143− isHealthy = false
185+ healthy = false
144 }
186 }
187+ let aboutValue: ServerAbout?
188+ let aboutError: String?
145 do {
189 do {
146− about = try await aboutResult
190+ aboutValue = try await aboutResult
191+ aboutError = nil
147 } catch {
192 } catch {
148− about = nil
193+ aboutValue = nil
149− errorMessage = ParseableError.userFriendlyMessage(for: error)
194+ aboutError = ParseableError.userFriendlyMessage(for: error)
150 }
195 }
151
196
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
152 isLoading = false
206 isLoading = false
153 }
207 }
154}
208}
ParseableViewer/Views/SettingsView.swift+21−2
@@ -6,6 +6,13 @@ struct SettingsView: View {
6 @AppStorage("maxQueryResults") private var maxQueryResults = 1000
6 @AppStorage("maxQueryResults") private var maxQueryResults = 1000
7 @AppStorage("liveTailPollInterval") private var liveTailPollInterval = 2.0
7 @AppStorage("liveTailPollInterval") private var liveTailPollInterval = 2.0
8 @AppStorage("liveTailMaxEntries") private var liveTailMaxEntries = 5000
8 @AppStorage("liveTailMaxEntries") private var liveTailMaxEntries = 5000
9+ // The Settings scene is a separate window from ContentView. A .sheet presents
10+ // over the window hosting the modifier, and binding both windows to the shared
11+ // appState.showConnectionSheet would present two sheets at once. So the Settings
12+ // window drives its own connection sheet with local state, fully independent of
13+ // the main window's.
14+ @State private var showConnectionSheet = false
15+ @State private var sheetConnection: ServerConnection?
9 var body: some View {
16 var body: some View {
10 TabView {
17 TabView {
11 // General settings
18 // General settings
@@ -76,11 +83,20 @@ struct SettingsView: View {
76 .clipShape(RoundedRectangle(cornerRadius: 4))
83 .clipShape(RoundedRectangle(cornerRadius: 4))
77 }
84 }
78
85
86+ Button {
87+ sheetConnection = connection
88+ showConnectionSheet = true
89+ } label: {
90+ Image(systemName: "pencil")
91+ }
92+ .help("Edit connection")
93+
79 Button(role: .destructive) {
94 Button(role: .destructive) {
80 appState.removeConnection(connection)
95 appState.removeConnection(connection)
81 } label: {
96 } label: {
82 Image(systemName: "trash")
97 Image(systemName: "trash")
83 }
98 }
99+ .help("Delete connection")
84 }
100 }
85 .padding(.vertical, 4)
101 .padding(.vertical, 4)
86 }
102 }
@@ -89,8 +105,8 @@ struct SettingsView: View {
89 HStack {
105 HStack {
90 Spacer()
106 Spacer()
91 Button("Add Connection...") {
107 Button("Add Connection...") {
92− appState.editingConnection = nil
108+ sheetConnection = nil
93− appState.showConnectionSheet = true
109+ showConnectionSheet = true
94 }
110 }
95 }
111 }
96 .padding()
112 .padding()
@@ -100,5 +116,8 @@ struct SettingsView: View {
100 }
116 }
101 }
117 }
102 .frame(width: 500, height: 400)
118 .frame(width: 500, height: 400)
119+ .sheet(isPresented: $showConnectionSheet) {
120+ ConnectionSheet(connection: sheetConnection)
121+ }
103 }
122 }
104}
123}
ParseableViewer/Views/SidebarView.swift+95−44
@@ -6,11 +6,19 @@ struct SidebarView: View {
6 @State private var newStreamName = ""
6 @State private var newStreamName = ""
7 @State private var showDeleteConfirm = false
7 @State private var showDeleteConfirm = false
8 @State private var streamToDelete: String?
8 @State private var streamToDelete: String?
9+ /// The connection `streamToDelete` was picked on. Stream names aren't unique
10+ /// across servers, and the delete runs against whatever client is current
11+ /// when the user confirms — so a server switch while the stats lookup was in
12+ /// flight could otherwise delete a same-named stream on the *new* server.
13+ @State private var streamToDeleteConnectionID: UUID?
14+ /// The client the stream was picked with. Editing the active connection's URL
15+ /// keeps its id but installs a new client for a possibly different server.
16+ @State private var streamToDeleteClient: ParseableClient?
9 @State private var deleteStreamDetail: String?
17 @State private var deleteStreamDetail: String?
10
18
11 /// Validates a stream name, returning an error message or nil if valid.
19 /// Validates a stream name, returning an error message or nil if valid.
12 static func validateStreamName(_ name: String) -> String? {
20 static func validateStreamName(_ name: String) -> String? {
13− if name.trimmingCharacters(in: .whitespaces).isEmpty {
21+ if name.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty {
14 return "Stream name cannot be empty."
22 return "Stream name cannot be empty."
15 }
23 }
16 if name.count > 255 {
24 if name.count > 255 {
@@ -58,11 +66,21 @@ struct SidebarView: View {
58 }
66 }
59 Divider()
67 Divider()
60 Button("Delete...", role: .destructive) {
68 Button("Delete...", role: .destructive) {
69+ // Capture the target so a slower stats fetch
70+ // for a *previously* clicked stream can't write
71+ // its detail (or raise the confirm) against the
72+ // stream the user has since selected — which
73+ // would show the wrong stats on a destructive
74+ // confirmation.
61− streamToDelete = stream.name
75+ let target = stream.name
76+ let targetConnectionID = appState.activeConnection?.id
77+ streamToDelete = target
78+ streamToDeleteConnectionID = targetConnectionID
79+ streamToDeleteClient = appState.client
62 deleteStreamDetail = nil
80 deleteStreamDetail = nil
63 Task {
81 Task {
64 if let client = appState.client,
82 if let client = appState.client,
65− let stats = try? await client.getStreamStats(stream: stream.name) {
83+ let stats = try? await client.getStreamStats(stream: target) {
66 var parts: [String] = []
84 var parts: [String] = []
67 if let count = stats.ingestion?.lifetime_count {
85 if let count = stats.ingestion?.lifetime_count {
68 parts.append("\(count) total records ingested")
86 parts.append("\(count) total records ingested")
@@ -70,21 +88,25 @@ struct SidebarView: View {
70 if let size = stats.storage?.lifetime_size ?? stats.storage?.size {
88 if let size = stats.storage?.lifetime_size ?? stats.storage?.size {
71 parts.append("\(size) storage used")
89 parts.append("\(size) storage used")
72 }
90 }
73− if !parts.isEmpty {
91+ if !parts.isEmpty, streamToDelete == target {
74 deleteStreamDetail = parts.joined(separator: ", ")
92 deleteStreamDetail = parts.joined(separator: ", ")
75 }
93 }
76 }
94 }
95+ if streamToDelete == target,
96+ appState.activeConnection?.id == targetConnectionID,
97+ appState.client === streamToDeleteClient {
77− showDeleteConfirm = true
98+ showDeleteConfirm = true
99+ }
78 }
100 }
79 }
101 }
80 }
102 }
81 }
103 }
82 }
104 }
83
105
84− if !appState.savedQueries.isEmpty {
106+ if !appState.filters.isEmpty {
85− Section("Saved Queries") {
107+ Section("Filters") {
86− ForEach(appState.savedQueries) { query in
108+ ForEach(appState.filters) { filter in
87− SavedQueryRow(query: query)
109+ FilterRow(filter: filter)
88 }
110 }
89 }
111 }
90 }
112 }
@@ -155,10 +177,11 @@ struct SidebarView: View {
155 .alert("Create Log Stream", isPresented: $showCreateStream) {
177 .alert("Create Log Stream", isPresented: $showCreateStream) {
156 TextField("Stream name", text: $newStreamName)
178 TextField("Stream name", text: $newStreamName)
157 Button("Create") {
179 Button("Create") {
158− let name = newStreamName.trimmingCharacters(in: .whitespaces)
180+ let name = newStreamName.trimmingCharacters(in: .whitespacesAndNewlines)
159− guard !name.isEmpty else { return }
181+ guard !name.isEmpty else { newStreamName = ""; return }
160 if let error = Self.validateStreamName(name) {
182 if let error = Self.validateStreamName(name) {
161 appState.showErrorMessage(error)
183 appState.showErrorMessage(error)
184+ newStreamName = ""
162 return
185 return
163 }
186 }
164 Task {
187 Task {
@@ -177,6 +200,11 @@ struct SidebarView: View {
177 .alert("Delete Stream", isPresented: $showDeleteConfirm) {
200 .alert("Delete Stream", isPresented: $showDeleteConfirm) {
178 Button("Delete", role: .destructive) {
201 Button("Delete", role: .destructive) {
179 if let name = streamToDelete {
202 if let name = streamToDelete {
203+ guard appState.activeConnection?.id == streamToDeleteConnectionID,
204+ appState.client === streamToDeleteClient else {
205+ appState.showErrorMessage("The server changed since \"\(name)\" was selected, so it was not deleted.")
206+ return
207+ }
180 Task {
208 Task {
181 do {
209 do {
182 try await appState.deleteStream(name: name)
210 try await appState.deleteStream(name: name)
@@ -199,6 +227,7 @@ struct SidebarView: View {
199
227
200struct ConnectionStatusView: View {
228struct ConnectionStatusView: View {
201 @Environment(AppState.self) private var appState
229 @Environment(AppState.self) private var appState
230+ static let addConnectionID = UUID(uuidString: "00000000-0000-0000-0000-000000000000")!
202
231
203 var body: some View {
232 var body: some View {
204 VStack(alignment: .leading, spacing: 4) {
233 VStack(alignment: .leading, spacing: 4) {
@@ -211,38 +240,31 @@ struct ConnectionStatusView: View {
211 Picker("Connection", selection: Binding<UUID>(
240 Picker("Connection", selection: Binding<UUID>(
212 get: { connection.id },
241 get: { connection.id },
213 set: { newID in
242 set: { newID in
243+ if newID == ConnectionStatusView.addConnectionID {
244+ appState.editingConnection = nil
245+ appState.showConnectionSheet = true
246+ } else if newID != connection.id,
214− if let conn = appState.connections.first(where: { $0.id == newID }) {
247+ let conn = appState.connections.first(where: { $0.id == newID }) {
248+ // Only reconnect when a *different* connection is
249+ // picked. Re-selecting the active one would tear
250+ // down and rebuild the client for no reason (a full
251+ // health-check + stream reload, with a visible flicker).
215 Task { await appState.connect(to: conn) }
252 Task { await appState.connect(to: conn) }
216 }
253 }
217 }
254 }
218 )) {
255 )) {
256+ Section {
219− ForEach(appState.connections) { conn in
257+ ForEach(appState.connections) { conn in
220− Text(conn.name).tag(conn.id)
258+ Text(conn.name).tag(conn.id)
259+ }
260+ }
261+ Section {
262+ Text("Add Connection...").tag(ConnectionStatusView.addConnectionID)
221 }
263 }
222 }
264 }
223 .labelsHidden()
265 .labelsHidden()
224 .pickerStyle(.menu)
266 .pickerStyle(.menu)
225 .accessibilityLabel("Connection selector")
267 .accessibilityLabel("Connection selector")
226− Spacer()
227− Menu {
228− Button("Add Connection...") {
229− appState.editingConnection = nil
230− appState.showConnectionSheet = true
231− }
232− Button("Edit Connection...") {
233− appState.editingConnection = connection
234− appState.showConnectionSheet = true
235− }
236− Divider()
237− Button("Disconnect") {
238− appState.disconnect()
239− }
240− } label: {
241− Image(systemName: "gear")
242− }
243− .menuStyle(.borderlessButton)
244− .frame(width: 24)
245− .accessibilityLabel("Connection options")
246 }
268 }
247 } else if appState.isConnecting {
269 } else if appState.isConnecting {
248 HStack {
270 HStack {
@@ -276,32 +298,51 @@ struct StreamRowView: View {
276 }
298 }
277}
299}
278
300
279−struct SavedQueryRow: View {
301+struct FilterRow: View {
280 @Environment(AppState.self) private var appState
302 @Environment(AppState.self) private var appState
281− let query: SavedQuery
303+ let filter: ParseableFilter
304+ @State private var isDeleting = false
282
305
283 private var streamExists: Bool {
306 private var streamExists: Bool {
284− appState.streams.contains { $0.name == query.stream }
307+ appState.streams.contains { $0.name == filter.streamName }
308+ }
309+
310+ private var isSQLFilter: Bool {
311+ filter.query.filterType == "sql"
285 }
312 }
286
313
287 var body: some View {
314 var body: some View {
288 Button {
315 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 }
289− appState.selectedStream = query.stream
320+ appState.selectedStream = filter.streamName
290− appState.pendingSavedQuery = query
321+ if let sql = filter.query.filterQuery {
322+ appState.pendingFilterSQL = sql
323+ }
291 appState.currentTab = .query
324 appState.currentTab = .query
292 } label: {
325 } label: {
293 HStack {
326 HStack {
294 Image(systemName: "bookmark")
327 Image(systemName: "bookmark")
295− .foregroundStyle(streamExists ? .orange : .secondary)
328+ .foregroundStyle(streamExists ? Color.secondary : Color.orange)
296 VStack(alignment: .leading) {
329 VStack(alignment: .leading) {
297− Text(query.name)
330+ Text(filter.filterName)
298 .lineLimit(1)
331 .lineLimit(1)
299 HStack(spacing: 4) {
332 HStack(spacing: 4) {
300− Text(query.stream)
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+ }
301 if !streamExists {
342 if !streamExists {
302 Image(systemName: "exclamationmark.triangle.fill")
343 Image(systemName: "exclamationmark.triangle.fill")
303 .foregroundStyle(.orange)
344 .foregroundStyle(.orange)
304− .help("Stream \"\(query.stream)\" no longer exists")
345+ .help("Stream \"\(filter.streamName)\" no longer exists")
305 }
346 }
306 }
347 }
307 .font(.caption2)
348 .font(.caption2)
@@ -312,8 +353,18 @@ struct SavedQueryRow: View {
312 .buttonStyle(.plain)
353 .buttonStyle(.plain)
313 .contextMenu {
354 .contextMenu {
314 Button("Delete", role: .destructive) {
355 Button("Delete", role: .destructive) {
315− appState.removeSavedQuery(query)
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+ }
316 }
366 }
367+ .disabled(isDeleting)
317 }
368 }
318 }
369 }
319}
370}
ParseableViewer/Views/StreamDetailView.swift+56−17
@@ -8,6 +8,9 @@ struct StreamDetailView: View {
8 @State private var retention: [RetentionConfig] = []
8 @State private var retention: [RetentionConfig] = []
9 @State private var isLoading = false
9 @State private var isLoading = false
10 @State private var errorMessage: String?
10 @State private var errorMessage: String?
11+ /// Bumped by every load. A superseded load (its `.task(id:)` cancelled by a
12+ /// stream switch) still resumes, and must not clear the newer load's spinner.
13+ @State private var loadGeneration = 0
11
14
12 var body: some View {
15 var body: some View {
13 Group {
16 Group {
@@ -20,20 +23,12 @@ struct StreamDetailView: View {
20 Text(stream)
23 Text(stream)
21 .font(.title2)
24 .font(.title2)
22 .fontWeight(.semibold)
25 .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− }
31 }
26 }
32
27
33 Spacer()
28 Spacer()
34
29
35 Button {
30 Button {
36− Task { await loadData(stream: stream) }
31+ Task { await loadData(stream: stream, forceRefresh: true) }
37 } label: {
32 } label: {
38 Image(systemName: "arrow.clockwise")
33 Image(systemName: "arrow.clockwise")
39 }
34 }
@@ -52,7 +47,7 @@ struct StreamDetailView: View {
52 Text(error)
47 Text(error)
53 Spacer()
48 Spacer()
54 Button("Retry") {
49 Button("Retry") {
55− Task { await loadData(stream: stream) }
50+ Task { await loadData(stream: stream, forceRefresh: true) }
56 }
51 }
57 .buttonStyle(.bordered)
52 .buttonStyle(.bordered)
58 .controlSize(.small)
53 .controlSize(.small)
@@ -165,7 +160,11 @@ struct StreamDetailView: View {
165 }
160 }
166 .padding()
161 .padding()
167 }
162 }
163+ // Key on the connection too: switching to a different server that
164+ // exposes a same-named stream leaves `stream` unchanged, so without
165+ // the connection id the task wouldn't refire and the old server's
166+ // data would linger.
168− .task(id: stream) {
167+ .task(id: "\(appState.activeConnection?.id.uuidString ?? "")|\(stream)") {
169 await loadData(stream: stream)
168 await loadData(stream: stream)
170 }
169 }
171 } else {
170 } else {
@@ -178,8 +177,22 @@ struct StreamDetailView: View {
178 }
177 }
179 }
178 }
180
179
181− private func loadData(stream: String) async {
180+ private func loadData(stream: String, forceRefresh: Bool = false) async {
182 guard let client = appState.client else { return }
181 guard let client = appState.client else { return }
182+ // The client caches schema/stats/info for 60 s. That's right for the
183+ // automatic load on appear, but an explicit Refresh/Retry must hit the
184+ // server — otherwise it repaints the same cached numbers and looks like a
185+ // refresh that found nothing new.
186+ if forceRefresh {
187+ await client.invalidateCache()
188+ }
189+ // Capture the connection this fetch belongs to. Two servers can expose a
190+ // same-named stream, so guarding on the stream name alone would let a
191+ // slow fetch write the previous server's data under the new server's
192+ // stream after a server switch.
193+ let connID = appState.activeConnection?.id
194+ loadGeneration &+= 1
195+ let generation = loadGeneration
183 isLoading = true
196 isLoading = true
184 errorMessage = nil
197 errorMessage = nil
185 // Clear previous stream's data so it isn't shown under the new header
198 // Clear previous stream's data so it isn't shown under the new header
@@ -196,17 +209,43 @@ struct StreamDetailView: View {
196
209
197 var failures: [String] = []
210 var failures: [String] = []
198 var sawNotFound = false
211 var sawNotFound = false
212+ var newSchema: StreamSchema?
213+ var newStats: StreamStats?
214+ var newInfo: StreamInfo?
215+ var newRetention: [RetentionConfig] = []
199
216
200 func checkNotFound(_ error: Error) {
217 func checkNotFound(_ error: Error) {
201 if case .serverError(let code, _) = error as? ParseableError, code == 404 { sawNotFound = true }
218 if case .serverError(let code, _) = error as? ParseableError, code == 404 { sawNotFound = true }
202 }
219 }
203
220
204− do { schema = try await schemaFetch } catch { schema = nil; failures.append("schema"); checkNotFound(error) }
221+ do { newSchema = try await schemaFetch } catch { failures.append("schema"); checkNotFound(error) }
205− do { stats = try await statsFetch } catch { stats = nil; failures.append("stats"); checkNotFound(error) }
222+ do { newStats = try await statsFetch } catch { failures.append("stats"); checkNotFound(error) }
206− do { info = try await infoFetch } catch { info = nil; failures.append("info"); checkNotFound(error) }
223+ do { newInfo = try await infoFetch } catch { failures.append("info"); checkNotFound(error) }
207− do { retention = try await retentionFetch } catch { retention = []; failures.append("retention"); 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
208
243
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".
209− if sawNotFound && failures.count == 4 {
248+ if sawNotFound && failures.count >= 2 {
210 errorMessage = "Stream \"\(stream)\" was not found. It may have been deleted."
249 errorMessage = "Stream \"\(stream)\" was not found. It may have been deleted."
211 } else if !failures.isEmpty {
250 } else if !failures.isEmpty {
212 errorMessage = "Failed to load: \(failures.joined(separator: ", "))"
251 errorMessage = "Failed to load: \(failures.joined(separator: ", "))"
ParseableViewer/Views/SyntaxHighlighting.swift+85−71
@@ -44,6 +44,37 @@ enum SQLSyntaxHighlighter {
44 "UPPER",
44 "UPPER",
45 ]
45 ]
46
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+
47 /// Applies SQL syntax highlighting to an NSTextStorage.
78 /// Applies SQL syntax highlighting to an NSTextStorage.
48 static func highlight(_ text: String, in textStorage: NSTextStorage, baseFont: NSFont) {
79 static func highlight(_ text: String, in textStorage: NSTextStorage, baseFont: NSFont) {
49 let fullRange = NSRange(location: 0, length: textStorage.length)
80 let fullRange = NSRange(location: 0, length: textStorage.length)
@@ -58,66 +89,52 @@ enum SQLSyntaxHighlighter {
58 let nsText = text as NSString
89 let nsText = text as NSString
59 var protectedRanges: [NSRange] = []
90 var protectedRanges: [NSRange] = []
60
91
61− // 1. Comments (-- to end of line)
62− applyPattern("--[^\n]*", to: textStorage, text: nsText,
63− color: .secondaryLabelColor, protectedRanges: &protectedRanges)
64−
65− // 2. Single-quoted strings
92+ // 1–3. Single-quoted strings, double-quoted identifiers, and comments, in
66− applyPattern("'[^']*(?:''[^']*)*'", to: textStorage, text: nsText,
93+ // one pass so none of them can start inside another (`'a--b'` stays a
67− color: .systemRed, protectedRanges: &protectedRanges)
94+ // string, `-- don't` stays a comment).
68−
95+ let literalMatches = literalOrCommentRegex.matches(
69− // 3. Double-quoted identifiers
96+ in: text, range: NSRange(location: 0, length: nsText.length)
70− applyPattern("\"[^\"]*(?:\"\"[^\"]*)*\"", to: textStorage, text: nsText,
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+ )
71− color: .systemRed, protectedRanges: &protectedRanges)
105+ protectedRanges.append(match.range)
106+ }
72
107
73 // 4. Numbers (outside protected ranges)
108 // 4. Numbers (outside protected ranges)
74− applyUnprotectedPattern("\\b\\d+(?:\\.\\d+)?\\b", to: textStorage, text: nsText,
109+ applyUnprotectedRegex(numberRegex, to: textStorage, text: nsText,
75− color: .systemPurple, protectedRanges: protectedRanges)
110+ color: .systemPurple, protectedRanges: protectedRanges)
76
111
77 // 5. Keywords (outside protected ranges)
112 // 5. Keywords (outside protected ranges)
78− let keywordPattern = "\\b(?:" + keywords.sorted().joined(separator: "|") + ")\\b"
113+ applyUnprotectedKeywordRegex(keywordRegex, to: textStorage, text: nsText,
79− applyUnprotectedKeywordPattern(keywordPattern, to: textStorage, text: nsText,
80− color: .systemBlue, bold: true, baseFont: baseFont,
114+ color: .systemBlue, bold: true, baseFont: baseFont,
81− protectedRanges: protectedRanges)
115+ protectedRanges: protectedRanges)
82
116
83 // 6. Functions (outside protected ranges)
117 // 6. Functions (outside protected ranges)
84− let functionPattern = "\\b(?:" + functions.sorted().joined(separator: "|") + ")\\b"
118+ applyUnprotectedKeywordRegex(functionRegex, to: textStorage, text: nsText,
85− applyUnprotectedKeywordPattern(functionPattern, to: textStorage, text: nsText,
86− color: .systemTeal, bold: false, baseFont: baseFont,
119+ color: .systemTeal, bold: false, baseFont: baseFont,
87− protectedRanges: protectedRanges)
120+ protectedRanges: protectedRanges)
88 }
121 }
89
122
90− private static func applyPattern(
123+ private static func applyUnprotectedRegex(
91− _ pattern: String,
124+ _ regex: NSRegularExpression,
92− to textStorage: NSTextStorage,
93− text: NSString,
94− color: NSColor,
95− protectedRanges: inout [NSRange]
96− ) {
97− guard let regex = try? NSRegularExpression(pattern: pattern, options: [.caseInsensitive]) else { return }
98− let matches = regex.matches(in: text as String, range: NSRange(location: 0, length: text.length))
99− for match in matches {
100− textStorage.addAttribute(.foregroundColor, value: color, range: match.range)
101− protectedRanges.append(match.range)
102− }
103− }
104−
105− private static func applyUnprotectedPattern(
106− _ pattern: String,
107 to textStorage: NSTextStorage,
125 to textStorage: NSTextStorage,
108 text: NSString,
126 text: NSString,
109 color: NSColor,
127 color: NSColor,
110 protectedRanges: [NSRange]
128 protectedRanges: [NSRange]
111 ) {
129 ) {
112− guard let regex = try? NSRegularExpression(pattern: pattern, options: []) else { return }
113 let matches = regex.matches(in: text as String, range: NSRange(location: 0, length: text.length))
130 let matches = regex.matches(in: text as String, range: NSRange(location: 0, length: text.length))
114 for match in matches where !isProtected(match.range, by: protectedRanges) {
131 for match in matches where !isProtected(match.range, by: protectedRanges) {
115 textStorage.addAttribute(.foregroundColor, value: color, range: match.range)
132 textStorage.addAttribute(.foregroundColor, value: color, range: match.range)
116 }
133 }
117 }
134 }
118
135
119− private static func applyUnprotectedKeywordPattern(
136+ private static func applyUnprotectedKeywordRegex(
120− _ pattern: String,
137+ _ regex: NSRegularExpression,
121 to textStorage: NSTextStorage,
138 to textStorage: NSTextStorage,
122 text: NSString,
139 text: NSString,
123 color: NSColor,
140 color: NSColor,
@@ -125,7 +142,6 @@ enum SQLSyntaxHighlighter {
125 baseFont: NSFont,
142 baseFont: NSFont,
126 protectedRanges: [NSRange]
143 protectedRanges: [NSRange]
127 ) {
144 ) {
128− guard let regex = try? NSRegularExpression(pattern: pattern, options: [.caseInsensitive]) else { return }
129 let boldFont = NSFont.monospacedSystemFont(ofSize: baseFont.pointSize, weight: .bold)
145 let boldFont = NSFont.monospacedSystemFont(ofSize: baseFont.pointSize, weight: .bold)
130 let matches = regex.matches(in: text as String, range: NSRange(location: 0, length: text.length))
146 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) {
147 for match in matches where !isProtected(match.range, by: protectedRanges) {
@@ -151,6 +167,12 @@ enum SQLSyntaxHighlighter {
151
167
152enum JSONSyntaxHighlighter {
168enum JSONSyntaxHighlighter {
153
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+
154 /// Creates a syntax-highlighted AttributedString from a JSON string.
176 /// Creates a syntax-highlighted AttributedString from a JSON string.
155 static func highlight(_ json: String, font: NSFont? = nil) -> AttributedString {
177 static func highlight(_ json: String, font: NSFont? = nil) -> AttributedString {
156 let resolvedFont = font ?? NSFont.monospacedSystemFont(ofSize: NSFont.smallSystemFontSize, weight: .regular)
178 let resolvedFont = font ?? NSFont.monospacedSystemFont(ofSize: NSFont.smallSystemFontSize, weight: .regular)
@@ -168,45 +190,37 @@ enum JSONSyntaxHighlighter {
168 var stringRanges: [NSRange] = []
190 var stringRanges: [NSRange] = []
169
191
170 // 1. Double-quoted strings — distinguish keys from values
192 // 1. Double-quoted strings — distinguish keys from values
171− if let regex = try? NSRegularExpression(pattern: "\"(?:[^\"\\\\]|\\\\.)*\"", options: []) {
172− let matches = regex.matches(in: json, range: fullRange)
193+ let matches = stringRegex.matches(in: json, range: fullRange)
173− for match in matches {
194+ for match in matches {
174− stringRanges.append(match.range)
195+ stringRanges.append(match.range)
175−
196+
176− let isKey = isJSONKey(after: match.range, in: nsString)
197+ let isKey = isJSONKey(after: match.range, in: nsString)
177− let color: NSColor = isKey ? .systemCyan : .systemRed
198+ let color: NSColor = isKey ? .systemCyan : .systemRed
178− attributed.addAttribute(.foregroundColor, value: color, range: match.range)
199+ attributed.addAttribute(.foregroundColor, value: color, range: match.range)
179− if isKey {
200+ if isKey {
180− attributed.addAttribute(
201+ attributed.addAttribute(
181− .font,
202+ .font,
182− value: NSFont.monospacedSystemFont(ofSize: resolvedFont.pointSize, weight: .medium),
203+ value: NSFont.monospacedSystemFont(ofSize: resolvedFont.pointSize, weight: .medium),
183− range: match.range)
204+ range: match.range)
184− }
185 }
205 }
186 }
206 }
187
207
188 // 2. Booleans (outside strings)
208 // 2. Booleans (outside strings)
189− if let regex = try? NSRegularExpression(pattern: "\\b(true|false)\\b", options: []) {
190− for match in regex.matches(in: json, range: fullRange)
209+ for match in boolRegex.matches(in: json, range: fullRange)
191− where !isInString(match.range, stringRanges: stringRanges) {
210+ where !isInString(match.range, stringRanges: stringRanges) {
192− attributed.addAttribute(.foregroundColor, value: NSColor.systemOrange, range: match.range)
211+ attributed.addAttribute(.foregroundColor, value: NSColor.systemOrange, range: match.range)
193− }
194 }
212 }
195
213
196 // 3. Null (outside strings)
214 // 3. Null (outside strings)
197− if let regex = try? NSRegularExpression(pattern: "\\bnull\\b", options: []) {
198− for match in regex.matches(in: json, range: fullRange)
215+ for match in nullRegex.matches(in: json, range: fullRange)
199− where !isInString(match.range, stringRanges: stringRanges) {
216+ where !isInString(match.range, stringRanges: stringRanges) {
200− attributed.addAttribute(.foregroundColor, value: NSColor.secondaryLabelColor, range: match.range)
217+ attributed.addAttribute(.foregroundColor, value: NSColor.secondaryLabelColor, range: match.range)
201− }
202 }
218 }
203
219
204 // 4. Numbers (outside strings)
220 // 4. Numbers (outside strings)
205− if let regex = try? NSRegularExpression(pattern: "-?\\b\\d+(?:\\.\\d+)?(?:[eE][+-]?\\d+)?\\b", options: []) {
206− for match in regex.matches(in: json, range: fullRange)
221+ for match in numberRegex.matches(in: json, range: fullRange)
207− where !isInString(match.range, stringRanges: stringRanges) {
222+ where !isInString(match.range, stringRanges: stringRanges) {
208− attributed.addAttribute(.foregroundColor, value: NSColor.systemPurple, range: match.range)
223+ attributed.addAttribute(.foregroundColor, value: NSColor.systemPurple, range: match.range)
209− }
210 }
224 }
211
225
212 return AttributedString(attributed)
226 return AttributedString(attributed)
ParseableViewer/Views/TimeRangePicker.swift+25−3
@@ -31,6 +31,27 @@ struct TimeRangePicker: View {
31 .font(.caption)
31 .font(.caption)
32 }
32 }
33 }
33 }
34+ .onChange(of: showCustomPicker) { _, isShown in
35+ // Dismissing the popover by clicking outside bypasses the "Done"
36+ // button's `customEnd > customStart` validation, which could leave an
37+ // inverted or zero-width range selected (the menu already set
38+ // `option == .custom`). Sanitize on close so a backwards range can
39+ // never reach a query.
40+ guard !isShown else { return }
41+ if customEnd <= customStart {
42+ customEnd = customStart.addingTimeInterval(3600)
43+ }
44+ // Commit on *every* close path, not just the "Done" button. Closing
45+ // the popover by clicking outside is the common dismissal gesture;
46+ // without this the picker reads "Custom" while results still reflect
47+ // the previously selected preset, since QueryView only re-queries on
48+ // `timeRangeOption` changes (which it skips for `.custom`) — never on
49+ // the custom dates. Done sets `showCustomPicker = false` and lets this
50+ // handler do the single commit, so there's no double-query.
51+ if option == .custom {
52+ onCommit?()
53+ }
54+ }
34 .popover(isPresented: $showCustomPicker) {
55 .popover(isPresented: $showCustomPicker) {
35 VStack(spacing: 12) {
56 VStack(spacing: 12) {
36 Text("Custom Time Range")
57 Text("Custom Time Range")
@@ -41,7 +62,7 @@ struct TimeRangePicker: View {
41 DatePicker("End:", selection: $customEnd)
62 DatePicker("End:", selection: $customEnd)
42 .datePickerStyle(.field)
63 .datePickerStyle(.field)
43
64
44− if customEnd < customStart {
65+ if customEnd <= customStart {
45 HStack(spacing: 4) {
66 HStack(spacing: 4) {
46 Image(systemName: "exclamationmark.triangle.fill")
67 Image(systemName: "exclamationmark.triangle.fill")
47 Text("End date must be after start date")
68 Text("End date must be after start date")
@@ -78,11 +99,12 @@ struct TimeRangePicker: View {
78 Spacer()
99 Spacer()
79
100
80 Button("Done") {
101 Button("Done") {
102+ // Just close — the `showCustomPicker` change handler is the
103+ // single commit point for all close paths (Done + click-out).
81 showCustomPicker = false
104 showCustomPicker = false
82− onCommit?()
83 }
105 }
84 .buttonStyle(.borderedProminent)
106 .buttonStyle(.borderedProminent)
85− .disabled(customEnd < customStart)
107+ .disabled(customEnd <= customStart)
86 }
108 }
87 }
109 }
88 .padding()
110 .padding()
ParseableViewer/Views/UsersView.swift+26−3
@@ -5,6 +5,9 @@ struct UsersView: View {
5 @State private var users: [UserInfo] = []
5 @State private var users: [UserInfo] = []
6 @State private var isLoading = false
6 @State private var isLoading = false
7 @State private var errorMessage: String?
7 @State private var errorMessage: String?
8+ /// Bumped by every load so a superseded (cancelled) load can't clear the
9+ /// newer load's spinner.
10+ @State private var loadGeneration = 0
8
11
9 var body: some View {
12 var body: some View {
10 VStack(spacing: 0) {
13 VStack(spacing: 0) {
@@ -72,22 +75,42 @@ struct UsersView: View {
72 }
75 }
73 }
76 }
74 }
77 }
75− .task {
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) {
76 await loadUsers()
81 await loadUsers()
77 }
82 }
78 }
83 }
79
84
80 private func loadUsers() async {
85 private func loadUsers() async {
81 guard let client = appState.client else { return }
86 guard let client = appState.client else { return }
87+ let connID = appState.activeConnection?.id
88+ loadGeneration &+= 1
89+ let generation = loadGeneration
82 isLoading = true
90 isLoading = true
83 errorMessage = nil
91 errorMessage = nil
92+ // Clear the previous server's list up front so it can't briefly show through
93+ // after a switch (e.g. on the stale-guard early-return below). Matches the
94+ // sibling views (AlertsView clears alertConfig, ServerInfoView clears about).
95+ users = []
84
96
97+ let loaded: [UserInfo]?
98+ let loadError: String?
85 do {
99 do {
86− users = try await client.listUsers()
100+ loaded = try await client.listUsers()
101+ loadError = nil
87 } catch {
102 } catch {
103+ loaded = nil
88− errorMessage = ParseableError.userFriendlyMessage(for: error)
104+ loadError = ParseableError.userFriendlyMessage(for: error)
89 }
105 }
90
106
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
91 isLoading = false
114 isLoading = false
92 }
115 }
93}
116}
ParseableViewerTests/ConnectionStoreTests.swift+71−108
@@ -2,26 +2,25 @@ import XCTest
2@testable import ParseableViewer
2@testable import ParseableViewer
3
3
4final class ConnectionStoreTests: XCTestCase {
4final class ConnectionStoreTests: XCTestCase {
5− private let testSuiteKey = "parseable_connections_test"
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+ }()
6
15
7 override func tearDown() {
16 override func tearDown() {
8 super.tearDown()
17 super.tearDown()
9− // Clean up any test data from UserDefaults
18+ KeychainService.deleteData(for: "parseable_connections_list")
10− UserDefaults.standard.removeObject(forKey: "parseable_connections")
11− UserDefaults.standard.removeObject(forKey: "parseable_active_connection_id")
19+ KeychainService.deleteData(for: "parseable_active_connection_id")
12− UserDefaults.standard.removeObject(forKey: "parseable_saved_queries")
13 }
20 }
14
21
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)
22− }
23−
24− func testSaveNilActiveConnectionID() {
22+ func testSaveNilActiveConnectionID() throws {
23+ try XCTSkipUnless(Self.keychainAvailable, "Data-protection keychain unavailable")
25 let id = UUID()
24 let id = UUID()
26 ConnectionStore.saveActiveConnectionID(id)
25 ConnectionStore.saveActiveConnectionID(id)
27 ConnectionStore.saveActiveConnectionID(nil)
26 ConnectionStore.saveActiveConnectionID(nil)
@@ -30,7 +29,7 @@ final class ConnectionStoreTests: XCTestCase {
30 }
29 }
31
30
32 func testLoadActiveConnectionIDWhenEmpty() {
31 func testLoadActiveConnectionIDWhenEmpty() {
33− UserDefaults.standard.removeObject(forKey: "parseable_active_connection_id")
32+ KeychainService.deleteData(for: "parseable_active_connection_id")
34 let loaded = ConnectionStore.loadActiveConnectionID()
33 let loaded = ConnectionStore.loadActiveConnectionID()
35 XCTAssertNil(loaded)
34 XCTAssertNil(loaded)
36 }
35 }
@@ -38,35 +37,74 @@ final class ConnectionStoreTests: XCTestCase {
38 // MARK: - Connection persistence
37 // MARK: - Connection persistence
39
38
40 func testLoadConnectionsWhenEmpty() {
39 func testLoadConnectionsWhenEmpty() {
41− UserDefaults.standard.removeObject(forKey: "parseable_connections")
40+ KeychainService.deleteData(for: "parseable_connections_list")
42 let connections = ConnectionStore.loadConnections()
41 let connections = ConnectionStore.loadConnections()
43 XCTAssertTrue(connections.isEmpty)
42 XCTAssertTrue(connections.isEmpty)
44 }
43 }
45
44
46− func testSaveAndLoadConnections() {
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")
47− let connection = ServerConnection(
55+ let conn = ServerConnection(
48− name: "Test Server",
56+ name: "Prod",
49 url: "https://logs.example.com",
57 url: "https://logs.example.com",
50 username: "admin",
58 username: "admin",
51− password: "secret123"
59+ password: "s3cret!"
52 )
60 )
53− ConnectionStore.saveConnections([connection])
61+ ConnectionStore.saveConnections([conn])
62+
54 let loaded = ConnectionStore.loadConnections()
63 let loaded = ConnectionStore.loadConnections()
55 XCTAssertEqual(loaded.count, 1)
64 XCTAssertEqual(loaded.count, 1)
56− XCTAssertEqual(loaded[0].name, "Test Server")
65+ XCTAssertEqual(loaded.first?.id, conn.id)
66+ XCTAssertEqual(loaded.first?.name, "Prod")
57− XCTAssertEqual(loaded[0].url, "https://logs.example.com")
67+ XCTAssertEqual(loaded.first?.url, "https://logs.example.com")
58− XCTAssertEqual(loaded[0].username, "admin")
68+ XCTAssertEqual(loaded.first?.username, "admin")
69+ XCTAssertEqual(loaded.first?.password, "s3cret!")
70+
71+ // Clean up
72+ KeychainService.deletePassword(for: conn.id)
59 }
73 }
60
74
61− func testSaveMultipleConnections() {
75+ func testRoundTripMultipleConnections() throws {
62− let c1 = ServerConnection(name: "Prod", url: "https://prod.example.com", username: "admin", password: "p1")
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+ )
63− let c2 = ServerConnection(name: "Dev", url: "https://dev.example.com", username: "dev", password: "p2")
83+ let conn2 = ServerConnection(
84+ name: "Server B",
85+ url: "https://b.example.com",
86+ username: "user2",
87+ password: "pass2"
88+ )
64− ConnectionStore.saveConnections([c1, c2])
89+ ConnectionStore.saveConnections([conn1, conn2])
90+
65 let loaded = ConnectionStore.loadConnections()
91 let loaded = ConnectionStore.loadConnections()
66 XCTAssertEqual(loaded.count, 2)
92 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)
67 }
104 }
68
105
69− func testPasswordNotInUserDefaults() {
106+ func testPasswordNotInKeychainConnectionData() throws {
107+ try XCTSkipUnless(Self.keychainAvailable, "Data-protection keychain unavailable")
70 let connection = ServerConnection(
108 let connection = ServerConnection(
71 name: "Test",
109 name: "Test",
72 url: "https://example.com",
110 url: "https://example.com",
@@ -75,86 +113,11 @@ final class ConnectionStoreTests: XCTestCase {
75 )
113 )
76 ConnectionStore.saveConnections([connection])
114 ConnectionStore.saveConnections([connection])
77
115
78− // Verify password is NOT in the UserDefaults data
116+ // Verify password is NOT in the connections list data
79− if let data = UserDefaults.standard.data(forKey: "parseable_connections"),
117+ if let data = KeychainService.loadData(for: "parseable_connections_list"),
80 let jsonString = String(data: data, encoding: .utf8) {
118 let jsonString = String(data: data, encoding: .utf8) {
81 XCTAssertFalse(jsonString.contains("supersecret"),
119 XCTAssertFalse(jsonString.contains("supersecret"),
82− "Password should not be stored in UserDefaults JSON")
120+ "Password should not be stored in connection list JSON")
83 }
121 }
84 }
122 }
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 testSavedQueryStoreRoundTripWithColumnConfig() {
109− let query = SavedQuery(
110− name: "With Columns",
111− sql: "SELECT * FROM logs",
112− stream: "backend-logs",
113− columnOrder: ["level", "message", "p_timestamp"],
114− hiddenColumns: ["p_metadata"]
115− )
116− SavedQueryStore.save([query])
117− let loaded = SavedQueryStore.load()
118− XCTAssertEqual(loaded.count, 1)
119− XCTAssertEqual(loaded[0].columnOrder, ["level", "message", "p_timestamp"])
120− XCTAssertEqual(loaded[0].hiddenColumns, ["p_metadata"])
121− }
122−
123− func testSavedQueryStoreBackwardCompatibility() {
124− // Simulate a saved query without column fields (pre-feature data)
125− let json = """
126− [{"id":"00000000-0000-0000-0000-000000000001","name":"Old Query","sql":"SELECT 1","stream":"s","createdAt":0}]
127− """
128− UserDefaults.standard.set(json.data(using: .utf8), forKey: "parseable_saved_queries")
129− let loaded = SavedQueryStore.load()
130− XCTAssertEqual(loaded.count, 1)
131− XCTAssertEqual(loaded[0].name, "Old Query")
132− XCTAssertNil(loaded[0].columnOrder)
133− XCTAssertNil(loaded[0].hiddenColumns)
134− }
135−
136− func testSavedQueryStoreMultiple() {
137− let q1 = SavedQuery(name: "Query 1", sql: "SELECT 1", stream: "s1")
138− let q2 = SavedQuery(name: "Query 2", sql: "SELECT 2", stream: "s2")
139− SavedQueryStore.save([q1, q2])
140− let loaded = SavedQueryStore.load()
141− XCTAssertEqual(loaded.count, 2)
142− }
143−
144− // MARK: - SavedQuery model
145−
146− func testSavedQueryIdentifiable() {
147− let q1 = SavedQuery(name: "A", sql: "SELECT 1", stream: "s")
148− let q2 = SavedQuery(name: "B", sql: "SELECT 2", stream: "s")
149− XCTAssertNotEqual(q1.id, q2.id)
150− }
151−
152− func testSavedQueryHashable() {
153− let q1 = SavedQuery(name: "A", sql: "SELECT 1", stream: "s")
154− var set = Set<SavedQuery>()
155− set.insert(q1)
156− XCTAssertEqual(set.count, 1)
157− set.insert(q1) // Same query again
158− XCTAssertEqual(set.count, 1)
159− }
160}
123}
ParseableViewerTests/JSONValueTests.swift+18
@@ -75,6 +75,24 @@ final class JSONValueTests: XCTestCase {
75 XCTAssertEqual(original, decoded)
75 XCTAssertEqual(original, decoded)
76 }
76 }
77
77
78+ func testEncodeNonFiniteDoubleAsNull() throws {
79+ // A non-finite double must encode as JSON null rather than throwing — a
80+ // single infinity/NaN otherwise aborts the whole JSONEncoder pass and
81+ // collapses exportAsJSON() to "[]" (total silent data loss).
82+ let encoder = JSONEncoder()
83+ for value: Double in [.infinity, -.infinity, .nan] {
84+ let data = try encoder.encode(JSONValue.double(value))
85+ XCTAssertEqual(String(data: data, encoding: .utf8), "null")
86+ }
87+ }
88+
89+ func testEncodeArrayWithNonFiniteDoubleDoesNotThrow() throws {
90+ // Mirrors the export path: one non-finite value must not poison the rest.
91+ let original: JSONValue = .array([.int(1), .double(.infinity), .string("ok")])
92+ let data = try JSONEncoder().encode(original)
93+ XCTAssertEqual(String(data: data, encoding: .utf8), "[1,null,\"ok\"]")
94+ }
95+
78 // MARK: - displayString
96 // MARK: - displayString
79
97
80 func testDisplayStringNull() {
98 func testDisplayStringNull() {
ParseableViewerTests/LiveTailViewModelTests.swift+54−29
@@ -63,43 +63,38 @@ final class LiveTailViewModelTests: XCTestCase {
63 // MARK: - Timestamp parsing
63 // MARK: - Timestamp parsing
64
64
65 func testParseTimestampISO8601WithFractionalSeconds() {
65 func testParseTimestampISO8601WithFractionalSeconds() {
66− let vm = LiveTailViewModel()
67 let record: LogRecord = ["p_timestamp": .string("2024-06-15T10:30:45.123Z")]
66 let record: LogRecord = ["p_timestamp": .string("2024-06-15T10:30:45.123Z")]
68− let date = vm.parseTimestamp(from: record)
67+ let date = LiveTailViewModel.parseTimestamp(from: record)
69 XCTAssertNotNil(date)
68 XCTAssertNotNil(date)
70 }
69 }
71
70
72 func testParseTimestampISO8601Basic() {
71 func testParseTimestampISO8601Basic() {
73− let vm = LiveTailViewModel()
74 let record: LogRecord = ["p_timestamp": .string("2024-06-15T10:30:45Z")]
72 let record: LogRecord = ["p_timestamp": .string("2024-06-15T10:30:45Z")]
75− let date = vm.parseTimestamp(from: record)
73+ let date = LiveTailViewModel.parseTimestamp(from: record)
76 XCTAssertNotNil(date)
74 XCTAssertNotNil(date)
77 }
75 }
78
76
79 func testParseTimestampFallbackFields() {
77 func testParseTimestampFallbackFields() {
80− let vm = LiveTailViewModel()
81−
82 // "timestamp" field
78 // "timestamp" field
83 let r1: LogRecord = ["timestamp": .string("2024-06-15T10:30:45Z")]
79 let r1: LogRecord = ["timestamp": .string("2024-06-15T10:30:45Z")]
84− XCTAssertNotNil(vm.parseTimestamp(from: r1))
80+ XCTAssertNotNil(LiveTailViewModel.parseTimestamp(from: r1))
85
81
86 // "time" field
82 // "time" field
87 let r2: LogRecord = ["time": .string("2024-06-15T10:30:45Z")]
83 let r2: LogRecord = ["time": .string("2024-06-15T10:30:45Z")]
88− XCTAssertNotNil(vm.parseTimestamp(from: r2))
84+ XCTAssertNotNil(LiveTailViewModel.parseTimestamp(from: r2))
89
85
90 // "@timestamp" field
86 // "@timestamp" field
91 let r3: LogRecord = ["@timestamp": .string("2024-06-15T10:30:45Z")]
87 let r3: LogRecord = ["@timestamp": .string("2024-06-15T10:30:45Z")]
92− XCTAssertNotNil(vm.parseTimestamp(from: r3))
88+ XCTAssertNotNil(LiveTailViewModel.parseTimestamp(from: r3))
93 }
89 }
94
90
95 func testParseTimestampPriorityOrder() {
91 func testParseTimestampPriorityOrder() {
96− let vm = LiveTailViewModel()
97 // p_timestamp should take priority
92 // p_timestamp should take priority
98 let record: LogRecord = [
93 let record: LogRecord = [
99 "p_timestamp": .string("2024-01-01T00:00:00Z"),
94 "p_timestamp": .string("2024-01-01T00:00:00Z"),
100 "timestamp": .string("2024-12-31T23:59:59Z")
95 "timestamp": .string("2024-12-31T23:59:59Z")
101 ]
96 ]
102− let date = vm.parseTimestamp(from: record)
97+ let date = LiveTailViewModel.parseTimestamp(from: record)
103 XCTAssertNotNil(date)
98 XCTAssertNotNil(date)
104
99
105 let calendar = Calendar.current
100 let calendar = Calendar.current
@@ -109,64 +104,57 @@ final class LiveTailViewModelTests: XCTestCase {
109 }
104 }
110
105
111 func testParseTimestampNoTimestampField() {
106 func testParseTimestampNoTimestampField() {
112− let vm = LiveTailViewModel()
113 let record: LogRecord = ["message": .string("no timestamp")]
107 let record: LogRecord = ["message": .string("no timestamp")]
114− let date = vm.parseTimestamp(from: record)
108+ let date = LiveTailViewModel.parseTimestamp(from: record)
115 XCTAssertNil(date)
109 XCTAssertNil(date)
116 }
110 }
117
111
118 func testParseTimestampNonStringValue() {
112 func testParseTimestampNonStringValue() {
119− let vm = LiveTailViewModel()
120 let record: LogRecord = ["p_timestamp": .int(1718446245)]
113 let record: LogRecord = ["p_timestamp": .int(1718446245)]
121− let date = vm.parseTimestamp(from: record)
114+ let date = LiveTailViewModel.parseTimestamp(from: record)
122 XCTAssertNil(date)
115 XCTAssertNil(date)
123 }
116 }
124
117
125 func testParseTimestampInvalidString() {
118 func testParseTimestampInvalidString() {
126− let vm = LiveTailViewModel()
127 let record: LogRecord = ["p_timestamp": .string("not-a-date")]
119 let record: LogRecord = ["p_timestamp": .string("not-a-date")]
128− let date = vm.parseTimestamp(from: record)
120+ let date = LiveTailViewModel.parseTimestamp(from: record)
129 XCTAssertNil(date)
121 XCTAssertNil(date)
130 }
122 }
131
123
132 // MARK: - Summary building
124 // MARK: - Summary building
133
125
134 func testBuildSummaryWithLevelAndMessage() {
126 func testBuildSummaryWithLevelAndMessage() {
135− let vm = LiveTailViewModel()
136 let record: LogRecord = [
127 let record: LogRecord = [
137 "level": .string("info"),
128 "level": .string("info"),
138 "message": .string("Server started")
129 "message": .string("Server started")
139 ]
130 ]
140− let summary = vm.buildSummary(from: record)
131+ let summary = LiveTailViewModel.buildSummary(from: record)
141 XCTAssertTrue(summary.contains("[info]"))
132 XCTAssertTrue(summary.contains("[info]"))
142 XCTAssertTrue(summary.contains("Server started"))
133 XCTAssertTrue(summary.contains("Server started"))
143 }
134 }
144
135
145 func testBuildSummaryAlternateFieldNames() {
136 func testBuildSummaryAlternateFieldNames() {
146− let vm = LiveTailViewModel()
147 let record: LogRecord = [
137 let record: LogRecord = [
148 "severity": .string("warn"),
138 "severity": .string("warn"),
149 "msg": .string("Disk almost full")
139 "msg": .string("Disk almost full")
150 ]
140 ]
151− let summary = vm.buildSummary(from: record)
141+ let summary = LiveTailViewModel.buildSummary(from: record)
152 XCTAssertTrue(summary.contains("[warn]"))
142 XCTAssertTrue(summary.contains("[warn]"))
153 XCTAssertTrue(summary.contains("Disk almost full"))
143 XCTAssertTrue(summary.contains("Disk almost full"))
154 }
144 }
155
145
156 func testBuildSummaryFallbackToScalarFields() {
146 func testBuildSummaryFallbackToScalarFields() {
157− let vm = LiveTailViewModel()
158 let record: LogRecord = [
147 let record: LogRecord = [
159 "status": .int(200),
148 "status": .int(200),
160 "path": .string("/api/health")
149 "path": .string("/api/health")
161 ]
150 ]
162− let summary = vm.buildSummary(from: record)
151+ let summary = LiveTailViewModel.buildSummary(from: record)
163 XCTAssertFalse(summary.isEmpty)
152 XCTAssertFalse(summary.isEmpty)
164 }
153 }
165
154
166 func testBuildSummaryEmptyRecord() {
155 func testBuildSummaryEmptyRecord() {
167− let vm = LiveTailViewModel()
168 let record: LogRecord = [:]
156 let record: LogRecord = [:]
169− let summary = vm.buildSummary(from: record)
157+ let summary = LiveTailViewModel.buildSummary(from: record)
170 XCTAssertEqual(summary, "")
158 XCTAssertEqual(summary, "")
171 }
159 }
172
160
@@ -216,7 +204,7 @@ final class LiveTailViewModelTests: XCTestCase {
216 func testFilteredEntriesEmptyFilter() {
204 func testFilteredEntriesEmptyFilter() {
217 let vm = LiveTailViewModel()
205 let vm = LiveTailViewModel()
218 vm.filterText = ""
206 vm.filterText = ""
219− XCTAssertEqual(vm.filteredEntries.count, vm.entries.count)
207+ XCTAssertEqual(vm.cachedFilteredEntries.count, vm.entries.count)
220 }
208 }
221
209
222 func testStopResetsRunningState() {
210 func testStopResetsRunningState() {
@@ -269,12 +257,11 @@ final class LiveTailViewModelTests: XCTestCase {
269 }
257 }
270
258
271 func testBuildSummaryWithLogField() {
259 func testBuildSummaryWithLogField() {
272− let vm = LiveTailViewModel()
273 let record: LogRecord = [
260 let record: LogRecord = [
274 "log_level": .string("debug"),
261 "log_level": .string("debug"),
275 "log": .string("Processing request")
262 "log": .string("Processing request")
276 ]
263 ]
277− let summary = vm.buildSummary(from: record)
264+ let summary = LiveTailViewModel.buildSummary(from: record)
278 XCTAssertTrue(summary.contains("[debug]"))
265 XCTAssertTrue(summary.contains("[debug]"))
279 XCTAssertTrue(summary.contains("Processing request"))
266 XCTAssertTrue(summary.contains("Processing request"))
280 }
267 }
@@ -417,10 +404,23 @@ final class LiveTailViewModelTests: XCTestCase {
417 XCTAssertEqual(vm.columnFilters.count, 1)
404 XCTAssertEqual(vm.columnFilters.count, 1)
418 }
405 }
419
406
420− func testAddFilterDifferentValueIsAdditive() {
407+ func testAddFilterSameColumnIncludeReplaces() {
421 let vm = LiveTailViewModel()
408 let vm = LiveTailViewModel()
422 vm.addColumnFilter(column: "level", value: .string("error"), exclude: false)
409 vm.addColumnFilter(column: "level", value: .string("error"), exclude: false)
410+ // A second include on the same column replaces the first: includes are
411+ // AND-combined and a column can only equal one value, so stacking two
412+ // includes would match zero rows and silently empty the tail.
423 vm.addColumnFilter(column: "level", value: .string("warn"), exclude: false)
413 vm.addColumnFilter(column: "level", value: .string("warn"), exclude: false)
414+ XCTAssertEqual(vm.columnFilters.count, 1)
415+ XCTAssertEqual(vm.columnFilters[0].value, .string("warn"))
416+ }
417+
418+ func testAddFilterSameColumnExcludesStack() {
419+ let vm = LiveTailViewModel()
420+ vm.addColumnFilter(column: "level", value: .string("debug"), exclude: true)
421+ // Excludes on the same column legitimately stack: level ≠ debug AND
422+ // level ≠ trace is meaningful, so both filters are retained.
423+ vm.addColumnFilter(column: "level", value: .string("trace"), exclude: true)
424 XCTAssertEqual(vm.columnFilters.count, 2)
424 XCTAssertEqual(vm.columnFilters.count, 2)
425 }
425 }
426
426
@@ -461,4 +461,29 @@ final class LiveTailViewModelTests: XCTestCase {
461 let filter = LiveTailViewModel.ColumnFilter(column: "field", value: nil, exclude: false)
461 let filter = LiveTailViewModel.ColumnFilter(column: "field", value: nil, exclude: false)
462 XCTAssertEqual(filter.displayLabel, "field = null")
462 XCTAssertEqual(filter.displayLabel, "field = null")
463 }
463 }
464+
465+ // MARK: - Zone-less timestamps / saved column order
466+
467+ func testParseTimestampTreatsZonelessAsUTC() {
468+ // Parseable serializes p_timestamp without a zone designator.
469+ let naive = LiveTailViewModel.parseTimestamp(from: ["p_timestamp": .string("2024-06-15T10:30:45.123")])
470+ let zoned = LiveTailViewModel.parseTimestamp(from: ["p_timestamp": .string("2024-06-15T10:30:45.123Z")])
471+ XCTAssertNotNil(naive)
472+ XCTAssertEqual(naive, zoned)
473+ let naiveWhole = LiveTailViewModel.parseTimestamp(from: ["p_timestamp": .string("2024-06-15T10:30:45")])
474+ XCTAssertEqual(naiveWhole, LiveTailViewModel.parseTimestamp(from: ["p_timestamp": .string("2024-06-15T10:30:45Z")]))
475+ }
476+
477+ func testMergedColumnOrderRestoresSavedPosition() {
478+ let merged = QueryViewModel.mergedColumnOrder(
479+ ["p_timestamp", "level", "message"],
480+ inserting: ["trace_id"],
481+ reference: ["p_timestamp", "trace_id", "level", "message"]
482+ )
483+ XCTAssertEqual(merged, ["p_timestamp", "trace_id", "level", "message"])
484+ }
485+
486+ func testMergedColumnOrderAppendsColumnsWithoutSavedPosition() {
487+ XCTAssertEqual(QueryViewModel.mergedColumnOrder(["a"], inserting: ["b"], reference: []), ["a", "b"])
488+ }
464}
489}
ParseableViewerTests/LogTableHelperTests.swift+14−7
@@ -161,37 +161,44 @@ final class LogTableHelperTests: XCTestCase {
161
161
162 func testExtractSeverityFromLevelColumn() {
162 func testExtractSeverityFromLevelColumn() {
163 let record: LogRecord = ["level": .string("error"), "message": .string("boom")]
163 let record: LogRecord = ["level": .string("error"), "message": .string("boom")]
164+ let sevCols = buildSeverityColumnSet(columns: Array(record.keys))
164− XCTAssertEqual(extractSeverity(from: record), .error)
165+ XCTAssertEqual(extractSeverity(from: record, severityColumns: sevCols), .error)
165 }
166 }
166
167
167 func testExtractSeverityFromSeverityColumn() {
168 func testExtractSeverityFromSeverityColumn() {
168 let record: LogRecord = ["severity": .string("warning"), "msg": .string("watch out")]
169 let record: LogRecord = ["severity": .string("warning"), "msg": .string("watch out")]
170+ let sevCols = buildSeverityColumnSet(columns: Array(record.keys))
169− XCTAssertEqual(extractSeverity(from: record), .warning)
171+ XCTAssertEqual(extractSeverity(from: record, severityColumns: sevCols), .warning)
170 }
172 }
171
173
172 func testExtractSeverityFromSeverityTextColumn() {
174 func testExtractSeverityFromSeverityTextColumn() {
173 let record: LogRecord = ["severity_text": .string("FATAL")]
175 let record: LogRecord = ["severity_text": .string("FATAL")]
176+ let sevCols = buildSeverityColumnSet(columns: Array(record.keys))
174− XCTAssertEqual(extractSeverity(from: record), .fatal)
177+ XCTAssertEqual(extractSeverity(from: record, severityColumns: sevCols), .fatal)
175 }
178 }
176
179
177 func testExtractSeverityFromLogLevelColumn() {
180 func testExtractSeverityFromLogLevelColumn() {
178 let record: LogRecord = ["log_level": .string("info")]
181 let record: LogRecord = ["log_level": .string("info")]
182+ let sevCols = buildSeverityColumnSet(columns: Array(record.keys))
179− XCTAssertEqual(extractSeverity(from: record), .info)
183+ XCTAssertEqual(extractSeverity(from: record, severityColumns: sevCols), .info)
180 }
184 }
181
185
182 func testExtractSeverityFromNumericValue() {
186 func testExtractSeverityFromNumericValue() {
183 let record: LogRecord = ["priority": .int(3)]
187 let record: LogRecord = ["priority": .int(3)]
188+ let sevCols = buildSeverityColumnSet(columns: Array(record.keys))
184− XCTAssertEqual(extractSeverity(from: record), .error)
189+ XCTAssertEqual(extractSeverity(from: record, severityColumns: sevCols), .error)
185 }
190 }
186
191
187 func testExtractSeverityNoSeverityColumn() {
192 func testExtractSeverityNoSeverityColumn() {
188 let record: LogRecord = ["message": .string("hello"), "timestamp": .string("now")]
193 let record: LogRecord = ["message": .string("hello"), "timestamp": .string("now")]
194+ let sevCols = buildSeverityColumnSet(columns: Array(record.keys))
189− XCTAssertEqual(extractSeverity(from: record), .unknown)
195+ XCTAssertEqual(extractSeverity(from: record, severityColumns: sevCols), .unknown)
190 }
196 }
191
197
192 func testExtractSeverityUnrecognizedValue() {
198 func testExtractSeverityUnrecognizedValue() {
193 let record: LogRecord = ["level": .string("custom_level")]
199 let record: LogRecord = ["level": .string("custom_level")]
200+ let sevCols = buildSeverityColumnSet(columns: Array(record.keys))
194− XCTAssertEqual(extractSeverity(from: record), .unknown)
201+ XCTAssertEqual(extractSeverity(from: record, severityColumns: sevCols), .unknown)
195 }
202 }
196
203
197 // MARK: - severityRowTint
204 // MARK: - severityRowTint
ParseableViewerTests/ParseableClientTests.swift+183
@@ -477,6 +477,131 @@ final class ParseableClientTests: XCTestCase {
477 XCTAssertTrue(error?.lowercased().contains("empty") ?? false)
477 XCTAssertTrue(error?.lowercased().contains("empty") ?? false)
478 }
478 }
479
479
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+
480 // MARK: - URL scheme validation
605 // MARK: - URL scheme validation
481
606
482 func testValidateURLAcceptsHTTPS() {
607 func testValidateURLAcceptsHTTPS() {
@@ -521,4 +646,62 @@ final class ParseableClientTests: XCTestCase {
521 // Carriage returns in values should be wrapped in quotes per RFC 4180
646 // Carriage returns in values should be wrapped in quotes per RFC 4180
522 XCTAssertTrue(csv.contains("\"line1\r\nline2\""))
647 XCTAssertTrue(csv.contains("\"line1\r\nline2\""))
523 }
648 }
649+
650+ // MARK: - Server response key spellings
651+
652+ func testServerAboutDecodesCamelCaseKeys() throws {
653+ let json = #"{"uiVersion": "v1.2", "deploymentId": "01H", "grpcPort": 8001}"#.data(using: .utf8)!
654+ let about = try JSONDecoder().decode(ServerAbout.self, from: json)
655+ XCTAssertEqual(about.uiVersion, "v1.2")
656+ XCTAssertEqual(about.deploymentId, "01H")
657+ XCTAssertEqual(about.grpcPort, 8001)
658+ }
659+
660+ func testStreamInfoDecodesSnakeCaseKeys() throws {
661+ let json = #"{"created-at": "c", "first-event-at": "f", "time_partition": "ts", "static_schema_flag": "true", "cache_enabled": true}"#
662+ .data(using: .utf8)!
663+ let info = try JSONDecoder().decode(StreamInfo.self, from: json)
664+ XCTAssertEqual(info.createdAt, "c")
665+ XCTAssertEqual(info.firstEventAt, "f")
666+ XCTAssertEqual(info.timePartition, "ts")
667+ XCTAssertEqual(info.staticSchemaFlag, true)
668+ XCTAssertEqual(info.cacheEnabled, true)
669+ }
670+
671+ func testStreamInfoDecodesCamelCaseKeys() throws {
672+ let json = #"{"createdAt": "c", "firstEventAt": "f", "timePartition": "ts", "staticSchemaFlag": "true"}"#
673+ .data(using: .utf8)!
674+ let info = try JSONDecoder().decode(StreamInfo.self, from: json)
675+ XCTAssertEqual(info.createdAt, "c")
676+ XCTAssertEqual(info.firstEventAt, "f")
677+ XCTAssertEqual(info.timePartition, "ts")
678+ XCTAssertEqual(info.staticSchemaFlag, true)
679+ }
680+
681+ func testStorageStatsReadsFormatAndUnitSuffixedSizes() throws {
682+ let json = #"{"storage": {"size": "2048 Bytes", "format": "parquet"}}"#.data(using: .utf8)!
683+ let stats = try JSONDecoder().decode(StreamStats.self, from: json)
684+ XCTAssertEqual(stats.storage?.type, "parquet")
685+ XCTAssertEqual(stats.storage?.size, ByteCountFormatter.string(fromByteCount: 2048, countStyle: .file))
686+ }
687+
688+ func testSchemaFieldDescribesParameterizedArrowTypes() throws {
689+ let json = #"{"name": "p_timestamp", "data_type": {"Timestamp": ["Millisecond", null]}}"#.data(using: .utf8)!
690+ let field = try JSONDecoder().decode(SchemaField.self, from: json)
691+ XCTAssertEqual(field.dataType, "Timestamp(Millisecond, None)")
692+ }
693+
694+ func testLegacyAlertRuleWithObjectConfigKeepsRuleType() throws {
695+ let json = #"{"name": "a", "rule": {"type": "column", "config": {"column": "status", "operator": "=", "value": 500}}}"#
696+ .data(using: .utf8)!
697+ let rule = try JSONDecoder().decode(AlertRule.self, from: json)
698+ XCTAssertEqual(rule.rule?.type, "column")
699+ XCTAssertNotNil(rule.rule?.config)
700+ }
701+
702+ func testServerAboutDecodesObjectLicense() throws {
703+ let json = #"{"license": {"name": "AGPL-3.0-only", "plan": "OSS"}}"#.data(using: .utf8)!
704+ let about = try JSONDecoder().decode(ServerAbout.self, from: json)
705+ XCTAssertEqual(about.license, "AGPL-3.0-only (OSS)")
706+ }
524}
707}
ParseableViewerTests/QueryViewModelTests.swift+169−4
@@ -1,6 +1,8 @@
1import XCTest
1import XCTest
2@testable import ParseableViewer
2@testable import ParseableViewer
3
3
4+// QueryViewModel is @MainActor, so its tests must run on the main actor too.
5+@MainActor
4final class QueryViewModelTests: XCTestCase {
6final class QueryViewModelTests: XCTestCase {
5 // MARK: - Column extraction
7 // MARK: - Column extraction
6
8
@@ -61,7 +63,7 @@ final class QueryViewModelTests: XCTestCase {
61 XCTAssertEqual(vm.filteredResults.count, 2)
63 XCTAssertEqual(vm.filteredResults.count, 2)
62 }
64 }
63
65
64− func testFilteredResultsMatchesContent() {
66+ func testFilteredResultsMatchesContent() async {
65 let vm = QueryViewModel()
67 let vm = QueryViewModel()
66 vm.results = [
68 vm.results = [
67 ["message": .string("hello world")],
69 ["message": .string("hello world")],
@@ -69,16 +71,18 @@ final class QueryViewModelTests: XCTestCase {
69 ["message": .string("Hello there")]
71 ["message": .string("Hello there")]
70 ]
72 ]
71 vm.filterText = "hello"
73 vm.filterText = "hello"
74+ try? await Task.sleep(for: .milliseconds(200))
72 XCTAssertEqual(vm.filteredResults.count, 2) // case-insensitive
75 XCTAssertEqual(vm.filteredResults.count, 2) // case-insensitive
73 }
76 }
74
77
75− func testFilteredResultsNoMatch() {
78+ func testFilteredResultsNoMatch() async {
76 let vm = QueryViewModel()
79 let vm = QueryViewModel()
77 vm.results = [
80 vm.results = [
78 ["message": .string("hello")],
81 ["message": .string("hello")],
79 ["message": .string("world")]
82 ["message": .string("world")]
80 ]
83 ]
81 vm.filterText = "xyz"
84 vm.filterText = "xyz"
85+ try? await Task.sleep(for: .milliseconds(200))
82 XCTAssertEqual(vm.filteredResults.count, 0)
86 XCTAssertEqual(vm.filteredResults.count, 0)
83 }
87 }
84
88
@@ -136,6 +140,19 @@ final class QueryViewModelTests: XCTestCase {
136 XCTAssertTrue(json.contains("\"value\""))
140 XCTAssertTrue(json.contains("\"value\""))
137 }
141 }
138
142
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+
139 // MARK: - Time range
156 // MARK: - Time range
140
157
141 func testTimeRangeOptionsAllHaveValues() {
158 func testTimeRangeOptionsAllHaveValues() {
@@ -218,7 +235,7 @@ final class QueryViewModelTests: XCTestCase {
218 let vm = QueryViewModel()
235 let vm = QueryViewModel()
219 vm.sqlQuery = "SELECT * FROM \"logs\" ORDER BY p_timestamp DESC LIMIT 1000"
236 vm.sqlQuery = "SELECT * FROM \"logs\" ORDER BY p_timestamp DESC LIMIT 1000"
220 vm.addColumnFilter(column: "level", value: .string("debug"), exclude: true)
237 vm.addColumnFilter(column: "level", value: .string("debug"), exclude: true)
221− XCTAssertTrue(vm.sqlQuery.contains("WHERE \"level\" <> 'debug'"))
238+ XCTAssertTrue(vm.sqlQuery.contains("WHERE \"level\" IS DISTINCT FROM 'debug'"))
222 XCTAssertTrue(vm.sqlQuery.contains("ORDER BY"))
239 XCTAssertTrue(vm.sqlQuery.contains("ORDER BY"))
223 }
240 }
224
241
@@ -244,6 +261,15 @@ final class QueryViewModelTests: XCTestCase {
244 XCTAssertTrue(vm.sqlQuery.contains("WHERE \"level\" = 'error'"))
261 XCTAssertTrue(vm.sqlQuery.contains("WHERE \"level\" = 'error'"))
245 }
262 }
246
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+
247 func testAddColumnFilterIntValue() {
273 func testAddColumnFilterIntValue() {
248 let vm = QueryViewModel()
274 let vm = QueryViewModel()
249 vm.sqlQuery = "SELECT * FROM \"logs\""
275 vm.sqlQuery = "SELECT * FROM \"logs\""
@@ -262,7 +288,7 @@ final class QueryViewModelTests: XCTestCase {
262 let vm = QueryViewModel()
288 let vm = QueryViewModel()
263 vm.sqlQuery = "SELECT * FROM \"logs\""
289 vm.sqlQuery = "SELECT * FROM \"logs\""
264 vm.addColumnFilter(column: "level", value: .string("warn"), exclude: true)
290 vm.addColumnFilter(column: "level", value: .string("warn"), exclude: true)
265− XCTAssertTrue(vm.sqlQuery.contains("WHERE \"level\" <> 'warn'"))
291+ XCTAssertTrue(vm.sqlQuery.contains("WHERE \"level\" IS DISTINCT FROM 'warn'"))
266 }
292 }
267
293
268 func testAddColumnFilterEscapesSingleQuotesInValue() {
294 func testAddColumnFilterEscapesSingleQuotesInValue() {
@@ -333,6 +359,25 @@ final class QueryViewModelTests: XCTestCase {
333 XCTAssertEqual(vm.columnOrder, ["c", "a", "b"])
359 XCTAssertEqual(vm.columnOrder, ["c", "a", "b"])
334 }
360 }
335
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+
336 func testMoveColumnByIndexSet() {
381 func testMoveColumnByIndexSet() {
337 let vm = QueryViewModel()
382 let vm = QueryViewModel()
338 vm.columns = ["a", "b", "c"]
383 vm.columns = ["a", "b", "c"]
@@ -626,5 +671,125 @@ final class QueryViewModelTests: XCTestCase {
626 let empty = vm.emptyColumns(in: records, columns: ["a"])
671 let empty = vm.emptyColumns(in: records, columns: ["a"])
627 XCTAssertTrue(empty.isEmpty)
672 XCTAssertTrue(empty.isEmpty)
628 }
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+ }
629}
794}
630
795
ParseableViewerTests/SQLAutoCompleteTests.swift+56
@@ -275,6 +275,62 @@ final class SQLAutoCompleteTests: XCTestCase {
275 let ctx = SQLCompletionProvider.determineContext(text: "SELECT * FROM a LEFT JOIN ", position: 26)
275 let ctx = SQLCompletionProvider.determineContext(text: "SELECT * FROM a LEFT JOIN ", position: 26)
276 XCTAssertEqual(ctx, .tableRef)
276 XCTAssertEqual(ctx, .tableRef)
277 }
277 }
278+
279+ // MARK: - Identifier quoting / non-SQL regions
280+
281+ func testColumnCompletionQuotesNamesThatNeedIt() {
282+ let fields = [SchemaField(name: "statusCode", dataType: "Int64"), SchemaField(name: "status", dataType: "Utf8")]
283+ let result = SQLCompletionProvider.completions(
284+ for: "SELECT * FROM t WHERE stat", cursorPosition: 26,
285+ streamNames: [], schemaFields: fields
286+ )
287+ let columns = result.items.filter { $0.kind == .column }.map(\.text)
288+ XCTAssertEqual(columns, ["status", "\"statusCode\""])
289+ }
290+
291+ func testNoCompletionsInsideLineComment() {
292+ let text = "-- grouped by date"
293+ let result = SQLCompletionProvider.completions(
294+ for: text, cursorPosition: (text as NSString).length,
295+ streamNames: streams, schemaFields: fields
296+ )
297+ XCTAssertTrue(result.items.isEmpty)
298+ }
299+
300+ func testNoCompletionsInsideStringLiteral() {
301+ let text = "SELECT * FROM t WHERE msg LIKE '%sel"
302+ let result = SQLCompletionProvider.completions(
303+ for: text, cursorPosition: (text as NSString).length,
304+ streamNames: streams, schemaFields: fields
305+ )
306+ XCTAssertTrue(result.items.isEmpty)
307+ }
308+
309+ func testFullyTypedMixedCaseColumnStillOffersQuotedForm() {
310+ let fields = [SchemaField(name: "statusCode", dataType: "Int64")]
311+ let bare = "SELECT * FROM t WHERE statusCode"
312+ let result = SQLCompletionProvider.completions(
313+ for: bare, cursorPosition: (bare as NSString).length,
314+ streamNames: [], schemaFields: fields
315+ )
316+ XCTAssertEqual(result.items.map(\.text), ["\"statusCode\""])
317+
318+ // Already quoted: accepting would change nothing, so no popup.
319+ let quoted = "SELECT * FROM t WHERE \"statusCode\""
320+ let quotedResult = SQLCompletionProvider.completions(
321+ for: quoted, cursorPosition: (quoted as NSString).length - 1,
322+ streamNames: [], schemaFields: fields
323+ )
324+ XCTAssertTrue(quotedResult.items.isEmpty)
325+ }
326+
327+ func testReservedWordColumnIsQuoted() {
328+ let fields = [SchemaField(name: "order", dataType: "Utf8")]
329+ let result = SQLCompletionProvider.completions(
330+ for: "SELECT ord", cursorPosition: 10, streamNames: [], schemaFields: fields
331+ )
332+ XCTAssertEqual(result.items.filter { $0.kind == .column }.map(\.text), ["\"order\""])
333+ }
278}
334}
279
335
280// Make SQLCompletionContext Equatable for assertions
336// Make SQLCompletionContext Equatable for assertions
ParseableViewerTests/SQLTokenizerTests.swiftnew file+366
@@ -0,0 +1,366 @@
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,4 +77,14 @@ final class ServerConnectionTests: XCTestCase {
77 let conn = ServerConnection(name: "test", url: "https://", username: "admin", password: "pass")
77 let conn = ServerConnection(name: "test", url: "https://", username: "admin", password: "pass")
78 XCTAssertNil(conn.baseURL)
78 XCTAssertNil(conn.baseURL)
79 }
79 }
80+
81+ func testBaseURLAcceptsUppercaseScheme() {
82+ let conn = ServerConnection(name: "t", url: "HTTP://10.0.0.5:8000", username: "u", password: "p")
83+ XCTAssertEqual(conn.baseURL?.absoluteString, "http://10.0.0.5:8000")
84+ }
85+
86+ func testBaseURLRejectsNonHTTPScheme() {
87+ let conn = ServerConnection(name: "t", url: "ftp://example.com", username: "u", password: "p")
88+ XCTAssertNil(conn.baseURL)
89+ }
80}
90}
ParseableViewerTests/SyntaxHighlightingTests.swift+10
@@ -140,6 +140,16 @@ final class SyntaxHighlightingTests: XCTestCase {
140 "Double-quoted identifiers should differ from keywords")
140 "Double-quoted identifiers should differ from keywords")
141 }
141 }
142
142
143+ func testSQLApostropheInCommentDoesNotStartString() {
144+ // The later quoted literal gives the stray apostrophe a closing partner, so
145+ // separate string/comment passes painted everything in between as a string.
146+ let sql = "-- don't\nSELECT * FROM t WHERE a = 'b'"
147+ let ts = sqlHighlight(sql)
148+ let selectIdx = (sql as NSString).range(of: "SELECT").location
149+ XCTAssertEqual(colorAt(ts, selectIdx), colorAt(sqlHighlight("SELECT x"), 0),
150+ "An apostrophe inside a comment must not open a string literal")
151+ }
152+
143 // MARK: - JSON Highlighting
153 // MARK: - JSON Highlighting
144
154
145 func testJSONHighlightProducesOutput() {
155 func testJSONHighlightProducesOutput() {
README.md+1−1
@@ -143,7 +143,7 @@ ParseableViewer/
143| `Cmd+R` | Refresh streams |
143| `Cmd+R` | Refresh streams |
144| `Cmd+Enter` | Run query |
144| `Cmd+Enter` | Run query |
145| `Cmd+P` | Pause/resume live tail |
145| `Cmd+P` | Pause/resume live tail |
146−| `Cmd+Delete` | Clear live tail entries |
146+| `Cmd+K` | Clear live tail entries |
147| `Cmd+Ctrl+S` | Toggle sidebar |
147| `Cmd+Ctrl+S` | Toggle sidebar |
148
148
149## License
149## License
parseable-viewer.rbnew file+19
@@ -0,0 +1,19 @@
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,6 +37,15 @@ targets:
37 Release:
37 Release:
38 SWIFT_OPTIMIZATION_LEVEL: "-O"
38 SWIFT_OPTIMIZATION_LEVEL: "-O"
39 SWIFT_COMPILATION_MODE: "wholemodule"
39 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
40 entitlements:
49 entitlements:
41 path: ParseableViewer/ParseableViewer.entitlements
50 path: ParseableViewer/ParseableViewer.entitlements
42
51
@@ -51,6 +60,7 @@ targets:
51 base:
60 base:
52 SWIFT_VERSION: "5.9"
61 SWIFT_VERSION: "5.9"
53 MACOSX_DEPLOYMENT_TARGET: "14.0"
62 MACOSX_DEPLOYMENT_TARGET: "14.0"
63+ GENERATE_INFOPLIST_FILE: YES
54 BUNDLE_LOADER: "$(BUILT_PRODUCTS_DIR)/Parseable Viewer.app/Contents/MacOS/Parseable Viewer"
64 BUNDLE_LOADER: "$(BUILT_PRODUCTS_DIR)/Parseable Viewer.app/Contents/MacOS/Parseable Viewer"
55 TEST_HOST: "$(BUNDLE_LOADER)"
65 TEST_HOST: "$(BUNDLE_LOADER)"
56 dependencies:
66 dependencies: