git.iko.soy/parseable-macos
on master help sign in
Fix URL double-encoding and 11 other UI bugs
a85b560iko
ParseableViewer/Models/AlertModels.swift+6−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"
ParseableViewer/Services/ParseableClient.swift+10−3
@@ -136,11 +136,18 @@ final class ParseableClient: Sendable {
136 }
136 }
137
137
138 private func buildRequest(method: String, path: String, body: Data? = nil, queryItems: [URLQueryItem]? = nil) throws -> URLRequest {
138 private func buildRequest(method: String, path: String, body: Data? = nil, queryItems: [URLQueryItem]? = nil) throws -> URLRequest {
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.
139− var components = URLComponents(url: baseURL.appendingPathComponent(path), resolvingAgainstBaseURL: false)
143+ guard var components = URLComponents(url: baseURL, resolvingAgainstBaseURL: false) else {
144+ throw ParseableError.invalidURL
145+ }
146+ components.percentEncodedPath += path
140 if let queryItems {
147 if let queryItems {
141− components?.queryItems = queryItems
148+ components.queryItems = queryItems
142 }
149 }
143− guard let url = components?.url else {
150+ guard let url = components.url else {
144 throw ParseableError.invalidURL
151 throw ParseableError.invalidURL
145 }
152 }
146
153
ParseableViewer/ViewModels/QueryViewModel.swift+30−13
@@ -538,23 +538,40 @@ final class QueryViewModel {
538 var sql = sqlQuery.trimmingCharacters(in: .whitespacesAndNewlines)
538 var sql = sqlQuery.trimmingCharacters(in: .whitespacesAndNewlines)
539 if sql.isEmpty { return }
539 if sql.isEmpty { return }
540
540
541− let wherePattern = #"(?i)\bWHERE\b"#
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.
542− let tailPattern = #"(?i)\b(ORDER\s+BY|GROUP\s+BY|HAVING|LIMIT)\b"#
544+ let tailKeywords: Set<String> = ["ORDER", "GROUP", "HAVING", "LIMIT", "OFFSET"]
543−
545+ var depth = 0
544− if let whereRange = sql.range(of: wherePattern, options: .regularExpression) {
546+ var whereEnd: String.Index?
545− let afterWhere = whereRange.upperBound
547+ var tailStart: String.Index?
546− let searchRange = afterWhere..<sql.endIndex
548+ for token in SQLTokenizer.tokenize(sql) {
547− if let tailRange = sql.range(of: tailPattern, options: .regularExpression, range: searchRange) {
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 {
548− sql.insert(contentsOf: "AND \(condition) ", at: tailRange.lowerBound)
567+ sql.insert(contentsOf: "AND \(condition) ", at: tailStart)
549 } else {
568 } else {
550 sql += " AND \(condition)"
569 sql += " AND \(condition)"
551 }
570 }
571+ } else if let tailStart {
572+ sql.insert(contentsOf: "WHERE \(condition) ", at: tailStart)
552 } else {
573 } 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)"
574+ sql += " WHERE \(condition)"
557− }
558 }
575 }
559
576
560 sqlQuery = sql
577 sqlQuery = sql
ParseableViewer/Views/LiveTailView.swift+6−10
@@ -12,7 +12,6 @@ struct LiveTailView: View {
12 @State private var sortAscending = false
12 @State private var sortAscending = false
13 @State private var columnWidths: [String: CGFloat] = [:]
13 @State private var columnWidths: [String: CGFloat] = [:]
14 @State private var cachedSorted: [LiveTailViewModel.LiveTailEntry] = []
14 @State private var cachedSorted: [LiveTailViewModel.LiveTailEntry] = []
15− @State private var cachedSeverities: [SeverityLevel] = []
16 @State private var severityColumnSet: Set<String> = []
15 @State private var severityColumnSet: Set<String> = []
17 @State private var liveTailSortTask: Task<Void, Never>?
16 @State private var liveTailSortTask: Task<Void, Never>?
18
17
@@ -284,7 +283,6 @@ struct LiveTailView: View {
284 let sevCols = buildSeverityColumnSet(columns: cols)
283 let sevCols = buildSeverityColumnSet(columns: cols)
285 guard let col = sortColumn else {
284 guard let col = sortColumn else {
286 cachedSorted = entries
285 cachedSorted = entries
287− cachedSeverities = entries.map { extractSeverity(from: $0.record, severityColumns: sevCols) }
288 severityColumnSet = sevCols
286 severityColumnSet = sevCols
289 return
287 return
290 }
288 }
@@ -302,7 +300,6 @@ struct LiveTailView: View {
302 }.value
300 }.value
303 guard !Task.isCancelled else { return }
301 guard !Task.isCancelled else { return }
304 cachedSorted = sorted
302 cachedSorted = sorted
305− cachedSeverities = sorted.map { extractSeverity(from: $0.record, severityColumns: sevCols) }
306 severityColumnSet = sevCols
303 severityColumnSet = sevCols
307 }
304 }
308 }
305 }
@@ -319,7 +316,7 @@ struct LiveTailView: View {
319 columnWidths: columnWidths,
316 columnWidths: columnWidths,
320 isSelected: selectedRecord == entry.record,
317 isSelected: selectedRecord == entry.record,
321 isAlternate: index % 2 == 1,
318 isAlternate: index % 2 == 1,
322− severity: index < cachedSeverities.count ? cachedSeverities[index] : .unknown,
319+ severity: extractSeverity(from: entry.record, severityColumns: severityColumnSet),
323 severityColumns: severityColumnSet,
320 severityColumns: severityColumnSet,
324 wrapText: wrapText,
321 wrapText: wrapText,
325 onCellFilter: { column, value, exclude in
322 onCellFilter: { column, value, exclude in
@@ -360,13 +357,12 @@ struct LiveTailView: View {
360 .onChange(of: sortAscending) { _, _ in
357 .onChange(of: sortAscending) { _, _ in
361 rebuildSortedEntries()
358 rebuildSortedEntries()
362 }
359 }
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: viewModel.entries.count) { _, _ in
363+ .onChange(of: cachedSorted.count) { _, _ in
364 guard autoScroll, sortColumn == nil else { return }
364 guard autoScroll, sortColumn == nil else { return }
365− let hasFilters = !viewModel.filterText.isEmpty || !viewModel.columnFilters.isEmpty
366− let target = hasFilters
367− ? viewModel.cachedFilteredEntries.last
368− : viewModel.entries.last
369− if let target {
365+ if let target = cachedSorted.last {
370 proxy.scrollTo(target.id, anchor: .bottom)
366 proxy.scrollTo(target.id, anchor: .bottom)
371 }
367 }
372 }
368 }
ParseableViewer/Views/LogDetailView.swift+6−1
@@ -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"
@@ -61,8 +62,12 @@ struct LogDetailView: View {
61 guard success else { return }
62 guard success else { return }
62
63
63 showCopyConfirmation = true
64 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 {
65 try? await Task.sleep(for: .seconds(1.5))
69 try? await Task.sleep(for: .seconds(1.5))
70+ guard !Task.isCancelled else { return }
66 showCopyConfirmation = false
71 showCopyConfirmation = false
67 }
72 }
68 }
73 }
ParseableViewer/Views/LogTableView.swift+22−12
@@ -301,12 +301,27 @@ struct LogTableView: View {
301 guard !Task.isCancelled else { return }
301 guard !Task.isCancelled else { return }
302 cachedSorted = indexed
302 cachedSorted = indexed
303 severityColumnSet = sevCols
303 severityColumnSet = sevCols
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+ }
304− if let selectedRecord,
319+ if let selectedRecord,
305− let match = cachedSorted.first(where: { $0.record == selectedRecord }) {
320+ let match = cachedSorted.first(where: { $0.record == selectedRecord }) {
306− selectedIndex = match.id
321+ selectedIndex = match.id
307− } else {
322+ } else {
308− selectedIndex = nil
323+ selectedIndex = nil
309− }
324+ self.selectedRecord = nil
310 }
325 }
311 }
326 }
312
327
@@ -327,12 +342,7 @@ struct LogTableView: View {
327 IndexedRecord(id: $0.offset, record: $0.element, severity: extractSeverity(from: $0.element, severityColumns: sevCols))
342 IndexedRecord(id: $0.offset, record: $0.element, severity: extractSeverity(from: $0.element, severityColumns: sevCols))
328 }
343 }
329 severityColumnSet = sevCols
344 severityColumnSet = sevCols
330− if let selectedRecord,
345+ reconcileSelection()
331− let match = cachedSorted.first(where: { $0.record == selectedRecord }) {
332− selectedIndex = match.id
333− } else {
334− selectedIndex = nil
335− }
336 }
346 }
337}
347}
338
348
ParseableViewer/Views/SQLAutoComplete.swift+11
@@ -339,6 +339,17 @@ final class SQLCompletionPopup: NSObject, NSTableViewDataSource, NSTableViewDele
339 panel.orderFront(nil)
339 panel.orderFront(nil)
340 }
340 }
341
341
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+
342 func hide() {
353 func hide() {
343 panel.parent?.removeChildWindow(panel)
354 panel.parent?.removeChildWindow(panel)
344 panel.orderOut(nil)
355 panel.orderOut(nil)
ParseableViewer/Views/SQLEditorView.swift+20−3
@@ -59,7 +59,10 @@ struct SQLEditorView: NSViewRepresentable {
59 let selectedRanges = textView.selectedRanges
59 let selectedRanges = textView.selectedRanges
60 textView.string = text
60 textView.string = text
61 context.coordinator.applyHighlighting(to: textView)
61 context.coordinator.applyHighlighting(to: textView)
62+ // NSRange is UTF-16 based; compare against the NSString length, not
63+ // String.count (grapheme count), or wide characters drop the selection.
64+ let length = (textView.string as NSString).length
62− let validRanges = selectedRanges.filter { NSMaxRange($0.rangeValue) <= textView.string.count }
65+ let validRanges = selectedRanges.filter { NSMaxRange($0.rangeValue) <= length }
63 if !validRanges.isEmpty {
66 if !validRanges.isEmpty {
64 textView.selectedRanges = validRanges
67 textView.selectedRanges = validRanges
65 }
68 }
@@ -71,6 +74,10 @@ struct SQLEditorView: NSViewRepresentable {
71 Coordinator(text: $text, streamNames: streamNames, schemaFields: schemaFields)
74 Coordinator(text: $text, streamNames: streamNames, schemaFields: schemaFields)
72 }
75 }
73
76
77+ static func dismantleNSView(_ nsView: NSScrollView, coordinator: Coordinator) {
78+ coordinator.cleanup()
79+ }
80+
74 final class Coordinator: NSObject, NSTextViewDelegate {
81 final class Coordinator: NSObject, NSTextViewDelegate {
75 var text: Binding<String>
82 var text: Binding<String>
76 var isEditing = false
83 var isEditing = false
@@ -155,12 +162,22 @@ struct SQLEditorView: NSViewRepresentable {
155
162
156 completionPopup.update(items: result.items)
163 completionPopup.update(items: result.items)
157
164
158− if !completionPopup.isVisible, let window = textView.window {
165+ if let window = textView.window {
159 let screenPoint = cursorScreenPoint(for: textView)
166 let screenPoint = cursorScreenPoint(for: textView)
167+ if completionPopup.isVisible {
168+ completionPopup.reposition(at: screenPoint)
169+ } else {
160− completionPopup.show(at: screenPoint, in: window)
170+ completionPopup.show(at: screenPoint, in: window)
171+ }
161 }
172 }
162 }
173 }
163
174
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+
164 private func insertCompletion(_ item: SQLCompletionItem) {
181 private func insertCompletion(_ item: SQLCompletionItem) {
165 // Find the text view — walk up from the popup's parent window
182 // Find the text view — walk up from the popup's parent window
166 guard let textView = findTextView() else { return }
183 guard let textView = findTextView() else { return }
ParseableViewer/Views/ServerInfoView.swift+4−1
@@ -52,7 +52,7 @@ struct ServerInfoView: View {
52 .padding(8)
52 .padding(8)
53 }
53 }
54
54
55− if let about = about ?? appState.serverAbout {
55+ if let about {
56 GroupBox("Server Details") {
56 GroupBox("Server Details") {
57 VStack(alignment: .leading, spacing: 8) {
57 VStack(alignment: .leading, spacing: 8) {
58 InfoRow(label: "Version", value: about.version ?? "N/A")
58 InfoRow(label: "Version", value: about.version ?? "N/A")
@@ -132,6 +132,9 @@ struct ServerInfoView: View {
132 guard let client = appState.client else { return }
132 guard let client = appState.client else { return }
133 isLoading = true
133 isLoading = true
134 errorMessage = nil
134 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 }
135
138
136 async let healthCheck: Void = client.checkHealth()
139 async let healthCheck: Void = client.checkHealth()
137 async let aboutResult = client.getAbout()
140 async let aboutResult = client.getAbout()
ParseableViewer/Views/SidebarView.swift+1−1
@@ -288,7 +288,7 @@ struct FilterRow: View {
288 } label: {
288 } label: {
289 HStack {
289 HStack {
290 Image(systemName: "bookmark")
290 Image(systemName: "bookmark")
291− .foregroundStyle(streamExists ? .orange : .secondary)
291+ .foregroundStyle(streamExists ? Color.secondary : Color.orange)
292 VStack(alignment: .leading) {
292 VStack(alignment: .leading) {
293 Text(filter.filterName)
293 Text(filter.filterName)
294 .lineLimit(1)
294 .lineLimit(1)
ParseableViewer/Views/StreamDetailView.swift+5−1
@@ -206,7 +206,11 @@ struct StreamDetailView: View {
206 do { info = try await infoFetch } catch { info = nil; failures.append("info"); checkNotFound(error) }
206 do { info = try await infoFetch } catch { info = nil; failures.append("info"); checkNotFound(error) }
207 do { retention = try await retentionFetch } catch { retention = []; failures.append("retention"); checkNotFound(error) }
207 do { retention = try await retentionFetch } catch { retention = []; failures.append("retention"); checkNotFound(error) }
208
208
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".
209− if sawNotFound && failures.count == 4 {
213+ if sawNotFound && failures.count >= 2 {
210 errorMessage = "Stream \"\(stream)\" was not found. It may have been deleted."
214 errorMessage = "Stream \"\(stream)\" was not found. It may have been deleted."
211 } else if !failures.isEmpty {
215 } else if !failures.isEmpty {
212 errorMessage = "Failed to load: \(failures.joined(separator: ", "))"
216 errorMessage = "Failed to load: \(failures.joined(separator: ", "))"