git.iko.soy/parseable-macos
on master help sign in
Fix 7 bugs: live-tail polling, string comparison contract, stale connection state, reconnect loop, zero-length time range, negative byte sizes
commit8ac672a16bc09559b20c269c28041e5b6275fa89 authoriko <mail@iko.soy> dateSun 21 Jun 2026, 12:00 (3 months ago) parentf28fd22
6 files changed+48−9
ParseableViewer/App/AppState.swift+14 ParseableViewer/Models/JSONValue.swift+7−1 ParseableViewer/Models/LogStream.swift+11−4 ParseableViewer/ViewModels/LiveTailViewModel.swift+8−1 ParseableViewer/Views/SidebarView.swift+6−1 ParseableViewer/Views/TimeRangePicker.swift+2−2
ParseableViewer/App/AppState.swift+14
@@ -231,6 +231,13 @@ final class AppState {
231231 selectedStream = nil
232232 serverAbout = nil
233233 filters = []
234+ // Clear transient stream/UI state too, otherwise a leftover stream-load
235+ // error keeps rendering in the sidebar after disconnect and a stale search
236+ // filter / tab carries into the next connection.
237+ streamLoadError = nil
238+ isLoadingStreams = false
239+ streamSearchText = ""
240+ currentTab = .query
234241 ConnectionStore.saveActiveConnectionID(nil)
235242 }
236243
@@ -277,7 +284,14 @@ final class AppState {
277284 // about). Clear the stream selection first so a now-missing stream doesn't
278285 // keep driving detail-tab requests during the reconnect.
279286 if activeConnection?.id == connection.id {
287+ // `performConnect` skips its stale-state cleanup because the id is
288+ // unchanged, but the edited URL/credentials may point at a different
289+ // server. Clear the stream-specific UI state here so a leftover sidebar
290+ // search filter doesn't hide the new server's streams and a
291+ // stream-specific tab doesn't linger with no valid selection.
280292 selectedStream = nil
293+ streamSearchText = ""
294+ currentTab = .query
281295 Task { await connect(to: connection) }
282296 }
283297 }
ParseableViewer/Models/JSONValue.swift+7−1
@@ -211,7 +211,13 @@ enum JSONValue: Codable, Hashable, Sendable, Comparable {
211211 case (.int(let a), .double(let b)): return intLessThanDouble(a, b)
212212 case (.double(let a), .int(let b)): return doubleLessThanInt(a, b)
213213 case (.string(let a), .string(let b)):
214− return a.localizedStandardCompare(b) == .orderedAscending
214+ // Two distinct strings can fold to `.orderedSame` under
215+ // `localizedStandardCompare` (e.g. full-width vs half-width forms that
216+ // Swift's `==` still treats as different). Break that tie with the raw
217+ // `<` so order-equivalent values stay `==`, as `Comparable` requires —
218+ // the same fallback `LogStream.<` already applies.
219+ let order = a.localizedStandardCompare(b)
220+ return order == .orderedAscending || (order == .orderedSame && a < b)
215221 case (.array, .array), (.object, .object):
216222 // Same rank, structured: equal values must not be ordered; otherwise
217223 // use a deterministic serialization tiebreaker.
ParseableViewer/Models/LogStream.swift+11−4
@@ -96,6 +96,13 @@ private func formatByteSize(_ value: Double) -> String? {
9696 return ByteCountFormatter.string(fromByteCount: Int64(value), countStyle: .file)
9797}
9898
99+private func formatByteSize(_ value: Int) -> String? {
100+ // A negative byte count isn't meaningful; `ByteCountFormatter` would render it
101+ // as a bogus "-1 KB"/"Zero KB" string, so drop it like the `Double` path does.
102+ guard value >= 0 else { return nil }
103+ return ByteCountFormatter.string(fromByteCount: Int64(value), countStyle: .file)
104+}
105+
99106struct StreamStats: Codable, Sendable {
100107 let ingestion: IngestionStats?
101108 let storage: StorageStats?
@@ -118,7 +125,7 @@ struct StreamStats: Codable, Sendable {
118125 if let s = try? container.decode(String.self, forKey: .size) {
119126 self.size = s
120127 } else if let n = try? container.decode(Int.self, forKey: .size) {
121− self.size = ByteCountFormatter.string(fromByteCount: Int64(n), countStyle: .file)
128+ self.size = formatByteSize(n)
122129 } else if let d = try? container.decode(Double.self, forKey: .size) {
123130 self.size = formatByteSize(d)
124131 } else {
@@ -127,7 +134,7 @@ struct StreamStats: Codable, Sendable {
127134 if let s = try? container.decode(String.self, forKey: .lifetime_size) {
128135 self.lifetime_size = s
129136 } else if let n = try? container.decode(Int.self, forKey: .lifetime_size) {
130− self.lifetime_size = ByteCountFormatter.string(fromByteCount: Int64(n), countStyle: .file)
137+ self.lifetime_size = formatByteSize(n)
131138 } else if let d = try? container.decode(Double.self, forKey: .lifetime_size) {
132139 self.lifetime_size = formatByteSize(d)
133140 } else {
@@ -151,7 +158,7 @@ struct StreamStats: Codable, Sendable {
151158 if let s = try? container.decode(String.self, forKey: .size) {
152159 self.size = s
153160 } else if let n = try? container.decode(Int.self, forKey: .size) {
154− self.size = ByteCountFormatter.string(fromByteCount: Int64(n), countStyle: .file)
161+ self.size = formatByteSize(n)
155162 } else if let d = try? container.decode(Double.self, forKey: .size) {
156163 self.size = formatByteSize(d)
157164 } else {
@@ -160,7 +167,7 @@ struct StreamStats: Codable, Sendable {
160167 if let s = try? container.decode(String.self, forKey: .lifetime_size) {
161168 self.lifetime_size = s
162169 } else if let n = try? container.decode(Int.self, forKey: .lifetime_size) {
163− self.lifetime_size = ByteCountFormatter.string(fromByteCount: Int64(n), countStyle: .file)
170+ self.lifetime_size = formatByteSize(n)
164171 } else if let d = try? container.decode(Double.self, forKey: .lifetime_size) {
165172 self.lifetime_size = formatByteSize(d)
166173 } else {
ParseableViewer/ViewModels/LiveTailViewModel.swift+8−1
@@ -198,12 +198,19 @@ final class LiveTailViewModel {
198198 currentStream = stream
199199 rebuildFilteredEntries()
200200
201− timer = Timer.scheduledTimer(withTimeInterval: pollInterval, repeats: true) { [weak self] _ in
201+ // Install the timer in `.common` modes so polling keeps firing while the
202+ // main run loop is in event-tracking mode (scrolling, dragging a column,
203+ // an open menu/popover). A `.default`-only timer — what
204+ // `scheduledTimer(withTimeInterval:)` installs — silently pauses during
205+ // those interactions, freezing the "live" tail until the user lets go.
206+ let pollTimer = Timer(timeInterval: pollInterval, repeats: true) { [weak self] _ in
202207 Task { @MainActor [weak self] in
203208 guard let self, !self.isPaused else { return }
204209 await self.poll(client: client, stream: stream)
205210 }
206211 }
212+ RunLoop.main.add(pollTimer, forMode: .common)
213+ timer = pollTimer
207214 }
208215
209216 func stop() {
ParseableViewer/Views/SidebarView.swift+6−1
@@ -225,7 +225,12 @@ struct ConnectionStatusView: View {
225225 if newID == ConnectionStatusView.addConnectionID {
226226 appState.editingConnection = nil
227227 appState.showConnectionSheet = true
228− } else if let conn = appState.connections.first(where: { $0.id == newID }) {
228+ } else if newID != connection.id,
229+ let conn = appState.connections.first(where: { $0.id == newID }) {
230+ // Only reconnect when a *different* connection is
231+ // picked. Re-selecting the active one would tear
232+ // down and rebuild the client for no reason (a full
233+ // health-check + stream reload, with a visible flicker).
229234 Task { await appState.connect(to: conn) }
230235 }
231236 }
ParseableViewer/Views/TimeRangePicker.swift+2−2
@@ -41,7 +41,7 @@ struct TimeRangePicker: View {
4141 DatePicker("End:", selection: $customEnd)
4242 .datePickerStyle(.field)
4343
44− if customEnd < customStart {
44+ if customEnd <= customStart {
4545 HStack(spacing: 4) {
4646 Image(systemName: "exclamationmark.triangle.fill")
4747 Text("End date must be after start date")
@@ -82,7 +82,7 @@ struct TimeRangePicker: View {
8282 onCommit?()
8383 }
8484 .buttonStyle(.borderedProminent)
85− .disabled(customEnd < customStart)
85+ .disabled(customEnd <= customStart)
8686 }
8787 }
8888 .padding()