From befb522dc823942485a12a6faa1892d9c33efbc7 Mon Sep 17 00:00:00 2001 From: Alistair Pullen Date: Tue, 2 Sep 2025 20:18:24 +0000 Subject: [PATCH 1/5] fix: improve JSONL selection handling and cancellation behavior in updateTreeForSelectedRow method Co-authored-by: Genie --- JSONViewer/ViewModels/AppViewModel.swift | 44 ++++++++++++++++++------ 1 file changed, 33 insertions(+), 11 deletions(-) diff --git a/JSONViewer/ViewModels/AppViewModel.swift b/JSONViewer/ViewModels/AppViewModel.swift index 336d022..dce8e58 100644 --- a/JSONViewer/ViewModels/AppViewModel.swift +++ b/JSONViewer/ViewModels/AppViewModel.swift @@ -309,43 +309,65 @@ final class AppViewModel: ObservableObject { @discardableResult func updateTreeForSelectedRow() async -> Bool { + // Only valid for JSONL mode with a concrete selection guard mode == .jsonl, let id = selectedRowID else { return false } + // Cancel any in-flight compute and start a fresh one for the current selection currentComputeTask?.cancel() - guard let index = jsonlIndex else { - // Pasted JSONL - guard let row = selectedRow else { return false } - let raw = row.raw + + // File-backed JSONL + if let index = jsonlIndex { + // Only proceed if the line slice is available (during indexing this may be false) + guard index.sliceRange(forLine: id) != nil else { return false } + + let selectionAtStart = id currentComputeTask = Task.detached(priority: .userInitiated) { [weak self] in + // Respect cancellation between stages + if Task.isCancelled { return } + let raw = (try? index.readLine(at: selectionAtStart, maxBytes: nil)) ?? "" + if Task.isCancelled { return } + let data = raw.data(using: .utf8) ?? Data() let pretty = (try? JSONPrettyPrinter.pretty(data: data)) ?? raw let tree = try? JSONTreeBuilder.build(from: data) + await MainActor.run { guard let self else { return } + // Drop stale results: only apply if selection and index are still current + guard !Task.isCancelled, + self.mode == .jsonl, + self.selectedRowID == selectionAtStart, + self.jsonlIndex === index else { return } + self.prettyJSON = pretty self.currentTreeRoot = tree self.presentation = .tree + if self.expandedPaths.isEmpty { self.expandedPaths.insert("") } } } return true } - // File-backed: only proceed if line slice is available - guard let _ = index.sliceRange(forLine: id) else { - return false - } - + // Pasted JSONL + guard let row = selectedRow else { return false } + let raw = row.raw + let selectionAtStart = id currentComputeTask = Task.detached(priority: .userInitiated) { [weak self] in - let raw = (try? index.readLine(at: id, maxBytes: nil)) ?? "" + if Task.isCancelled { return } let data = raw.data(using: .utf8) ?? Data() let pretty = (try? JSONPrettyPrinter.pretty(data: data)) ?? raw let tree = try? JSONTreeBuilder.build(from: data) await MainActor.run { guard let self else { return } + // Drop stale results: only apply if selection is still the same and we're still in JSONL paste mode + guard !Task.isCancelled, + self.mode == .jsonl, + self.jsonlIndex == nil, + self.selectedRowID == selectionAtStart else { return } + self.prettyJSON = pretty self.currentTreeRoot = tree self.presentation = .tree - if self.expandedPaths.isEmpty { self.expandedPaths.insert("") } } } return true From ced236a7be1d2b44fcee9c7fbdb56666e001c501 Mon Sep 17 00:00:00 2001 From: Alistair Pullen Date: Thu, 4 Sep 2025 15:11:24 +0000 Subject: [PATCH 2/5] fix(UI): prevent preview text from displaying stale data on SidebarRowView Co-authored-by: Genie --- JSONViewer/UI/SidebarRowView.swift | 24 ++++++++++++++++++++---- 1 file changed, 20 insertions(+), 4 deletions(-) diff --git a/JSONViewer/UI/SidebarRowView.swift b/JSONViewer/UI/SidebarRowView.swift index ef70827..170e995 100644 --- a/JSONViewer/UI/SidebarRowView.swift +++ b/JSONViewer/UI/SidebarRowView.swift @@ -5,6 +5,21 @@ struct SidebarRowView: View { let id: Int @State private var previewText: String = "Loading…" + // Guards against async preview callbacks updating a cell after it's been reused for another id. + @State private var requestToken = UUID() + + private func requestPreview() { + // Invalidate any in-flight callback for a previous id + let token = UUID() + requestToken = token + previewText = "Loading…" + viewModel.preview(for: id) { text in + // Apply only if this response corresponds to the most recent request for this cell + if token == requestToken { + previewText = text + } + } + } var body: some View { VStack(alignment: .leading, spacing: 4) { @@ -18,10 +33,11 @@ struct SidebarRowView: View { .padding(.vertical, 4) .contentShape(Rectangle()) .onAppear { - viewModel.preview(for: id) { text in - // Only update if still showing the same id (cells may be reused) - previewText = text - } + requestPreview() + } + .onDisappear { + // Invalidate callbacks as the cell is leaving the screen + requestToken = UUID() } } } \ No newline at end of file From ccb59aff1a8dd4a1543ca8b2fa02bf329cee644c Mon Sep 17 00:00:00 2001 From: Alistair Pullen Date: Thu, 4 Sep 2025 15:22:21 +0000 Subject: [PATCH 3/5] feat: add tags to SidebarRowView for selection binding in SidebarView Co-authored-by: Genie --- JSONViewer/UI/SidebarView.swift | 3 +++ 1 file changed, 3 insertions(+) diff --git a/JSONViewer/UI/SidebarView.swift b/JSONViewer/UI/SidebarView.swift index 2e7c1d3..42dc6f5 100644 --- a/JSONViewer/UI/SidebarView.swift +++ b/JSONViewer/UI/SidebarView.swift @@ -76,6 +76,7 @@ struct SidebarView: View { ForEach(filtered, id: \.self) { i in SidebarRowView(viewModel: viewModel, id: i) .id(i) + .tag(i) // Ensure List selection binding uses this row id .onAppear { if let last = filtered.last, i == last { isAtBottom = true } } @@ -87,6 +88,7 @@ struct SidebarView: View { ForEach(0.. Date: Thu, 4 Sep 2025 15:44:29 +0000 Subject: [PATCH 4/5] fix: update tree for selected row with explicit ID handling in SidebarView and AppViewModel Co-authored-by: Genie --- JSONViewer/UI/SidebarView.swift | 4 ++-- JSONViewer/ViewModels/AppViewModel.swift | 10 ++++++++-- 2 files changed, 10 insertions(+), 4 deletions(-) diff --git a/JSONViewer/UI/SidebarView.swift b/JSONViewer/UI/SidebarView.swift index 42dc6f5..d4d4f83 100644 --- a/JSONViewer/UI/SidebarView.swift +++ b/JSONViewer/UI/SidebarView.swift @@ -196,8 +196,8 @@ struct SidebarView: View { viewModel.runSidebarSearchDebounced() } } - .onChange(of: viewModel.selectedRowID) { _ in - Task { _ = await viewModel.updateTreeForSelectedRow() } + .onChange(of: viewModel.selectedRowID) { newID in + Task { _ = await viewModel.updateTreeForSelectedRow(selectedID: newID) } } } } \ No newline at end of file diff --git a/JSONViewer/ViewModels/AppViewModel.swift b/JSONViewer/ViewModels/AppViewModel.swift index dce8e58..11e27e6 100644 --- a/JSONViewer/ViewModels/AppViewModel.swift +++ b/JSONViewer/ViewModels/AppViewModel.swift @@ -309,8 +309,15 @@ final class AppViewModel: ObservableObject { @discardableResult func updateTreeForSelectedRow() async -> Bool { + // Forward to the explicit-id variant using the current selection. + return await updateTreeForSelectedRow(selectedID: selectedRowID) + } + + // Explicit-id variant used by views to avoid any timing race on reading selectedRowID. + @discardableResult + func updateTreeForSelectedRow(selectedID: Int?) async -> Bool { // Only valid for JSONL mode with a concrete selection - guard mode == .jsonl, let id = selectedRowID else { return false } + guard mode == .jsonl, let id = selectedID else { return false } // Cancel any in-flight compute and start a fresh one for the current selection currentComputeTask?.cancel() @@ -322,7 +329,6 @@ final class AppViewModel: ObservableObject { let selectionAtStart = id currentComputeTask = Task.detached(priority: .userInitiated) { [weak self] in - // Respect cancellation between stages if Task.isCancelled { return } let raw = (try? index.readLine(at: selectionAtStart, maxBytes: nil)) ?? "" if Task.isCancelled { return } From 3ef264039a92b5d00fed5a7865e07fb9f98be68c Mon Sep 17 00:00:00 2001 From: Alistair Pullen Date: Fri, 16 Jan 2026 12:01:52 +0000 Subject: [PATCH 5/5] chore: update for liquid glass and performance improvements --- .claude/settings.local.json | 7 + JSONViewer.xcodeproj/project.pbxproj | 16 +- JSONViewer/Core/JSONL/JSONLIndex.swift | 187 +++++- JSONViewer/Core/JSONTree.swift | 89 ++- JSONViewer/Info.plist | 15 +- JSONViewer/UI/AISidebarView.swift | 94 +-- JSONViewer/UI/AppShellView.swift | 90 +-- JSONViewer/UI/CommandBarView.swift | 43 +- JSONViewer/UI/JSONTreeView.swift | 164 +++-- .../UI/Preferences/PreferencesView.swift | 18 +- JSONViewer/UI/SidebarRowView.swift | 29 +- JSONViewer/UI/SidebarView.swift | 15 +- JSONViewer/ViewModels/AppViewModel.swift | 584 +++++++++--------- 13 files changed, 770 insertions(+), 581 deletions(-) create mode 100644 .claude/settings.local.json diff --git a/.claude/settings.local.json b/.claude/settings.local.json new file mode 100644 index 0000000..5cc8143 --- /dev/null +++ b/.claude/settings.local.json @@ -0,0 +1,7 @@ +{ + "permissions": { + "allow": [ + "Bash(xcodebuild:*)" + ] + } +} diff --git a/JSONViewer.xcodeproj/project.pbxproj b/JSONViewer.xcodeproj/project.pbxproj index 1b435f9..b1a6276 100644 --- a/JSONViewer.xcodeproj/project.pbxproj +++ b/JSONViewer.xcodeproj/project.pbxproj @@ -422,12 +422,12 @@ UTTypeTagSpecification = { "public.filename-extension" = ( "jsonl" ); "public.mime-type" = "application/x-ndjson"; }; }, ); - INFOPLIST_KEY_NSHumanReadableCopyright = ""; + INFOPLIST_KEY_NSHumanReadableCopyright = "By Alistair Pullen"; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", "@executable_path/../Frameworks", ); - MARKETING_VERSION = 1.0; + MARKETING_VERSION = 1.1; PRODUCT_BUNDLE_IDENTIFIER = com.cosine.JSONViewer; PRODUCT_NAME = Prism; SWIFT_EMIT_LOC_STRINGS = YES; @@ -470,12 +470,12 @@ UTTypeTagSpecification = { "public.filename-extension" = ( "jsonl" ); "public.mime-type" = "application/x-ndjson"; }; }, ); - INFOPLIST_KEY_NSHumanReadableCopyright = ""; + INFOPLIST_KEY_NSHumanReadableCopyright = "By Alistair Pullen"; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", "@executable_path/../Frameworks", ); - MARKETING_VERSION = 1.0; + MARKETING_VERSION = 1.1; PRODUCT_BUNDLE_IDENTIFIER = com.cosine.JSONViewer; PRODUCT_NAME = Prism; SWIFT_EMIT_LOC_STRINGS = YES; @@ -491,7 +491,7 @@ CURRENT_PROJECT_VERSION = 1; GENERATE_INFOPLIST_FILE = YES; MACOSX_DEPLOYMENT_TARGET = 15.1; - MARKETING_VERSION = 1.0; + MARKETING_VERSION = 1.1; PRODUCT_BUNDLE_IDENTIFIER = com.cosine.JSONViewerTests; PRODUCT_NAME = "$(TARGET_NAME)"; SWIFT_EMIT_LOC_STRINGS = NO; @@ -508,7 +508,7 @@ CURRENT_PROJECT_VERSION = 1; GENERATE_INFOPLIST_FILE = YES; MACOSX_DEPLOYMENT_TARGET = 15.1; - MARKETING_VERSION = 1.0; + MARKETING_VERSION = 1.1; PRODUCT_BUNDLE_IDENTIFIER = com.cosine.JSONViewerTests; PRODUCT_NAME = "$(TARGET_NAME)"; SWIFT_EMIT_LOC_STRINGS = NO; @@ -523,7 +523,7 @@ CODE_SIGN_STYLE = Automatic; CURRENT_PROJECT_VERSION = 1; GENERATE_INFOPLIST_FILE = YES; - MARKETING_VERSION = 1.0; + MARKETING_VERSION = 1.1; PRODUCT_BUNDLE_IDENTIFIER = com.cosine.JSONViewerUITests; PRODUCT_NAME = "$(TARGET_NAME)"; SWIFT_EMIT_LOC_STRINGS = NO; @@ -538,7 +538,7 @@ CODE_SIGN_STYLE = Automatic; CURRENT_PROJECT_VERSION = 1; GENERATE_INFOPLIST_FILE = YES; - MARKETING_VERSION = 1.0; + MARKETING_VERSION = 1.1; PRODUCT_BUNDLE_IDENTIFIER = com.cosine.JSONViewerUITests; PRODUCT_NAME = "$(TARGET_NAME)"; SWIFT_EMIT_LOC_STRINGS = NO; diff --git a/JSONViewer/Core/JSONL/JSONLIndex.swift b/JSONViewer/Core/JSONL/JSONLIndex.swift index 94a4129..ad9a0a7 100644 --- a/JSONViewer/Core/JSONL/JSONLIndex.swift +++ b/JSONViewer/Core/JSONL/JSONLIndex.swift @@ -1,20 +1,41 @@ import Foundation +import Accelerate final class JSONLIndex { let url: URL private(set) var offsets: [UInt64] = [0] // start offsets for each line private(set) var fileSize: UInt64 = 0 - private let chunkSize = 8 * 1024 * 1024 private let newline: UInt8 = 0x0A - private let scanQueue = DispatchQueue(label: "com.prism.jsonlindex.scan", qos: .utility) + private let scanQueue = DispatchQueue(label: "com.prism.jsonlindex.scan", qos: .userInitiated) private let scanQueueKey = DispatchSpecificKey() + // FileHandle pooling to avoid opening/closing a new handle for every read operation. + // This dramatically improves performance when scrolling through large JSONL files. + private var cachedReadHandle: FileHandle? + private var readHandleUseCount: Int = 0 + private let readHandleLock = NSLock() + private let maxHandleUses = 1000 // Reopen handle periodically to avoid stale file state + init(url: URL) { self.url = url scanQueue.setSpecific(key: scanQueueKey, value: ()) } + deinit { + invalidateReadHandle() + } + + /// Invalidate the cached read handle. Call when the file is refreshed/reloaded. + func invalidateReadHandle() { + readHandleLock.lock() + defer { readHandleLock.unlock() } + try? cachedReadHandle?.close() + cachedReadHandle = nil + readHandleUseCount = 0 + } + + private func syncOnScanQueue(_ block: () throws -> T) rethrows -> T { if DispatchQueue.getSpecific(key: scanQueueKey) != nil { return try block() @@ -23,8 +44,8 @@ final class JSONLIndex { } } - // Build index progressively, reporting progress and current lineCount after each chunk. - // The shouldCancel closure allows callers (Tasks) to request early exit during file save bursts. + // Build index using parallel scanning with SIMD acceleration. + // Splits file into chunks and scans them concurrently on all available cores. func build(progress: ((Double) -> Void)? = nil, onUpdate: ((Int) -> Void)? = nil, shouldCancel: (() -> Bool)? = nil) throws { @@ -35,46 +56,119 @@ final class JSONLIndex { if cancelled() { throw CancellationError() } - let handle = try FileHandle(forReadingFrom: url) - defer { try? handle.close() } - let attrs = try FileManager.default.attributesOfItem(atPath: url.path) fileSize = (attrs[.size] as? NSNumber)?.uint64Value ?? 0 - offsets = [0] - var position: UInt64 = 0 - while true { - if cancelled() { throw CancellationError() } + if fileSize == 0 { + offsets = [0] + progress?(1.0) + onUpdate?(0) + return + } - try handle.seek(toOffset: position) - guard let chunk = try handle.read(upToCount: chunkSize), !chunk.isEmpty else { - break - } + // Use memory-mapped file for zero-copy access + let data = try Data(contentsOf: url, options: [.alwaysMapped]) + let count = data.count + + if cancelled() { throw CancellationError() } - if cancelled() { throw CancellationError() } + // Determine number of parallel chunks based on CPU cores + let coreCount = ProcessInfo.processInfo.activeProcessorCount + let chunkCount = min(coreCount, max(1, count / (1024 * 1024))) // At least 1MB per chunk + let chunkSize = (count + chunkCount - 1) / chunkCount + + // Each chunk will collect its own offsets + var chunkOffsets: [[UInt64]] = Array(repeating: [], count: chunkCount) + var completedChunks = 0 + let progressLock = NSLock() + + progress?(0.0) + + data.withUnsafeBytes { (buffer: UnsafeRawBufferPointer) in + guard let base = buffer.baseAddress?.assumingMemoryBound(to: UInt8.self) else { return } + + // Scan chunks in parallel + DispatchQueue.concurrentPerform(iterations: chunkCount) { chunkIndex in + if cancelled() { return } + + let start = chunkIndex * chunkSize + let end = min(start + chunkSize, count) + var localOffsets: [UInt64] = [] + localOffsets.reserveCapacity((end - start) / 200) // Estimate + + // SIMD-accelerated newline scanning using 16-byte vectors + var i = start + let newlineVec = SIMD16(repeating: newline) + + // Process 16 bytes at a time with SIMD + while i + 16 <= end { + let vec = SIMD16( + base[i], base[i+1], base[i+2], base[i+3], + base[i+4], base[i+5], base[i+6], base[i+7], + base[i+8], base[i+9], base[i+10], base[i+11], + base[i+12], base[i+13], base[i+14], base[i+15] + ) + let matches = vec .== newlineVec + + // Check each lane for matches + if matches[0] { localOffsets.append(UInt64(i + 1)) } + if matches[1] { localOffsets.append(UInt64(i + 2)) } + if matches[2] { localOffsets.append(UInt64(i + 3)) } + if matches[3] { localOffsets.append(UInt64(i + 4)) } + if matches[4] { localOffsets.append(UInt64(i + 5)) } + if matches[5] { localOffsets.append(UInt64(i + 6)) } + if matches[6] { localOffsets.append(UInt64(i + 7)) } + if matches[7] { localOffsets.append(UInt64(i + 8)) } + if matches[8] { localOffsets.append(UInt64(i + 9)) } + if matches[9] { localOffsets.append(UInt64(i + 10)) } + if matches[10] { localOffsets.append(UInt64(i + 11)) } + if matches[11] { localOffsets.append(UInt64(i + 12)) } + if matches[12] { localOffsets.append(UInt64(i + 13)) } + if matches[13] { localOffsets.append(UInt64(i + 14)) } + if matches[14] { localOffsets.append(UInt64(i + 15)) } + if matches[15] { localOffsets.append(UInt64(i + 16)) } + + i += 16 + } - chunk.withUnsafeBytes { (ptr: UnsafeRawBufferPointer) in - guard let base = ptr.bindMemory(to: UInt8.self).baseAddress else { return } - for i in 0.. 0 ? Double(position) / Double(fileSize) : 0) - onUpdate?(lineCount) - if chunk.count < chunkSize { break } + chunkOffsets[chunkIndex] = localOffsets + + // Update progress + progressLock.lock() + completedChunks += 1 + let prog = Double(completedChunks) / Double(chunkCount) + progressLock.unlock() + progress?(prog) + } } if cancelled() { throw CancellationError() } + // Merge all chunk offsets (already sorted since chunks are sequential) + var totalCount = 1 // Start with offset 0 + for chunk in chunkOffsets { + totalCount += chunk.count + } + + offsets = [0] + offsets.reserveCapacity(totalCount + 1) + for chunk in chunkOffsets { + offsets.append(contentsOf: chunk) + } + // Ensure EOF offset is present for final line slicing if offsets.last != fileSize { offsets.append(fileSize) } + progress?(1.0) onUpdate?(lineCount) } @@ -84,6 +178,8 @@ final class JSONLIndex { func refresh(progress: ((Double) -> Void)? = nil, onUpdate: ((Int) -> Void)? = nil, shouldCancel: (() -> Bool)? = nil) throws { + // Invalidate any cached read handle since the file content has changed + invalidateReadHandle() try build(progress: progress, onUpdate: onUpdate, shouldCancel: shouldCancel) } @@ -105,10 +201,21 @@ final class JSONLIndex { guard let range = sliceRange(forLine: index) else { return nil } let length = range.upperBound - range.lowerBound let toRead = maxBytes.map { min(UInt64($0), length) } ?? length - let handle = try FileHandle(forReadingFrom: url) - defer { try? handle.close() } - try handle.seek(toOffset: range.lowerBound) - let data = try handle.read(upToCount: Int(toRead)) ?? Data() + + // Use the pooled handle with synchronized seek+read to avoid concurrent seeks interfering. + // This is MUCH faster than opening/closing a new FileHandle for every read. + readHandleLock.lock() + let data: Data + do { + let handle = try acquireReadHandleUnlocked() + try handle.seek(toOffset: range.lowerBound) + data = try handle.read(upToCount: Int(toRead)) ?? Data() + readHandleLock.unlock() + } catch { + readHandleLock.unlock() + throw error + } + // Strip trailing newline if present let trimmed: Data if data.last == newline { @@ -118,4 +225,22 @@ final class JSONLIndex { } return String(data: trimmed, encoding: .utf8) } + + /// Internal unlocked version - caller must hold readHandleLock + private func acquireReadHandleUnlocked() throws -> FileHandle { + // Reuse existing handle if under the use limit + if let handle = cachedReadHandle, readHandleUseCount < maxHandleUses { + readHandleUseCount += 1 + return handle + } + + // Close old handle if exists + try? cachedReadHandle?.close() + + // Open fresh handle + let handle = try FileHandle(forReadingFrom: url) + cachedReadHandle = handle + readHandleUseCount = 1 + return handle + } } \ No newline at end of file diff --git a/JSONViewer/Core/JSONTree.swift b/JSONViewer/Core/JSONTree.swift index c2e4bd7..c6f00f0 100644 --- a/JSONViewer/Core/JSONTree.swift +++ b/JSONViewer/Core/JSONTree.swift @@ -85,28 +85,89 @@ extension JSONTreeNode { } } + /// Full JSON string for this node. func asJSONString() -> String { + return asJSONString(limit: nil) + } + + /// JSON string for this node, optionally truncated to a maximum number of characters. + /// When truncated, the returned string will end with "… (truncated)" and may not be valid JSON. + func asJSONString(limit: Int?) -> String { + var output = "" + var remaining = limit ?? Int.max + var truncated = false + + writeJSON(into: &output, remaining: &remaining, truncated: &truncated) + + if truncated, limit != nil { + output.append("… (truncated)") + } + return output + } + + private func writeJSON(into output: inout String, remaining: inout Int, truncated: inout Bool) { + if truncated || remaining <= 0 { + truncated = true + return + } + if let scalar { switch scalar { - case .string(let s): return "\"\(s)\"" - case .number(let d): return String(d) - case .bool(let b): return b ? "true" : "false" - case .null: return "null" + case .string(let s): + append("\"\(s)\"", to: &output, remaining: &remaining, truncated: &truncated) + case .number(let d): + append(String(d), to: &output, remaining: &remaining, truncated: &truncated) + case .bool(let b): + append(b ? "true" : "false", to: &output, remaining: &remaining, truncated: &truncated) + case .null: + append("null", to: &output, remaining: &remaining, truncated: &truncated) } - } else if let children { - if children.first?.key?.hasPrefix("[") == true { - // array - let inner = children.map { $0.asJSONString() }.joined(separator: ", ") - return "[\(inner)]" + return + } + + if let children { + let isArray = children.first?.key?.hasPrefix("[") == true + if isArray { + append("[", to: &output, remaining: &remaining, truncated: &truncated) + for (index, child) in children.enumerated() { + if truncated || remaining <= 0 { break } + if index > 0 { + append(", ", to: &output, remaining: &remaining, truncated: &truncated) + } + child.writeJSON(into: &output, remaining: &remaining, truncated: &truncated) + } + append("]", to: &output, remaining: &remaining, truncated: &truncated) } else { - let inner = children.map { child in + append("{", to: &output, remaining: &remaining, truncated: &truncated) + for (index, child) in children.enumerated() { + if truncated || remaining <= 0 { break } + if index > 0 { + append(", ", to: &output, remaining: &remaining, truncated: &truncated) + } let key = child.key ?? "" - return "\"\(key)\": \(child.asJSONString())" - }.joined(separator: ", ") - return "{\(inner)}" + append("\"\(key)\": ", to: &output, remaining: &remaining, truncated: &truncated) + child.writeJSON(into: &output, remaining: &remaining, truncated: &truncated) + } + append("}", to: &output, remaining: &remaining, truncated: &truncated) } } else { - return "null" + append("null", to: &output, remaining: &remaining, truncated: &truncated) + } + } + + private func append(_ string: String, to output: inout String, remaining: inout Int, truncated: inout Bool) { + if truncated || remaining <= 0 { + truncated = true + return + } + if string.count <= remaining { + output.append(string) + remaining -= string.count + } else { + let index = string.index(string.startIndex, offsetBy: remaining) + output.append(contentsOf: string[string.startIndex..CFBundleTypeName JSON Document CFBundleTypeRole - Viewer + Editor + LSHandlerRank + Alternate LSItemContentTypes public.json @@ -34,7 +36,9 @@ CFBundleTypeName JSON Lines Document CFBundleTypeRole - Viewer + Editor + LSHandlerRank + Owner LSItemContentTypes org.jsonlines.jsonl @@ -53,15 +57,20 @@ UTTypeConformsTo public.text + public.data UTTypeTagSpecification public.filename-extension jsonl + ndjson public.mime-type - application/x-ndjson + + application/x-ndjson + application/jsonl + diff --git a/JSONViewer/UI/AISidebarView.swift b/JSONViewer/UI/AISidebarView.swift index 264d33f..039952f 100644 --- a/JSONViewer/UI/AISidebarView.swift +++ b/JSONViewer/UI/AISidebarView.swift @@ -1,98 +1,10 @@ import SwiftUI +// AI features have been removed. This file is kept as a placeholder. struct AISidebarView: View { @ObservedObject var viewModel: AppViewModel var body: some View { - VStack(spacing: 0) { - header - Divider() - ScrollViewReader { proxy in - ScrollView { - VStack(alignment: .leading, spacing: 12) { - ForEach(viewModel.aiMessages) { msg in - messageBubble(msg) - .id(msg.id) - } - if let streaming = viewModel.aiStreamingText, !streaming.isEmpty { - // Show live streaming text - messageBubble(.init(role: "assistant", text: streaming)) - } - } - .padding(12) - } - .onChange(of: viewModel.aiMessages.count) { _ in - if let last = viewModel.aiMessages.last { proxy.scrollTo(last.id, anchor: .bottom) } - } - } - } - .frame(minWidth: 280, idealWidth: 320, maxWidth: 420) + EmptyView() } - - private var header: some View { - HStack(spacing: 8) { - Label("AI", systemImage: "brain.head.profile") - .font(.system(size: 13, weight: .semibold)) - - // Show concise AI status or AI-specific statusMessage (e.g. "AI error: ...") - Group { - let banner: String? = { - if !viewModel.aiStatus.isEmpty { return viewModel.aiStatus } - if let m = viewModel.statusMessage, m.hasPrefix("AI") { return m } - return nil - }() - if let b = banner { - Text(b) - .font(.system(size: 12)) - .foregroundStyle(.secondary) - .lineLimit(1) - .truncationMode(.tail) - } - } - - Spacer() - if viewModel.aiIsStreaming { - ProgressView() - .scaleEffect(0.7) - } - Button { - viewModel.cancelAIStream() - } label: { - Image(systemName: "stop.circle") - } - .help("Stop streaming") - .disabled(!viewModel.aiIsStreaming) - - Button { - viewModel.clearAIConversation() - } label: { - Image(systemName: "trash") - } - .help("Clear conversation") - } - .padding(8) - } - - private func messageBubble(_ msg: AppViewModel.AIMessage) -> some View { - HStack { - if msg.role == "assistant" { - bubble(text: msg.text, color: Color(NSColor.windowBackgroundColor), align: .leading) - Spacer(minLength: 0) - } else { - Spacer(minLength: 0) - bubble(text: msg.text, color: Color.accentColor.opacity(0.18), align: .trailing) - } - } - } - - private enum Align { case leading, trailing } - private func bubble(text: String, color: Color, align: Align) -> some View { - Text(text) - .font(.system(size: 13)) - .textSelection(.enabled) - .padding(.vertical, 8) - .padding(.horizontal, 10) - .background(color, in: RoundedRectangle(cornerRadius: 8, style: .continuous)) - .frame(maxWidth: .infinity, alignment: align == .leading ? .leading : .trailing) - } -} \ No newline at end of file +} diff --git a/JSONViewer/UI/AppShellView.swift b/JSONViewer/UI/AppShellView.swift index 6ee2070..504d7d0 100644 --- a/JSONViewer/UI/AppShellView.swift +++ b/JSONViewer/UI/AppShellView.swift @@ -10,7 +10,6 @@ struct AppShellView: View { @State private var nsWindow: NSWindow? #endif @State private var isInspectorVisible: Bool = false - @State private var isAISidebarVisible: Bool = false @Environment(\.openWindow) private var openWindow private var displayText: String { @@ -27,7 +26,7 @@ struct AppShellView: View { .navigationSplitViewColumnWidth(min: 220, ideal: 260, max: 340) } detail: { HStack(spacing: 0) { - ZStack(alignment: .bottomLeading) { + VStack(spacing: 0) { Group { switch viewModel.presentation { case .text: @@ -39,6 +38,7 @@ struct AppShellView: View { viewModel.didSelectTreeNode(node) withAnimation { isInspectorVisible = true } } + .id(viewModel.currentTreeRoot?.id) } } .animation(.easeInOut(duration: 0.2), value: viewModel.mode) @@ -46,22 +46,26 @@ struct AppShellView: View { .frame(maxWidth: .infinity, maxHeight: .infinity) if viewModel.mode != .none { - CommandBarContainer( - mode: $viewModel.commandMode, - placeholder: viewModel.commandMode == .jq - ? "jq filter (e.g. .items | length)" - : "Use natural language to search or transform" - ) { text in - switch viewModel.commandMode { - case .jq: + Divider() + HStack { + CommandBarContainer( + text: $viewModel.commandText, + placeholder: "jq filter (e.g. .items | length)" + ) { text in viewModel.runJQ(filter: text) - case .ai: - withAnimation { isAISidebarVisible = true } - viewModel.runAI(prompt: text) + } onClear: { + viewModel.clearJQFilter() + } onResignFocus: { [weak nsWindow] in + // Clear the NSWindow's first responder so focus is fully released + // and keyboard shortcuts like Cmd+V work for the main view + nsWindow?.makeFirstResponder(nil) } + Spacer() } } } + .frame(maxWidth: .infinity, maxHeight: .infinity) + if isInspectorVisible { Divider() InspectorView(viewModel: viewModel) @@ -69,11 +73,6 @@ struct AppShellView: View { .frame(maxHeight: .infinity) .transition(.move(edge: .trailing).combined(with: .opacity)) } - if isAISidebarVisible { - Divider() - AISidebarView(viewModel: viewModel) - .transition(.move(edge: .trailing).combined(with: .opacity)) - } } .navigationSplitViewColumnWidth(min: 420, ideal: 680, max: .infinity) .navigationTitle(viewModel.fileURL?.lastPathComponent ?? "Prism") @@ -113,16 +112,6 @@ struct AppShellView: View { .keyboardShortcut("i", modifiers: [.command, .option]) .help(isInspectorVisible ? "Hide Inspector (⌥⌘I)" : "Show Inspector (⌥⌘I)") - Button { - withAnimation { - isAISidebarVisible.toggle() - } - } label: { - Label(isAISidebarVisible ? "Hide AI Sidebar" : "Show AI Sidebar", systemImage: "bubble.right") - } - .keyboardShortcut("a", modifiers: [.command, .option]) - .help(isAISidebarVisible ? "Hide AI Sidebar (⌥⌘A)" : "Show AI Sidebar (⌥⌘A)") - if viewModel.fileURL != nil { Button { if let url = viewModel.fileURL { revealInFinder(url) } @@ -265,19 +254,50 @@ struct AppShellView: View { } private struct CommandBarContainer: View { - @Binding var mode: CommandBarView.Mode + @Binding var text: String var placeholder: String var onRun: (String) -> Void + var onClear: () -> Void + var onResignFocus: (() -> Void)? = nil - @State private var textLocal: String = "" + // Use local state as a buffer to avoid focus entanglement with @Published properties. + // The direct binding to viewModel.commandText causes focus to get "stuck" because + // every keystroke triggers objectWillChange which propagates through the view hierarchy. + @State private var localText: String = "" + @State private var hadNonEmptyText: Bool = false + @State private var didInitialize: Bool = false var body: some View { CommandBarView( - mode: $mode, - text: $textLocal, - placeholder: placeholder - ) { - onRun(textLocal) + text: $localText, + placeholder: placeholder, + onRun: { + // Sync local text to viewModel on run + text = localText + onRun(localText) + }, + onResignFocus: onResignFocus + ) + .onAppear { + // Initialize local text from the binding on first appear + if !didInitialize { + localText = text + didInitialize = true + } + } + .onChange(of: text) { newValue in + // Sync external changes (e.g., programmatic clear) to local text + if localText != newValue { + localText = newValue + } + } + .onChange(of: localText) { newValue in + let isEmpty = newValue.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty + if isEmpty && hadNonEmptyText { + text = "" + onClear() + } + hadNonEmptyText = !isEmpty } } } \ No newline at end of file diff --git a/JSONViewer/UI/CommandBarView.swift b/JSONViewer/UI/CommandBarView.swift index a7272b0..d0ac157 100644 --- a/JSONViewer/UI/CommandBarView.swift +++ b/JSONViewer/UI/CommandBarView.swift @@ -1,34 +1,36 @@ import SwiftUI struct CommandBarView: View { - enum Mode: String, CaseIterable, Identifiable { - case jq = "JQ" - case ai = "AI" - var id: String { rawValue } - } - - @Binding var mode: Mode @Binding var text: String var placeholder: String var onRun: () -> Void + var onResignFocus: (() -> Void)? = nil + + @FocusState private var isTextFieldFocused: Bool var body: some View { HStack(spacing: 10) { - Picker("", selection: $mode) { - ForEach(Mode.allCases) { m in - Text(m.rawValue).tag(m) - } - } - .pickerStyle(.segmented) - .frame(width: 90) + Image(systemName: "terminal") + .foregroundStyle(.secondary) + .font(.system(size: 14, weight: .medium)) TextField(placeholder, text: $text) .textFieldStyle(.roundedBorder) .font(.system(size: 14)) - .onSubmit { onRun() } + .focused($isTextFieldFocused) + .onSubmit { + onRun() + // Release focus after submitting so user can interact with the rest of the app + isTextFieldFocused = false + onResignFocus?() + } - Button(action: onRun) { + Button(action: { + onRun() + isTextFieldFocused = false + onResignFocus?() + }) { Image(systemName: "arrow.up.circle.fill") .font(.system(size: 20, weight: .semibold)) } @@ -36,9 +38,14 @@ struct CommandBarView: View { } .padding(.horizontal, 12) .padding(.vertical, 8) - .background(.ultraThinMaterial, in: Capsule()) - .shadow(color: Color.black.opacity(0.12), radius: 8, x: 0, y: 2) .padding(.bottom, 10) .padding(.horizontal, 14) + #if os(macOS) + .onExitCommand { + // Handle Escape key to release focus + isTextFieldFocused = false + onResignFocus?() + } + #endif } } \ No newline at end of file diff --git a/JSONViewer/UI/JSONTreeView.swift b/JSONViewer/UI/JSONTreeView.swift index f39c12d..e2108a4 100644 --- a/JSONViewer/UI/JSONTreeView.swift +++ b/JSONViewer/UI/JSONTreeView.swift @@ -1,32 +1,13 @@ import SwiftUI -struct JSONTreeView: View { - @ObservedObject var viewModel: AppViewModel - let root: JSONTreeNode? - var onSelect: (JSONTreeNode) -> Void - @FocusState private var findFocused: Bool - - // Cache heavy rows computation so arbitrary AppViewModel changes (e.g. typing in AI/JQ) - // don't trigger an expensive full-tree traversal on every keystroke. - @State private var cachedRows: [RowItem] = [] - @State private var lastRootId: UUID? = nil - @State private var lastQuery: String = "" - @State private var lastExpanded: Set = [] - @State private var queryDebounce: Task? = nil - - private struct RowItem: Identifiable, Hashable { - let id: UUID = UUID() - let node: JSONTreeNode - let depth: Int - } - - private func nodeMatches(_ node: JSONTreeNode, query: String) -> Bool { +private struct JSONTreeRowComputation { + static func nodeMatches(_ node: JSONTreeNode, query: String) -> Bool { if query.isEmpty { return true } let text = "\(node.displayKey) \(node.previewValue) \(node.path)".lowercased() return text.contains(query) } - private func ancestorPathsForMatches(root: JSONTreeNode, query: String) -> Set { + static func ancestorPathsForMatches(root: JSONTreeNode, query: String) -> Set { var result: Set = [] guard !query.isEmpty else { return result } let q = query.lowercased() @@ -50,19 +31,16 @@ struct JSONTreeView: View { return result } - private func recomputeRows() { - guard let root else { - cachedRows = [] - lastRootId = nil - return - } - let q = viewModel.treeSearchQuery.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() + static func buildRows(root: JSONTreeNode, + query: String, + expandedPaths: Set) -> [(node: JSONTreeNode, depth: Int)] { + let q = query.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() let autoExpand = ancestorPathsForMatches(root: root, query: q) - let expansionSet = viewModel.expandedPaths.union(autoExpand) + let expansionSet = expandedPaths.union(autoExpand) - var rows: [RowItem] = [] + var rows: [(JSONTreeNode, Int)] = [] func walk(_ node: JSONTreeNode, depth: Int) { - rows.append(RowItem(node: node, depth: depth)) + rows.append((node, depth)) if let children = node.children, expansionSet.contains(node.path) { for c in children { walk(c, depth: depth + 1) @@ -72,15 +50,79 @@ struct JSONTreeView: View { walk(root, depth: 0) if q.isEmpty { - cachedRows = rows + return rows } else { - cachedRows = rows.filter { item in - nodeMatches(item.node, query: q) || autoExpand.contains(item.node.path) + return rows.filter { item in + nodeMatches(item.0, query: q) || autoExpand.contains(item.0.path) + } + } + } +} + +struct JSONTreeView: View { + @ObservedObject var viewModel: AppViewModel + let root: JSONTreeNode? + var onSelect: (JSONTreeNode) -> Void + @FocusState private var findFocused: Bool + + // Cache heavy rows computation so arbitrary AppViewModel changes (e.g. typing in AI/JQ) + // don't trigger an expensive full-tree traversal on every keystroke. + @State private var cachedRows: [RowItem] = [] + @State private var lastRootId: UUID? = nil + @State private var lastQuery: String = "" + @State private var lastExpanded: Set = [] + @State private var queryDebounce: Task? = nil + + private struct RowItem: Identifiable, Hashable { + let id: UUID = UUID() + let node: JSONTreeNode + let depth: Int + } + + // MARK: - Row computation (off-main-thread) + + private func recomputeRows(searchDriven: Bool) { + guard let root else { + cachedRows = [] + lastRootId = nil + return + } + + let query = viewModel.treeSearchQuery + let expanded = viewModel.expandedPaths + + #if DEBUG + let rootIdDescription = root.id.uuidString + print("[Tree] recomputeRows(searchDriven: \(searchDriven)) start, rootId=\(rootIdDescription), selectedRowID=\(String(describing: viewModel.selectedRowID))") + #endif + + queryDebounce?.cancel() + queryDebounce = Task.detached(priority: .userInitiated) { + if searchDriven { + // Small debounce so rapid typing doesn't recompute the whole tree on every keystroke. + try? await Task.sleep(nanoseconds: 120_000_000) + } + if Task.isCancelled { return } + + let tuples = JSONTreeRowComputation.buildRows(root: root, query: query, expandedPaths: expanded) + if Task.isCancelled { return } + + await MainActor.run { + cachedRows = tuples.map { RowItem(node: $0.node, depth: $0.depth) } + lastRootId = root.id + lastQuery = query.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() + lastExpanded = expanded + #if DEBUG + let firstPreview: String + if let first = cachedRows.first { + firstPreview = "\(first.node.displayKey): \(String(first.node.previewValue.prefix(80)))" + } else { + firstPreview = "" + } + print("[Tree] recomputeRows applied, rootId=\(root.id.uuidString), rows=\(cachedRows.count), first=\(firstPreview)") + #endif } } - lastRootId = root.id - lastQuery = q - lastExpanded = viewModel.expandedPaths } private func toggle(_ node: JSONTreeNode) { @@ -103,7 +145,17 @@ struct JSONTreeView: View { TextField("Find in tree", text: $viewModel.treeSearchQuery) .textFieldStyle(.plain) .focused($findFocused) + .onSubmit { + // Release focus on Enter + findFocused = false + } + } + #if os(macOS) + .onExitCommand { + // Release focus on Escape + findFocused = false } + #endif .padding(.vertical, 8) .padding(.horizontal, 12) .background( @@ -178,24 +230,27 @@ struct JSONTreeView: View { findFocused = true } .onChange(of: viewModel.treeSearchQuery) { _ in - // Recompute rows when the shared query changes. - recomputeRows() + // Recompute rows when the shared query changes (debounced, off-main-thread). + recomputeRows(searchDriven: true) } .onChange(of: viewModel.expandedPaths) { _ in - recomputeRows() - } - .onChange(of: root?.id) { _ in - recomputeRows() + recomputeRows(searchDriven: false) } .onAppear { // Do not focus search by default findFocused = false - // Ensure root has an entry to control expansion - if viewModel.expandedPaths.isEmpty { - viewModel.expandedPaths.insert("") - } // Build rows for current query - recomputeRows() + #if DEBUG + if let r = root { + print("[Tree] onAppear with rootId=\\(r.id.uuidString), selectedRowID=\\(String(describing: viewModel.selectedRowID))") + } else { + print("[Tree] onAppear with no root") + } + #endif + recomputeRows(searchDriven: false) + } + .onDisappear { + queryDebounce?.cancel() } } else { VStack(spacing: 6) { @@ -220,20 +275,23 @@ private struct JSONTreeRowView: View { var onToggle: () -> Void var body: some View { - HStack(alignment: .top, spacing: 8) { + HStack(alignment: .center, spacing: 4) { // Indentation - Color.clear.frame(width: CGFloat(depth) * 14, height: 0) + Color.clear.frame(width: CGFloat(depth) * 14, height: 1) + // Chevron toggle with large hit area if node.children != nil { Button(action: onToggle) { Image(systemName: isExpanded ? "chevron.down" : "chevron.right") + .font(.system(size: 11, weight: .semibold)) .foregroundStyle(.secondary) + .frame(width: 28, height: 24) + .contentShape(Rectangle()) } .buttonStyle(.plain) - .keyboardShortcut(.space, modifiers: []) // allows quick toggle when focused } else { // Align with disclosure space - Color.clear.frame(width: 14, height: 0) + Color.clear.frame(width: 28, height: 1) } Text(node.displayKey) diff --git a/JSONViewer/UI/Preferences/PreferencesView.swift b/JSONViewer/UI/Preferences/PreferencesView.swift index 51859af..5152a84 100644 --- a/JSONViewer/UI/Preferences/PreferencesView.swift +++ b/JSONViewer/UI/Preferences/PreferencesView.swift @@ -1,23 +1,9 @@ import SwiftUI struct PreferencesView: View { - @State private var selection: Int = 0 - var body: some View { - TabView(selection: $selection) { - AppearancePreferences() - .tabItem { - Label("Appearance", systemImage: "paintbrush") - } - .tag(0) - - AIPreferences() - .tabItem { - Label("AI", systemImage: "brain.head.profile") - } - .tag(1) - } - .padding(.top, 8) + AppearancePreferences() + .padding(.top, 8) } } diff --git a/JSONViewer/UI/SidebarRowView.swift b/JSONViewer/UI/SidebarRowView.swift index 170e995..d713e35 100644 --- a/JSONViewer/UI/SidebarRowView.swift +++ b/JSONViewer/UI/SidebarRowView.swift @@ -7,16 +7,33 @@ struct SidebarRowView: View { @State private var previewText: String = "Loading…" // Guards against async preview callbacks updating a cell after it's been reused for another id. @State private var requestToken = UUID() + // Task for debounced preview request - cancelled if row scrolls away before firing + @State private var previewTask: Task? private func requestPreview() { // Invalidate any in-flight callback for a previous id let token = UUID() requestToken = token previewText = "Loading…" - viewModel.preview(for: id) { text in - // Apply only if this response corresponds to the most recent request for this cell - if token == requestToken { - previewText = text + + // Cancel any pending debounced request + previewTask?.cancel() + + // Debounce the preview request to skip rows that scroll by quickly. + // This dramatically reduces FileHandle operations during fast scrolling. + previewTask = Task { + // Short delay - if user is scrolling fast, this row will disappear + // before the delay completes and the request will be cancelled + try? await Task.sleep(nanoseconds: 30_000_000) // 30ms + + // Check if still valid after delay + guard !Task.isCancelled, token == requestToken else { return } + + viewModel.preview(for: id) { text in + // Apply only if this response corresponds to the most recent request for this cell + if token == requestToken { + previewText = text + } } } } @@ -36,7 +53,9 @@ struct SidebarRowView: View { requestPreview() } .onDisappear { - // Invalidate callbacks as the cell is leaving the screen + // Cancel pending request and invalidate callbacks as the cell is leaving the screen + previewTask?.cancel() + previewTask = nil requestToken = UUID() } } diff --git a/JSONViewer/UI/SidebarView.swift b/JSONViewer/UI/SidebarView.swift index d4d4f83..b66a19d 100644 --- a/JSONViewer/UI/SidebarView.swift +++ b/JSONViewer/UI/SidebarView.swift @@ -4,6 +4,7 @@ struct SidebarView: View { @ObservedObject var viewModel: AppViewModel @State private var lastRowCount: Int = 0 @State private var isAtBottom: Bool = false + @FocusState private var isSearchFocused: Bool private var filteredRows: [AppViewModel.JSONLRow] { if viewModel.searchText.isEmpty { return viewModel.jsonlRows } @@ -50,6 +51,11 @@ struct SidebarView: View { .foregroundStyle(.secondary) TextField("Search", text: $viewModel.searchText) .textFieldStyle(.plain) + .focused($isSearchFocused) + .onSubmit { + // Release focus on Enter + isSearchFocused = false + } } .padding(.vertical, 8) .padding(.horizontal, 12) @@ -61,6 +67,12 @@ struct SidebarView: View { RoundedRectangle(cornerRadius: 10) .strokeBorder(Color.secondary.opacity(0.25), lineWidth: 1) ) + #if os(macOS) + .onExitCommand { + // Release focus on Escape + isSearchFocused = false + } + #endif } .padding(.horizontal, 12) .padding(.top, 8) @@ -196,8 +208,5 @@ struct SidebarView: View { viewModel.runSidebarSearchDebounced() } } - .onChange(of: viewModel.selectedRowID) { newID in - Task { _ = await viewModel.updateTreeForSelectedRow(selectedID: newID) } - } } } \ No newline at end of file diff --git a/JSONViewer/ViewModels/AppViewModel.swift b/JSONViewer/ViewModels/AppViewModel.swift index 11e27e6..5edd22a 100644 --- a/JSONViewer/ViewModels/AppViewModel.swift +++ b/JSONViewer/ViewModels/AppViewModel.swift @@ -50,34 +50,66 @@ final class AppViewModel: ObservableObject { private let previewCache = NSCache() private let previewSemaphore = DispatchSemaphore(value: 4) - @Published var selectedRowID: Int? + @Published var selectedRowID: Int? { + didSet { + // Only drive JSONL recomputation off selection changes when in JSONL mode. + guard mode == .jsonl, selectedRowID != oldValue else { return } + + // CRITICAL: Capture the value NOW, before the Task starts. + // Reading self.selectedRowID inside the Task creates a race condition + // where rapid clicks cause the wrong row to be displayed. + let capturedID = selectedRowID + selectionVersion += 1 + let capturedVersion = selectionVersion + + #if DEBUG + print("[JSONL] selectedRowID didSet: id=\(String(describing: capturedID)), version=\(capturedVersion), old=\(String(describing: oldValue))") + #endif + + // Cancel any in-flight task immediately + currentComputeTask?.cancel() + + Task { [weak self] in + guard let self else { return } + + // Check if this is still the latest selection before doing expensive work + guard self.selectionVersion == capturedVersion else { + #if DEBUG + print("[JSONL] skipping stale selection version=\(capturedVersion), current=\(self.selectionVersion)") + #endif + return + } + + #if DEBUG + print("[JSONL] selectedRowID didSet Task: updateTreeForSelectedRow(selectedID: \(String(describing: capturedID)), version=\(capturedVersion))") + #endif + _ = await self.updateTreeForSelectedRow(selectedID: capturedID, selectionVersion: capturedVersion) + } + } + } + + // Version counter to track selection ordering and prevent out-of-order updates + private var selectionVersion: Int = 0 @Published var isLoading: Bool = false @Published var statusMessage: String? @Published var searchText: String = "" @Published var indexingProgress: Double? @Published var lastUpdatedAt: Date? + // Original JSON document data (for restoring full view after jq filters) + private var originalJSONData: Data? = nil + init() { - // Prevent unbounded memory growth while scrolling many rows - previewCache.countLimit = 5000 + // Prevent unbounded memory growth while scrolling many rows. + // Increased from 5000 to 10000 to reduce repeated file reads for large files, + // since we now have FileHandle pooling that makes reads much cheaper. + previewCache.countLimit = 10000 + // Also set a memory limit to avoid excessive memory usage (50MB max) + previewCache.totalCostLimit = 50 * 1024 * 1024 } - // Command bar - @Published var commandMode: CommandBarView.Mode = .jq + // Command bar (jq filter) @Published var commandText: String = "" - @Published var aiStatus: String = "" - - // AI conversation - struct AIMessage: Identifiable, Hashable { - let id = UUID() - let role: String // "user" | "assistant" - let text: String - let date: Date = Date() - } - @Published var aiMessages: [AIMessage] = [] - @Published var aiStreamingText: String? = nil - @Published var aiIsStreaming: Bool = false - private var aiStreamTask: Task? // Sidebar (JSONL) search @Published var sidebarFilteredRowIDs: [Int]? = nil @@ -89,6 +121,7 @@ final class AppViewModel: ObservableObject { private var indexTask: Task? private var fileWatcher: FileWatcher? private var fileChangeDebounce: Task? + private var inspectorSelectionID: Int = 0 #if os(macOS) private var securityScopedURL: URL? #endif @@ -98,6 +131,8 @@ final class AppViewModel: ObservableObject { return jsonlRows.first(where: { $0.id == id }) } + + func clear() { mode = .none presentation = .tree @@ -130,6 +165,8 @@ final class AppViewModel: ObservableObject { sidebarSearchTask = nil sidebarSearchDebounce?.cancel() sidebarSearchDebounce = nil + inspectorSelectionID = 0 + originalJSONData = nil #if os(macOS) securityScopedURL?.stopAccessingSecurityScopedResource() securityScopedURL = nil @@ -161,6 +198,8 @@ final class AppViewModel: ObservableObject { self.presentation = .tree self.mode = .json self.statusMessage = "Pasted JSON" + self.originalJSONData = data + if self.expandedPaths.isEmpty { self.expandedPaths.insert("") } } } return @@ -221,6 +260,7 @@ final class AppViewModel: ObservableObject { self.mode = .json self.statusMessage = "Loaded JSON (\(self.formattedByteCount(data.count)))" if self.expandedPaths.isEmpty { self.expandedPaths.insert("") } // default expand root + self.originalJSONData = data } } } catch { @@ -246,30 +286,41 @@ final class AppViewModel: ObservableObject { Task { @MainActor in self?.indexingProgress = progress self?.statusMessage = "Indexing… \(Int(progress * 100))%" - self?.lastUpdatedAt = Date() } }, onUpdate: { count in + // Only update the row count during indexing - don't trigger tree builds + // Tree will be built once at the end when indexing completes Task { @MainActor in - guard let self else { return } - self.jsonlRowCount = count - self.lastUpdatedAt = Date() - if self.selectedRowID == nil && count > 0 { - self.selectedRowID = 0 - Task { _ = await self.updateTreeForSelectedRow() } - } else if let sel = self.selectedRowID, sel < count { - // If the selected row becomes available during indexing, update view - Task { _ = await self.updateTreeForSelectedRow() } - } + self?.jsonlRowCount = count } }, shouldCancel: { Task.isCancelled }) + + // Indexing complete - now select row 0 and build the tree ONCE await MainActor.run { - self?.statusMessage = "Indexed \(index.lineCount) rows" - self?.lastUpdatedAt = Date() + guard let self else { return } + #if DEBUG + print("[JSONL] index build completed: lineCount=\(index.lineCount)") + #endif + self.jsonlRowCount = index.lineCount + self.statusMessage = "Indexed \(index.lineCount) rows" + self.lastUpdatedAt = Date() + self.indexingProgress = nil + + // Auto-select first row and build tree only after indexing is fully complete + if self.selectedRowID == nil && index.lineCount > 0 { + self.selectedRowID = 0 + } else if self.selectedRowID != nil { + // Refresh tree for already-selected row + Task { _ = await self.updateTreeForSelectedRow() } + } } } catch { // Treat cancellation as non-fatal (likely caused by quick file close/open or replacement) if (error as? CancellationError) == nil { await MainActor.run { + #if DEBUG + print("[JSONL] index build failed: \(error.localizedDescription)") + #endif self?.statusMessage = "Failed to index JSONL" } } @@ -310,70 +361,167 @@ final class AppViewModel: ObservableObject { @discardableResult func updateTreeForSelectedRow() async -> Bool { // Forward to the explicit-id variant using the current selection. - return await updateTreeForSelectedRow(selectedID: selectedRowID) + // Capture the current version for external callers (e.g., file refresh). + return await updateTreeForSelectedRow(selectedID: selectedRowID, selectionVersion: selectionVersion) } // Explicit-id variant used by views to avoid any timing race on reading selectedRowID. + // The selectionVersion parameter allows callers to pass the version at the time of selection, + // preventing out-of-order updates when rapid selections occur. @discardableResult - func updateTreeForSelectedRow(selectedID: Int?) async -> Bool { + func updateTreeForSelectedRow(selectedID: Int?, selectionVersion startVersion: Int) async -> Bool { + #if DEBUG + print("[JSONL] updateTreeForSelectedRow(entry): selectedID=\(String(describing: selectedID)), version=\(startVersion), mode=\(mode)") + #endif // Only valid for JSONL mode with a concrete selection - guard mode == .jsonl, let id = selectedID else { return false } + guard mode == .jsonl, let id = selectedID else { + #if DEBUG + print("[JSONL] updateTreeForSelectedRow: early-exit (mode=\(mode), selectedID=\(String(describing: selectedID)))") + #endif + return false + } + + // Early exit if selection has already changed + guard selectionVersion == startVersion else { + #if DEBUG + print("[JSONL] updateTreeForSelectedRow: stale version \(startVersion), current \(selectionVersion)") + #endif + return false + } // Cancel any in-flight compute and start a fresh one for the current selection + if currentComputeTask != nil { + #if DEBUG + print("[JSONL] updateTreeForSelectedRow: cancelling previous compute task for id=\(id)") + #endif + } currentComputeTask?.cancel() // File-backed JSONL if let index = jsonlIndex { // Only proceed if the line slice is available (during indexing this may be false) - guard index.sliceRange(forLine: id) != nil else { return false } + let hasSlice = index.sliceRange(forLine: id) != nil + #if DEBUG + print("[JSONL] updateTreeForSelectedRow(file-backed): id=\(id), version=\(startVersion), hasSlice=\(hasSlice), rowCount=\(jsonlRowCount)") + #endif + guard hasSlice else { return false } let selectionAtStart = id currentComputeTask = Task.detached(priority: .userInitiated) { [weak self] in + #if DEBUG + print("[JSONL] compute task started for id=\(selectionAtStart), version=\(startVersion)") + #endif + + // Check version before I/O + guard let self else { return } if Task.isCancelled { return } + guard await self.selectionVersion == startVersion else { + #if DEBUG + print("[JSONL] compute task aborting (pre-IO): stale version \(startVersion)") + #endif + return + } + let raw = (try? index.readLine(at: selectionAtStart, maxBytes: nil)) ?? "" + #if DEBUG + print("[JSONL] readLine completed for id=\(selectionAtStart), rawLength=\(raw.count)") + #endif + + // Check version after I/O if Task.isCancelled { return } + guard await self.selectionVersion == startVersion else { + #if DEBUG + print("[JSONL] compute task aborting (post-IO): stale version \(startVersion)") + #endif + return + } let data = raw.data(using: .utf8) ?? Data() let pretty = (try? JSONPrettyPrinter.pretty(data: data)) ?? raw let tree = try? JSONTreeBuilder.build(from: data) await MainActor.run { - guard let self else { return } - // Drop stale results: only apply if selection and index are still current - guard !Task.isCancelled, - self.mode == .jsonl, - self.selectedRowID == selectionAtStart, - self.jsonlIndex === index else { return } + let currentSel = self.selectedRowID + let currentVersion = self.selectionVersion + let isModeJSONL = self.mode == .jsonl + let sameIndex = self.jsonlIndex === index + let notCancelled = !Task.isCancelled + #if DEBUG + print("[JSONL] compute task finishing for id=\(selectionAtStart): currentSel=\(String(describing: currentSel)), version=\(startVersion)/\(currentVersion), notCancelled=\(notCancelled), isModeJSONL=\(isModeJSONL), sameIndex=\(sameIndex)") + #endif + // Drop stale results: only apply if selection, version, and index are still current + guard notCancelled, + isModeJSONL, + currentVersion == startVersion, + currentSel == selectionAtStart, + sameIndex else { return } self.prettyJSON = pretty self.currentTreeRoot = tree self.presentation = .tree if self.expandedPaths.isEmpty { self.expandedPaths.insert("") } + #if DEBUG + let snippet = pretty.replacingOccurrences(of: "\n", with: " ") + let preview = String(snippet.prefix(120)) + print("[JSONL] applied tree for id=\(selectionAtStart), preview=\(preview)") + #endif } } return true } // Pasted JSONL - guard let row = selectedRow else { return false } + guard let row = selectedRow else { + #if DEBUG + print("[JSONL] updateTreeForSelectedRow(pasted): no selectedRow for id=\(id)") + #endif + return false + } let raw = row.raw let selectionAtStart = id currentComputeTask = Task.detached(priority: .userInitiated) { [weak self] in + #if DEBUG + print("[JSONL] compute task started (pasted) for id=\(selectionAtStart), version=\(startVersion)") + #endif + + guard let self else { return } if Task.isCancelled { return } + + // Check version before expensive work + guard await self.selectionVersion == startVersion else { + #if DEBUG + print("[JSONL] compute task aborting (pasted pre-compute): stale version \(startVersion)") + #endif + return + } + let data = raw.data(using: .utf8) ?? Data() let pretty = (try? JSONPrettyPrinter.pretty(data: data)) ?? raw let tree = try? JSONTreeBuilder.build(from: data) + await MainActor.run { - guard let self else { return } + let currentSel = self.selectedRowID + let currentVersion = self.selectionVersion + let isModeJSONL = self.mode == .jsonl + let isFileBacked = self.jsonlIndex != nil + let notCancelled = !Task.isCancelled + #if DEBUG + print("[JSONL] compute task finishing (pasted) for id=\(selectionAtStart): currentSel=\(String(describing: currentSel)), version=\(startVersion)/\(currentVersion), notCancelled=\(notCancelled), isModeJSONL=\(isModeJSONL), isFileBacked=\(isFileBacked)") + #endif // Drop stale results: only apply if selection is still the same and we're still in JSONL paste mode - guard !Task.isCancelled, - self.mode == .jsonl, - self.jsonlIndex == nil, - self.selectedRowID == selectionAtStart else { return } + guard notCancelled, + isModeJSONL, + currentVersion == startVersion, + !isFileBacked, + currentSel == selectionAtStart else { return } self.prettyJSON = pretty self.currentTreeRoot = tree self.presentation = .tree + if self.expandedPaths.isEmpty { self.expandedPaths.insert("") } + #if DEBUG + print("[JSONL] applied tree (pasted) for id=\(selectionAtStart)") + #endif } } return true @@ -381,6 +529,12 @@ final class AppViewModel: ObservableObject { func didSelectTreeNode(_ node: JSONTreeNode) { inspectorPath = node.path.isEmpty ? "(root)" : node.path + inspectorSelectionID &+= 1 + let selectionID = inspectorSelectionID + #if DEBUG + print("[Inspector] didSelectTreeNode: path=\(inspectorPath), selectionID=\(selectionID), isScalar=\(node.scalar != nil)") + #endif + if let scalar = node.scalar { switch scalar { case .string(let s): @@ -393,7 +547,37 @@ final class AppViewModel: ObservableObject { inspectorValueText = "null" } } else { - inspectorValueText = node.asJSONString() + // For large objects/arrays, compute the JSON text off the main actor and + // truncate very deep structures to keep the UI responsive. + inspectorValueText = "Loading…" + let nodeSnapshot = node + Task.detached(priority: .userInitiated) { [weak self] in + #if DEBUG + print("[Inspector] task started: selectionID=\(selectionID), path=\(nodeSnapshot.path)") + #endif + let text = nodeSnapshot.asJSONString(limit: 200_000) + #if DEBUG + print("[Inspector] task finished compute: selectionID=\(selectionID), textLength=\(text.count)") + #endif + if Task.isCancelled { return } + await MainActor.run { + guard let self else { return } + #if DEBUG + print("[Inspector] main apply check: currentSelectionID=\(self.inspectorSelectionID), taskSelectionID=\(selectionID), inspectorPath=\(self.inspectorPath)") + #endif + // Only apply the result if the selection hasn't changed. + guard self.inspectorSelectionID == selectionID else { + #if DEBUG + print("[Inspector] skipping stale inspector update for selectionID=\(selectionID)") + #endif + return + } + self.inspectorValueText = text + #if DEBUG + print("[Inspector] applied inspector text for selectionID=\(selectionID)") + #endif + } + } } #if os(macOS) NSPasteboard.general.clearContents() @@ -404,10 +588,10 @@ final class AppViewModel: ObservableObject { #if os(macOS) let text: String switch mode { - case .json: - text = presentation == .text ? prettyJSON : (currentTreeRoot?.asJSONString() ?? prettyJSON) - case .jsonl: - text = presentation == .text ? prettyJSON : (currentTreeRoot?.asJSONString() ?? prettyJSON) + case .json, .jsonl: + // Always use the pretty-printed representation for copy to keep this fast, + // regardless of whether the user is in text or tree presentation. + text = prettyJSON case .none: text = "" } @@ -450,6 +634,7 @@ final class AppViewModel: ObservableObject { withAnimation(.easeInOut(duration: 0.15)) { self.prettyJSON = pretty self.currentTreeRoot = tree + self.originalJSONData = data } // If inspector path refers to a node, refresh its value too self.refreshInspectorFromCurrentPath() @@ -642,8 +827,14 @@ final class AppViewModel: ObservableObject { private func currentDocumentDataForJQ() throws -> (Data, JQInputKind) { switch mode { case .json: - let json = currentTreeRoot?.asJSONString() ?? prettyJSON - return (json.data(using: .utf8) ?? Data(), .json) + if let data = originalJSONData { + return (data, .json) + } else { + // Fall back to the pretty-printed text representation rather than + // rebuilding from the tree to avoid walking very large structures. + let data = prettyJSON.data(using: .utf8) ?? Data() + return (data, .json) + } case .jsonl: if let url = fileURL, FileManager.default.fileExists(atPath: url.path) { let data = try Data(contentsOf: url, options: [.mappedIfSafe]) @@ -657,6 +848,35 @@ final class AppViewModel: ObservableObject { } } + func clearJQFilter() { + switch mode { + case .json: + guard let data = originalJSONData else { return } + isLoading = false + statusMessage = nil + currentComputeTask?.cancel() + currentComputeTask = Task.detached(priority: .userInitiated) { [weak self] in + let pretty = (try? JSONPrettyPrinter.pretty(data: data)) ?? (String(data: data, encoding: .utf8) ?? "") + let tree = try? JSONTreeBuilder.build(from: data) + await MainActor.run { + guard let self else { return } + self.prettyJSON = pretty + self.currentTreeRoot = tree + self.presentation = .tree + if self.expandedPaths.isEmpty { self.expandedPaths.insert("") } + } + } + case .jsonl: + isLoading = false + statusMessage = nil + Task { + _ = await self.updateTreeForSelectedRow() + } + case .none: + break + } + } + func writeCurrentDocumentToTmp() throws -> URL { let tmp = URL(fileURLWithPath: NSTemporaryDirectory()).appendingPathComponent("prism-work", isDirectory: true) try? FileManager.default.createDirectory(at: tmp, withIntermediateDirectories: true) @@ -665,8 +885,12 @@ final class AppViewModel: ObservableObject { let url = tmp.appendingPathComponent("\(name).\(ext)") switch mode { case .json: - let json = currentTreeRoot?.asJSONString() ?? prettyJSON - try json.data(using: .utf8)?.write(to: url) + if let data = originalJSONData { + try data.write(to: url) + } else { + let data = prettyJSON.data(using: .utf8) ?? Data() + try data.write(to: url) + } case .jsonl: if let f = fileURL, FileManager.default.fileExists(atPath: f.path) { let data = try Data(contentsOf: f, options: [.mappedIfSafe]) @@ -681,252 +905,4 @@ final class AppViewModel: ObservableObject { return url } - func runAI(prompt: String) { - let trimmed = prompt.trimmingCharacters(in: .whitespacesAndNewlines) - guard !trimmed.isEmpty else { return } - guard let apiKey = OpenAIClient.loadAPIKeyFromDefaultsOrEnv() else { - statusMessage = "Missing OpenAI API key (set in Preferences > AI or OPENAI_API_KEY env var)." - // Surface this in the AI sidebar so it's obvious why nothing streamed. - aiMessages.append(AIMessage(role: "assistant", text: "Missing OpenAI API key. Open Preferences → AI and paste your key, or set the OPENAI_API_KEY environment variable in your run scheme.")) - aiIsStreaming = false - aiStreamingText = nil - return - } - - aiMessages.append(AIMessage(role: "user", text: trimmed)) - aiStreamingText = "" - aiIsStreaming = true - aiStatus = "Thinking…" - isLoading = true - - aiStreamTask?.cancel() - aiStreamTask = Task.detached(priority: .userInitiated) { [weak self] in - guard let self else { return } - let systemPrompt = await Self.agentSystemPrompt() - let tools = await Self.toolSchemas() - - do { - let model = "gpt-5" - try await OpenAIStreamClient.streamCreateResponse( - config: .init(apiKey: apiKey, model: model, systemPrompt: systemPrompt), - userText: trimmed, - tools: tools - ) { event in - Task { @MainActor in - switch event { - case .textDelta(let t): - self.aiStreamingText = (self.aiStreamingText ?? "") + t - case .requiresAction(let responseId, let calls): - // Execute tools locally, then stream the continuation - self.statusMessage = "AI using tools…" - #if DEBUG - print("[AI] requiresAction: responseId=\(responseId), calls=\(calls.map { $0.name })") - #endif - Task.detached(priority: .userInitiated) { [weak self] in - guard let self else { return } - var outputs: [OpenAIClient.ToolOutput] = [] - for call in calls { - #if DEBUG - print("[AI] running tool:", call.name) - if call.argumentsJSON.count < 300 { - print("[AI] tool args:", call.argumentsJSON) - } else { - print("[AI] tool args (trunc):", String(call.argumentsJSON.prefix(300)) + "…") - } - #endif - if call.name == "run_jq" { - if let json = call.argumentsJSON.data(using: .utf8), - let dict = try? JSONSerialization.jsonObject(with: json) as? [String: Any], - let filter = dict["filter"] as? String { - do { - let (data, kind) = try await self.currentDocumentDataForJQ() - let result = try JQRunner.run(filter: filter, input: data, kind: kind) - let output = result.stdout - outputs.append(.init(toolCallId: call.id, output: output)) - #if DEBUG - print("[AI] jq stdout len:", output.count) - #endif - // Do not alter the main document view from AI tool runs. - // The tool output will be summarized back in the chat by the model. - } catch { - outputs.append(.init(toolCallId: call.id, output: "{\"error\":\"\(error.localizedDescription)\"}")) - #if DEBUG - print("[AI] jq error:", error.localizedDescription) - #endif - } - } else { - #if DEBUG - print("[AI] run_jq missing/invalid filter argument") - #endif - } - } else if call.name == "run_python" { - if let json = call.argumentsJSON.data(using: .utf8), - let dict = try? JSONSerialization.jsonObject(with: json) as? [String: Any], - let code = dict["code"] as? String { - do { - let inputURL = try await self.writeCurrentDocumentToTmp() - let result = try PythonRunner.run(code: code, inputPath: inputURL) - let desc: [String: Any] = [ - "stdout": result.stdout, - "stderr": result.stderr, - "output_files": result.outputFiles.map { $0.path } - ] - let outputJSON = String(data: try JSONSerialization.data(withJSONObject: desc), encoding: .utf8) ?? "{}" - outputs.append(.init(toolCallId: call.id, output: outputJSON)) - #if DEBUG - print("[AI] python stdout len:", result.stdout.count, "stderr len:", result.stderr.count, "files:", result.outputFiles.count) - #endif - } catch { - outputs.append(.init(toolCallId: call.id, output: "{\"error\":\"\(error.localizedDescription)\"}")) - #if DEBUG - print("[AI] python error:", error.localizedDescription) - #endif - } - } else { - #if DEBUG - print("[AI] run_python missing/invalid code argument") - #endif - } - } else { - #if DEBUG - print("[AI] unknown tool:", call.name) - #endif - } - } - do { - // Start second stream (continuation) visibly - await MainActor.run { - self.aiIsStreaming = true - if self.aiStreamingText == nil { self.aiStreamingText = "" } - self.aiStatus = "Using tools…" - } - #if DEBUG - print("[AI] submitting tool outputs count:", outputs.count, "responseId:", responseId) - #endif - try await OpenAIStreamClient.streamSubmitToolOutputs( - apiKey: apiKey, - model: model, - responseId: responseId, - toolOutputs: outputs - ) { evt in - Task { @MainActor in - switch evt { - case .textDelta(let txt): - #if DEBUG - print("[AI] submit stream textDelta len:", txt.count) - #endif - self.aiStreamingText = (self.aiStreamingText ?? "") + txt - case .completed: - #if DEBUG - print("[AI] submit stream completed") - #endif - if let text = self.aiStreamingText, !text.isEmpty { - self.aiMessages.append(AIMessage(role: "assistant", text: text)) - } - self.aiStreamingText = nil - self.aiIsStreaming = false - self.isLoading = false - self.aiStatus = "" - case .requiresAction: - // Nested tool calls not handled in this first version. - self.statusMessage = "AI requested nested tools; unsupported in this version." - #if DEBUG - print("[AI] nested requiresAction received (not handled)") - #endif - } - } - } - } catch { - await MainActor.run { - self.statusMessage = "AI tool submit error: \(error.localizedDescription)" - self.aiMessages.append(AIMessage(role: "assistant", text: "AI tool submit error: \(error.localizedDescription)")) - self.aiIsStreaming = false - self.aiStreamingText = nil - self.isLoading = false - self.aiStatus = "" - } - #if DEBUG - print("[AI] submit error:", error.localizedDescription) - #endif - } - } - case .completed: - if let text = self.aiStreamingText, !text.isEmpty { - self.aiMessages.append(AIMessage(role: "assistant", text: text)) - } - self.aiStreamingText = nil - self.aiIsStreaming = false - self.isLoading = false - self.aiStatus = "" - } - } - } - } catch { - await MainActor.run { - self.statusMessage = "AI error: \(error.localizedDescription)" - self.aiMessages.append(AIMessage(role: "assistant", text: "AI error: \(error.localizedDescription)")) - self.aiIsStreaming = false - self.aiStreamingText = nil - self.isLoading = false - self.aiStatus = "" - } - } - } - } - - func cancelAIStream() { - aiStreamTask?.cancel() - aiIsStreaming = false - aiStreamingText = nil - aiStatus = "Stopped" - isLoading = false - } - - func clearAIConversation() { - aiMessages.removeAll() - aiStreamingText = nil - aiStatus = "" - } - - private static func agentSystemPrompt() -> String { - """ - You are Prism's data assistant embedded in a macOS JSON/JSONL viewer. - You can: - - run_jq(filter): run a jq program on the CURRENT document (JSON or JSONL). For JSONL, assume input is slurped (-s) into an array of objects. Return the jq output text to the user. - - run_python(code): Run a short Python 3 script against a temporary COPY of the current document located at a path passed as argv[1]; a writable output directory path is provided as argv[2]. Your script should read argv[1] and print results to stdout. If you save files to argv[2], they will be surfaced back to the user. - - Always prefer run_jq for filtering/transforming JSON and JSONL. Use run_python for more complex transformations. - Never modify the original file; operate only on provided temp paths. - Respond concisely. - """ - } - - private static func toolSchemas() -> [[String: Any]] { - // Responses API expects top-level "name" for tools. Keep JSON Schema under "parameters". - let runJQ: [String: Any] = [ - "type": "function", - "name": "run_jq", - "description": "Run a jq filter on the current document (JSON or slurped JSONL). Returns jq output text.", - "parameters": [ - "type": "object", - "properties": [ - "filter": ["type": "string", "description": "jq filter, e.g. .items | length"] - ], - "required": ["filter"] - ] - ] - let runPy: [String: Any] = [ - "type": "function", - "name": "run_python", - "description": "Execute Python 3 code on a temp copy of the current document. Read argv[1]; write optional outputs to argv[2]; print results to stdout.", - "parameters": [ - "type": "object", - "properties": [ - "code": ["type": "string", "description": "Python 3 script source code"] - ], - "required": ["code"] - ] - ] - return [runJQ, runPy] - } } \ No newline at end of file