From cfb5cc03fa06bbe54829b9c55bdbe908874357c3 Mon Sep 17 00:00:00 2001 From: Alistair Pullen Date: Tue, 9 Sep 2025 14:50:44 +0000 Subject: [PATCH] feat: add dynamic field filtering to SidebarView Co-authored-by: Genie --- JSONViewer/UI/SidebarView.swift | 101 ++++++++++++++++--- JSONViewer/ViewModels/AppViewModel.swift | 121 ++++++++++++++++++++--- 2 files changed, 195 insertions(+), 27 deletions(-) diff --git a/JSONViewer/UI/SidebarView.swift b/JSONViewer/UI/SidebarView.swift index 2e7c1d3..d06179e 100644 --- a/JSONViewer/UI/SidebarView.swift +++ b/JSONViewer/UI/SidebarView.swift @@ -4,11 +4,29 @@ struct SidebarView: View { @ObservedObject var viewModel: AppViewModel @State private var lastRowCount: Int = 0 @State private var isAtBottom: Bool = false + @State private var showFieldFilters: Bool = true private var filteredRows: [AppViewModel.JSONLRow] { - if viewModel.searchText.isEmpty { return viewModel.jsonlRows } + // Pasted-mode filtering + let q = viewModel.searchText.trimmingCharacters(in: .whitespacesAndNewlines) + let selected = viewModel.selectedFieldFilters return viewModel.jsonlRows.filter { row in - row.preview.localizedCaseInsensitiveContains(viewModel.searchText) || row.raw.localizedCaseInsensitiveContains(viewModel.searchText) + // Text match + let textOK = q.isEmpty || row.preview.localizedCaseInsensitiveContains(q) || row.raw.localizedCaseInsensitiveContains(q) + if !textOK { return false } + if selected.isEmpty { return true } + guard let data = row.raw.data(using: .utf8), + let obj = try? JSONSerialization.jsonObject(with: data) as? [String: Any] else { + // Fallback heuristic + for k in selected { + if row.raw.range(of: "\"\(k)\":") == nil { return false } + } + return true + } + for k in selected { + if obj[k] == nil { return false } + } + return true } } @@ -41,6 +59,61 @@ struct SidebarView: View { .padding(.bottom, 8) } + @ViewBuilder + private func fieldFilterView() -> some View { + if viewModel.mode == .jsonl { + let items = viewModel.availableFields.sorted { a, b in + if a.value == b.value { return a.key < b.key } + return a.value > b.value + } + if !items.isEmpty { + DisclosureGroup(isExpanded: $showFieldFilters) { + // Limit height to keep sidebar compact + ScrollView { + VStack(alignment: .leading, spacing: 6) { + ForEach(items, id: \.key) { kv in + let key = kv.key + let count = kv.value + Toggle(isOn: Binding( + get: { viewModel.selectedFieldFilters.contains(key) }, + set: { on in + if on { viewModel.selectedFieldFilters.insert(key) } + else { viewModel.selectedFieldFilters.remove(key) } + // Trigger filtering for file-backed + if viewModel.jsonlIndex != nil { + viewModel.runSidebarFilterDebounced() + } + } + )) { + HStack { + Text(key) + Spacer() + Text("\(count)") + .foregroundStyle(.secondary) + } + } + .toggleStyle(.checkbox) + } + } + .padding(.vertical, 4) + } + .frame(maxHeight: 160) + } label: { + HStack { + Image(systemName: "line.3.horizontal.decrease.circle") + Text("Filter by fields") + Spacer() + if !viewModel.selectedFieldFilters.isEmpty { + Text("\(viewModel.selectedFieldFilters.count) selected") + .foregroundStyle(.secondary) + } + } + } + .padding(.horizontal, 12) + } + } + } + var body: some View { VStack(spacing: 10) { // Styled, integrated search field matching tree viewer @@ -65,6 +138,9 @@ struct SidebarView: View { .padding(.horizontal, 12) .padding(.top, 8) + // Field filters (dynamic) + fieldFilterView() + Group { switch viewModel.mode { case .jsonl: @@ -176,21 +252,22 @@ struct SidebarView: View { .onAppear { lastRowCount = viewModel.jsonlRowCount } - .onChange(of: viewModel.searchText) { newVal in + .onChange(of: viewModel.searchText) { _ in if viewModel.jsonlIndex != nil { - if newVal.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty { - viewModel.sidebarFilteredRowIDs = nil - } else { - viewModel.runSidebarSearchDebounced() - } + viewModel.runSidebarFilterDebounced() } else { - viewModel.sidebarFilteredRowIDs = nil + // Pasted mode uses local filtering; nothing to compute + } + } + .onChange(of: viewModel.selectedFieldFilters) { _ in + if viewModel.jsonlIndex != nil { + viewModel.runSidebarFilterDebounced() } } .onChange(of: viewModel.jsonlRowCount) { _ in - if viewModel.jsonlIndex != nil && !viewModel.searchText.isEmpty { - // Re-run search when new rows arrive, but debounce to avoid thrashing during indexing - viewModel.runSidebarSearchDebounced() + if viewModel.jsonlIndex != nil && (!viewModel.searchText.isEmpty || !viewModel.selectedFieldFilters.isEmpty) { + // Re-run filter when new rows arrive, but debounce to avoid thrashing during indexing + viewModel.runSidebarFilterDebounced() } } .onChange(of: viewModel.selectedRowID) { _ in diff --git a/JSONViewer/ViewModels/AppViewModel.swift b/JSONViewer/ViewModels/AppViewModel.swift index 336d022..2a8c266 100644 --- a/JSONViewer/ViewModels/AppViewModel.swift +++ b/JSONViewer/ViewModels/AppViewModel.swift @@ -57,6 +57,12 @@ final class AppViewModel: ObservableObject { @Published var indexingProgress: Double? @Published var lastUpdatedAt: Date? + // Dynamic field filtering + @Published var availableFields: [String: Int] = [:] // field -> count (approx) + @Published var selectedFieldFilters: Set = [] + private var fieldDiscoveryTask: Task? + private var sidebarFilterDebounce: Task? + init() { // Prevent unbounded memory growth while scrolling many rows previewCache.countLimit = 5000 @@ -130,6 +136,13 @@ final class AppViewModel: ObservableObject { sidebarSearchTask = nil sidebarSearchDebounce?.cancel() sidebarSearchDebounce = nil + // Field filtering + fieldDiscoveryTask?.cancel() + fieldDiscoveryTask = nil + availableFields = [:] + selectedFieldFilters = [] + sidebarFilterDebounce?.cancel() + sidebarFilterDebounce = nil #if os(macOS) securityScopedURL?.stopAccessingSecurityScopedResource() securityScopedURL = nil @@ -181,6 +194,8 @@ final class AppViewModel: ObservableObject { statusMessage = "Pasted JSONL (\(rows.count) rows shown)" selectedRowID = rows.first?.id lastUpdatedAt = Date() + // Build available field list from pasted rows + buildAvailableFieldsFromPasted() await updateTreeForSelectedRow() } @@ -239,6 +254,8 @@ final class AppViewModel: ObservableObject { jsonlIndex = index indexingProgress = 0 jsonlRowCount = 0 + availableFields = [:] + selectedFieldFilters = [] indexTask?.cancel() indexTask = Task.detached(priority: .userInitiated) { [weak self] in do { @@ -260,6 +277,8 @@ final class AppViewModel: ObservableObject { // If the selected row becomes available during indexing, update view Task { _ = await self.updateTreeForSelectedRow() } } + // Kick off/continue field discovery when we see more rows + self.startFieldDiscoveryIfNeeded() } }, shouldCancel: { Task.isCancelled }) await MainActor.run { @@ -275,6 +294,8 @@ final class AppViewModel: ObservableObject { } } } + // Also start early field discovery + startFieldDiscoveryIfNeeded() } // MARK: - JSONL utility @@ -470,6 +491,53 @@ final class AppViewModel: ObservableObject { } } + // MARK: - Field discovery (JSONL) + private func startFieldDiscoveryIfNeeded() { + guard mode == .jsonl, fieldDiscoveryTask == nil, let index = jsonlIndex else { return } + fieldDiscoveryTask = Task.detached(priority: .utility) { [weak self] in + guard let self else { return } + var counts: [String: Int] = [:] + let sampleLimit = min(index.lineCount, 5000) + var lastPublish = CFAbsoluteTimeGetCurrent() + for i in 0.. 0.2 { + lastPublish = now + let snapshot = counts + await MainActor.run { + // Do not overwrite if user has navigated away + if self.mode == .jsonl { + self.availableFields = snapshot + } + } + } + } + await MainActor.run { + if self.mode == .jsonl { + self.availableFields = counts + } + } + } + } + + private func buildAvailableFieldsFromPasted() { + var counts: [String: Int] = [:] + for row in jsonlRows { + guard let data = row.raw.data(using: .utf8), + let obj = try? JSONSerialization.jsonObject(with: data) as? [String: Any] else { continue } + for k in obj.keys { counts[k, default: 0] += 1 } + } + availableFields = counts + } + func expandAll() { guard let root = currentTreeRoot else { return } var set: Set = [] @@ -517,26 +585,26 @@ final class AppViewModel: ObservableObject { // MARK: - Sidebar filtering for file-backed JSONL - func runSidebarSearchDebounced() { - sidebarSearchDebounce?.cancel() - sidebarSearchDebounce = Task { @MainActor in + func runSidebarFilterDebounced() { + sidebarFilterDebounce?.cancel() + sidebarFilterDebounce = Task { @MainActor in try? await Task.sleep(nanoseconds: 250_000_000) - runSidebarSearch() + runSidebarFilter() } } - func runSidebarSearch() { + func runSidebarFilter() { sidebarSearchTask?.cancel() guard mode == .jsonl, let index = jsonlIndex else { sidebarFilteredRowIDs = nil return } - let q = searchText.trimmingCharacters(in: .whitespacesAndNewlines) - if q.isEmpty { + let q = searchText.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() + let selected = selectedFieldFilters + if q.isEmpty && selected.isEmpty { sidebarFilteredRowIDs = nil return } - let query = q.lowercased() let total = index.lineCount sidebarSearchTask = Task.detached(priority: .userInitiated) { [weak self] in @@ -544,24 +612,47 @@ final class AppViewModel: ObservableObject { var lastPublish = CFAbsoluteTimeGetCurrent() for i in 0.. 0.1 { lastPublish = now await MainActor.run { - if self?.searchText.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() == query { - self?.sidebarFilteredRowIDs = matches + guard let strong = self else { return } + let curQ = strong.searchText.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() + if curQ == q && strong.selectedFieldFilters == selected { + strong.sidebarFilteredRowIDs = matches } } } } await MainActor.run { - if self?.searchText.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() == query { - self?.sidebarFilteredRowIDs = matches + guard let strong = self else { return } + let curQ = strong.searchText.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() + if curQ == q && strong.selectedFieldFilters == selected { + strong.sidebarFilteredRowIDs = matches } } }