@@ -7,7 +7,9 @@ final class LiveTailViewModel {
77 var entries: [LiveTailEntry] = []
88 var isRunning = false
99 var isPaused = false
10− var filterText = ""
10+ var filterText = "" {
11+ didSet { rebuildFilteredEntries() }
12+ }
1113 var columnFilters: [ColumnFilter] = []
1214 var pollInterval: TimeInterval = 2.0
1315 var maxEntries = 5000
@@ -73,10 +75,12 @@ final class LiveTailViewModel {
7375 }
7476 }
7577
76− var filteredEntries: [LiveTailEntry] {
78+ private(set) var cachedFilteredEntries: [LiveTailEntry] = []
79+ private(set) var filteredEntriesGeneration: Int = 0
80+
81+ private func rebuildFilteredEntries() {
7782 var result = entries
7883
79− // Apply column filters
8084 for filter in columnFilters {
8185 result = result.filter { entry in
8286 let recordValue = entry.record[filter.column]
@@ -90,7 +94,6 @@ final class LiveTailViewModel {
9094 }
9195 }
9296
93− // Apply text filter
9497 if !filterText.isEmpty {
9598 result = result.filter { entry in
9699 entry.summary.localizedCaseInsensitiveContains(filterText) ||
@@ -98,25 +101,29 @@ final class LiveTailViewModel {
98101 }
99102 }
100103
101− return result
104+ cachedFilteredEntries = result
105+ filteredEntriesGeneration += 1
102106 }
103107
104108 func addColumnFilter(column: String, value: JSONValue?, exclude: Bool) {
105109 // Remove any existing filter on the same column with the same value
106110 columnFilters.removeAll { $0.column == column && $0.value == value && $0.exclude == exclude }
107111 columnFilters.append(ColumnFilter(column: column, value: value, exclude: exclude))
112+ rebuildFilteredEntries()
108113 }
109114
110115 func removeColumnFilter(_ filter: ColumnFilter) {
111116 columnFilters.removeAll { $0.id == filter.id }
117+ rebuildFilteredEntries()
112118 }
113119
114120 func clearColumnFilters() {
115121 columnFilters.removeAll()
122+ rebuildFilteredEntries()
116123 }
117124
118125 var entryCount: Int { entries.count }
119− var displayedCount: Int { filteredEntries.count }
126+ var displayedCount: Int { cachedFilteredEntries.count }
120127
121128 func start(client: ParseableClient?, stream: String?) {
122129 guard let client, let stream, !isRunning else { return }
@@ -141,6 +148,7 @@ final class LiveTailViewModel {
141148 hiddenColumns = []
142149 columnFilters = []
143150 currentStream = stream
151+ rebuildFilteredEntries()
144152
145153 timer = Timer.scheduledTimer(withTimeInterval: pollInterval, repeats: true) { [weak self] _ in
146154 Task { @MainActor [weak self] in
@@ -169,6 +177,7 @@ final class LiveTailViewModel {
169177 columnOrder = []
170178 hiddenColumns = []
171179 columnFilters = []
180+ rebuildFilteredEntries()
172181 }
173182
174183 // MARK: - Column Management
@@ -337,6 +346,7 @@ final class LiveTailViewModel {
337346 updateColumns(stream: stream)
338347 }
339348
349+ rebuildFilteredEntries()
340350 lastTimestamp = now
341351 lastPollTime = now
342352 errorMessage = nil
@@ -364,16 +374,72 @@ final class LiveTailViewModel {
364374 }
365375 h0 = (h0 ^ 0) &* 1099511628211 // separator
366376 if let val = record[key] {
367− // Use exportString so nested objects/arrays contribute their
368− // actual content to the hash, not just their field count.
369− for byte in val.exportString.utf8 {
377+ hashJSONValue(val, h0: &h0, h1: &h1)
378+ }
379+ h1 = (h1 ^ 0xFF) &* 6700417 // field separator
380+ }
381+ return String(h0, radix: 36) + String(h1, radix: 36)
382+ }
383+
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) {
387+ switch value {
388+ case .null:
389+ h0 = (h0 ^ 0x00) &* 1099511628211
390+ h1 = (h1 ^ 0x00) &* 6700417
391+ case .bool(let b):
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
397+ case .int(let i):
398+ h0 = (h0 ^ 0x02) &* 1099511628211
399+ h1 = (h1 ^ 0x02) &* 6700417
400+ var bits = UInt64(bitPattern: Int64(i))
401+ for _ in 0..<8 {
402+ h0 = (h0 ^ (bits & 0xFF)) &* 1099511628211
403+ h1 = (h1 ^ (bits & 0xFF)) &* 6700417
404+ bits >>= 8
405+ }
406+ case .double(let d):
407+ h0 = (h0 ^ 0x03) &* 1099511628211
408+ h1 = (h1 ^ 0x03) &* 6700417
409+ var bits = d.bitPattern
410+ for _ in 0..<8 {
411+ h0 = (h0 ^ UInt64(bits & 0xFF)) &* 1099511628211
412+ h1 = (h1 ^ UInt64(bits & 0xFF)) &* 6700417
413+ bits >>= 8
414+ }
415+ case .string(let s):
416+ h0 = (h0 ^ 0x04) &* 1099511628211
417+ h1 = (h1 ^ 0x04) &* 6700417
418+ for byte in s.utf8 {
419+ h0 = (h0 ^ UInt64(byte)) &* 1099511628211
420+ h1 = (h1 ^ UInt64(byte)) &* 6700417
421+ }
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
428+ }
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 {
370434 h0 = (h0 ^ UInt64(byte)) &* 1099511628211
371435 h1 = (h1 ^ UInt64(byte)) &* 6700417
372436 }
437+ h0 = (h0 ^ 0xFD) &* 1099511628211 // key-value separator
438+ if let val = dict[key] {
439+ hashJSONValue(val, h0: &h0, h1: &h1)
440+ }
373441 }
374− h1 = (h1 ^ 0xFF) &* 6700417 // field separator
375442 }
376− return String(h0, radix: 36) + String(h1, radix: 36)
377443 }
378444
379445 func parseTimestamp(from record: LogRecord) -> Date? {