git.iko.soy/parseable-macos
on master help sign in
Fix URL double-encoding and 11 other UI bugs

- ParseableClient: Fix double percent-encoding of stream/filter names in paths - AlertModels: Stabilize AlertRule.id to prevent SwiftUI list churn - StreamDetailView: Relax 'stream deleted' condition to trigger on 2+ failures - ServerInfoView: Remove stale data fallback that showed errors incorrectly - LogTableView: Add reconcileSelection() to prevent selection jump and stale detail pane - SQLEditorView: Use NSString.length for UTF-16 range validation - SidebarView: Invert bookmark color so broken filters get orange emphasis - LogDetailView: Cancel prior copy confirmation timer on rapid re-copy - LiveTailView: Compute severity inline; fix auto-scroll to target cachedSorted - QueryViewModel.addColumnFilter: Use tokenizer for WHERE/LIMIT detection - SQLAutoComplete: Add reposition() to keep popup anchored to caret - SQLEditorView: Add dismantleNSView to cleanup completion popup

commita85b560fc5eadd42d2819782d7a3788416d7178e authoriko <mail@iko.soy> dateSat 20 Jun 2026, 17:32 (3 months ago) parent741d046
11 files changed+121−46
ParseableViewer/Models/AlertModels.swift+6−1 ParseableViewer/Services/ParseableClient.swift+10−3 ParseableViewer/ViewModels/QueryViewModel.swift+30−13 ParseableViewer/Views/LiveTailView.swift+6−10 ParseableViewer/Views/LogDetailView.swift+6−1 ParseableViewer/Views/LogTableView.swift+22−12 ParseableViewer/Views/SQLAutoComplete.swift+11 ParseableViewer/Views/SQLEditorView.swift+20−3 ParseableViewer/Views/ServerInfoView.swift+4−1 ParseableViewer/Views/SidebarView.swift+1−1 ParseableViewer/Views/StreamDetailView.swift+5−1
ParseableViewer/Models/AlertModels.swift+6−1
@@ -45,10 +45,15 @@ struct AlertRule: Identifiable, Codable, Sendable {
4545 let rule: AlertRuleSpec?
4646 let targets: [AlertTarget]?
4747
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+
4853 /// Display name, preferring the modern `title` field over legacy `name`.
4954 var displayName: String { title ?? name ?? "Unnamed Alert" }
5055
51− var id: String { alertId ?? name ?? title ?? UUID().uuidString }
56+ var id: String { alertId ?? name ?? title ?? localID.uuidString }
5257
5358 private enum CodingKeys: String, CodingKey {
5459 case alertId = "id"
ParseableViewer/Services/ParseableClient.swift+10−3
@@ -136,11 +136,18 @@ final class ParseableClient: Sendable {
136136 }
137137
138138 private func buildRequest(method: String, path: String, body: Data? = nil, queryItems: [URLQueryItem]? = nil) throws -> URLRequest {
139− var components = URLComponents(url: baseURL.appendingPathComponent(path), resolvingAgainstBaseURL: false)
139+ // `path` already contains percent-encoded dynamic segments (see
140+ // `encodePathComponent`). Append it via `percentEncodedPath` rather than
141+ // `URL.appendingPathComponent`, which would re-encode the `%` characters
142+ // and corrupt names containing spaces or other reserved characters.
143+ guard var components = URLComponents(url: baseURL, resolvingAgainstBaseURL: false) else {
144+ throw ParseableError.invalidURL
145+ }
146+ components.percentEncodedPath += path
140147 if let queryItems {
141− components?.queryItems = queryItems
148+ components.queryItems = queryItems
142149 }
143− guard let url = components?.url else {
150+ guard let url = components.url else {
144151 throw ParseableError.invalidURL
145152 }
146153
ParseableViewer/ViewModels/QueryViewModel.swift+30−13
@@ -538,23 +538,40 @@ final class QueryViewModel {
538538 var sql = sqlQuery.trimmingCharacters(in: .whitespacesAndNewlines)
539539 if sql.isEmpty { return }
540540
541− let wherePattern = #"(?i)\bWHERE\b"#
542− let tailPattern = #"(?i)\b(ORDER\s+BY|GROUP\s+BY|HAVING|LIMIT)\b"#
543−
544− if let whereRange = sql.range(of: wherePattern, options: .regularExpression) {
545− let afterWhere = whereRange.upperBound
546− let searchRange = afterWhere..<sql.endIndex
547− if let tailRange = sql.range(of: tailPattern, options: .regularExpression, range: searchRange) {
548− sql.insert(contentsOf: "AND \(condition) ", at: tailRange.lowerBound)
541+ // Locate the top-level WHERE and the first trailing clause using the
542+ // tokenizer rather than a raw regex, so keywords inside quoted
543+ // identifiers, string literals, comments, or subqueries are ignored.
544+ let tailKeywords: Set<String> = ["ORDER", "GROUP", "HAVING", "LIMIT", "OFFSET"]
545+ var depth = 0
546+ var whereEnd: String.Index?
547+ var tailStart: String.Index?
548+ for token in SQLTokenizer.tokenize(sql) {
549+ switch token.kind {
550+ case .leftParen:
551+ depth += 1
552+ case .rightParen:
553+ depth = max(0, depth - 1)
554+ case .keyword(let kw) where depth == 0:
555+ if kw == "WHERE", whereEnd == nil {
556+ whereEnd = token.range.upperBound
557+ } else if tailKeywords.contains(kw), tailStart == nil {
558+ tailStart = token.range.lowerBound
559+ }
560+ default:
561+ break
562+ }
563+ }
564+
565+ if let whereEnd {
566+ if let tailStart, tailStart > whereEnd {
567+ sql.insert(contentsOf: "AND \(condition) ", at: tailStart)
549568 } else {
550569 sql += " AND \(condition)"
551570 }
571+ } else if let tailStart {
572+ sql.insert(contentsOf: "WHERE \(condition) ", at: tailStart)
552573 } else {
553− if let tailRange = sql.range(of: tailPattern, options: .regularExpression) {
554− sql.insert(contentsOf: "WHERE \(condition) ", at: tailRange.lowerBound)
555− } else {
556− sql += " WHERE \(condition)"
557− }
574+ sql += " WHERE \(condition)"
558575 }
559576
560577 sqlQuery = sql
ParseableViewer/Views/LiveTailView.swift+6−10
@@ -12,7 +12,6 @@ struct LiveTailView: View {
1212 @State private var sortAscending = false
1313 @State private var columnWidths: [String: CGFloat] = [:]
1414 @State private var cachedSorted: [LiveTailViewModel.LiveTailEntry] = []
15− @State private var cachedSeverities: [SeverityLevel] = []
1615 @State private var severityColumnSet: Set<String> = []
1716 @State private var liveTailSortTask: Task<Void, Never>?
1817
@@ -284,7 +283,6 @@ struct LiveTailView: View {
284283 let sevCols = buildSeverityColumnSet(columns: cols)
285284 guard let col = sortColumn else {
286285 cachedSorted = entries
287− cachedSeverities = entries.map { extractSeverity(from: $0.record, severityColumns: sevCols) }
288286 severityColumnSet = sevCols
289287 return
290288 }
@@ -302,7 +300,6 @@ struct LiveTailView: View {
302300 }.value
303301 guard !Task.isCancelled else { return }
304302 cachedSorted = sorted
305− cachedSeverities = sorted.map { extractSeverity(from: $0.record, severityColumns: sevCols) }
306303 severityColumnSet = sevCols
307304 }
308305 }
@@ -319,7 +316,7 @@ struct LiveTailView: View {
319316 columnWidths: columnWidths,
320317 isSelected: selectedRecord == entry.record,
321318 isAlternate: index % 2 == 1,
322− severity: index < cachedSeverities.count ? cachedSeverities[index] : .unknown,
319+ severity: extractSeverity(from: entry.record, severityColumns: severityColumnSet),
323320 severityColumns: severityColumnSet,
324321 wrapText: wrapText,
325322 onCellFilter: { column, value, exclude in
@@ -360,13 +357,12 @@ struct LiveTailView: View {
360357 .onChange(of: sortAscending) { _, _ in
361358 rebuildSortedEntries()
362359 }
363− .onChange(of: viewModel.entries.count) { _, _ in
360+ // Scroll when the rendered list (`cachedSorted`) changes, targeting
361+ // its last element. Keying off `entries.count` could fire before the
362+ // rebuild, scrolling to an id not yet present (a silent no-op).
363+ .onChange(of: cachedSorted.count) { _, _ in
364364 guard autoScroll, sortColumn == nil else { return }
365− let hasFilters = !viewModel.filterText.isEmpty || !viewModel.columnFilters.isEmpty
366− let target = hasFilters
367− ? viewModel.cachedFilteredEntries.last
368− : viewModel.entries.last
369− if let target {
365+ if let target = cachedSorted.last {
370366 proxy.scrollTo(target.id, anchor: .bottom)
371367 }
372368 }
ParseableViewer/Views/LogDetailView.swift+6−1
@@ -4,6 +4,7 @@ struct LogDetailView: View {
44 let record: LogRecord
55 @State private var viewMode: ViewMode = .formatted
66 @State private var showCopyConfirmation = false
7+ @State private var copyConfirmationTask: Task<Void, Never>?
78
89 enum ViewMode: String, CaseIterable {
910 case formatted = "Formatted"
@@ -61,8 +62,12 @@ struct LogDetailView: View {
6162 guard success else { return }
6263
6364 showCopyConfirmation = true
64− Task {
65+ // Cancel any in-flight timer so a rapid re-copy doesn't let an
66+ // earlier task clear the checkmark while a newer copy is active.
67+ copyConfirmationTask?.cancel()
68+ copyConfirmationTask = Task {
6569 try? await Task.sleep(for: .seconds(1.5))
70+ guard !Task.isCancelled else { return }
6671 showCopyConfirmation = false
6772 }
6873 }
ParseableViewer/Views/LogTableView.swift+22−12
@@ -301,12 +301,27 @@ struct LogTableView: View {
301301 guard !Task.isCancelled else { return }
302302 cachedSorted = indexed
303303 severityColumnSet = sevCols
304− if let selectedRecord,
305− let match = cachedSorted.first(where: { $0.record == selectedRecord }) {
306− selectedIndex = match.id
307− } else {
308− selectedIndex = nil
309− }
304+ reconcileSelection()
305+ }
306+ }
307+
308+ /// Re-establishes the current selection after `cachedSorted` is rebuilt.
309+ /// Prefers the stable row id so a plain re-sort keeps the exact selected row
310+ /// (rather than jumping to the first duplicate). Falls back to value matching
311+ /// only when the records themselves changed, and clears both the index and
312+ /// the bound record when nothing matches so the detail pane can't go stale.
313+ private func reconcileSelection() {
314+ if let selectedIndex,
315+ let match = cachedSorted.first(where: { $0.id == selectedIndex }),
316+ match.record == selectedRecord {
317+ return
318+ }
319+ if let selectedRecord,
320+ let match = cachedSorted.first(where: { $0.record == selectedRecord }) {
321+ selectedIndex = match.id
322+ } else {
323+ selectedIndex = nil
324+ self.selectedRecord = nil
310325 }
311326 }
312327
@@ -327,12 +342,7 @@ struct LogTableView: View {
327342 IndexedRecord(id: $0.offset, record: $0.element, severity: extractSeverity(from: $0.element, severityColumns: sevCols))
328343 }
329344 severityColumnSet = sevCols
330− if let selectedRecord,
331− let match = cachedSorted.first(where: { $0.record == selectedRecord }) {
332− selectedIndex = match.id
333− } else {
334− selectedIndex = nil
335− }
345+ reconcileSelection()
336346 }
337347}
338348
ParseableViewer/Views/SQLAutoComplete.swift+11
@@ -339,6 +339,17 @@ final class SQLCompletionPopup: NSObject, NSTableViewDataSource, NSTableViewDele
339339 panel.orderFront(nil)
340340 }
341341
342+ /// Moves an already-visible popup so it stays anchored to the caret as the
343+ /// user keeps typing (otherwise it lingers at its original position).
344+ func reposition(at screenPoint: NSPoint) {
345+ guard panel.isVisible else { return }
346+ let rowCount = min(items.count, Self.maxVisibleRows)
347+ let height = CGFloat(rowCount) * Self.rowHeight + 4
348+ let frame = NSRect(x: screenPoint.x, y: screenPoint.y - height,
349+ width: Self.panelWidth, height: height)
350+ panel.setFrame(frame, display: true)
351+ }
352+
342353 func hide() {
343354 panel.parent?.removeChildWindow(panel)
344355 panel.orderOut(nil)
ParseableViewer/Views/SQLEditorView.swift+20−3
@@ -59,7 +59,10 @@ struct SQLEditorView: NSViewRepresentable {
5959 let selectedRanges = textView.selectedRanges
6060 textView.string = text
6161 context.coordinator.applyHighlighting(to: textView)
62− 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+ let length = (textView.string as NSString).length
65+ let validRanges = selectedRanges.filter { NSMaxRange($0.rangeValue) <= length }
6366 if !validRanges.isEmpty {
6467 textView.selectedRanges = validRanges
6568 }
@@ -71,6 +74,10 @@ struct SQLEditorView: NSViewRepresentable {
7174 Coordinator(text: $text, streamNames: streamNames, schemaFields: schemaFields)
7275 }
7376
77+ static func dismantleNSView(_ nsView: NSScrollView, coordinator: Coordinator) {
78+ coordinator.cleanup()
79+ }
80+
7481 final class Coordinator: NSObject, NSTextViewDelegate {
7582 var text: Binding<String>
7683 var isEditing = false
@@ -155,12 +162,22 @@ struct SQLEditorView: NSViewRepresentable {
155162
156163 completionPopup.update(items: result.items)
157164
158− if !completionPopup.isVisible, let window = textView.window {
165+ if let window = textView.window {
159166 let screenPoint = cursorScreenPoint(for: textView)
160− completionPopup.show(at: screenPoint, in: window)
167+ if completionPopup.isVisible {
168+ completionPopup.reposition(at: screenPoint)
169+ } else {
170+ completionPopup.show(at: screenPoint, in: window)
171+ }
161172 }
162173 }
163174
175+ /// Hides the completion popup when the editor is torn down so the
176+ /// borderless child window can't linger after the view disappears.
177+ func cleanup() {
178+ completionPopup.hide()
179+ }
180+
164181 private func insertCompletion(_ item: SQLCompletionItem) {
165182 // Find the text view — walk up from the popup's parent window
166183 guard let textView = findTextView() else { return }
ParseableViewer/Views/ServerInfoView.swift+4−1
@@ -52,7 +52,7 @@ struct ServerInfoView: View {
5252 .padding(8)
5353 }
5454
55− if let about = about ?? appState.serverAbout {
55+ if let about {
5656 GroupBox("Server Details") {
5757 VStack(alignment: .leading, spacing: 8) {
5858 InfoRow(label: "Version", value: about.version ?? "N/A")
@@ -132,6 +132,9 @@ struct ServerInfoView: View {
132132 guard let client = appState.client else { return }
133133 isLoading = true
134134 errorMessage = nil
135+ // Seed from the app-wide cache so first load isn't blank, but never fall
136+ // back to it on refresh failure (that would show stale data under the error).
137+ if about == nil { about = appState.serverAbout }
135138
136139 async let healthCheck: Void = client.checkHealth()
137140 async let aboutResult = client.getAbout()
ParseableViewer/Views/SidebarView.swift+1−1
@@ -288,7 +288,7 @@ struct FilterRow: View {
288288 } label: {
289289 HStack {
290290 Image(systemName: "bookmark")
291− .foregroundStyle(streamExists ? .orange : .secondary)
291+ .foregroundStyle(streamExists ? Color.secondary : Color.orange)
292292 VStack(alignment: .leading) {
293293 Text(filter.filterName)
294294 .lineLimit(1)
ParseableViewer/Views/StreamDetailView.swift+5−1
@@ -206,7 +206,11 @@ struct StreamDetailView: View {
206206 do { info = try await infoFetch } catch { info = nil; failures.append("info"); checkNotFound(error) }
207207 do { retention = try await retentionFetch } catch { retention = []; failures.append("retention"); checkNotFound(error) }
208208
209− if sawNotFound && failures.count == 4 {
209+ // A deleted stream makes these endpoints 404. Requiring *all four* to
210+ // fail was too strict (e.g. retention can succeed with an empty result),
211+ // so a deleted stream often fell through to the generic message. Treat
212+ // a 404 alongside multiple failures as "stream gone".
213+ if sawNotFound && failures.count >= 2 {
210214 errorMessage = "Stream \"\(stream)\" was not found. It may have been deleted."
211215 } else if !failures.isEmpty {
212216 errorMessage = "Failed to load: \(failures.joined(separator: ", "))"