git.iko.soy/parseable-macos
on master help sign in
Fix 10 bugs: column widths reset, server tabs unreachable, connect race, severity refresh, stale highlights, Int/Double ordering, poll overlap, and hidden column persistence
commit9a66b83fdcefe9c48c932624fe338ed7199e8d1a authoriko <mail@iko.soy> dateSun 21 Jun 2026, 00:33 (3 months ago) parent3eb694c
10 files changed+194−62
ParseableViewer/App/AppState.swift+29−3 ParseableViewer/Models/JSONValue.swift+26−14 ParseableViewer/Models/SQLTokenizer.swift+28−1 ParseableViewer/ViewModels/LiveTailViewModel.swift+29−1 ParseableViewer/ViewModels/QueryViewModel.swift+14−1 ParseableViewer/Views/ConnectionSheet.swift+4 ParseableViewer/Views/ContentView.swift+43−35 ParseableViewer/Views/LiveTailView.swift+6−6 ParseableViewer/Views/LogDetailView.swift+4 ParseableViewer/Views/LogTableView.swift+11−1
ParseableViewer/App/AppState.swift+29−3
@@ -14,7 +14,7 @@ final class AppState {
1414 private(set) var isNetworkAvailable = true
1515 private let networkMonitor = NWPathMonitor()
1616 private let networkMonitorQueue = DispatchQueue(label: "com.parseableviewer.network-monitor")
17− private var connectTask: Task<Void, Never>?
17+ private var connectTask: Task<Bool, Never>?
1818
1919 // MARK: - Stream State
2020 var streams: [LogStream] = []
@@ -62,6 +62,15 @@ final class AppState {
6262 case .serverInfo: return "server.rack"
6363 }
6464 }
65+
66+ /// Whether the tab's content is scoped to a selected stream. Server-level
67+ /// tabs (Users, Server Info) stay reachable even with no stream selected.
68+ var requiresStream: Bool {
69+ switch self {
70+ case .query, .liveTail, .streamInfo, .alerts: return true
71+ case .users, .serverInfo: return false
72+ }
73+ }
6574 }
6675
6776 var filteredStreams: [LogStream] {
@@ -87,8 +96,8 @@ final class AppState {
8796
8897 if let activeID = ConnectionStore.loadActiveConnectionID(),
8998 let connection = connections.first(where: { $0.id == activeID }) {
90− connectTask = Task { @MainActor in
91− await connect(to: connection)
99+ connectTask = Task { @MainActor [weak self] in
100+ await self?.performConnect(to: connection) ?? false
92101 }
93102 }
94103 }
@@ -98,6 +107,23 @@ final class AppState {
98107 @MainActor
99108 @discardableResult
100109 func connect(to connection: ServerConnection) async -> Bool {
110+ // Supersede any connect already in flight (e.g. the launch auto-reconnect
111+ // or a prior request) instead of silently dropping this one — which would
112+ // surface as a spurious "Connection failed". Cancel it and wait for it to
113+ // unwind so two connects never run concurrently.
114+ if let existing = connectTask {
115+ existing.cancel()
116+ _ = await existing.value
117+ }
118+ let task = Task { @MainActor [weak self] in
119+ await self?.performConnect(to: connection) ?? false
120+ }
121+ connectTask = task
122+ return await task.value
123+ }
124+
125+ @MainActor
126+ private func performConnect(to connection: ServerConnection) async -> Bool {
101127 guard !isConnecting else { return false }
102128
103129 if !isNetworkAvailable {
ParseableViewer/Models/JSONValue.swift+26−14
@@ -231,27 +231,39 @@ enum JSONValue: Codable, Hashable, Sendable, Comparable {
231231 return a < b
232232 }
233233
234− /// Exact `Int < Double` comparison that avoids the precision loss of
235− /// `Double(Int)` for magnitudes beyond 2^53. NaN sorts after all numbers.
234+ /// Exact three-way comparison of an `Int` against a `Double` with no
235+ /// precision loss. Returns a negative value if `a < b`, zero if they are
236+ /// numerically equal, and a positive value if `a > b`. `NaN` sorts after all
237+ /// numbers (so any int compares as less than `NaN`).
238+ ///
239+ /// Comparing via `Double(a)` loses precision for `|a| > 2^53`, and
240+ /// `Int(exactly: b)` is `nil` once `b` falls outside the `Int` range — the
241+ /// previous helpers fell back to the lossy path there, which made `<`, `>`,
242+ /// and `==` mutually inconsistent at the 2^63 boundary (e.g. `Int.max` vs
243+ /// `Double(Int.max)`, which rounds up to 2^63). This range-checks `b` first
244+ /// so the integer parts are always compared exactly.
245+ private static func compareIntDouble(_ a: Int, _ b: Double) -> Int {
246+ if b.isNaN { return -1 } // NaN sorts after numbers
247+ if b >= 9223372036854775808.0 { return -1 } // b > Int.max (incl. +inf)
248+ if b < -9223372036854775808.0 { return 1 } // b < Int.min (incl. -inf)
249+ // b is finite and its floor fits in Int.
250+ let bFloor = Int(b.rounded(.down))
251+ if a != bFloor { return a < bFloor ? -1 : 1 }
252+ // Integer parts are equal: a fractional b is the larger of the two.
253+ return b > Double(bFloor) ? -1 : 0
254+ }
255+
236256 private static func intLessThanDouble(_ a: Int, _ b: Double) -> Bool {
237− if b.isNaN { return true }
238− if let bi = Int(exactly: b) { return a < bi } // b is a whole, in-range number
239− return Double(a) < b // b is fractional / infinite / out of range
257+ compareIntDouble(a, b) < 0
240258 }
241259
242− /// Exact `Double < Int` comparison, the mirror of `intLessThanDouble`.
243− /// NaN sorts after all numbers, so `NaN < anyInt` is false.
244260 private static func doubleLessThanInt(_ a: Double, _ b: Int) -> Bool {
245− if a.isNaN { return false }
246− if let ai = Int(exactly: a) { return ai < b } // a is a whole, in-range number
247− return a < Double(b) // a is fractional / infinite / out of range
261+ // a < b ⟺ b > a ⟺ compareIntDouble(b, a) is positive.
262+ compareIntDouble(b, a) > 0
248263 }
249264
250− /// Exact `Int == Double` test (true only when the double represents exactly
251− /// the same integer — `Int(exactly:)` is nil for fractional/out-of-range b).
252265 private static func intEqualsDouble(_ a: Int, _ b: Double) -> Bool {
253− if let bi = Int(exactly: b) { return a == bi }
254− return false
266+ compareIntDouble(a, b) == 0
255267 }
256268
257269 /// JSON-safe rendering of a double: JSON has no NaN/Infinity literals, so
ParseableViewer/Models/SQLTokenizer.swift+28−1
@@ -302,7 +302,26 @@ struct SQLTokenizer: Sendable {
302302 i += 1
303303 }
304304
305− let offset = i + (column - 1)
305+ // Advance (column - 1) characters from the line start. `column` is a
306+ // 1-based character position, so a surrogate pair (e.g. an emoji) counts
307+ // as one character but two UTF-16 units; stepping char-by-char keeps the
308+ // offset correct for non-BMP text. For all-ASCII/BMP lines this is
309+ // identical to `i + (column - 1)`.
310+ var offset = i
311+ var remaining = column - 1
312+ while remaining > 0 {
313+ // Running out of characters means the column points past the end of
314+ // the input — out of bounds, matching the original contract.
315+ guard offset < length else { return nil }
316+ let unit = nsString.character(at: offset)
317+ if unit == 0x0A { break } // a column can't extend past the line's newline
318+ if unit >= 0xD800 && unit <= 0xDBFF && offset + 1 < length {
319+ offset += 2 // high surrogate: skip the trailing low surrogate too
320+ } else {
321+ offset += 1
322+ }
323+ remaining -= 1
324+ }
306325 guard offset <= length else { return nil }
307326 return offset
308327 }
@@ -324,6 +343,14 @@ struct SQLTokenizer: Sendable {
324343 return NSRange(next.range, in: sql)
325344 }
326345 }
346+ // No following real token (e.g. error reported at trailing
347+ // whitespace): snap back to the previous non-trivia token
348+ // instead of returning no highlight at all.
349+ for prev in tokens[..<idx].reversed() {
350+ if !prev.kind.isTrivia {
351+ return NSRange(prev.range, in: sql)
352+ }
353+ }
327354 return nil
328355 }
329356 return nsRange
ParseableViewer/ViewModels/LiveTailViewModel.swift+29−1
@@ -21,6 +21,12 @@ final class LiveTailViewModel {
2121 var columns: [String] = []
2222 var columnOrder: [String] = []
2323 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> = []
2430 private var currentStream: String?
2531
2632 // nonisolated(unsafe) so deinit can invalidate the timer.
@@ -37,6 +43,11 @@ final class LiveTailViewModel {
3743 private var allKnownKeys: Set<String> = []
3844 private var consecutiveErrors = 0
3945 private static let maxConsecutiveErrors = 5
46+ /// Guards against overlapping polls: if a poll's network round-trip outlasts
47+ /// the timer interval, the next tick must not start a second concurrent poll
48+ /// (which would let the two interleave and write `lastTimestamp` /
49+ /// `consecutiveErrors` out of order).
50+ private var isPolling = false
4051
4152 deinit {
4253 timer?.invalidate()
@@ -170,6 +181,7 @@ final class LiveTailViewModel {
170181 columns = []
171182 columnOrder = []
172183 hiddenColumns = []
184+ savedHiddenColumns = []
173185 columnFilters = []
174186 currentStream = stream
175187 rebuildFilteredEntries()
@@ -201,6 +213,7 @@ final class LiveTailViewModel {
201213 columns = []
202214 columnOrder = []
203215 hiddenColumns = []
216+ savedHiddenColumns = []
204217 columnFilters = []
205218 rebuildFilteredEntries()
206219 }
@@ -214,10 +227,12 @@ final class LiveTailViewModel {
214227 func toggleColumnVisibility(_ column: String) {
215228 if hiddenColumns.contains(column) {
216229 hiddenColumns.remove(column)
230+ savedHiddenColumns.remove(column)
217231 } else {
218232 let visibleCount = columnOrder.count - hiddenColumns.count
219233 if visibleCount > 1 {
220234 hiddenColumns.insert(column)
235+ savedHiddenColumns.insert(column)
221236 }
222237 }
223238 saveColumnConfig()
@@ -225,6 +240,7 @@ final class LiveTailViewModel {
225240
226241 func showAllColumns() {
227242 hiddenColumns.removeAll()
243+ savedHiddenColumns.removeAll()
228244 saveColumnConfig()
229245 }
230246
@@ -245,6 +261,7 @@ final class LiveTailViewModel {
245261 func resetColumnConfig() {
246262 columnOrder = columns
247263 hiddenColumns.removeAll()
264+ savedHiddenColumns.removeAll()
248265 saveColumnConfig()
249266 }
250267
@@ -256,7 +273,9 @@ final class LiveTailViewModel {
256273
257274 private func saveColumnConfig() {
258275 guard let stream = currentStream else { return }
259− let config = QueryViewModel.ColumnConfiguration(order: columnOrder, hidden: hiddenColumns)
276+ // Persist the full intended hidden set (including columns not yet seen
277+ // this session) so it survives across restarts and incremental discovery.
278+ let config = QueryViewModel.ColumnConfiguration(order: columnOrder, hidden: savedHiddenColumns)
260279 if let data = try? JSONEncoder().encode(config) {
261280 UserDefaults.standard.set(data, forKey: Self.columnConfigKey(for: stream))
262281 }
@@ -298,9 +317,11 @@ final class LiveTailViewModel {
298317 merged.append(col)
299318 }
300319 columnOrder = merged
320+ savedHiddenColumns = config.hidden
301321 hiddenColumns = config.hidden.intersection(extractedSet)
302322 } else {
303323 columnOrder = extracted
324+ savedHiddenColumns = []
304325 hiddenColumns = []
305326 }
306327 } else {
@@ -313,11 +334,18 @@ final class LiveTailViewModel {
313334 if !newCols.isEmpty {
314335 columns.append(contentsOf: newCols)
315336 columnOrder.append(contentsOf: newCols)
337+ // Re-hide any newly appearing column that was hidden in a prior
338+ // session but hadn't been seen yet this run.
339+ hiddenColumns.formUnion(newCols.filter { savedHiddenColumns.contains($0) })
316340 }
317341 }
318342 }
319343
320344 private func poll(client: ParseableClient, stream: String) async {
345+ guard !isPolling else { return }
346+ isPolling = true
347+ defer { isPolling = false }
348+
321349 let now = Date()
322350 // The Parseable server truncates query time-ranges to minute
323351 // boundaries, so a narrow window (< 60 s) can collapse to a
ParseableViewer/ViewModels/QueryViewModel.swift+14−1
@@ -3,7 +3,14 @@ import SwiftUI
33
44@Observable
55final class QueryViewModel {
6− var sqlQuery = ""
6+ var sqlQuery = "" {
7+ didSet {
8+ // Editing the query invalidates any error underline, whose offsets
9+ // were computed against the previous text. Clearing here keeps a
10+ // late-arriving highlight from landing on the wrong position.
11+ if sqlQuery != oldValue { errorRange = nil }
12+ }
13+ }
714 var results: [LogRecord] = [] {
815 didSet {
916 // Show all results immediately
@@ -404,6 +411,12 @@ final class QueryViewModel {
404411 }
405412 results = []
406413 columns = []
414+ // Drop the previous query's column layout too, otherwise derived
415+ // state like `visibleColumns` (and a CSV export reading it) would
416+ // report stale column names after the failure.
417+ columnOrder = []
418+ hiddenColumns = []
419+ autoHiddenColumns = []
407420 }
408421
409422 isLoading = false
ParseableViewer/Views/ConnectionSheet.swift+4
@@ -49,6 +49,8 @@ struct ConnectionSheet: View {
4949 .accessibilityLabel("Server URL")
5050 .onChange(of: url) { _, newValue in
5151 urlValidationError = Self.validateURL(newValue)
52+ // A prior test result no longer reflects the edited fields.
53+ testResult = nil
5254 }
5355
5456 if let urlError = urlValidationError, !url.isEmpty {
@@ -60,10 +62,12 @@ struct ConnectionSheet: View {
6062 TextField("Username:", text: $username)
6163 .textFieldStyle(.roundedBorder)
6264 .accessibilityLabel("Username")
65+ .onChange(of: username) { _, _ in testResult = nil }
6366
6467 SecureField("Password:", text: $password)
6568 .textFieldStyle(.roundedBorder)
6669 .accessibilityLabel("Password")
70+ .onChange(of: password) { _, _ in testResult = nil }
6771 }
6872 .formStyle(.grouped)
6973 .padding()
ParseableViewer/Views/ContentView.swift+43−35
@@ -10,21 +10,7 @@ struct ContentView: View {
1010 SidebarView()
1111 } detail: {
1212 if appState.isConnected {
13− if appState.selectedStream != nil {
14− MainContentView()
15− } else {
16− VStack(spacing: 12) {
17− Image(systemName: "sidebar.left")
18− .font(.system(size: 48))
19− .foregroundStyle(.secondary)
20− Text("Select a stream")
21− .font(.title2)
22− .foregroundStyle(.secondary)
23− Text("Choose a log stream from the sidebar to get started.")
24− .foregroundStyle(.tertiary)
25− }
26− .frame(maxWidth: .infinity, maxHeight: .infinity)
27− }
13+ MainContentView()
2814 } else {
2915 WelcomeView()
3016 }
@@ -66,31 +52,53 @@ struct MainContentView: View {
6652
6753 Divider()
6854
69− // Content — only the active tab is instantiated
70− switch appState.currentTab {
71− case .query:
72− QueryView()
73− .id(AppState.AppTab.query)
74− case .liveTail:
75− LiveTailView()
76− .id(AppState.AppTab.liveTail)
77− case .streamInfo:
78− StreamDetailView()
79− .id(AppState.AppTab.streamInfo)
80− case .alerts:
81− AlertsView()
82− .id(AppState.AppTab.alerts)
83− case .users:
84− UsersView()
85− .id(AppState.AppTab.users)
86− case .serverInfo:
87− ServerInfoView()
88− .id(AppState.AppTab.serverInfo)
55+ // Content — only the active tab is instantiated. Stream-scoped tabs
56+ // show a placeholder until a stream is selected; server-level tabs
57+ // (Users, Server Info) render regardless.
58+ if appState.currentTab.requiresStream && appState.selectedStream == nil {
59+ SelectStreamPlaceholder()
60+ } else {
61+ switch appState.currentTab {
62+ case .query:
63+ QueryView()
64+ .id(AppState.AppTab.query)
65+ case .liveTail:
66+ LiveTailView()
67+ .id(AppState.AppTab.liveTail)
68+ case .streamInfo:
69+ StreamDetailView()
70+ .id(AppState.AppTab.streamInfo)
71+ case .alerts:
72+ AlertsView()
73+ .id(AppState.AppTab.alerts)
74+ case .users:
75+ UsersView()
76+ .id(AppState.AppTab.users)
77+ case .serverInfo:
78+ ServerInfoView()
79+ .id(AppState.AppTab.serverInfo)
80+ }
8981 }
9082 }
9183 }
9284}
9385
86+struct SelectStreamPlaceholder: View {
87+ var body: some View {
88+ VStack(spacing: 12) {
89+ Image(systemName: "sidebar.left")
90+ .font(.system(size: 48))
91+ .foregroundStyle(.secondary)
92+ Text("Select a stream")
93+ .font(.title2)
94+ .foregroundStyle(.secondary)
95+ Text("Choose a log stream from the sidebar to get started.")
96+ .foregroundStyle(.tertiary)
97+ }
98+ .frame(maxWidth: .infinity, maxHeight: .infinity)
99+ }
100+}
101+
94102struct TabButton: View {
95103 let title: String
96104 let icon: String
ParseableViewer/Views/LiveTailView.swift+6−6
@@ -272,12 +272,12 @@ struct LiveTailView: View {
272272 columnWidths = [:]
273273 cachedSorted = []
274274 }
275− .onChange(of: viewModel.visibleColumns) { _, newColumns in
276− // Compute widths for any newly visible columns
277− let records = viewModel.cachedFilteredRecords
278− for col in newColumns where columnWidths[col] == nil {
279− columnWidths[col] = idealColumnWidth(for: col, records: records)
280− }
275+ .onChange(of: viewModel.visibleColumns) { _, _ in
276+ // Showing/hiding a column changes the severity column set, so rebuild
277+ // (which also fills in widths for any newly visible columns). A
278+ // width-only update would leave row severity tinting stale until the
279+ // next poll.
280+ rebuildSortedEntries()
281281 }
282282 .onAppear {
283283 rebuildSortedEntries()
ParseableViewer/Views/LogDetailView.swift+4
@@ -205,6 +205,10 @@ struct RawJSONView: View {
205205 .padding(8)
206206 .frame(maxWidth: .infinity, alignment: .leading)
207207 .task(id: record) {
208+ // Drop the previous record's highlight first so we fall back to the
209+ // new record's plain text while the new highlight computes, instead
210+ // of briefly showing the old record's highlighted JSON.
211+ highlightedText = nil
208212 let json = JSONValue.object(record).prettyPrinted()
209213 highlightedText = JSONSyntaxHighlighter.highlight(json)
210214 }
ParseableViewer/Views/LogTableView.swift+11−1
@@ -293,7 +293,17 @@ struct LogTableView: View {
293293 widthTask = Task.detached(priority: .userInitiated) {
294294 let widths = computeColumnWidths(columns: cols, records: newRecords)
295295 guard !Task.isCancelled else { return }
296− await MainActor.run { columnWidths = widths }
296+ await MainActor.run {
297+ // Preserve any width the user dragged or auto-fit; only fill
298+ // in columns that don't have a width yet (e.g. newly appearing
299+ // columns). A wholesale overwrite would discard manual resizes
300+ // on every re-query/filter/refresh.
301+ var merged = columnWidths
302+ for (col, width) in widths where merged[col] == nil {
303+ merged[col] = width
304+ }
305+ columnWidths = merged
306+ }
297307 }
298308 debouncedRebuildSort()
299309 }