git.iko.soy/parseable-macos
on master help sign in
Implement 8 performance optimizations: async width/sort computation, filter debouncing, caching (#45)
commitf7b34e952fa81241d88ce78de8ff29cddb04e652 authoriko <mail@iko.soy> dateMon 16 Feb 2026, 02:35 (8 months ago) parentcc502cf
7 files changed+197−48
ParseableViewer/Services/ParseableClient.swift+15−10 ParseableViewer/ViewModels/LiveTailViewModel.swift+77−11 ParseableViewer/ViewModels/QueryViewModel.swift+15−1 ParseableViewer/Views/LiveTailView.swift+43−19 ParseableViewer/Views/LogTableView.swift+42−4 ParseableViewerTests/LiveTailViewModelTests.swift+1−1 ParseableViewerTests/QueryViewModelTests.swift+4−2
ParseableViewer/Services/ParseableClient.swift+15−10
@@ -227,16 +227,21 @@ final class ParseableClient: Sendable {
227227 return []
228228 }
229229
230− // Handle both response formats:
231− // 1. Wrapped: {"records": [...], "fields": [...]}
232− // 2. Direct array: [...]
233− if let wrapped = try? JSONDecoder().decode(QueryResponse.self, from: responseData) {
234− return wrapped.records
235− }
236− do {
237− return try JSONDecoder().decode([LogRecord].self, from: responseData)
238− } catch {
239− throw ParseableError.decodingError("Unexpected query response format")
230+ // Peek at the first non-whitespace byte to choose the decoder path:
231+ // '{' → wrapped response, '[' → direct array.
232+ let firstByte = responseData.first(where: { $0 != 0x20 && $0 != 0x0A && $0 != 0x0D && $0 != 0x09 })
233+ if firstByte == 0x7B { // '{'
234+ do {
235+ return try JSONDecoder().decode(QueryResponse.self, from: responseData).records
236+ } catch {
237+ throw ParseableError.decodingError("Unexpected query response format")
238+ }
239+ } else {
240+ do {
241+ return try JSONDecoder().decode([LogRecord].self, from: responseData)
242+ } catch {
243+ throw ParseableError.decodingError("Unexpected query response format")
244+ }
240245 }
241246 }
242247
ParseableViewer/ViewModels/LiveTailViewModel.swift+77−11
@@ -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? {
ParseableViewer/ViewModels/QueryViewModel.swift+15−1
@@ -21,8 +21,9 @@ final class QueryViewModel {
2121 var queryDuration: TimeInterval?
2222 var selectedLogEntry: LogRecord?
2323 var filterText = "" {
24− didSet { updateFilteredResults() }
24+ didSet { debouncedUpdateFilter() }
2525 }
26+ private var filterTask: Task<Void, Never>?
2627 var resultsTruncated = false
2728
2829 // Schema fields for autocomplete
@@ -248,6 +249,19 @@ final class QueryViewModel {
248249
249250 private(set) var filteredResults: [LogRecord] = []
250251
252+ private func debouncedUpdateFilter() {
253+ filterTask?.cancel()
254+ if filterText.isEmpty {
255+ updateFilteredResults()
256+ return
257+ }
258+ filterTask = Task {
259+ try? await Task.sleep(for: .milliseconds(150))
260+ guard !Task.isCancelled else { return }
261+ updateFilteredResults()
262+ }
263+ }
264+
251265 private func updateFilteredResults() {
252266 if filterText.isEmpty {
253267 filteredResults = results
ParseableViewer/Views/LiveTailView.swift+43−19
@@ -11,6 +11,8 @@ struct LiveTailView: View {
1111 @State private var sortColumn: String?
1212 @State private var sortAscending = false
1313 @State private var columnWidths: [String: CGFloat] = [:]
14+ @State private var cachedSorted: [LiveTailViewModel.LiveTailEntry] = []
15+ @State private var liveTailSortTask: Task<Void, Never>?
1416
1517 var body: some View {
1618 @Bindable var vm = viewModel
@@ -109,6 +111,7 @@ struct LiveTailView: View {
109111 selectedRecord = nil
110112 sortColumn = nil
111113 columnWidths = [:]
114+ cachedSorted = []
112115 } label: {
113116 Image(systemName: "trash")
114117 }
@@ -259,23 +262,38 @@ struct LiveTailView: View {
259262 selectedRecord = nil
260263 sortColumn = nil
261264 columnWidths = [:]
265+ cachedSorted = []
262266 }
263267 .onChange(of: viewModel.visibleColumns) { _, newColumns in
264268 // Compute widths for any newly visible columns
265− let records = viewModel.filteredEntries.map { $0.record }
269+ let records = viewModel.cachedFilteredEntries.map { $0.record }
266270 for col in newColumns where columnWidths[col] == nil {
267271 columnWidths[col] = idealColumnWidth(for: col, records: records)
268272 }
269273 }
274+ .onAppear {
275+ rebuildSortedEntries()
276+ }
270277 }
271278
272− private var sortedEntries: [LiveTailViewModel.LiveTailEntry] {
273− let entries = viewModel.filteredEntries
274− guard let sortColumn else { return entries }
275− return entries.sorted { a, b in
276− let aVal = a.record[sortColumn] ?? .null
277− let bVal = b.record[sortColumn] ?? .null
278− return sortAscending ? aVal < bVal : bVal < aVal
279+ private func rebuildSortedEntries() {
280+ let entries = viewModel.cachedFilteredEntries
281+ guard let col = sortColumn else {
282+ cachedSorted = entries
283+ return
284+ }
285+ liveTailSortTask?.cancel()
286+ let asc = sortAscending
287+ liveTailSortTask = Task {
288+ try? await Task.sleep(for: .milliseconds(150))
289+ guard !Task.isCancelled else { return }
290+ let sorted = entries.sorted { a, b in
291+ let aVal = a.record[col] ?? .null
292+ let bVal = b.record[col] ?? .null
293+ return asc ? aVal < bVal : bVal < aVal
294+ }
295+ guard !Task.isCancelled else { return }
296+ cachedSorted = sorted
279297 }
280298 }
281299
@@ -284,25 +302,24 @@ struct LiveTailView: View {
284302 ScrollView([.horizontal, .vertical]) {
285303 LazyVStack(alignment: .leading, spacing: 0, pinnedViews: [.sectionHeaders]) {
286304 Section {
287− let sorted = sortedEntries
288− ForEach(sorted.indices, id: \.self) { index in
305+ ForEach(cachedSorted.indices, id: \.self) { index in
289306 LogRowView(
290− record: sorted[index].record,
307+ record: cachedSorted[index].record,
291308 columns: viewModel.visibleColumns,
292309 columnWidths: columnWidths,
293− isSelected: selectedRecord == sorted[index].record,
310+ isSelected: selectedRecord == cachedSorted[index].record,
294311 isAlternate: index % 2 == 1,
295312 wrapText: wrapText,
296313 onCellFilter: { column, value, exclude in
297314 viewModel.addColumnFilter(column: column, value: value, exclude: exclude)
298315 }
299316 )
300− .id(sorted[index].id)
317+ .id(cachedSorted[index].id)
301318 .onTapGesture {
302− if selectedRecord == sorted[index].record {
319+ if selectedRecord == cachedSorted[index].record {
303320 selectedRecord = nil
304321 } else {
305− selectedRecord = sorted[index].record
322+ selectedRecord = cachedSorted[index].record
306323 }
307324 }
308325 }
@@ -312,7 +329,7 @@ struct LiveTailView: View {
312329 sortColumn: $sortColumn,
313330 sortAscending: $sortAscending,
314331 columnWidths: $columnWidths,
315− records: viewModel.filteredEntries.map { $0.record },
332+ records: viewModel.cachedFilteredEntries.map { $0.record },
316333 onMoveColumn: { from, to in
317334 viewModel.moveColumn(from, to: to)
318335 },
@@ -323,13 +340,20 @@ struct LiveTailView: View {
323340 }
324341 }
325342 }
343+ .onChange(of: viewModel.filteredEntriesGeneration) { _, _ in
344+ rebuildSortedEntries()
345+ }
346+ .onChange(of: sortColumn) { _, _ in
347+ rebuildSortedEntries()
348+ }
349+ .onChange(of: sortAscending) { _, _ in
350+ rebuildSortedEntries()
351+ }
326352 .onChange(of: viewModel.entries.count) { _, _ in
327353 guard autoScroll, sortColumn == nil else { return }
328− // When a filter is active, scroll to the last filtered entry;
329− // when no filter, scroll to the absolute last entry.
330354 let hasFilters = !viewModel.filterText.isEmpty || !viewModel.columnFilters.isEmpty
331355 let target = hasFilters
332− ? viewModel.filteredEntries.last
356+ ? viewModel.cachedFilteredEntries.last
333357 : viewModel.entries.last
334358 if let target {
335359 proxy.scrollTo(target.id, anchor: .bottom)
ParseableViewer/Views/LogTableView.swift+42−4
@@ -157,6 +157,7 @@ struct LogTableView: View {
157157 @State private var selectedIndex: Int?
158158 @State private var cachedSorted: [LogRecord] = []
159159 @State private var sortTask: Task<Void, Never>?
160+ @State private var widthTask: Task<Void, Never>?
160161 @State private var columnWidths: [String: CGFloat] = [:]
161162
162163 var body: some View {
@@ -229,8 +230,14 @@ struct LogTableView: View {
229230 .controlSize(.large)
230231 }
231232 }
232− .onChange(of: records) { _, _ in
233− columnWidths = computeColumnWidths(columns: columns, records: records)
233+ .onChange(of: records) { _, newRecords in
234+ widthTask?.cancel()
235+ let cols = columns
236+ widthTask = Task.detached(priority: .userInitiated) {
237+ let widths = computeColumnWidths(columns: cols, records: newRecords)
238+ guard !Task.isCancelled else { return }
239+ await MainActor.run { columnWidths = widths }
240+ }
234241 debouncedRebuildSort()
235242 }
236243 .onChange(of: sortColumn) { _, _ in debouncedRebuildSort() }
@@ -247,7 +254,29 @@ struct LogTableView: View {
247254 sortTask = Task {
248255 try? await Task.sleep(for: .milliseconds(150))
249256 guard !Task.isCancelled else { return }
250− rebuildSort()
257+ let recs = records
258+ let col = sortColumn
259+ let asc = sortAscending
260+ let sorted: [LogRecord]
261+ if let col {
262+ sorted = await Task.detached(priority: .userInitiated) {
263+ recs.sorted { a, b in
264+ let aVal = a[col] ?? .null
265+ let bVal = b[col] ?? .null
266+ return asc ? aVal < bVal : bVal < aVal
267+ }
268+ }.value
269+ } else {
270+ sorted = recs
271+ }
272+ guard !Task.isCancelled else { return }
273+ cachedSorted = sorted
274+ if let selectedRecord,
275+ let idx = cachedSorted.firstIndex(where: { $0 == selectedRecord }) {
276+ selectedIndex = idx
277+ } else {
278+ selectedIndex = nil
279+ }
251280 }
252281 }
253282
@@ -323,10 +352,14 @@ struct LogHeaderView: View {
323352
324353 @State private var draggedColumn: String?
325354 @State private var dropTargetColumn: String?
355+ @State private var uniqueValuesCache: [String: [JSONValue]] = [:]
326356
327357 private static let maxFilterValues = 20
328358
329359 private func uniqueValues(for column: String) -> [JSONValue] {
360+ if let cached = uniqueValuesCache[column] {
361+ return cached
362+ }
330363 var seen = Set<JSONValue>()
331364 var result: [JSONValue] = []
332365 for record in records {
@@ -335,7 +368,9 @@ struct LogHeaderView: View {
335368 result.append(value)
336369 }
337370 }
338− return result.sorted()
371+ let sorted = result.sorted()
372+ uniqueValuesCache[column] = sorted
373+ return sorted
339374 }
340375
341376 private func dropIndicatorAlignment(for targetColumn: String) -> Alignment {
@@ -476,6 +511,9 @@ struct LogHeaderView: View {
476511 }
477512 .background(Color(nsColor: .controlBackgroundColor))
478513 .border(Color.secondary.opacity(0.2), width: 0.5)
514+ .onChange(of: records.count) { _, _ in
515+ uniqueValuesCache = [:]
516+ }
479517 }
480518}
481519
ParseableViewerTests/LiveTailViewModelTests.swift+1−1
@@ -216,7 +216,7 @@ final class LiveTailViewModelTests: XCTestCase {
216216 func testFilteredEntriesEmptyFilter() {
217217 let vm = LiveTailViewModel()
218218 vm.filterText = ""
219− XCTAssertEqual(vm.filteredEntries.count, vm.entries.count)
219+ XCTAssertEqual(vm.cachedFilteredEntries.count, vm.entries.count)
220220 }
221221
222222 func testStopResetsRunningState() {
ParseableViewerTests/QueryViewModelTests.swift+4−2
@@ -61,7 +61,7 @@ final class QueryViewModelTests: XCTestCase {
6161 XCTAssertEqual(vm.filteredResults.count, 2)
6262 }
6363
64− func testFilteredResultsMatchesContent() {
64+ func testFilteredResultsMatchesContent() async {
6565 let vm = QueryViewModel()
6666 vm.results = [
6767 ["message": .string("hello world")],
@@ -69,16 +69,18 @@ final class QueryViewModelTests: XCTestCase {
6969 ["message": .string("Hello there")]
7070 ]
7171 vm.filterText = "hello"
72+ try? await Task.sleep(for: .milliseconds(200))
7273 XCTAssertEqual(vm.filteredResults.count, 2) // case-insensitive
7374 }
7475
75− func testFilteredResultsNoMatch() {
76+ func testFilteredResultsNoMatch() async {
7677 let vm = QueryViewModel()
7778 vm.results = [
7879 ["message": .string("hello")],
7980 ["message": .string("world")]
8081 ]
8182 vm.filterText = "xyz"
83+ try? await Task.sleep(for: .milliseconds(200))
8284 XCTAssertEqual(vm.filteredResults.count, 0)
8385 }
8486