Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
101 changes: 89 additions & 12 deletions JSONViewer/UI/SidebarView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
}

Expand Down Expand Up @@ -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<Bool>(
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
Expand All @@ -65,6 +138,9 @@ struct SidebarView: View {
.padding(.horizontal, 12)
.padding(.top, 8)

// Field filters (dynamic)
fieldFilterView()

Group {
switch viewModel.mode {
case .jsonl:
Expand Down Expand Up @@ -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
Expand Down
121 changes: 106 additions & 15 deletions JSONViewer/ViewModels/AppViewModel.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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<String> = []
private var fieldDiscoveryTask: Task<Void, Never>?
private var sidebarFilterDebounce: Task<Void, Never>?

init() {
// Prevent unbounded memory growth while scrolling many rows
previewCache.countLimit = 5000
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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()
}

Expand Down Expand Up @@ -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 {
Expand All @@ -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 {
Expand All @@ -275,6 +294,8 @@ final class AppViewModel: ObservableObject {
}
}
}
// Also start early field discovery
startFieldDiscoveryIfNeeded()
}

// MARK: - JSONL utility
Expand Down Expand Up @@ -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..<sampleLimit {
if Task.isCancelled { return }
guard let line = try? index.readLine(at: i, maxBytes: nil) ?? "" else { continue }
if let data = line.data(using: .utf8),
let obj = try? JSONSerialization.jsonObject(with: data) as? [String: Any] {
for key in obj.keys {
counts[key, default: 0] += 1
}
}
let now = CFAbsoluteTimeGetCurrent()
if now - lastPublish > 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<String> = []
Expand Down Expand Up @@ -517,51 +585,74 @@ 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
var matches: [Int] = []
var lastPublish = CFAbsoluteTimeGetCurrent()
for i in 0..<total {
if Task.isCancelled { return }
let line = (try? index.readLine(at: i, maxBytes: 4096)) ?? ""
if line.range(of: query, options: .caseInsensitive) != nil {
matches.append(i)
guard let line = try? index.readLine(at: i, maxBytes: nil) ?? "" else { continue }
// Text query check
if !q.isEmpty && line.range(of: q, options: .caseInsensitive) == nil {
continue
}
// Field selection check: require all selected fields to be present as top-level keys
if !selected.isEmpty {
// Try to parse quickly
var hasAll = true
if let data = line.data(using: .utf8),
let obj = (try? JSONSerialization.jsonObject(with: data)) as? [String: Any] {
for key in selected {
if obj[key] == nil { hasAll = false; break }
}
} else {
// Fallback to substring heuristic if not valid JSON
for key in selected {
if line.range(of: "\"\(key)\":") == nil { hasAll = false; break }
}
}
if !hasAll { continue }
}
matches.append(i)
// Throttle UI updates to ~10 per second
let now = CFAbsoluteTimeGetCurrent()
if now - lastPublish > 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
}
}
}
Expand Down