@@ -7,7 +7,9 @@ final class LiveTailViewModel {
7 var entries: [LiveTailEntry] = []
7 var entries: [LiveTailEntry] = []
8 var isRunning = false
8 var isRunning = false
9 var isPaused = false
9 var isPaused = false
10− var filterText = ""
10+ var filterText = "" {
11+ didSet { rebuildFilteredEntries() }
11 var columnFilters: [ColumnFilter] = []
13 var columnFilters: [ColumnFilter] = []
12 var pollInterval: TimeInterval = 2.0
14 var pollInterval: TimeInterval = 2.0
13 var maxEntries = 5000
15 var maxEntries = 5000
@@ -73,10 +75,12 @@ final class LiveTailViewModel {
76− var filteredEntries: [LiveTailEntry] {
78+ private(set) var cachedFilteredEntries: [LiveTailEntry] = []
79+ private(set) var filteredEntriesGeneration: Int = 0
81+ private func rebuildFilteredEntries() {
77 var result = entries
82 var result = entries
79− // Apply column filters
80 for filter in columnFilters {
84 for filter in columnFilters {
81 result = result.filter { entry in
85 result = result.filter { entry in
82 let recordValue = entry.record[filter.column]
86 let recordValue = entry.record[filter.column]
@@ -90,7 +94,6 @@ final class LiveTailViewModel {
94 if !filterText.isEmpty {
97 if !filterText.isEmpty {
95 result = result.filter { entry in
98 result = result.filter { entry in
96 entry.summary.localizedCaseInsensitiveContains(filterText) ||
99 entry.summary.localizedCaseInsensitiveContains(filterText) ||
@@ -98,25 +101,29 @@ final class LiveTailViewModel {
101− return result
104+ cachedFilteredEntries = result
105+ filteredEntriesGeneration += 1
104 func addColumnFilter(column: String, value: JSONValue?, exclude: Bool) {
108 func addColumnFilter(column: String, value: JSONValue?, exclude: Bool) {
105 // Remove any existing filter on the same column with the same value
109 // Remove any existing filter on the same column with the same value
106 columnFilters.removeAll { $0.column == column && $0.value == value && $0.exclude == exclude }
110 columnFilters.removeAll { $0.column == column && $0.value == value && $0.exclude == exclude }
107 columnFilters.append(ColumnFilter(column: column, value: value, exclude: exclude))
111 columnFilters.append(ColumnFilter(column: column, value: value, exclude: exclude))
112+ rebuildFilteredEntries()
110 func removeColumnFilter(_ filter: ColumnFilter) {
115 func removeColumnFilter(_ filter: ColumnFilter) {
111 columnFilters.removeAll { $0.id == filter.id }
116 columnFilters.removeAll { $0.id == filter.id }
117+ rebuildFilteredEntries()
114 func clearColumnFilters() {
120 func clearColumnFilters() {
115 columnFilters.removeAll()
121 columnFilters.removeAll()
122+ rebuildFilteredEntries()
118 var entryCount: Int { entries.count }
125 var entryCount: Int { entries.count }
119− var displayedCount: Int { filteredEntries.count }
126+ var displayedCount: Int { cachedFilteredEntries.count }
121 func start(client: ParseableClient?, stream: String?) {
128 func start(client: ParseableClient?, stream: String?) {
122 guard let client, let stream, !isRunning else { return }
129 guard let client, let stream, !isRunning else { return }
@@ -141,6 +148,7 @@ final class LiveTailViewModel {
141 hiddenColumns = []
148 hiddenColumns = []
142 columnFilters = []
149 columnFilters = []
143 currentStream = stream
150 currentStream = stream
151+ rebuildFilteredEntries()
145 timer = Timer.scheduledTimer(withTimeInterval: pollInterval, repeats: true) { [weak self] _ in
153 timer = Timer.scheduledTimer(withTimeInterval: pollInterval, repeats: true) { [weak self] _ in
146 Task { @MainActor [weak self] in
154 Task { @MainActor [weak self] in
@@ -169,6 +177,7 @@ final class LiveTailViewModel {
169 columnOrder = []
177 columnOrder = []
170 hiddenColumns = []
178 hiddenColumns = []
171 columnFilters = []
179 columnFilters = []
180+ rebuildFilteredEntries()
174 // MARK: - Column Management
183 // MARK: - Column Management
@@ -337,6 +346,7 @@ final class LiveTailViewModel {
337 updateColumns(stream: stream)
346 updateColumns(stream: stream)
349+ rebuildFilteredEntries()
340 lastTimestamp = now
350 lastTimestamp = now
341 lastPollTime = now
351 lastPollTime = now
342 errorMessage = nil
352 errorMessage = nil
@@ -364,16 +374,72 @@ final class LiveTailViewModel {
365 h0 = (h0 ^ 0) &* 1099511628211 // separator
375 h0 = (h0 ^ 0) &* 1099511628211 // separator
366 if let val = record[key] {
376 if let val = record[key] {
367− // Use exportString so nested objects/arrays contribute their
377+ hashJSONValue(val, h0: &h0, h1: &h1)
368− // actual content to the hash, not just their field count.
378+ }
379+ h1 = (h1 ^ 0xFF) &* 6700417 // field separator
381+ return String(h0, radix: 36) + String(h1, radix: 36)
384+ /// Recursively hashes a JSONValue tree without allocating intermediate strings.
385+ /// Uses type-discriminator bytes to prevent cross-type collisions.
386+ private static func hashJSONValue(_ value: JSONValue, h0: inout UInt64, h1: inout UInt64) {
389+ h0 = (h0 ^ 0x00) &* 1099511628211
390+ h1 = (h1 ^ 0x00) &* 6700417
392+ h0 = (h0 ^ 0x01) &* 1099511628211
393+ h1 = (h1 ^ 0x01) &* 6700417
394+ let byte: UInt64 = b ? 1 : 0
395+ h0 = (h0 ^ byte) &* 1099511628211
396+ h1 = (h1 ^ byte) &* 6700417
398+ h0 = (h0 ^ 0x02) &* 1099511628211
399+ h1 = (h1 ^ 0x02) &* 6700417
400+ var bits = UInt64(bitPattern: Int64(i))
402+ h0 = (h0 ^ (bits & 0xFF)) &* 1099511628211
403+ h1 = (h1 ^ (bits & 0xFF)) &* 6700417
406+ case .double(let d):
407+ h0 = (h0 ^ 0x03) &* 1099511628211
408+ h1 = (h1 ^ 0x03) &* 6700417
409+ var bits = d.bitPattern
411+ h0 = (h0 ^ UInt64(bits & 0xFF)) &* 1099511628211
412+ h1 = (h1 ^ UInt64(bits & 0xFF)) &* 6700417
415+ case .string(let s):
416+ h0 = (h0 ^ 0x04) &* 1099511628211
417+ h1 = (h1 ^ 0x04) &* 6700417
369− for byte in val.exportString.utf8 {
418+ for byte in s.utf8 {
419+ h0 = (h0 ^ UInt64(byte)) &* 1099511628211
420+ h1 = (h1 ^ UInt64(byte)) &* 6700417
422+ case .array(let arr):
423+ h0 = (h0 ^ 0x05) &* 1099511628211
424+ h1 = (h1 ^ 0x05) &* 6700417
425+ for element in arr {
426+ hashJSONValue(element, h0: &h0, h1: &h1)
427+ h0 = (h0 ^ 0xFE) &* 1099511628211 // element separator
429+ case .object(let dict):
430+ h0 = (h0 ^ 0x06) &* 1099511628211
431+ h1 = (h1 ^ 0x06) &* 6700417
432+ for key in dict.keys.sorted() {
433+ for byte in key.utf8 {
370 h0 = (h0 ^ UInt64(byte)) &* 1099511628211
434 h0 = (h0 ^ UInt64(byte)) &* 1099511628211
371 h1 = (h1 ^ UInt64(byte)) &* 6700417
435 h1 = (h1 ^ UInt64(byte)) &* 6700417
437+ h0 = (h0 ^ 0xFD) &* 1099511628211 // key-value separator
438+ if let val = dict[key] {
439+ hashJSONValue(val, h0: &h0, h1: &h1)
374− h1 = (h1 ^ 0xFF) &* 6700417 // field separator
376− return String(h0, radix: 36) + String(h1, radix: 36)
379 func parseTimestamp(from record: LogRecord) -> Date? {
445 func parseTimestamp(from record: LogRecord) -> Date? {