From df051b13115b2ea8cd3ccb2ec1d15b88b03e2fcd Mon Sep 17 00:00:00 2001 From: Alistair Pullen Date: Tue, 2 Sep 2025 11:17:12 +0000 Subject: [PATCH 1/2] feat: add sidebar preview fields customization in preferences Co-authored-by: Genie --- .../UI/Preferences/PreferencesView.swift | 19 ++++ JSONViewer/UI/SidebarView.swift | 18 ++- JSONViewer/ViewModels/AppViewModel.swift | 104 ++++++++++++++++-- 3 files changed, 132 insertions(+), 9 deletions(-) diff --git a/JSONViewer/UI/Preferences/PreferencesView.swift b/JSONViewer/UI/Preferences/PreferencesView.swift index 51859af..487ec31 100644 --- a/JSONViewer/UI/Preferences/PreferencesView.swift +++ b/JSONViewer/UI/Preferences/PreferencesView.swift @@ -23,6 +23,7 @@ struct PreferencesView: View { private struct AppearancePreferences: View { @AppStorage("themePreference") private var themePreference: String = "system" + @AppStorage("sidebarPreviewFields") private var sidebarPreviewFields: String = "" private struct ThemeOption: Identifiable { let id: String @@ -55,6 +56,24 @@ private struct AppearancePreferences: View { .frame(width: 340) } + Divider().padding(.vertical, 4) + + VStack(alignment: .leading, spacing: 8) { + HStack(alignment: .center, spacing: 12) { + Text("Row preview") + .frame(width: 80, alignment: .trailing) + .foregroundStyle(.secondary) + TextField("e.g. time,level,message or user.name,details.id", text: $sidebarPreviewFields) + .textFieldStyle(.roundedBorder) + .frame(width: 340) + .help("Comma-separated keys (supports dot-paths and array indices). Leave empty to show default raw preview.") + } + Text("Choose which JSON fields to show in the sidebar row preview. Leave blank to use the default preview.") + .font(.footnote) + .foregroundStyle(.secondary) + .frame(maxWidth: 500, alignment: .leading) + } + Spacer() } .padding(24) diff --git a/JSONViewer/UI/SidebarView.swift b/JSONViewer/UI/SidebarView.swift index 1a68c33..d81014e 100644 --- a/JSONViewer/UI/SidebarView.swift +++ b/JSONViewer/UI/SidebarView.swift @@ -84,7 +84,7 @@ struct SidebarView: View { .lineLimit(2) } .id(i) - .task(id: i) { + .task(id: "\(i)-\(viewModel.previewFieldsChangeToken)") { if previews[i] == nil { viewModel.preview(for: i) { text in // Ensure update happens next runloop to avoid update-during-view warnings @@ -138,10 +138,20 @@ struct SidebarView: View { Text("Row \(row.id)") .font(.callout) .foregroundStyle(.secondary) - Text(row.preview) + Text(previews[row.id] ?? "Loading…") .font(.system(.caption, design: .monospaced)) .lineLimit(2) } + .id(row.id) + .task(id: "\(row.id)-\(viewModel.previewFieldsChangeToken)") { + if previews[row.id] == nil { + viewModel.preview(for: row.id) { text in + DispatchQueue.main.async { + previews[row.id] = text + } + } + } + } .padding(.vertical, 4) .contentShape(Rectangle()) .onAppear { @@ -197,5 +207,9 @@ struct SidebarView: View { .onChange(of: viewModel.selectedRowID) { _ in Task { _ = await viewModel.updateTreeForSelectedRow() } } + .onChange(of: viewModel.previewFieldsChangeToken) { _ in + // Clear local previews so visible rows re-fetch with new field selection + previews = [:] + } } } \ No newline at end of file diff --git a/JSONViewer/ViewModels/AppViewModel.swift b/JSONViewer/ViewModels/AppViewModel.swift index 0439536..f946931 100644 --- a/JSONViewer/ViewModels/AppViewModel.swift +++ b/JSONViewer/ViewModels/AppViewModel.swift @@ -77,6 +77,10 @@ final class AppViewModel: ObservableObject { @Published var sidebarFilteredRowIDs: [Int]? = nil private var sidebarSearchTask: Task? + // Row preview fields preference change token (used by views to refresh preview tasks) + @Published var previewFieldsChangeToken: Int = 0 + private var lastPreviewFieldsKey: String = "" + // Work management private var currentComputeTask: Task? private var fileWatcher: FileWatcher? @@ -109,6 +113,8 @@ final class AppViewModel: ObservableObject { searchText = "" indexingProgress = nil previewCache.removeAllObjects() + lastPreviewFieldsKey = "" + previewFieldsChangeToken = 0 currentComputeTask?.cancel() currentComputeTask = nil fileChangeDebounce?.cancel() @@ -263,31 +269,115 @@ final class AppViewModel: ObservableObject { // MARK: - JSONL utility func preview(for row: Int, completion: @escaping (String) -> Void) { + // Resolve user preference for preview fields (comma-separated, optional dot-paths) + let prefRaw = (UserDefaults.standard.string(forKey: "sidebarPreviewFields") ?? "") + .trimmingCharacters(in: .whitespacesAndNewlines) + let prefKey = prefRaw.lowercased() + + // Invalidate cache when preference changes + if prefKey != lastPreviewFieldsKey { + lastPreviewFieldsKey = prefKey + previewCache.removeAllObjects() + previewFieldsChangeToken &+= 1 + } + if let cached = previewCache.object(forKey: NSNumber(value: row)) { completion(cached as String) return } + let fields: [String] = prefRaw + .split(separator: ",") + .map { $0.trimmingCharacters(in: .whitespacesAndNewlines) } + .filter { !$0.isEmpty } + + // Pasted JSONL (no index) guard let index = jsonlIndex else { - // Fallback to pasted rows array if let item = jsonlRows.first(where: { $0.id == row }) { - completion(item.preview) + if fields.isEmpty { + // Default behavior + let preview = item.preview + previewCache.setObject(preview as NSString, forKey: NSNumber(value: row)) + completion(preview) + } else { + DispatchQueue.global(qos: .utility).async { + let computed = Self.computePreview(fromRaw: item.raw, fields: fields) + let result = computed ?? String(item.raw.prefix(160)) + self.previewCache.setObject(result as NSString, forKey: NSNumber(value: row)) + DispatchQueue.main.async { completion(result) } + } + } } else { completion("") } return } + // File-backed JSONL DispatchQueue.global(qos: .utility).async { - let text = (try? index.readLine(at: row, maxBytes: 200)) ?? "" - let preview = String(text.prefix(160)) - self.previewCache.setObject(preview as NSString, forKey: NSNumber(value: row)) - DispatchQueue.main.async { - completion(preview) + if fields.isEmpty { + let text = (try? index.readLine(at: row, maxBytes: 200)) ?? "" + let preview = String(text.prefix(160)) + self.previewCache.setObject(preview as NSString, forKey: NSNumber(value: row)) + DispatchQueue.main.async { completion(preview) } + } else { + let raw = (try? index.readLine(at: row, maxBytes: nil)) ?? "" + let computed = Self.computePreview(fromRaw: raw, fields: fields) + let result = computed ?? String(raw.prefix(160)) + self.previewCache.setObject(result as NSString, forKey: NSNumber(value: row)) + DispatchQueue.main.async { completion(result) } } } } + // Build a preview string from specific fields in a JSON object. + // - fields: comma-separated tokenized into an array; supports dot-paths and array indices. + private static func computePreview(fromRaw raw: String, fields: [String]) -> String? { + guard !fields.isEmpty, let data = raw.data(using: .utf8) else { return nil } + guard let obj = try? JSONSerialization.jsonObject(with: data, options: [] ) else { return nil } + var parts: [String] = [] + for f in fields { + let path = f.split(separator: ".").map(String.init) + if let v = valueForKeyPath(path, in: obj), let s = stringForPreviewValue(v) { + parts.append(s) + } + } + if parts.isEmpty { return nil } + return parts.joined(separator: " · ") + } + + private static func valueForKeyPath(_ path: [String], in object: Any) -> Any? { + var current: Any? = object + for token in path { + guard let c = current else { return nil } + if let dict = c as? [String: Any] { + current = dict[token] + } else if let arr = c as? [Any], let idx = Int(token), idx >= 0, idx < arr.count { + current = arr[idx] + } else { + return nil + } + } + return current + } + + private static func stringForPreviewValue(_ value: Any) -> String? { + if let s = value as? String { return s } + if let n = value as? NSNumber { + if CFGetTypeID(n) == CFBooleanGetTypeID() { + return n.boolValue ? "true" : "false" + } + return n.stringValue + } + if value is NSNull { return "null" } + if JSONSerialization.isValidJSONObject(value), + let d = try? JSONSerialization.data(withJSONObject: value, options: []), + let s = String(data: d, encoding: .utf8) { + return s + } + return nil + } + @discardableResult func updateTreeForSelectedRow() async -> Bool { guard mode == .jsonl, let id = selectedRowID else { return false } From f834ada894ee07af8c5ec78f0d9c95636455c976 Mon Sep 17 00:00:00 2001 From: Alistair Pullen Date: Tue, 2 Sep 2025 11:23:41 +0000 Subject: [PATCH 2/2] feat: add row preview fields picker and manage selected fields in sidebar Co-authored-by: Genie --- JSONViewer/UI/AppShellView.swift | 15 +- .../UI/Preferences/PreferencesView.swift | 19 --- JSONViewer/UI/PreviewFieldsPickerView.swift | 129 ++++++++++++++++++ JSONViewer/ViewModels/AppViewModel.swift | 73 ++++++++++ 4 files changed, 216 insertions(+), 20 deletions(-) create mode 100644 JSONViewer/UI/PreviewFieldsPickerView.swift diff --git a/JSONViewer/UI/AppShellView.swift b/JSONViewer/UI/AppShellView.swift index be94472..4d0a0da 100644 --- a/JSONViewer/UI/AppShellView.swift +++ b/JSONViewer/UI/AppShellView.swift @@ -11,6 +11,7 @@ struct AppShellView: View { #endif @State private var isInspectorVisible: Bool = false @State private var isAISidebarVisible: Bool = false + @State private var isPreviewPickerPresented: Bool = false @Environment(\.openWindow) private var openWindow private var displayText: String { @@ -79,7 +80,10 @@ struct AppShellView: View { .navigationSplitViewColumnWidth(min: 420, ideal: 680, max: .infinity) .navigationTitle(viewModel.fileURL?.lastPathComponent ?? "Prism") } - + .sheet(isPresented: $isPreviewPickerPresented) { + PreviewFieldsPickerView(viewModel: viewModel, isPresented: $isPreviewPickerPresented) + .frame(minWidth: 520, minHeight: 520) + } .toolbar { ToolbarItemGroup { Button { @@ -104,6 +108,15 @@ struct AppShellView: View { .frame(width: 100) .help("Toggle between raw text and tree") + // New: Row preview fields picker + Button { + isPreviewPickerPresented = true + } label: { + Label("Row Preview", systemImage: "slider.horizontal.3") + } + .help("Choose which fields are shown in the JSONL sidebar preview") + .disabled(viewModel.mode == .none) + Button { withAnimation { isInspectorVisible.toggle() diff --git a/JSONViewer/UI/Preferences/PreferencesView.swift b/JSONViewer/UI/Preferences/PreferencesView.swift index 487ec31..51859af 100644 --- a/JSONViewer/UI/Preferences/PreferencesView.swift +++ b/JSONViewer/UI/Preferences/PreferencesView.swift @@ -23,7 +23,6 @@ struct PreferencesView: View { private struct AppearancePreferences: View { @AppStorage("themePreference") private var themePreference: String = "system" - @AppStorage("sidebarPreviewFields") private var sidebarPreviewFields: String = "" private struct ThemeOption: Identifiable { let id: String @@ -56,24 +55,6 @@ private struct AppearancePreferences: View { .frame(width: 340) } - Divider().padding(.vertical, 4) - - VStack(alignment: .leading, spacing: 8) { - HStack(alignment: .center, spacing: 12) { - Text("Row preview") - .frame(width: 80, alignment: .trailing) - .foregroundStyle(.secondary) - TextField("e.g. time,level,message or user.name,details.id", text: $sidebarPreviewFields) - .textFieldStyle(.roundedBorder) - .frame(width: 340) - .help("Comma-separated keys (supports dot-paths and array indices). Leave empty to show default raw preview.") - } - Text("Choose which JSON fields to show in the sidebar row preview. Leave blank to use the default preview.") - .font(.footnote) - .foregroundStyle(.secondary) - .frame(maxWidth: 500, alignment: .leading) - } - Spacer() } .padding(24) diff --git a/JSONViewer/UI/PreviewFieldsPickerView.swift b/JSONViewer/UI/PreviewFieldsPickerView.swift new file mode 100644 index 0000000..6b540ed --- /dev/null +++ b/JSONViewer/UI/PreviewFieldsPickerView.swift @@ -0,0 +1,129 @@ +import SwiftUI + +struct PreviewFieldsPickerView: View { + @ObservedObject var viewModel: AppViewModel + @Binding var isPresented: Bool + + @State private var candidates: [String] = [] + @State private var selected: Set = [] + @State private var search: String = "" + @State private var isLoading: Bool = true + + var body: some View { + VStack(alignment: .leading, spacing: 12) { + header + Divider() + content + Divider() + footer + } + .padding(16) + .onAppear { + selected = Set(viewModel.currentSidebarPreviewFields()) + Task { + isLoading = true + let paths = await viewModel.collectCandidatePreviewPaths() + await MainActor.run { + self.candidates = paths + self.isLoading = false + } + } + } + } + + private var header: some View { + HStack { + Label("Row Preview Fields", systemImage: "slider.horizontal.3") + .font(.system(size: 16, weight: .semibold)) + Spacer() + Button { + isPresented = false + } label: { + Image(systemName: "xmark.circle.fill") + } + .buttonStyle(.plain) + } + } + + @ViewBuilder + private var content: some View { + VStack(alignment: .leading, spacing: 8) { + Text("Choose which JSON fields to show for each JSONL row in the sidebar.") + .foregroundStyle(.secondary) + .font(.callout) + + HStack(spacing: 8) { + Image(systemName: "magnifyingglass") + .foregroundStyle(.secondary) + TextField("Filter fields…", text: $search) + .textFieldStyle(.plain) + } + .padding(.vertical, 8) + .padding(.horizontal, 10) + .background(RoundedRectangle(cornerRadius: 8).fill(Color(nsColor: .controlBackgroundColor))) + .overlay(RoundedRectangle(cornerRadius: 8).strokeBorder(Color.secondary.opacity(0.25), lineWidth: 1)) + + if isLoading { + VStack(alignment: .center) { + ProgressView().padding(.top, 24) + Text("Scanning document for fields…") + .foregroundStyle(.secondary) + .font(.footnote) + } + .frame(maxWidth: .infinity, maxHeight: .infinity) + } else if candidates.isEmpty { + Text("No fields found.") + .foregroundStyle(.secondary) + .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .center) + } else { + let filtered = filteredCandidates + List { + ForEach(filtered, id: \.self) { path in + Toggle(isOn: Binding( + get: { selected.contains(path) }, + set: { on in + if on { selected.insert(path) } else { selected.remove(path) } + }) + ) { + Text(path.isEmpty ? "(root)" : path) + .font(.system(.body, design: .monospaced)) + } + } + } + .frame(minHeight: 320) + } + } + } + + private var footer: some View { + HStack { + Button { + selected.removeAll() + } label: { + Label("Select None", systemImage: "minus.circle") + } + Button { + selected.formUnion(filteredCandidates) + } label: { + Label("Select All Shown", systemImage: "checkmark.circle") + } + Spacer() + Button { + // Persist and refresh previews + let list = candidates.filter { selected.contains($0) } + viewModel.setSidebarPreviewFields(list) + isPresented = false + } label: { + Text("Apply") + } + .keyboardShortcut(.defaultAction) + .disabled(isLoading) + } + } + + private var filteredCandidates: [String] { + let q = search.trimmingCharacters(in: .whitespacesAndNewlines) + guard !q.isEmpty else { return candidates } + return candidates.filter { $0.localizedCaseInsensitiveContains(q) } + } +} \ No newline at end of file diff --git a/JSONViewer/ViewModels/AppViewModel.swift b/JSONViewer/ViewModels/AppViewModel.swift index f946931..7c146f9 100644 --- a/JSONViewer/ViewModels/AppViewModel.swift +++ b/JSONViewer/ViewModels/AppViewModel.swift @@ -634,6 +634,79 @@ final class AppViewModel: ObservableObject { return formatter.string(fromByteCount: Int64(bytes)) } + // MARK: - Preview fields management + + func setSidebarPreviewFields(_ fields: [String]) { + let joined = fields.joined(separator: ",") + UserDefaults.standard.set(joined, forKey: "sidebarPreviewFields") + let prefKey = joined.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() + if prefKey != lastPreviewFieldsKey { + lastPreviewFieldsKey = prefKey + previewCache.removeAllObjects() + previewFieldsChangeToken &+= 1 + } + } + + func currentSidebarPreviewFields() -> [String] { + let raw = UserDefaults.standard.string(forKey: "sidebarPreviewFields") ?? "" + return raw + .split(separator: ",") + .map { $0.trimmingCharacters(in: .whitespacesAndNewlines) } + .filter { !$0.isEmpty } + } + + func collectCandidatePreviewPaths(limitLines: Int = 200, maxDepth: Int = 4) async -> [String] { + var paths = Set() + + func addPaths(from any: Any, base: String, depth: Int) { + if depth > maxDepth { return } + if let dict = any as? [String: Any] { + for (k, v) in dict { + let path = base.isEmpty ? k : "\(base).\(k)" + paths.insert(path) + addPaths(from: v, base: path, depth: depth + 1) + } + } else if let arr = any as? [Any], let first = arr.first { + // Sample first element of the array + let idxPath = base.isEmpty ? "0" : "\(base).0" + addPaths(from: first, base: idxPath, depth: depth + 1) + } + } + + if mode == .jsonl { + if let index = jsonlIndex { + let total = index.lineCount + let count = min(limitLines, total) + for i in 0.. 400 { break } + } + } + } + } else { + // Pasted JSONL + for row in jsonlRows.prefix(limitLines) { + if let data = row.raw.data(using: .utf8), let obj = try? JSONSerialization.jsonObject(with: data) { + addPaths(from: obj, base: "", depth: 0) + if paths.count > 400 { break } + } + } + } + } else if mode == .json, let root = currentTreeRoot { + // If a plain JSON doc is open, propose paths from the tree to allow preconfiguration + func collectFromTree(_ node: JSONTreeNode) { + paths.insert(node.path) + node.children?.forEach(collectFromTree) + } + collectFromTree(root) + } + + let sorted = paths.sorted { $0.localizedCaseInsensitiveCompare($1) == .orderedAscending } + return Array(sorted.prefix(500)) + } + // MARK: - Command Bar Actions func runJQ(filter: String) {