Files
luma/Luma/JSInspectValueView.swift
Ole André Vadla Ravnås cf94135d6a Scroll REPL to the bottom when expanding output
Follow the new bottom after "Show more"/"Show all" for text and
"Show N more" for JavaScript objects, via a SwiftUI environment
action and a GTK expand callback that re-pins the console.
2026-06-27 19:32:44 +02:00

404 lines
13 KiB
Swift

import Combine
import SwiftUI
import LumaCore
struct JSInspectValueView: View {
let value: JSInspectValue
let sessionID: UUID
let engine: Engine
let selection: Binding<SidebarItemID?>
private let circularTargets: Set<Int>
@StateObject private var anchorStore = CircularAnchorStore()
init(
value: JSInspectValue,
sessionID: UUID,
engine: Engine,
selection: Binding<SidebarItemID?>
) {
self.value = value
self.sessionID = sessionID
self.engine = engine
self.selection = selection
self.circularTargets = JSInspectValueView.collectCircularTargets(in: value)
}
var body: some View {
JSInspectNodeView(
value: value,
depth: 0,
sessionID: sessionID,
engine: engine,
selection: selection
)
.environment(\.circularTargets, circularTargets)
.environmentObject(anchorStore)
.textSelection(.enabled)
.errorPopoverHost()
}
private static func collectCircularTargets(in value: JSInspectValue) -> Set<Int> {
var ids = Set<Int>()
func walk(_ v: JSInspectValue) {
switch v {
case .object(_, let props):
for p in props {
walk(p.key)
walk(p.value)
}
case .array(_, let elements):
for e in elements { walk(e) }
case .map(_, let entries):
for e in entries {
walk(e.key)
walk(e.value)
}
case .set(_, let elements):
for e in elements { walk(e) }
case .circular(let id):
ids.insert(id)
default:
break
}
}
walk(value)
return ids
}
}
private struct JSInspectNodeView: View {
static let chunkSize = 5
let value: JSInspectValue
let depth: Int
let sessionID: UUID
let engine: Engine
let selection: Binding<SidebarItemID?>
@State private var isExpanded: Bool
@State private var childLimit: Int = Self.chunkSize
@Environment(\.replExpandAction) private var onExpand
@Environment(\.errorPresenter) private var errorPresenter
@Environment(\.circularTargets) private var circularTargets
@EnvironmentObject private var anchorStore: CircularAnchorStore
init(
value: JSInspectValue,
depth: Int,
sessionID: UUID,
engine: Engine,
selection: Binding<SidebarItemID?>
) {
self.value = value
self.depth = depth
self.sessionID = sessionID
self.engine = engine
self.selection = selection
self._isExpanded = State(initialValue: depth == 0)
}
var body: some View {
switch value {
case .object(_, let props):
objectView(props)
case .array(_, let elements):
arrayView(elements)
case .map(_, let entries):
mapView(entries)
case .set(_, let elements):
setView(elements)
default:
leafView(value)
}
}
private func objectView(_ props: [JSInspectValue.Property]) -> some View {
DisclosureGroup(
isExpanded: $isExpanded,
content: {
LazyVStack(alignment: .leading, spacing: 2) {
ForEach(0..<min(props.count, childLimit), id: \.self) { idx in
let prop = props[idx]
HStack(alignment: .firstTextBaseline, spacing: 4) {
Text(prop.displayKey + ":")
.foregroundStyle(.green)
JSInspectNodeView(
value: prop.value,
depth: depth + 1,
sessionID: sessionID,
engine: engine,
selection: selection
)
}
}
if props.count > childLimit {
showMoreButton(total: props.count, noun: "properties")
}
}
.padding(.leading, 12)
},
label: {
disclosureLabel(
typeText: "Object{\(props.count)}\(anchorSuffix())",
preview: isExpanded ? nil : inlinePreview(value)
)
}
)
}
private func showMoreButton(total: Int, noun: String) -> some View {
let remaining = total - childLimit
let next = min(Self.chunkSize, remaining)
return Button("Show \(next) more (\(remaining) \(noun) left)…") {
childLimit = min(childLimit + Self.chunkSize, total)
onExpand()
}
.platformLinkButtonStyle()
}
private func disclosureLabel(typeText: String, preview: String?) -> some View {
var attributed = AttributedString(typeText)
attributed.foregroundColor = Color.jsTypeLabel
if let preview {
var previewPart = AttributedString(" \(preview)")
previewPart.foregroundColor = .secondary
attributed.append(previewPart)
}
return Text(attributed)
.lineLimit(1)
.truncationMode(.tail)
}
private func arrayView(_ elements: [JSInspectValue]) -> some View {
DisclosureGroup(
isExpanded: $isExpanded,
content: {
LazyVStack(alignment: .leading, spacing: 2) {
ForEach(0..<min(elements.count, childLimit), id: \.self) { idx in
HStack(alignment: .firstTextBaseline, spacing: 4) {
Text("[\(idx)]")
.foregroundStyle(.secondary)
JSInspectNodeView(
value: elements[idx],
depth: depth + 1,
sessionID: sessionID,
engine: engine,
selection: selection
)
}
}
if elements.count > childLimit {
showMoreButton(total: elements.count, noun: "items")
}
}
.padding(.leading, 12)
},
label: {
disclosureLabel(
typeText: "Array[\(elements.count)]\(anchorSuffix())",
preview: isExpanded ? nil : inlinePreview(value)
)
}
)
}
private func mapView(_ entries: [JSInspectValue.Property]) -> some View {
DisclosureGroup(
isExpanded: $isExpanded,
content: {
LazyVStack(alignment: .leading, spacing: 2) {
ForEach(0..<min(entries.count, childLimit), id: \.self) { idx in
let entry = entries[idx]
HStack(alignment: .firstTextBaseline, spacing: 4) {
JSInspectNodeView(
value: entry.key,
depth: depth + 1,
sessionID: sessionID,
engine: engine,
selection: selection
)
.foregroundStyle(.green)
Text("→")
.foregroundStyle(.secondary)
JSInspectNodeView(
value: entry.value,
depth: depth + 1,
sessionID: sessionID,
engine: engine,
selection: selection
)
}
}
if entries.count > childLimit {
showMoreButton(total: entries.count, noun: "entries")
}
}
.padding(.leading, 12)
},
label: {
disclosureLabel(
typeText: "Map{\(entries.count)}\(anchorSuffix())",
preview: isExpanded ? nil : inlinePreview(value)
)
}
)
}
private func setView(_ elements: [JSInspectValue]) -> some View {
DisclosureGroup(
isExpanded: $isExpanded,
content: {
LazyVStack(alignment: .leading, spacing: 2) {
ForEach(0..<min(elements.count, childLimit), id: \.self) { idx in
HStack(alignment: .firstTextBaseline, spacing: 4) {
Text("•")
.foregroundStyle(.secondary)
JSInspectNodeView(
value: elements[idx],
depth: depth + 1,
sessionID: sessionID,
engine: engine,
selection: selection
)
}
}
if elements.count > childLimit {
showMoreButton(total: elements.count, noun: "items")
}
}
.padding(.leading, 12)
},
label: {
disclosureLabel(
typeText: "Set{\(elements.count)}\(anchorSuffix())",
preview: isExpanded ? nil : inlinePreview(value)
)
}
)
}
@ViewBuilder
private func leafView(_ value: JSInspectValue) -> some View {
switch value {
case .bytes(let bytes):
VStack(alignment: .leading, spacing: 4) {
Text("Bytes(\(bytes.kind.rawValue)[\(bytes.data.count)])")
.foregroundStyle(.mint)
HexView(data: bytes.data)
}
default:
if case .nativePointer = value,
let addr = value.nativePointerAddress
{
Text(value.prettyAttributedDescription())
.fixedSize(horizontal: false, vertical: true)
.pointerActions(
engine: engine,
sessionID: sessionID,
value: String(format: "0x%llx", addr),
address: addr,
selection: selection
)
} else {
Text(value.prettyAttributedDescription())
.fixedSize(horizontal: false, vertical: true)
}
}
}
private func containerId() -> Int? {
switch value {
case .object(let id, _),
.array(let id, _),
.map(let id, _),
.set(let id, _):
return id
default:
return nil
}
}
private func anchorSuffix() -> String {
guard let id = containerId() else { return "" }
guard circularTargets.contains(id) else { return "" }
if !anchorStore.anchoredIds.contains(id) {
anchorStore.anchoredIds.insert(id)
return " *\(id)"
}
return ""
}
private func inlinePreview(_ value: JSInspectValue) -> String? {
switch value {
case .object(_, let props):
let preview =
props.prefix(3)
.map { "\($0.displayKey): \($0.value.inlineDescription)" }
.joined(separator: ", ")
return props.isEmpty ? nil : "{\(preview)\(props.count > 3 ? ", …" : "")}"
case .array(_, let elements):
let preview =
elements.prefix(3)
.map { $0.inlineDescription }
.joined(separator: ", ")
return elements.isEmpty ? nil : "[\(preview)\(elements.count > 3 ? ", …" : "")]"
case .map(_, let entries):
let preview =
entries.prefix(3)
.map { "\($0.key.inlineDescription) => \($0.value.inlineDescription)" }
.joined(separator: ", ")
return entries.isEmpty ? nil : "{\(preview)\(entries.count > 3 ? ", …" : "")}"
case .set(_, let elements):
let preview =
elements.prefix(3)
.map { $0.inlineDescription }
.joined(separator: ", ")
return elements.isEmpty ? nil : "{\(preview)\(elements.count > 3 ? ", …" : "")}"
default:
return nil
}
}
}
private final class CircularAnchorStore: ObservableObject {
@Published var anchoredIds: Set<Int> = []
}
extension EnvironmentValues {
var circularTargets: Set<Int> {
get { self[CircularTargetsKey.self] }
set { self[CircularTargetsKey.self] = newValue }
}
}
private struct CircularTargetsKey: EnvironmentKey {
static let defaultValue: Set<Int> = []
}