git.iko.soy/parseable-macos
on master help sign in
Add right-click filtering to live tail view (#15)

Wire up the existing onCellFilter callback in LogRowView so live tail cells show "Filter by This Value" and "Exclude This Value" context menu items. Column filters are applied in-memory on the entries buffer and displayed as removable chips in a filter bar between the status bar and the table. Include/exclude filters are visually distinguished (accent vs red background). Filters are cleared on stream switch, clear, and restart. https://claude.ai/code/session_011qvJsr7kBLsraPneTAWdee Co-authored-by: Claude <noreply@anthropic.com>

commite7b06dd2f494983ddac2f7ffe09502eda9fe77db authoriko <mail@iko.soy> dateSun 15 Feb 2026, 13:59 (8 months ago) parent4c45920
3 files changed+182−9
ParseableViewer/ViewModels/LiveTailViewModel.swift+54−4 ParseableViewer/Views/LiveTailView.swift+58−5 ParseableViewerTests/LiveTailViewModelTests.swift+70
ParseableViewer/ViewModels/LiveTailViewModel.swift+54−4
@@ -8,6 +8,7 @@ final class LiveTailViewModel {
88 var isRunning = false
99 var isPaused = false
1010 var filterText = ""
11+ var columnFilters: [ColumnFilter] = []
1112 var pollInterval: TimeInterval = 2.0
1213 var maxEntries = 5000
1314 var errorMessage: String?
@@ -59,12 +60,59 @@ final class LiveTailViewModel {
5960 let summary: String
6061 }
6162
63+ struct ColumnFilter: Identifiable, Equatable, Sendable {
64+ let id = UUID()
65+ let column: String
66+ let value: JSONValue?
67+ let exclude: Bool
68+
69+ var displayLabel: String {
70+ let op = exclude ? "≠" : "="
71+ let val = value?.displayString ?? "null"
72+ return "\(column) \(op) \(val)"
73+ }
74+ }
75+
6276 var filteredEntries: [LiveTailEntry] {
63− guard !filterText.isEmpty else { return entries }
64− return entries.filter { entry in
65− entry.summary.localizedCaseInsensitiveContains(filterText) ||
66− entry.record.values.contains { $0.displayString.localizedCaseInsensitiveContains(filterText) }
77+ var result = entries
78+
79+ // Apply column filters
80+ for filter in columnFilters {
81+ result = result.filter { entry in
82+ let recordValue = entry.record[filter.column]
83+ let matches: Bool
84+ if filter.value == nil || filter.value == .null {
85+ matches = recordValue == nil || recordValue == .null
86+ } else {
87+ matches = recordValue == filter.value
88+ }
89+ return filter.exclude ? !matches : matches
90+ }
91+ }
92+
93+ // Apply text filter
94+ if !filterText.isEmpty {
95+ result = result.filter { entry in
96+ entry.summary.localizedCaseInsensitiveContains(filterText) ||
97+ entry.record.values.contains { $0.displayString.localizedCaseInsensitiveContains(filterText) }
98+ }
6799 }
100+
101+ return result
102+ }
103+
104+ func addColumnFilter(column: String, value: JSONValue?, exclude: Bool) {
105+ // Remove any existing filter on the same column with the same value
106+ columnFilters.removeAll { $0.column == column && $0.value == value && $0.exclude == exclude }
107+ columnFilters.append(ColumnFilter(column: column, value: value, exclude: exclude))
108+ }
109+
110+ func removeColumnFilter(_ filter: ColumnFilter) {
111+ columnFilters.removeAll { $0.id == filter.id }
112+ }
113+
114+ func clearColumnFilters() {
115+ columnFilters.removeAll()
68116 }
69117
70118 var entryCount: Int { entries.count }
@@ -91,6 +139,7 @@ final class LiveTailViewModel {
91139 columns = []
92140 columnOrder = []
93141 hiddenColumns = []
142+ columnFilters = []
94143 currentStream = stream
95144
96145 timer = Timer.scheduledTimer(withTimeInterval: pollInterval, repeats: true) { [weak self] _ in
@@ -119,6 +168,7 @@ final class LiveTailViewModel {
119168 columns = []
120169 columnOrder = []
121170 hiddenColumns = []
171+ columnFilters = []
122172 }
123173
124174 // MARK: - Column Management
ParseableViewer/Views/LiveTailView.swift+58−5
@@ -149,7 +149,7 @@ struct LiveTailView: View {
149149 .font(.caption)
150150 .foregroundStyle(.secondary)
151151
152− if !viewModel.filterText.isEmpty && viewModel.displayedCount != viewModel.entryCount {
152+ if (!viewModel.filterText.isEmpty || !viewModel.columnFilters.isEmpty) && viewModel.displayedCount != viewModel.entryCount {
153153 Text("(of \(viewModel.entryCount) total)")
154154 .font(.caption)
155155 .foregroundStyle(.secondary)
@@ -161,6 +161,55 @@ struct LiveTailView: View {
161161
162162 Divider()
163163
164+ // Active column filters
165+ if !viewModel.columnFilters.isEmpty {
166+ HStack(spacing: 4) {
167+ Image(systemName: "line.3.horizontal.decrease.circle.fill")
168+ .foregroundStyle(.secondary)
169+ .font(.caption)
170+
171+ ScrollView(.horizontal, showsIndicators: false) {
172+ HStack(spacing: 4) {
173+ ForEach(viewModel.columnFilters) { filter in
174+ HStack(spacing: 2) {
175+ Text(filter.displayLabel)
176+ .font(.caption)
177+ .lineLimit(1)
178+ Button {
179+ viewModel.removeColumnFilter(filter)
180+ } label: {
181+ Image(systemName: "xmark")
182+ .font(.system(size: 8, weight: .bold))
183+ }
184+ .buttonStyle(.plain)
185+ }
186+ .padding(.horizontal, 6)
187+ .padding(.vertical, 2)
188+ .background(
189+ RoundedRectangle(cornerRadius: 4)
190+ .fill(filter.exclude
191+ ? Color.red.opacity(0.15)
192+ : Color.accentColor.opacity(0.15))
193+ )
194+ }
195+ }
196+ }
197+
198+ Spacer()
199+
200+ Button("Clear All") {
201+ viewModel.clearColumnFilters()
202+ }
203+ .font(.caption)
204+ .buttonStyle(.plain)
205+ .foregroundStyle(.secondary)
206+ }
207+ .padding(.horizontal, 8)
208+ .padding(.vertical, 4)
209+
210+ Divider()
211+ }
212+
164213 // Column table or empty state
165214 if viewModel.visibleColumns.isEmpty {
166215 VStack(spacing: 8) {
@@ -233,7 +282,10 @@ struct LiveTailView: View {
233282 columns: viewModel.visibleColumns,
234283 columnWidths: columnWidths,
235284 isSelected: selectedRecord == sorted[index].record,
236− isAlternate: index % 2 == 1
285+ isAlternate: index % 2 == 1,
286+ onCellFilter: { column, value, exclude in
287+ viewModel.addColumnFilter(column: column, value: value, exclude: exclude)
288+ }
237289 )
238290 .id(sorted[index].id)
239291 .onTapGesture {
@@ -258,9 +310,10 @@ struct LiveTailView: View {
258310 guard autoScroll, sortColumn == nil else { return }
259311 // When a filter is active, scroll to the last filtered entry;
260312 // when no filter, scroll to the absolute last entry.
261− let target = viewModel.filterText.isEmpty
262− ? viewModel.entries.last
263− : viewModel.filteredEntries.last
313+ let hasFilters = !viewModel.filterText.isEmpty || !viewModel.columnFilters.isEmpty
314+ let target = hasFilters
315+ ? viewModel.filteredEntries.last
316+ : viewModel.entries.last
264317 if let target {
265318 proxy.scrollTo(target.id, anchor: .bottom)
266319 }
ParseableViewerTests/LiveTailViewModelTests.swift+70
@@ -391,4 +391,74 @@ final class LiveTailViewModelTests: XCTestCase {
391391 vm.moveColumn(from: IndexSet(integer: 0), to: 3)
392392 XCTAssertEqual(vm.columnOrder, ["b", "c", "a"])
393393 }
394+
395+ // MARK: - Column Filters
396+
397+ func testAddColumnFilter() {
398+ let vm = LiveTailViewModel()
399+ vm.addColumnFilter(column: "level", value: .string("error"), exclude: false)
400+ XCTAssertEqual(vm.columnFilters.count, 1)
401+ XCTAssertEqual(vm.columnFilters[0].column, "level")
402+ XCTAssertEqual(vm.columnFilters[0].value, .string("error"))
403+ XCTAssertFalse(vm.columnFilters[0].exclude)
404+ }
405+
406+ func testAddColumnFilterExclude() {
407+ let vm = LiveTailViewModel()
408+ vm.addColumnFilter(column: "level", value: .string("debug"), exclude: true)
409+ XCTAssertEqual(vm.columnFilters.count, 1)
410+ XCTAssertTrue(vm.columnFilters[0].exclude)
411+ }
412+
413+ func testAddDuplicateFilterReplacesExisting() {
414+ let vm = LiveTailViewModel()
415+ vm.addColumnFilter(column: "level", value: .string("error"), exclude: false)
416+ vm.addColumnFilter(column: "level", value: .string("error"), exclude: false)
417+ XCTAssertEqual(vm.columnFilters.count, 1)
418+ }
419+
420+ func testAddFilterDifferentValueIsAdditive() {
421+ let vm = LiveTailViewModel()
422+ vm.addColumnFilter(column: "level", value: .string("error"), exclude: false)
423+ vm.addColumnFilter(column: "level", value: .string("warn"), exclude: false)
424+ XCTAssertEqual(vm.columnFilters.count, 2)
425+ }
426+
427+ func testRemoveColumnFilter() {
428+ let vm = LiveTailViewModel()
429+ vm.addColumnFilter(column: "level", value: .string("error"), exclude: false)
430+ let filter = vm.columnFilters[0]
431+ vm.removeColumnFilter(filter)
432+ XCTAssertTrue(vm.columnFilters.isEmpty)
433+ }
434+
435+ func testClearColumnFilters() {
436+ let vm = LiveTailViewModel()
437+ vm.addColumnFilter(column: "level", value: .string("error"), exclude: false)
438+ vm.addColumnFilter(column: "status", value: .int(500), exclude: true)
439+ vm.clearColumnFilters()
440+ XCTAssertTrue(vm.columnFilters.isEmpty)
441+ }
442+
443+ func testClearResetsColumnFilters() {
444+ let vm = LiveTailViewModel()
445+ vm.addColumnFilter(column: "level", value: .string("error"), exclude: false)
446+ vm.clear()
447+ XCTAssertTrue(vm.columnFilters.isEmpty)
448+ }
449+
450+ func testColumnFilterDisplayLabelInclude() {
451+ let filter = LiveTailViewModel.ColumnFilter(column: "level", value: .string("error"), exclude: false)
452+ XCTAssertEqual(filter.displayLabel, "level = error")
453+ }
454+
455+ func testColumnFilterDisplayLabelExclude() {
456+ let filter = LiveTailViewModel.ColumnFilter(column: "level", value: .string("debug"), exclude: true)
457+ XCTAssertEqual(filter.displayLabel, "level \u{2260} debug")
458+ }
459+
460+ func testColumnFilterDisplayLabelNull() {
461+ let filter = LiveTailViewModel.ColumnFilter(column: "field", value: nil, exclude: false)
462+ XCTAssertEqual(filter.displayLabel, "field = null")
463+ }
394464}