git.iko.soy/parseable-macos
on master help sign in
Fix 6 bugs: QueryViewModel data race, Settings sheet unreachable, dropped live-tail rebuilds, QueryResponse decoder, refresh token guard, popup anchoring
commit58be6829d92b5f6de8c1f0778af5a74a6e088969 authoriko <mail@iko.soy> dateMon 22 Jun 2026, 02:30 (3 months ago) parent129bc99
7 files changed+67−20
ParseableViewer/Models/JSONValue.swift+14 ParseableViewer/ViewModels/QueryViewModel.swift+19−12 ParseableViewer/Views/LiveTailView.swift+8−3 ParseableViewer/Views/QueryView.swift+4 ParseableViewer/Views/SQLAutoComplete.swift+6−1 ParseableViewer/Views/SettingsView.swift+14−4 ParseableViewerTests/QueryViewModelTests.swift+2
ParseableViewer/Models/JSONValue.swift+14
@@ -379,4 +379,18 @@ typealias LogRecord = [String: JSONValue]
379379struct QueryResponse: Codable, Sendable {
380380 let records: [LogRecord]
381381 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+ }
382396}
ParseableViewer/ViewModels/QueryViewModel.swift+19−12
@@ -2,6 +2,7 @@ import Foundation
22import SwiftUI
33
44@Observable
5+@MainActor
56final class QueryViewModel {
67 var sqlQuery = "" {
78 didSet {
@@ -24,14 +25,20 @@ final class QueryViewModel {
2425 // re-applies the filter.
2526 updateFilteredResults()
2627 let snapshot = results
27− searchTextTask = Task {
28− let texts = snapshot.map { record in
29− record.values.map { $0.displayString }.joined(separator: " ")
30− }
31− guard !Task.isCancelled else { return }
32− cachedSearchTexts = texts
33− if !filterText.isEmpty {
34− updateFilteredResults()
28+ // The class is @MainActor, so this Task inherits main-actor isolation —
29+ // its mutations of cachedSearchTexts/filteredResults are safely serialized
30+ // with SwiftUI reads. Only the pure (and potentially large) displayString
31+ // mapping is pushed off-main via Task.detached so it doesn't block the UI.
32+ searchTextTask = Task { [weak self] in
33+ let texts = await Task.detached(priority: .userInitiated) {
34+ snapshot.map { record in
35+ record.values.map { $0.displayString }.joined(separator: " ")
36+ }
37+ }.value
38+ guard let self, !Task.isCancelled else { return }
39+ self.cachedSearchTexts = texts
40+ if !self.filterText.isEmpty {
41+ self.updateFilteredResults()
3542 }
3643 }
3744 }
@@ -551,7 +558,7 @@ final class QueryViewModel {
551558 /// Projects each record down to the given columns so JSON export, like CSV,
552559 /// respects hidden columns instead of leaking every field. Safe to call from
553560 /// any thread.
554− static func projectRecords(_ records: [LogRecord], to columns: [String]) -> [LogRecord] {
561+ nonisolated static func projectRecords(_ records: [LogRecord], to columns: [String]) -> [LogRecord] {
555562 // No column list means no restriction (columns not computed yet) — export
556563 // the full records rather than stripping every field to an empty object.
557564 guard !columns.isEmpty else { return records }
@@ -560,7 +567,7 @@ final class QueryViewModel {
560567 }
561568
562569 /// Builds a CSV string from records and columns. Safe to call from any thread.
563− static func buildCSV(records: [LogRecord], columns: [String]) -> String {
570+ nonisolated static func buildCSV(records: [LogRecord], columns: [String]) -> String {
564571 guard !records.isEmpty, !columns.isEmpty else { return "" }
565572
566573 var csv = String()
@@ -586,7 +593,7 @@ final class QueryViewModel {
586593 return csv
587594 }
588595
589− private static func escapeCSV(_ value: String) -> String {
596+ nonisolated private static func escapeCSV(_ value: String) -> String {
590597 // Check at the Unicode scalar level to correctly detect \r and \n
591598 // inside \r\n (CRLF) grapheme clusters, which Swift's String.contains
592599 // treats as a single Character that doesn't match "\r" or "\n" alone.
@@ -731,7 +738,7 @@ final class QueryViewModel {
731738 }
732739
733740 /// Escapes a SQL identifier by doubling internal double-quotes, then wrapping in double-quotes.
734− static func escapeSQLIdentifier(_ identifier: String) -> String {
741+ nonisolated static func escapeSQLIdentifier(_ identifier: String) -> String {
735742 "\"\(identifier.replacingOccurrences(of: "\"", with: "\"\""))\""
736743 }
737744
ParseableViewer/Views/LiveTailView.swift+8−3
@@ -290,6 +290,14 @@ struct LiveTailView: View {
290290 // next poll.
291291 rebuildSortedEntries()
292292 }
293+ .onChange(of: viewModel.filteredEntriesGeneration) { _, _ in
294+ // Kept on the always-present container (not inside liveTailTable, which
295+ // is only mounted once visibleColumns is non-empty). A generation bump
296+ // that lands while the table is unmounted — e.g. the first poll arriving
297+ // before columns exist — would otherwise be dropped, leaving the new
298+ // entries unrendered until some other change happened to trigger a rebuild.
299+ rebuildSortedEntries()
300+ }
293301 .onAppear {
294302 rebuildSortedEntries()
295303 }
@@ -414,9 +422,6 @@ struct LiveTailView: View {
414422 }
415423 }
416424 }
417− .onChange(of: viewModel.filteredEntriesGeneration) { _, _ in
418− rebuildSortedEntries()
419− }
420425 .onChange(of: sortColumn) { _, _ in
421426 rebuildSortedEntries()
422427 }
ParseableViewer/Views/QueryView.swift+4
@@ -341,6 +341,10 @@ struct QueryView: View {
341341 }
342342 }
343343 .onChange(of: appState.queryRefreshToken) { _, _ in
344+ // Guard on a selected stream like every sibling query trigger in this
345+ // view. Without it, a refresh (Cmd+R) with no stream selected and an
346+ // empty editor surfaces a spurious "Select a stream..." error.
347+ guard appState.selectedStream != nil else { return }
344348 Task {
345349 await viewModel.executeQuery(
346350 client: appState.client,
ParseableViewer/Views/SQLAutoComplete.swift+6−1
@@ -389,7 +389,12 @@ final class SQLCompletionPopup: NSObject, NSTableViewDataSource, NSTableViewDele
389389
390390 let rowCount = min(items.count, Self.maxVisibleRows)
391391 let height = CGFloat(rowCount) * Self.rowHeight + 4
392− let frame = NSRect(x: panel.frame.origin.x, y: panel.frame.origin.y,
392+ // Anchor by the top edge (where the caret sits), matching show()/reposition().
393+ // The panel's origin is its bottom-left corner, so holding origin.y fixed
394+ // while the height changes would move the top edge — growing the list would
395+ // creep the popup upward over the caret. Pin the top edge instead.
396+ let topEdge = panel.frame.origin.y + panel.frame.height
397+ let frame = NSRect(x: panel.frame.origin.x, y: topEdge - height,
393398 width: Self.panelWidth, height: height)
394399 panel.setFrame(frame, display: true)
395400 }
ParseableViewer/Views/SettingsView.swift+14−4
@@ -6,6 +6,13 @@ struct SettingsView: View {
66 @AppStorage("maxQueryResults") private var maxQueryResults = 1000
77 @AppStorage("liveTailPollInterval") private var liveTailPollInterval = 2.0
88 @AppStorage("liveTailMaxEntries") private var liveTailMaxEntries = 5000
9+ // The Settings scene is a separate window from ContentView. A .sheet presents
10+ // over the window hosting the modifier, and binding both windows to the shared
11+ // appState.showConnectionSheet would present two sheets at once. So the Settings
12+ // window drives its own connection sheet with local state, fully independent of
13+ // the main window's.
14+ @State private var showConnectionSheet = false
15+ @State private var sheetConnection: ServerConnection?
916 var body: some View {
1017 TabView {
1118 // General settings
@@ -77,8 +84,8 @@ struct SettingsView: View {
7784 }
7885
7986 Button {
80− appState.editingConnection = connection
81− appState.showConnectionSheet = true
87+ sheetConnection = connection
88+ showConnectionSheet = true
8289 } label: {
8390 Image(systemName: "pencil")
8491 }
@@ -98,8 +105,8 @@ struct SettingsView: View {
98105 HStack {
99106 Spacer()
100107 Button("Add Connection...") {
101− appState.editingConnection = nil
102− appState.showConnectionSheet = true
108+ sheetConnection = nil
109+ showConnectionSheet = true
103110 }
104111 }
105112 .padding()
@@ -109,5 +116,8 @@ struct SettingsView: View {
109116 }
110117 }
111118 .frame(width: 500, height: 400)
119+ .sheet(isPresented: $showConnectionSheet) {
120+ ConnectionSheet(connection: sheetConnection)
121+ }
112122 }
113123}
ParseableViewerTests/QueryViewModelTests.swift+2
@@ -1,6 +1,8 @@
11import XCTest
22@testable import ParseableViewer
33
4+// QueryViewModel is @MainActor, so its tests must run on the main actor too.
5+@MainActor
46final class QueryViewModelTests: XCTestCase {
57 // MARK: - Column extraction
68