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.
404 lines
13 KiB
Swift
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> = []
|
|
}
|