git.iko.soy/parseable-macos
on master help sign in
Fix live tail ordering, clear, dedup, and column state

- Parse Parseable's zone-less p_timestamp (e.g. 2024-01-11T09:08:34.290) as UTC. Every entry fell back to the poll time, so each poll's batch was shown newest-first. - Clear no longer refills the view: polls always look back 90 s, so resetting the dedup set re-admitted the rows just cleared. - Dedup counts occurrences per fingerprint instead of using a set, so identical events from one ingest request are no longer merged. - Keep the saved column order for columns first seen in later polls. - Drop the sort when the sort column is hidden. - Clear moves from Cmd+Delete to Cmd+K: command-key equivalents fire before the focused Filter field, so Cmd+Delete wiped the buffer. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_015p9SN94YqDtQkEisiPQ3xJ

commitd9477111e61390ed5bb01f5c7659c53e45d81aa0 authorClaude <noreply@anthropic.com> dateWed 30 Sep 2026, 10:49 (1 week ago) parentc040e74
4 files changed+125−46
ParseableViewer/ViewModels/LiveTailViewModel.swift+87−43 ParseableViewer/Views/LiveTailView.swift+12−2 ParseableViewerTests/LiveTailViewModelTests.swift+25 README.md+1−1
ParseableViewer/ViewModels/LiveTailViewModel.swift+87−43
@@ -27,14 +27,26 @@ final class LiveTailViewModel {
2727 /// column hidden last session would reappear visible when it shows up in a
2828 /// later poll. This retains the intent so it stays hidden when it appears.
2929 private var savedHiddenColumns: Set<String> = []
30+ /// The full persisted column order, including columns not yet seen this
31+ /// session — the order counterpart of `savedHiddenColumns`. A column that
32+ /// first shows up in a later poll is slotted back into its saved position
33+ /// (instead of being appended), and saving keeps unseen columns' positions
34+ /// rather than dropping them from the stored order.
35+ private var savedColumnOrder: [String] = []
3036 private var currentStream: String?
3137
3238 // nonisolated(unsafe) so deinit can invalidate the timer.
3339 // Only mutated from @MainActor methods (start/stop).
3440 nonisolated(unsafe) private var timer: Timer?
3541 private var lastTimestamp: Date?
36− private var seenFingerprints: Set<String> = []
37− /// Insertion-ordered mirror of `seenFingerprints` (oldest first), used to
42+ /// How many records with each content fingerprint have been delivered. A
43+ /// count rather than a set: Parseable stamps every event of one ingest request
44+ /// with the same millisecond `p_timestamp`, so genuinely repeated events (the
45+ /// same log line emitted three times) fingerprint identically. A set collapsed
46+ /// them into one row; counting keeps each occurrence while still suppressing
47+ /// the re-fetch of the same rows by the next overlapping poll window.
48+ private var seenFingerprints: [String: Int] = [:]
49+ /// Insertion-ordered mirror of `seenFingerprints` keys (oldest first), used to
3850 /// evict the least-recently-seen fingerprints when the set is trimmed.
3951 private var seenFingerprintOrder: [String] = []
4052 /// Upper bound on the dedup fingerprint set. Kept well above `maxEntries` so
@@ -209,7 +221,7 @@ final class LiveTailViewModel {
209221 isPolling = false
210222 errorMessage = nil
211223 entries = []
212− seenFingerprints = []
224+ seenFingerprints = [:]
213225 seenFingerprintOrder = []
214226 allKnownKeys = []
215227 droppedCount = 0
@@ -220,6 +232,7 @@ final class LiveTailViewModel {
220232 columnOrder = []
221233 hiddenColumns = []
222234 savedHiddenColumns = []
235+ savedColumnOrder = []
223236 columnFilters = []
224237 // Clear the text filter too. Leaving it set while clearing the column-filter
225238 // chips is inconsistent and would let a stale search from a previous stream
@@ -262,12 +275,12 @@ final class LiveTailViewModel {
262275 // next timer tick skip itself via `guard !isPolling`.
263276 isPolling = false
264277 entries = []
265− seenFingerprints = []
266− seenFingerprintOrder = []
267− // Advance the look-back floor to now: without this, the next poll queries
268− // from the old `lastTimestamp` with an empty fingerprint set and re-inserts
269− // every just-cleared record still inside the ~90 s window, repopulating the
270− // view the user just emptied. Matches start()'s reset.
278+ // Keep the dedup fingerprints (unlike start(), which begins a new session):
279+ // every poll looks back at least 90 s regardless of `lastTimestamp` (see
280+ // `safeStart` in poll()), so the records just cleared are re-fetched by the
281+ // next poll. With an emptied fingerprint set they all passed dedup again and
282+ // the tail refilled with exactly what the user cleared. The set stays
283+ // bounded by the eviction in poll().
271284 lastTimestamp = Date()
272285 allKnownKeys = []
273286 droppedCount = 0
@@ -282,6 +295,7 @@ final class LiveTailViewModel {
282295 columnOrder = []
283296 hiddenColumns = []
284297 savedHiddenColumns = []
298+ savedColumnOrder = []
285299 columnFilters = []
286300 // Match start(): clear the text filter alongside the column-filter chips so
287301 // a clear()ed tail isn't immediately re-hidden by a lingering search string.
@@ -337,6 +351,7 @@ final class LiveTailViewModel {
337351
338352 func resetColumnConfig() {
339353 columnOrder = columns
354+ savedColumnOrder = []
340355 hiddenColumns.removeAll()
341356 savedHiddenColumns.removeAll()
342357 saveColumnConfig()
@@ -350,9 +365,16 @@ final class LiveTailViewModel {
350365
351366 private func saveColumnConfig() {
352367 guard let stream = currentStream else { return }
353− // Persist the full intended hidden set (including columns not yet seen
354− // this session) so it survives across restarts and incremental discovery.
355− let config = QueryViewModel.ColumnConfiguration(order: columnOrder, hidden: savedHiddenColumns)
368+ // Persist the full intended hidden set and order (including columns not
369+ // yet seen this session) so they survive restarts and incremental discovery.
370+ let seen = Set(columnOrder)
371+ let order = QueryViewModel.mergedColumnOrder(
372+ columnOrder,
373+ inserting: savedColumnOrder.filter { !seen.contains($0) },
374+ reference: savedColumnOrder
375+ )
376+ savedColumnOrder = order
377+ let config = QueryViewModel.ColumnConfiguration(order: order, hidden: savedHiddenColumns)
356378 if let data = try? JSONEncoder().encode(config) {
357379 UserDefaults.standard.set(data, forKey: Self.columnConfigKey(for: stream))
358380 }
@@ -394,10 +416,12 @@ final class LiveTailViewModel {
394416 merged.append(col)
395417 }
396418 columnOrder = merged
419+ savedColumnOrder = config.order
397420 savedHiddenColumns = config.hidden
398421 hiddenColumns = config.hidden.intersection(extractedSet)
399422 } else {
400423 columnOrder = extracted
424+ savedColumnOrder = []
401425 savedHiddenColumns = []
402426 hiddenColumns = []
403427 }
@@ -410,7 +434,7 @@ final class LiveTailViewModel {
410434 }
411435 if !newCols.isEmpty {
412436 columns.append(contentsOf: newCols)
413− columnOrder.append(contentsOf: newCols)
437+ columnOrder = QueryViewModel.mergedColumnOrder(columnOrder, inserting: newCols, reference: savedColumnOrder)
414438 // Re-hide any newly appearing column that was hidden in a prior
415439 // session but hadn't been seen yet this run.
416440 hiddenColumns.formUnion(newCols.filter { savedHiddenColumns.contains($0) })
@@ -468,22 +492,27 @@ final class LiveTailViewModel {
468492 // Process records off the main actor using a snapshot of seen fingerprints
469493 let seenSnapshot = seenFingerprints
470494 let pollTime = now
471− let candidateEntries = await Task.detached(priority: .userInitiated) {
472− var results: [LiveTailEntry] = []
495+ let (candidateEntries, batchCounts) = await Task.detached(priority: .userInitiated) {
496+ var results: [(entry: LiveTailEntry, occurrence: Int)] = []
497+ var counts: [String: Int] = [:]
473498 for record in records {
474499 let fp = Self.fingerprint(for: record)
475− guard !seenSnapshot.contains(fp) else { continue }
500+ let occurrence = (counts[fp] ?? 0) + 1
501+ counts[fp] = occurrence
502+ // The first `seen` occurrences of this content were delivered
503+ // by earlier polls; only later ones are new.
504+ guard occurrence > (seenSnapshot[fp] ?? 0) else { continue }
476505 let timestamp = Self.parseTimestamp(from: record) ?? pollTime
477506 let summary = Self.buildSummary(from: record)
478− results.append(LiveTailEntry(
507+ results.append((LiveTailEntry(
479508 timestamp: timestamp,
480509 record: record,
481510 displayTimestamp: Self.displayFormatter.string(from: timestamp),
482511 summary: summary,
483512 fingerprint: fp
484− ))
513+ ), occurrence))
485514 }
486− return results
515+ return (results, counts)
487516 }.value
488517
489518 // Re-check after the detached processing hop: a stop/clear could have
@@ -492,11 +521,13 @@ final class LiveTailViewModel {
492521
493522 // Authoritative dedup on main actor
494523 var newEntries: [LiveTailEntry] = []
495− for entry in candidateEntries {
496− if seenFingerprints.insert(entry.fingerprint).inserted {
497− seenFingerprintOrder.append(entry.fingerprint)
498− newEntries.append(entry)
499− }
524+ for candidate in candidateEntries
525+ where candidate.occurrence > (seenFingerprints[candidate.entry.fingerprint] ?? 0) {
526+ newEntries.append(candidate.entry)
527+ }
528+ for (fp, count) in batchCounts where count > (seenFingerprints[fp] ?? 0) {
529+ if seenFingerprints[fp] == nil { seenFingerprintOrder.append(fp) }
530+ seenFingerprints[fp] = count
500531 }
501532
502533 if !newEntries.isEmpty {
@@ -518,23 +549,27 @@ final class LiveTailViewModel {
518549 // would let it pass the dedup check and be re-appended as a
519550 // duplicate. Keep the full seen-set so dedup stays correct;
520551 // it is bounded below to avoid unbounded growth.
521− if seenFingerprints.count > maxSeenFingerprints {
522− // Evict the OLDEST surplus fingerprints by insertion order,
523− // not by what's currently buffered. Collapsing to only the
524− // buffered set would drop recently-evicted fingerprints that
525− // still fall inside the next poll's ~90 s look-back window,
526− // re-admitting those records as duplicates. Keeping the
527− // most-recently-seen tail preserves a full window of evicted
528− // fingerprints. Buffered fingerprints are always retained so
529− // a re-fetch of a buffered record stays deduplicated.
530− let overflow = seenFingerprints.count - maxSeenFingerprints
531− let buffered = Set(entries.map { $0.fingerprint })
532− let toDrop = Set(seenFingerprintOrder.prefix(overflow))
533− .subtracting(buffered)
534− if !toDrop.isEmpty {
535− seenFingerprints.subtract(toDrop)
536− seenFingerprintOrder.removeAll { toDrop.contains($0) }
537− }
552+ }
553+
554+ // Bound the seen-set on every poll, not only when the buffer
555+ // overflows: clear() keeps fingerprints while emptying `entries`,
556+ // so the set can outgrow the cap without the buffer ever filling.
557+ if seenFingerprints.count > maxSeenFingerprints {
558+ // Evict the OLDEST surplus fingerprints by insertion order,
559+ // not by what's currently buffered. Collapsing to only the
560+ // buffered set would drop recently-evicted fingerprints that
561+ // still fall inside the next poll's ~90 s look-back window,
562+ // re-admitting those records as duplicates. Keeping the
563+ // most-recently-seen tail preserves a full window of evicted
564+ // fingerprints. Buffered fingerprints are always retained so
565+ // a re-fetch of a buffered record stays deduplicated.
566+ let overflow = seenFingerprints.count - maxSeenFingerprints
567+ let buffered = Set(entries.map { $0.fingerprint })
568+ let toDrop = Set(seenFingerprintOrder.prefix(overflow))
569+ .subtracting(buffered)
570+ if !toDrop.isEmpty {
571+ for fp in toDrop { seenFingerprints.removeValue(forKey: fp) }
572+ seenFingerprintOrder.removeAll { toDrop.contains($0) }
538573 }
539574 }
540575
@@ -689,10 +724,19 @@ final class LiveTailViewModel {
689724 return nil
690725 }
691726 if case .string(let str) = value {
692− if let date = isoFractional.date(from: str) {
727+ if let date = isoFractional.date(from: str) ?? isoBasic.date(from: str) {
693728 return date
694729 }
695− return isoBasic.date(from: str)
730+ // Parseable stores `p_timestamp` as an Arrow timestamp without a
731+ // timezone, and the query API serializes it as a naive UTC string with
732+ // no offset (e.g. "2024-01-11T09:08:34.290"). `.withInternetDateTime`
733+ // requires a zone designator, so such values never parsed: every entry
734+ // fell back to the poll time, which also scrambled the tail's order
735+ // (each poll's DESC-ordered batch was appended newest-first). Retry as
736+ // UTC; a string that already carries an offset won't parse with an
737+ // extra "Z", so this can't reinterpret zoned timestamps.
738+ let utc = str + "Z"
739+ return isoFractional.date(from: utc) ?? isoBasic.date(from: utc)
696740 }
697741 return nil
698742 }
ParseableViewer/Views/LiveTailView.swift+12−2
@@ -123,7 +123,10 @@ struct LiveTailView: View {
123123 Image(systemName: "trash")
124124 }
125125 .help("Clear entries")
126− .keyboardShortcut(.delete, modifiers: .command)
126+ // ⌘K, not ⌘⌫: command-key equivalents are dispatched before the
127+ // focused text field sees the keystroke, so ⌘⌫ (delete to start
128+ // of line) in the Filter field would wipe the whole buffer.
129+ .keyboardShortcut("k", modifiers: .command)
127130 .accessibilityLabel("Clear all entries")
128131 }
129132 .padding(8)
@@ -284,7 +287,14 @@ struct LiveTailView: View {
284287 columnWidths = [:]
285288 cachedSorted = []
286289 }
287− .onChange(of: viewModel.visibleColumns) { _, _ in
290+ .onChange(of: viewModel.visibleColumns) { _, newColumns in
291+ // Hiding the sort column leaves no header to show or change the sort,
292+ // while rows stay ordered by the invisible value (and auto-scroll stays
293+ // off). Drop it like LogTableView does; the sortColumn change rebuilds.
294+ if let sortColumn, !newColumns.contains(sortColumn) {
295+ self.sortColumn = nil
296+ return
297+ }
288298 // Showing/hiding a column changes the severity column set, so rebuild
289299 // (which also fills in widths for any newly visible columns). A
290300 // width-only update would leave row severity tinting stale until the
ParseableViewerTests/LiveTailViewModelTests.swift+25
@@ -461,4 +461,29 @@ final class LiveTailViewModelTests: XCTestCase {
461461 let filter = LiveTailViewModel.ColumnFilter(column: "field", value: nil, exclude: false)
462462 XCTAssertEqual(filter.displayLabel, "field = null")
463463 }
464+
465+ // MARK: - Zone-less timestamps / saved column order
466+
467+ func testParseTimestampTreatsZonelessAsUTC() {
468+ // Parseable serializes p_timestamp without a zone designator.
469+ let naive = LiveTailViewModel.parseTimestamp(from: ["p_timestamp": .string("2024-06-15T10:30:45.123")])
470+ let zoned = LiveTailViewModel.parseTimestamp(from: ["p_timestamp": .string("2024-06-15T10:30:45.123Z")])
471+ XCTAssertNotNil(naive)
472+ XCTAssertEqual(naive, zoned)
473+ let naiveWhole = LiveTailViewModel.parseTimestamp(from: ["p_timestamp": .string("2024-06-15T10:30:45")])
474+ XCTAssertEqual(naiveWhole, LiveTailViewModel.parseTimestamp(from: ["p_timestamp": .string("2024-06-15T10:30:45Z")]))
475+ }
476+
477+ func testMergedColumnOrderRestoresSavedPosition() {
478+ let merged = QueryViewModel.mergedColumnOrder(
479+ ["p_timestamp", "level", "message"],
480+ inserting: ["trace_id"],
481+ reference: ["p_timestamp", "trace_id", "level", "message"]
482+ )
483+ XCTAssertEqual(merged, ["p_timestamp", "trace_id", "level", "message"])
484+ }
485+
486+ func testMergedColumnOrderAppendsColumnsWithoutSavedPosition() {
487+ XCTAssertEqual(QueryViewModel.mergedColumnOrder(["a"], inserting: ["b"], reference: []), ["a", "b"])
488+ }
464489}
README.md+1−1
@@ -143,7 +143,7 @@ ParseableViewer/
143143| `Cmd+R` | Refresh streams |
144144| `Cmd+Enter` | Run query |
145145| `Cmd+P` | Pause/resume live tail |
146−| `Cmd+Delete` | Clear live tail entries |
146+| `Cmd+K` | Clear live tail entries |
147147| `Cmd+Ctrl+S` | Toggle sidebar |
148148
149149## License