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
9a66b83iko
ParseableViewer/App/AppState.swift+29−3
@@ -14,7 +14,7 @@ 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
18
19 // MARK: - Stream State
19 // MARK: - Stream State
20 var streams: [LogStream] = []
20 var streams: [LogStream] = []
@@ -62,6 +62,15 @@ final class AppState {
62 case .serverInfo: return "server.rack"
62 case .serverInfo: return "server.rack"
63 }
63 }
64 }
64 }
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+ }
65 }
74 }
66
75
67 var filteredStreams: [LogStream] {
76 var filteredStreams: [LogStream] {
@@ -87,8 +96,8 @@ final class AppState {
87
96
88 if let activeID = ConnectionStore.loadActiveConnectionID(),
97 if let activeID = ConnectionStore.loadActiveConnectionID(),
89 let connection = connections.first(where: { $0.id == activeID }) {
98 let connection = connections.first(where: { $0.id == activeID }) {
90− connectTask = Task { @MainActor in
99+ connectTask = Task { @MainActor [weak self] in
91− await connect(to: connection)
100+ await self?.performConnect(to: connection) ?? false
92 }
101 }
93 }
102 }
94 }
103 }
@@ -98,6 +107,23 @@ final class AppState {
98 @MainActor
107 @MainActor
99 @discardableResult
108 @discardableResult
100 func connect(to connection: ServerConnection) async -> Bool {
109 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 {
101 guard !isConnecting else { return false }
127 guard !isConnecting else { return false }
102
128
103 if !isNetworkAvailable {
129 if !isNetworkAvailable {
ParseableViewer/Models/JSONValue.swift+26−14
@@ -231,27 +231,39 @@ enum JSONValue: Codable, Hashable, Sendable, Comparable {
231 return a < b
231 return a < b
232 }
232 }
233
233
234− /// Exact `Int < Double` comparison that avoids the precision loss of
234+ /// Exact three-way comparison of an `Int` against a `Double` with no
235− /// `Double(Int)` for magnitudes beyond 2^53. NaN sorts after all numbers.
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+
236 private static func intLessThanDouble(_ a: Int, _ b: Double) -> Bool {
256 private static func intLessThanDouble(_ a: Int, _ b: Double) -> Bool {
237− if b.isNaN { return true }
257+ compareIntDouble(a, b) < 0
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
240 }
258 }
241
259
242− /// Exact `Double < Int` comparison, the mirror of `intLessThanDouble`.
243− /// NaN sorts after all numbers, so `NaN < anyInt` is false.
244 private static func doubleLessThanInt(_ a: Double, _ b: Int) -> Bool {
260 private static func doubleLessThanInt(_ a: Double, _ b: Int) -> Bool {
245− if a.isNaN { return false }
261+ // a < b ⟺ b > a ⟺ compareIntDouble(b, a) is positive.
246− if let ai = Int(exactly: a) { return ai < b } // a is a whole, in-range number
262+ compareIntDouble(b, a) > 0
247− return a < Double(b) // a is fractional / infinite / out of range
248 }
263 }
249
264
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).
252 private static func intEqualsDouble(_ a: Int, _ b: Double) -> Bool {
265 private static func intEqualsDouble(_ a: Int, _ b: Double) -> Bool {
253− if let bi = Int(exactly: b) { return a == bi }
266+ compareIntDouble(a, b) == 0
254− return false
255 }
267 }
256
268
257 /// JSON-safe rendering of a double: JSON has no NaN/Infinity literals, so
269 /// 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 {
302 i += 1
302 i += 1
303 }
303 }
304
304
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+ }
306 guard offset <= length else { return nil }
325 guard offset <= length else { return nil }
307 return offset
326 return offset
308 }
327 }
@@ -324,6 +343,14 @@ struct SQLTokenizer: Sendable {
324 return NSRange(next.range, in: sql)
343 return NSRange(next.range, in: sql)
325 }
344 }
326 }
345 }
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+ }
327 return nil
354 return nil
328 }
355 }
329 return nsRange
356 return nsRange
ParseableViewer/ViewModels/LiveTailViewModel.swift+29−1
@@ -21,6 +21,12 @@ final class LiveTailViewModel {
21 var columns: [String] = []
21 var columns: [String] = []
22 var columnOrder: [String] = []
22 var columnOrder: [String] = []
23 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> = []
24 private var currentStream: String?
30 private var currentStream: String?
25
31
26 // nonisolated(unsafe) so deinit can invalidate the timer.
32 // nonisolated(unsafe) so deinit can invalidate the timer.
@@ -37,6 +43,11 @@ final class LiveTailViewModel {
37 private var allKnownKeys: Set<String> = []
43 private var allKnownKeys: Set<String> = []
38 private var consecutiveErrors = 0
44 private var consecutiveErrors = 0
39 private static let maxConsecutiveErrors = 5
45 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
40
51
41 deinit {
52 deinit {
42 timer?.invalidate()
53 timer?.invalidate()
@@ -170,6 +181,7 @@ final class LiveTailViewModel {
170 columns = []
181 columns = []
171 columnOrder = []
182 columnOrder = []
172 hiddenColumns = []
183 hiddenColumns = []
184+ savedHiddenColumns = []
173 columnFilters = []
185 columnFilters = []
174 currentStream = stream
186 currentStream = stream
175 rebuildFilteredEntries()
187 rebuildFilteredEntries()
@@ -201,6 +213,7 @@ final class LiveTailViewModel {
201 columns = []
213 columns = []
202 columnOrder = []
214 columnOrder = []
203 hiddenColumns = []
215 hiddenColumns = []
216+ savedHiddenColumns = []
204 columnFilters = []
217 columnFilters = []
205 rebuildFilteredEntries()
218 rebuildFilteredEntries()
206 }
219 }
@@ -214,10 +227,12 @@ final class LiveTailViewModel {
214 func toggleColumnVisibility(_ column: String) {
227 func toggleColumnVisibility(_ column: String) {
215 if hiddenColumns.contains(column) {
228 if hiddenColumns.contains(column) {
216 hiddenColumns.remove(column)
229 hiddenColumns.remove(column)
230+ savedHiddenColumns.remove(column)
217 } else {
231 } else {
218 let visibleCount = columnOrder.count - hiddenColumns.count
232 let visibleCount = columnOrder.count - hiddenColumns.count
219 if visibleCount > 1 {
233 if visibleCount > 1 {
220 hiddenColumns.insert(column)
234 hiddenColumns.insert(column)
235+ savedHiddenColumns.insert(column)
221 }
236 }
222 }
237 }
223 saveColumnConfig()
238 saveColumnConfig()
@@ -225,6 +240,7 @@ final class LiveTailViewModel {
225
240
226 func showAllColumns() {
241 func showAllColumns() {
227 hiddenColumns.removeAll()
242 hiddenColumns.removeAll()
243+ savedHiddenColumns.removeAll()
228 saveColumnConfig()
244 saveColumnConfig()
229 }
245 }
230
246
@@ -245,6 +261,7 @@ final class LiveTailViewModel {
245 func resetColumnConfig() {
261 func resetColumnConfig() {
246 columnOrder = columns
262 columnOrder = columns
247 hiddenColumns.removeAll()
263 hiddenColumns.removeAll()
264+ savedHiddenColumns.removeAll()
248 saveColumnConfig()
265 saveColumnConfig()
249 }
266 }
250
267
@@ -256,7 +273,9 @@ final class LiveTailViewModel {
256
273
257 private func saveColumnConfig() {
274 private func saveColumnConfig() {
258 guard let stream = currentStream else { return }
275 guard let stream = currentStream else { return }
276+ // Persist the full intended hidden set (including columns not yet seen
277+ // this session) so it survives across restarts and incremental discovery.
259− let config = QueryViewModel.ColumnConfiguration(order: columnOrder, hidden: hiddenColumns)
278+ let config = QueryViewModel.ColumnConfiguration(order: columnOrder, hidden: savedHiddenColumns)
260 if let data = try? JSONEncoder().encode(config) {
279 if let data = try? JSONEncoder().encode(config) {
261 UserDefaults.standard.set(data, forKey: Self.columnConfigKey(for: stream))
280 UserDefaults.standard.set(data, forKey: Self.columnConfigKey(for: stream))
262 }
281 }
@@ -298,9 +317,11 @@ final class LiveTailViewModel {
298 merged.append(col)
317 merged.append(col)
299 }
318 }
300 columnOrder = merged
319 columnOrder = merged
320+ savedHiddenColumns = config.hidden
301 hiddenColumns = config.hidden.intersection(extractedSet)
321 hiddenColumns = config.hidden.intersection(extractedSet)
302 } else {
322 } else {
303 columnOrder = extracted
323 columnOrder = extracted
324+ savedHiddenColumns = []
304 hiddenColumns = []
325 hiddenColumns = []
305 }
326 }
306 } else {
327 } else {
@@ -313,11 +334,18 @@ final class LiveTailViewModel {
313 if !newCols.isEmpty {
334 if !newCols.isEmpty {
314 columns.append(contentsOf: newCols)
335 columns.append(contentsOf: newCols)
315 columnOrder.append(contentsOf: newCols)
336 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) })
316 }
340 }
317 }
341 }
318 }
342 }
319
343
320 private func poll(client: ParseableClient, stream: String) async {
344 private func poll(client: ParseableClient, stream: String) async {
345+ guard !isPolling else { return }
346+ isPolling = true
347+ defer { isPolling = false }
348+
321 let now = Date()
349 let now = Date()
322 // The Parseable server truncates query time-ranges to minute
350 // The Parseable server truncates query time-ranges to minute
323 // boundaries, so a narrow window (< 60 s) can collapse to a
351 // boundaries, so a narrow window (< 60 s) can collapse to a
ParseableViewer/ViewModels/QueryViewModel.swift+14−1
@@ -3,7 +3,14 @@ import SwiftUI
3
3
4@Observable
4@Observable
5final class QueryViewModel {
5final 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+ }
7 var results: [LogRecord] = [] {
14 var results: [LogRecord] = [] {
8 didSet {
15 didSet {
9 // Show all results immediately
16 // Show all results immediately
@@ -404,6 +411,12 @@ final class QueryViewModel {
404 }
411 }
405 results = []
412 results = []
406 columns = []
413 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 = []
407 }
420 }
408
421
409 isLoading = false
422 isLoading = false
ParseableViewer/Views/ConnectionSheet.swift+4
@@ -49,6 +49,8 @@ struct ConnectionSheet: View {
49 .accessibilityLabel("Server URL")
49 .accessibilityLabel("Server URL")
50 .onChange(of: url) { _, newValue in
50 .onChange(of: url) { _, newValue in
51 urlValidationError = Self.validateURL(newValue)
51 urlValidationError = Self.validateURL(newValue)
52+ // A prior test result no longer reflects the edited fields.
53+ testResult = nil
52 }
54 }
53
55
54 if let urlError = urlValidationError, !url.isEmpty {
56 if let urlError = urlValidationError, !url.isEmpty {
@@ -60,10 +62,12 @@ struct ConnectionSheet: View {
60 TextField("Username:", text: $username)
62 TextField("Username:", text: $username)
61 .textFieldStyle(.roundedBorder)
63 .textFieldStyle(.roundedBorder)
62 .accessibilityLabel("Username")
64 .accessibilityLabel("Username")
65+ .onChange(of: username) { _, _ in testResult = nil }
63
66
64 SecureField("Password:", text: $password)
67 SecureField("Password:", text: $password)
65 .textFieldStyle(.roundedBorder)
68 .textFieldStyle(.roundedBorder)
66 .accessibilityLabel("Password")
69 .accessibilityLabel("Password")
70+ .onChange(of: password) { _, _ in testResult = nil }
67 }
71 }
68 .formStyle(.grouped)
72 .formStyle(.grouped)
69 .padding()
73 .padding()
ParseableViewer/Views/ContentView.swift+43−35
@@ -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 }
@@ -66,31 +52,53 @@ struct MainContentView: View {
66
52
67 Divider()
53 Divider()
68
54
69− // Content — only the active tab is instantiated
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 {
70− switch appState.currentTab {
61+ switch appState.currentTab {
71− case .query:
62+ case .query:
72− QueryView()
63+ QueryView()
73− .id(AppState.AppTab.query)
64+ .id(AppState.AppTab.query)
74− case .liveTail:
65+ case .liveTail:
75− LiveTailView()
66+ LiveTailView()
76− .id(AppState.AppTab.liveTail)
67+ .id(AppState.AppTab.liveTail)
77− case .streamInfo:
68+ case .streamInfo:
78− StreamDetailView()
69+ StreamDetailView()
79− .id(AppState.AppTab.streamInfo)
70+ .id(AppState.AppTab.streamInfo)
80− case .alerts:
71+ case .alerts:
81− AlertsView()
72+ AlertsView()
82− .id(AppState.AppTab.alerts)
73+ .id(AppState.AppTab.alerts)
83− case .users:
74+ case .users:
84− UsersView()
75+ UsersView()
85− .id(AppState.AppTab.users)
76+ .id(AppState.AppTab.users)
86− case .serverInfo:
77+ case .serverInfo:
87− ServerInfoView()
78+ ServerInfoView()
88− .id(AppState.AppTab.serverInfo)
79+ .id(AppState.AppTab.serverInfo)
80+ }
89 }
81 }
90 }
82 }
91 }
83 }
92}
84}
93
85
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+
94struct TabButton: View {
102struct TabButton: View {
95 let title: String
103 let title: String
96 let icon: String
104 let icon: String
ParseableViewer/Views/LiveTailView.swift+6−6
@@ -272,12 +272,12 @@ struct LiveTailView: View {
272 columnWidths = [:]
272 columnWidths = [:]
273 cachedSorted = []
273 cachedSorted = []
274 }
274 }
275− .onChange(of: viewModel.visibleColumns) { _, newColumns in
275+ .onChange(of: viewModel.visibleColumns) { _, _ in
276+ // Showing/hiding a column changes the severity column set, so rebuild
276− // Compute widths for any newly visible columns
277+ // (which also fills in widths for any newly visible columns). A
277− let records = viewModel.cachedFilteredRecords
278+ // width-only update would leave row severity tinting stale until the
278− for col in newColumns where columnWidths[col] == nil {
279+ // next poll.
279− columnWidths[col] = idealColumnWidth(for: col, records: records)
280+ rebuildSortedEntries()
280− }
281 }
281 }
282 .onAppear {
282 .onAppear {
283 rebuildSortedEntries()
283 rebuildSortedEntries()
ParseableViewer/Views/LogDetailView.swift+4
@@ -205,6 +205,10 @@ struct RawJSONView: View {
205 .padding(8)
205 .padding(8)
206 .frame(maxWidth: .infinity, alignment: .leading)
206 .frame(maxWidth: .infinity, alignment: .leading)
207 .task(id: record) {
207 .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
208 let json = JSONValue.object(record).prettyPrinted()
212 let json = JSONValue.object(record).prettyPrinted()
209 highlightedText = JSONSyntaxHighlighter.highlight(json)
213 highlightedText = JSONSyntaxHighlighter.highlight(json)
210 }
214 }
ParseableViewer/Views/LogTableView.swift+11−1
@@ -293,7 +293,17 @@ struct LogTableView: View {
293 widthTask = Task.detached(priority: .userInitiated) {
293 widthTask = Task.detached(priority: .userInitiated) {
294 let widths = computeColumnWidths(columns: cols, records: newRecords)
294 let widths = computeColumnWidths(columns: cols, records: newRecords)
295 guard !Task.isCancelled else { return }
295 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+ }
297 }
307 }
298 debouncedRebuildSort()
308 debouncedRebuildSort()
299 }
309 }