diff --git a/CURRENT_STATE.md b/CURRENT_STATE.md index 2faa972..3125b52 100644 --- a/CURRENT_STATE.md +++ b/CURRENT_STATE.md @@ -89,10 +89,18 @@ toolkit's: detection is capped at 50k events (several rules are O(n²)); past it the trace is fully inspectable but detection is skipped and the UI says so — the O(n) detection rewrite is tracked in #36. - -Still ahead in S3: the full message inspector + session panel (#30), the failure -panel (#31), and search / filter (#32). Interactive open (native dialog + -drag-drop) is deferred to #33 — it needs an ejected runner (see ADR-0006). +- **Message inspector + session panel (#30)** — selecting a timeline row unpacks + the event in the detail pane: normalized fields, the session it correlates + into (transaction id, status, connector, start/stop, event count) with a + jump-to-first-event control, a model-owned disclosure tree over the payload + (`ui.tree` + the ARIA keymap, bounded in depth/breadth/node-count for hostile + input), and the raw OCPP-J array pretty-printed. Jump selects the session's + first event (highlighting it and driving the panels); the literal timeline + viewport scroll rides the same runtime-eject as #33. + +Still ahead in S3: the failure panel (#31) and search / filter (#32). +Interactive open (native dialog + drag-drop) is deferred to #33 — it needs an +ejected runner (see ADR-0006). ## What's next @@ -110,7 +118,7 @@ wall-clock replay, and a headless CLI mode. | `repo` (tooling, CI) | ✅ done for S0 | | `docs` (docs, ADRs) | ✅ done for S0 | | `ocpp` (engine) | ✅ S2 + trusted ingestion (#29); O(n) detection pending (#36) | -| `ui` (native views) | 🚧 shell + timeline (S3, #27–#28); panes + search next | +| `ui` (native views) | 🚧 shell + timeline + message inspector (S3, #27–#28, #30); failure panel + search next | | `capture` (live proxy) | ⬜ not started (S5) | | `cli` (headless) | ⬜ not started (S4) | | `conformance` | ✅ done for S2 (15/15, `contract-v1`) | diff --git a/src/tests.zig b/src/tests.zig index 1579040..653834a 100644 --- a/src/tests.zig +++ b/src/tests.zig @@ -54,6 +54,52 @@ fn findRowWithText(widget: canvas.Widget, text: []const u8) ?canvas.Widget { return null; } +fn countRole(widget: canvas.Widget, role: canvas.WidgetRole) usize { + var n: usize = if (widget.semantics.role == role) 1 else 0; + for (widget.children) |child| n += countRole(child, role); + return n; +} + +/// The first `text` widget whose content starts with `prefix` — the raw JSON +/// view is one text node holding the whole pretty-printed array. +fn findTextPrefix(widget: canvas.Widget, prefix: []const u8) ?canvas.Widget { + if (widget.kind == .text and std.mem.startsWith(u8, widget.text, prefix)) return widget; + for (widget.children) |child| { + if (findTextPrefix(child, prefix)) |found| return found; + } + return null; +} + +/// The first `treeitem` row whose own label text equals `key` — used to grab a +/// payload-tree row (flat rows, so a row's subtree holds only its own texts). +fn findTreeItem(widget: canvas.Widget, key: []const u8) ?canvas.Widget { + if (widget.semantics.role == .treeitem and findByText(widget, .text, key) != null) return widget; + for (widget.children) |child| { + if (findTreeItem(child, key)) |found| return found; + } + return null; +} + +/// A single-Call trace whose MeterValues payload exercises every tree shape: +/// scalars, a nested object, an array, an over-deep chain, and an over-wide +/// array. `wide` holds 50 elements (past the breadth bound of 40); `deep` +/// nests 6 levels (past the depth bound). Caller owns nothing — `openBytes` +/// copies it. +fn nestedTraceBytes(a: std.mem.Allocator) ![]const u8 { + var buf: std.ArrayList(u8) = .empty; + try buf.appendSlice(a, "{\"events\":[{\"message\":[2,\"m1\",\"MeterValues\",{" ++ + "\"connectorId\":1," ++ + "\"meterValue\":[{\"timestamp\":\"2024-01-01T00:00:00Z\",\"sampledValue\":[{\"value\":\"42.5\",\"unit\":\"Wh\"}]}]," ++ + "\"deep\":{\"a\":{\"b\":{\"c\":{\"d\":{\"e\":{\"f\":\"tooDeep\"}}}}}}," ++ + "\"wide\":["); + for (0..50) |i| { + if (i > 0) try buf.appendSlice(a, ","); + try buf.append(a, '0' + @as(u8, @intCast(i % 10))); + } + try buf.appendSlice(a, "]}]}]}"); + return buf.items; +} + test "the empty workspace offers the open-sample affordance" { var arena_state = std.heap.ArenaAllocator.init(testing.allocator); defer arena_state.deinit(); @@ -112,6 +158,117 @@ test "clicking a timeline row selects the event and the detail pane reflects it" _ = try expectByText(tree.root, .text, "Message ID"); // a detail-row label } +test "selecting an event renders every message-inspector section" { + var arena_state = std.heap.ArenaAllocator.init(testing.allocator); + defer arena_state.deinit(); + const arena = arena_state.allocator(); + + var model = Model{ .backing = testing.allocator }; + defer model.deinitAll(); + workspace.update(&model, .open_sample); + workspace.update(&model, .{ .select_event = 0 }); + + const tree = try buildTree(arena, &model); + _ = try expectByText(tree.root, .text, "Details"); + _ = try expectByText(tree.root, .text, "Session"); + _ = try expectByText(tree.root, .text, "Payload"); + _ = try expectByText(tree.root, .text, "Raw message"); + _ = try expectByText(tree.root, .button, "Jump to first event"); + // The raw view pretty-prints the OCPP-J array into one text node opening + // with the array bracket on its own line. + const raw = findTextPrefix(tree.root, "[\n") orelse return error.WidgetNotFound; + try testing.expect(std.mem.indexOf(u8, raw.text, "BootNotification") != null); + // The payload renders as a disclosure tree with at least one treeitem row. + try testing.expect(countRole(tree.root, .treeitem) > 0); +} + +test "the session panel reflects the event's session and jumps to the first event" { + var arena_state = std.heap.ArenaAllocator.init(testing.allocator); + defer arena_state.deinit(); + const arena = arena_state.allocator(); + + var model = Model{ .backing = testing.allocator }; + defer model.deinitAll(); + workspace.update(&model, .open_sample); + // Select a mid-session event (the StopTransaction Call, well past event 0). + workspace.update(&model, .{ .select_event = 12 }); + + var tree = try buildTree(arena, &model); + // The sample's one session carries transactionId 100001 and completed. + _ = try expectByText(tree.root, .text, "100001"); + _ = try expectByText(tree.root, .text, "completed"); + + // Jump-to-first-event selects the session's first event (the BootNotification). + const jump = try expectByText(tree.root, .button, "Jump to first event"); + main.update(&model, tree.msgForPointer(jump.id, .up).?); + try testing.expectEqual(@as(?usize, 0), model.activeTrace().?.selected_event); + + tree = try buildTree(arena, &model); + _ = try expectByText(tree.root, .text, "evt-0001"); +} + +test "the payload tree renders object, array, and scalar shapes" { + var arena_state = std.heap.ArenaAllocator.init(testing.allocator); + defer arena_state.deinit(); + const arena = arena_state.allocator(); + + var model = Model{ .backing = testing.allocator }; + defer model.deinitAll(); + model.openBytes("nested.json", try nestedTraceBytes(arena)); + workspace.update(&model, .{ .select_event = 0 }); + + const tree = try buildTree(arena, &model); + // Object field (scalar), a nested array, and an array-index row. + _ = try expectByText(tree.root, .text, "connectorId"); + _ = try expectByText(tree.root, .text, "meterValue"); + _ = try expectByText(tree.root, .text, "sampledValue"); + // A scalar string leaf renders quoted; a number renders bare. + _ = try expectByText(tree.root, .text, "\"Wh\""); + _ = try expectByText(tree.root, .text, "1"); // connectorId's value +} + +test "the payload tree bounds depth and breadth" { + var arena_state = std.heap.ArenaAllocator.init(testing.allocator); + defer arena_state.deinit(); + const arena = arena_state.allocator(); + + var model = Model{ .backing = testing.allocator }; + defer model.deinitAll(); + model.openBytes("nested.json", try nestedTraceBytes(arena)); + workspace.update(&model, .{ .select_event = 0 }); + + const tree = try buildTree(arena, &model); + // The over-deep chain stops before its leaf: "tooDeep" never renders. + try testing.expect(findByText(tree.root, .text, "\"tooDeep\"") == null); + // The over-wide array shows a truncation marker instead of all 50 elements. + try testing.expect(findByText(tree.root, .text, "... 10 more") != null); + // The whole tree stays within the tracked node budget. + try testing.expect(countRole(tree.root, .treeitem) <= workspace.max_payload_tree_nodes); +} + +test "toggling a payload container collapses its children" { + var arena_state = std.heap.ArenaAllocator.init(testing.allocator); + defer arena_state.deinit(); + const arena = arena_state.allocator(); + + var model = Model{ .backing = testing.allocator }; + defer model.deinitAll(); + model.openBytes("nested.json", try nestedTraceBytes(arena)); + workspace.update(&model, .{ .select_event = 0 }); + + var tree = try buildTree(arena, &model); + // "timestamp" lives under meterValue → visible while expanded. + _ = try expectByText(tree.root, .text, "timestamp"); + const mv = findTreeItem(tree.root, "meterValue") orelse return error.WidgetNotFound; + + // Collapse meterValue through its row's on_press. + main.update(&model, tree.msgForPointer(mv.id, .up).?); + tree = try buildTree(arena, &model); + // meterValue itself stays; its descendant "timestamp" is gone. + _ = try expectByText(tree.root, .text, "meterValue"); + try testing.expect(findByText(tree.root, .text, "timestamp") == null); +} + test "the virtual window stays viewport-sized at dataset scale" { var arena_state = std.heap.ArenaAllocator.init(testing.allocator); defer arena_state.deinit(); @@ -183,6 +340,10 @@ test "the inspector view passes the accessibility sweep (empty and loaded)" { workspace.update(&model, .open_sample); // loaded state tree = try buildTree(arena, &model); try canvas.expectA11yAuditSweepClean(testing.allocator, tree.root, sweep); + + workspace.update(&model, .{ .select_event = 5 }); // detail pane: tree, session, raw + tree = try buildTree(arena, &model); + try canvas.expectA11yAuditSweepClean(testing.allocator, tree.root, sweep); } test "the inspector view lays out through the canvas engine" { diff --git a/src/ui/inspector.zig b/src/ui/inspector.zig index 803a1ed..506ae32 100644 --- a/src/ui/inspector.zig +++ b/src/ui/inspector.zig @@ -244,18 +244,62 @@ fn formatTime(arena: std.mem.Allocator, ts: ?i64) []const u8 { }) catch "--"; } -// --- detail pane: minimal for now; #30 enriches it ------------------------- +// --- detail pane: message inspector + session panel (#30) ------------------ +// +// The selected event, fully unpacked: its normalized fields, the session it +// correlates into (with a jump-to-first-event control), a disclosure tree over +// the payload, and the raw OCPP-J array pretty-printed. The whole pane scrolls. + +/// Payload-tree display bounds (the id space is `workspace.max_payload_tree_nodes`). +/// Depth/breadth keep a hostile payload from ballooning the widget-node budget; +/// past them the tree shows a compact "... N more" / "... truncated" marker. +const max_payload_tree_depth: usize = 6; +const max_payload_tree_breadth: usize = 40; +/// Per-row indentation (points) for the flat disclosure tree. +const tree_indent: f32 = 14; +/// Cap on the pretty-printed raw JSON so one event can't produce a giant text +/// layout; payloads are small, this only bites pathological input. +const max_raw_bytes: usize = 8 * 1024; fn detailPane(ui: *Ui, t: *const LoadedTrace) Node { - if (t.selected_event) |idx| { - if (idx < t.events.len) return eventDetail(ui, t.events[idx]); - } - return ui.column(.{ .min_width = 280, .grow = 1, .main = .center, .cross = .center, .padding = 24 }, .{ + const idx = t.selected_event orelse return detailPlaceholder(ui); + if (idx >= t.events.len) return detailPlaceholder(ui); + return ui.scroll(.{ .min_width = 300, .grow = 1 }, .{ + ui.column(.{ .gap = 14, .padding = 16 }, .{ + eventDetail(ui, t, idx), + }), + }); +} + +fn detailPlaceholder(ui: *Ui) Node { + return ui.column(.{ .min_width = 300, .grow = 1, .main = .center, .cross = .center, .padding = 24 }, .{ ui.text(.{ .style_tokens = .{ .foreground = .text_muted } }, "Select an event to inspect it"), }); } -fn eventDetail(ui: *Ui, e: types.Event) Node { +fn eventDetail(ui: *Ui, t: *const LoadedTrace, idx: usize) Node { + const e = t.events[idx]; + return ui.column(.{ .gap = 14 }, .{ + ui.text(.{ .size = .heading }, e.action orelse e.message_type.toWire()), + normalizedSection(ui, e), + sessionSection(ui, t, e.id), + payloadSection(ui, t, e), + rawSection(ui, e), + }); +} + +/// A titled, separated block. Every detail section shares this frame. +fn section(ui: *Ui, title: []const u8, body: Node) Node { + return ui.column(.{ .gap = 6 }, .{ + ui.text(.{ .style_tokens = .{ .foreground = .text_muted } }, title), + ui.separator(.{}), + body, + }); +} + +// --- normalized fields ----------------------------------------------------- + +fn normalizedSection(ui: *Ui, e: types.Event) Node { var rows: [7]Node = undefined; var n: usize = 0; rows[n] = detailRow(ui, "Event", e.id); @@ -276,19 +320,361 @@ fn eventDetail(ui: *Ui, e: types.Event) Node { rows[n] = detailRow(ui, "Error", desc); n += 1; } - return ui.column(.{ .min_width = 280, .grow = 1, .gap = 10, .padding = 16 }, .{ - ui.text(.{ .size = .heading }, e.action orelse e.message_type.toWire()), - ui.column(.{ .gap = 6 }, rows[0..n]), - }); + return section(ui, "Details", ui.column(.{ .gap = 6 }, rows[0..n])); } fn detailRow(ui: *Ui, label: []const u8, value: []const u8) Node { + return detailRowColored(ui, label, value, null); +} + +fn detailRowColored(ui: *Ui, label: []const u8, value: []const u8, color: ?canvas.ColorTokenName) Node { + const value_tokens: canvas.StyleTokenRefs = if (color) |c| .{ .foreground = c } else .{}; return ui.row(.{ .gap = 8, .cross = .start }, .{ ui.text(.{ .width = 96, .style_tokens = .{ .foreground = .text_muted } }, label), - ui.text(.{ .grow = 1 }, value), + ui.text(.{ .grow = 1, .style_tokens = value_tokens }, value), }); } +// --- session panel --------------------------------------------------------- + +fn sessionSection(ui: *Ui, t: *const LoadedTrace, event_id: []const u8) Node { + const si = findSessionOf(t, event_id) orelse return section( + ui, + "Session", + ui.text(.{ .style_tokens = .{ .foreground = .text_muted } }, "Not part of a correlated session"), + ); + const s = t.sessions[si]; + + var rows: [7]Node = undefined; + var n: usize = 0; + rows[n] = detailRow(ui, "Session", s.session_id); + n += 1; + rows[n] = detailRow(ui, "Transaction", optInt(ui.arena, s.transaction_id)); + n += 1; + rows[n] = detailRowColored(ui, "Status", s.status.toWire(), statusColor(s.status)); + n += 1; + rows[n] = detailRow(ui, "Connector", optInt(ui.arena, s.connector_id)); + n += 1; + rows[n] = detailRow(ui, "Started", formatTime(ui.arena, s.start_time)); + n += 1; + rows[n] = detailRow(ui, "Ended", formatTime(ui.arena, s.end_time)); + n += 1; + rows[n] = detailRow(ui, "Events", std.fmt.allocPrint(ui.arena, "{d}", .{s.events.len}) catch "?"); + n += 1; + + return section(ui, "Session", ui.column(.{ .gap = 6 }, .{ + ui.column(.{ .gap = 6 }, rows[0..n]), + ui.row(.{}, .{ + ui.button(.{ .on_press = .{ .select_session = si }, .variant = .ghost, .size = .sm }, "Jump to first event"), + }), + })); +} + +/// The index of the session containing `event_id`, or null. Sessions hold +/// id-bearing copies of their events, so membership is an id match. +fn findSessionOf(t: *const LoadedTrace, event_id: []const u8) ?usize { + for (t.sessions, 0..) |s, i| { + for (s.events) |e| { + if (std.mem.eql(u8, e.id, event_id)) return i; + } + } + return null; +} + +fn statusColor(s: types.Status) canvas.ColorTokenName { + return switch (s) { + .completed => .success, + .aborted => .destructive, + .active => .info, + }; +} + +fn optInt(arena: std.mem.Allocator, v: ?i64) []const u8 { + const n = v orelse return "none"; + return std.fmt.allocPrint(arena, "{d}", .{n}) catch "none"; +} + +// --- payload tree ---------------------------------------------------------- + +/// Flat-list disclosure tree over a JSON payload. Rows are emitted in pre-order; +/// a collapsed container simply omits its descendants' rows. Node ids are the +/// pre-order rank over the *bounded* structure and are independent of collapse +/// state (the walk always advances the id counter over every in-bounds node, +/// whether or not it emits a row), so a collapse bit always names the same node +/// across rebuilds. +const TreeWalk = struct { + ui: *Ui, + collapsed: *const workspace.PayloadCollapse, + list: *std.ArrayList(Node), + /// Next pre-order id to hand out. + id: usize = 0, + /// Set once the id space (`workspace.max_payload_tree_nodes`) is exhausted. + truncated: bool = false, +}; + +fn payloadSection(ui: *Ui, t: *const LoadedTrace, e: types.Event) Node { + switch (e.payload) { + .null => return section(ui, "Payload", emptyNote(ui, "No payload")), + .object => |o| if (o.count() == 0) return section(ui, "Payload", emptyNote(ui, "Empty object {}")), + .array => |a| if (a.items.len == 0) return section(ui, "Payload", emptyNote(ui, "Empty array []")), + else => {}, + } + + var list: std.ArrayList(Node) = .empty; + var w = TreeWalk{ .ui = ui, .collapsed = &t.payload_collapsed, .list = &list }; + if (isContainer(e.payload)) { + // Expose the payload's fields directly (no synthetic "payload" root row). + walkChildren(&w, e.payload, 0, true); + } else { + walkPayload(&w, "value", e.payload, 0, true); + } + if (w.truncated) { + list.append(ui.arena, moreRow(ui, "... payload truncated", 0)) catch { + ui.failed = true; + }; + } + return section(ui, "Payload", ui.tree(.{ .semantics = .{ .label = "payload" } }, list.items)); +} + +fn emptyNote(ui: *Ui, text: []const u8) Node { + return ui.text(.{ .style_tokens = .{ .foreground = .text_muted } }, text); +} + +/// Emit (and id-count) one JSON value as a tree row plus, when it is an expanded +/// container, its children. `visible` is false inside a collapsed ancestor — the +/// subtree still consumes ids so identities stay stable, but no rows are added. +fn walkPayload(w: *TreeWalk, key: []const u8, value: std.json.Value, depth: usize, visible: bool) void { + if (w.id >= workspace.max_payload_tree_nodes) { + w.truncated = true; + return; + } + const id = w.id; + w.id += 1; + + const child_count = jsonLen(value); + const expandable = isContainer(value) and child_count > 0 and depth + 1 < max_payload_tree_depth; + const expanded = expandable and !w.collapsed.isSet(id); + + if (visible) { + w.list.append(w.ui.arena, treeRow(w.ui, key, value, depth, id, expandable, expanded, child_count)) catch { + w.ui.failed = true; + }; + } + + if (!isContainer(value) or child_count == 0 or depth + 1 >= max_payload_tree_depth) return; + walkChildren(w, value, depth + 1, visible and expanded); +} + +/// Walk a container's children at `depth`, bounded by breadth. `child_visible` +/// gates emission; ids advance regardless. +fn walkChildren(w: *TreeWalk, value: std.json.Value, depth: usize, child_visible: bool) void { + const total = jsonLen(value); + const shown = @min(total, max_payload_tree_breadth); + switch (value) { + .array => |a| { + var i: usize = 0; + while (i < shown) : (i += 1) { + const label = std.fmt.allocPrint(w.ui.arena, "{d}", .{i}) catch "?"; + walkPayload(w, label, a.items[i], depth, child_visible); + } + }, + .object => |o| { + const keys = o.keys(); + const vals = o.values(); + var i: usize = 0; + while (i < shown) : (i += 1) { + walkPayload(w, keys[i], vals[i], depth, child_visible); + } + }, + else => {}, + } + if (child_visible and total > shown) { + const label = std.fmt.allocPrint(w.ui.arena, "... {d} more", .{total - shown}) catch "... more"; + w.list.append(w.ui.arena, moreRow(w.ui, label, depth)) catch { + w.ui.failed = true; + }; + } +} + +fn treeRow(ui: *Ui, key: []const u8, value: std.json.Value, depth: usize, id: usize, expandable: bool, expanded: bool, child_count: usize) Node { + const indent: f32 = @as(f32, @floatFromInt(depth)) * tree_indent; + return ui.row(.{ + .semantics = .{ .role = .treeitem, .label = key }, + .expanded = if (expandable) expanded else null, + .on_press = if (expandable) Msg{ .toggle_payload_node = id } else null, + .on_toggle = if (expandable) Msg{ .toggle_payload_node = id } else null, + .padding = 3, + .gap = 6, + .cross = .center, + }, .{ + ui.text(.{ .width = indent }, ""), + disclosureGlyph(ui, expandable, expanded), + ui.text(.{ .style_tokens = .{ .foreground = .text_muted } }, key), + ui.text(.{ .grow = 1 }, valueSummary(ui.arena, value, child_count)), + }); +} + +/// The leading disclosure slot: a chevron for expandable rows (the icon name is +/// comptime, so the two states are separate calls), a blank spacer for leaves. +fn disclosureGlyph(ui: *Ui, expandable: bool, expanded: bool) Node { + if (!expandable) return ui.text(.{ .width = 16 }, ""); + if (expanded) return ui.icon(.{ + .width = 16, + .style_tokens = .{ .foreground = .text_muted }, + .semantics = .{ .label = "collapse" }, + }, "chevron-down"); + return ui.icon(.{ + .width = 16, + .style_tokens = .{ .foreground = .text_muted }, + .semantics = .{ .label = "expand" }, + }, "chevron-right"); +} + +/// A non-interactive marker row (breadth/depth truncation), aligned to the key +/// column at `depth`. +fn moreRow(ui: *Ui, text: []const u8, depth: usize) Node { + const indent: f32 = @as(f32, @floatFromInt(depth)) * tree_indent + 16; + return ui.row(.{ .padding = 3, .gap = 6, .cross = .center }, .{ + ui.text(.{ .width = indent }, ""), + ui.text(.{ .grow = 1, .style_tokens = .{ .foreground = .text_muted } }, text), + }); +} + +fn isContainer(value: std.json.Value) bool { + return value == .array or value == .object; +} + +fn jsonLen(value: std.json.Value) usize { + return switch (value) { + .array => |a| a.items.len, + .object => |o| o.count(), + else => 0, + }; +} + +/// The compact right-hand summary of a tree row: a size for containers, the +/// scalar itself for leaves. +fn valueSummary(arena: std.mem.Allocator, value: std.json.Value, child_count: usize) []const u8 { + return switch (value) { + .object => if (child_count == 0) "{}" else std.fmt.allocPrint(arena, "{{ {d} }}", .{child_count}) catch "{ ... }", + .array => if (child_count == 0) "[]" else std.fmt.allocPrint(arena, "[ {d} ]", .{child_count}) catch "[ ... ]", + else => jsonScalar(arena, value), + }; +} + +/// A scalar JSON value rendered for display: strings quoted, everything else as +/// written. Long strings are cut at a UTF-8 boundary so nothing renders as tofu. +fn jsonScalar(arena: std.mem.Allocator, value: std.json.Value) []const u8 { + return switch (value) { + .null => "null", + .bool => |b| if (b) "true" else "false", + .integer => |n| std.fmt.allocPrint(arena, "{d}", .{n}) catch "?", + .float => |f| std.fmt.allocPrint(arena, "{d}", .{f}) catch "?", + .number_string => |s| truncateDisplay(arena, s, 96), + .string => |s| std.fmt.allocPrint(arena, "\"{s}\"", .{truncateDisplay(arena, s, 96)}) catch "\"...\"", + else => "", + }; +} + +fn truncateDisplay(arena: std.mem.Allocator, s: []const u8, max: usize) []const u8 { + if (s.len <= max) return s; + var cut = max; + // Back off any UTF-8 continuation bytes so we never split a codepoint. + while (cut > 0 and (s[cut] & 0xC0) == 0x80) cut -= 1; + return std.fmt.allocPrint(arena, "{s}...", .{s[0..cut]}) catch s[0..cut]; +} + +// --- raw OCPP-J array ------------------------------------------------------ + +fn rawSection(ui: *Ui, e: types.Event) Node { + var buf: std.ArrayList(u8) = .empty; + writeJson(ui.arena, &buf, e.raw_message, 0); + if (buf.items.len >= max_raw_bytes) { + buf.appendSlice(ui.arena, "\n... (truncated)") catch {}; + } + return section(ui, "Raw message", ui.text(.{ .grow = 1 }, buf.items)); +} + +/// Minimal pretty-printer over `std.json.Value` → 2-space-indented JSON. Bounded +/// by `max_raw_bytes`; used for the raw view only (never for engine output), so +/// display-faithful escaping is all it owes. +fn writeJson(a: std.mem.Allocator, buf: *std.ArrayList(u8), value: std.json.Value, depth: usize) void { + if (buf.items.len >= max_raw_bytes) return; + switch (value) { + .null => append(a, buf, "null"), + .bool => |b| append(a, buf, if (b) "true" else "false"), + .integer => |n| { + var tmp: [24]u8 = undefined; + append(a, buf, std.fmt.bufPrint(&tmp, "{d}", .{n}) catch "0"); + }, + .float => |f| { + var tmp: [32]u8 = undefined; + append(a, buf, std.fmt.bufPrint(&tmp, "{d}", .{f}) catch "0"); + }, + .number_string => |s| append(a, buf, s), + .string => |s| writeJsonString(a, buf, s), + .array => |arr| { + if (arr.items.len == 0) return append(a, buf, "[]"); + append(a, buf, "[\n"); + for (arr.items, 0..) |item, i| { + if (buf.items.len >= max_raw_bytes) break; + writeIndent(a, buf, depth + 1); + writeJson(a, buf, item, depth + 1); + if (i + 1 < arr.items.len) append(a, buf, ","); + append(a, buf, "\n"); + } + writeIndent(a, buf, depth); + append(a, buf, "]"); + }, + .object => |obj| { + if (obj.count() == 0) return append(a, buf, "{}"); + append(a, buf, "{\n"); + const keys = obj.keys(); + const vals = obj.values(); + for (keys, vals, 0..) |k, v, i| { + if (buf.items.len >= max_raw_bytes) break; + writeIndent(a, buf, depth + 1); + writeJsonString(a, buf, k); + append(a, buf, ": "); + writeJson(a, buf, v, depth + 1); + if (i + 1 < keys.len) append(a, buf, ","); + append(a, buf, "\n"); + } + writeIndent(a, buf, depth); + append(a, buf, "}"); + }, + } +} + +fn writeJsonString(a: std.mem.Allocator, buf: *std.ArrayList(u8), s: []const u8) void { + append(a, buf, "\""); + for (s) |c| switch (c) { + '"' => append(a, buf, "\\\""), + '\\' => append(a, buf, "\\\\"), + '\n' => append(a, buf, "\\n"), + '\r' => append(a, buf, "\\r"), + '\t' => append(a, buf, "\\t"), + else => { + if (c < 0x20) { + var tmp: [8]u8 = undefined; + append(a, buf, std.fmt.bufPrint(&tmp, "\\u{x:0>4}", .{c}) catch ""); + } else { + buf.append(a, c) catch {}; + } + }, + }; + append(a, buf, "\""); +} + +fn writeIndent(a: std.mem.Allocator, buf: *std.ArrayList(u8), depth: usize) void { + var i: usize = 0; + while (i < depth) : (i += 1) append(a, buf, " "); +} + +fn append(a: std.mem.Allocator, buf: *std.ArrayList(u8), s: []const u8) void { + buf.appendSlice(a, s) catch {}; +} + // --- status bar ------------------------------------------------------------ fn statusBar(ui: *Ui, model: *const Model) Node { diff --git a/src/ui/workspace.zig b/src/ui/workspace.zig index d03acc2..e02cd71 100644 --- a/src/ui/workspace.zig +++ b/src/ui/workspace.zig @@ -19,6 +19,20 @@ const types = ocpp.types; /// the tab strip and per-trace arenas bounded; opening past it is a no-op. pub const max_open_traces: usize = 8; +/// Upper bound on payload-tree node identities the inspector tracks for the +/// selected event. Node ids are pre-order ranks over the (depth/breadth-bounded) +/// payload the view walks; capping the id space keeps collapse state a +/// fixed-size bitset and stops a pathological payload from growing the tree past +/// the view's widget-node budget. `inspector.zig` enforces the same bound when +/// it walks the payload, so ids stay in [0, max_payload_tree_nodes). +pub const max_payload_tree_nodes: usize = 100; + +/// Collapse state for the selected event's payload tree: bit `id` set = node +/// `id` is collapsed. All-clear (the default) = every container expanded. A +/// plain value type, so it copies with the trace during tab compaction and +/// resets to empty when the trace is freed. +pub const PayloadCollapse = std.StaticBitSet(max_payload_tree_nodes); + /// One open trace and everything the engine derived from it. All borrowed slices /// (`name`, `parse`, `sessions`, `failures`) live in `arena`; freeing `arena` /// frees the whole trace. A failed load keeps its arena too (it holds `name` and @@ -42,6 +56,10 @@ pub const LoadedTrace = struct { detection_skipped: bool = false, /// The timeline row the user selected, if any (wired in the timeline PR). selected_event: ?usize = null, + /// Collapse state for the selected event's payload tree (`PayloadCollapse`). + /// Reset whenever `selected_event` changes, so each event's tree opens + /// fully expanded. + payload_collapsed: PayloadCollapse = PayloadCollapse.initEmpty(), pub fn isError(self: *const LoadedTrace) bool { return self.load_error != null; @@ -85,6 +103,12 @@ pub const Msg = union(enum) { close_trace: usize, /// Select event `payload` (a timeline row) in the active trace. select_event: usize, + /// Toggle collapse of payload-tree node `payload` for the active trace's + /// selected event. + toggle_payload_node: usize, + /// Jump to session `payload` in the active trace by selecting its first + /// event (drives the detail and session panels to that session). + select_session: usize, /// The timeline / detail splitter moved to fraction `payload`. timeline_resized: f32, }; @@ -143,11 +167,41 @@ pub const Model = struct { } /// Select a timeline row (event index) in the active trace. Out-of-range - /// indices are ignored. + /// indices are ignored. Changing the selection resets the payload-tree + /// collapse state, so the newly selected event opens fully expanded. pub fn selectEvent(self: *Model, index: usize) void { if (self.trace_count == 0) return; const t = &self.traces[self.active]; - if (index < t.events.len) t.selected_event = index; + if (index >= t.events.len) return; + if (t.selected_event != index) t.payload_collapsed = PayloadCollapse.initEmpty(); + t.selected_event = index; + } + + /// Toggle the collapsed state of payload-tree node `id` for the active + /// trace's selected event. Ids at or past the tracked range are ignored. + pub fn togglePayloadNode(self: *Model, id: usize) void { + if (self.trace_count == 0) return; + if (id >= max_payload_tree_nodes) return; + self.traces[self.active].payload_collapsed.toggle(id); + } + + /// Jump to session `index` in the active trace by selecting its first + /// event. Sessions hold id-bearing copies of their events (timeline.zig), + /// so the first event maps back to a timeline row by its stable event id. + /// Out-of-range indices and empty sessions are ignored. + pub fn selectSession(self: *Model, index: usize) void { + if (self.trace_count == 0) return; + const t = &self.traces[self.active]; + if (index >= t.sessions.len) return; + const s = t.sessions[index]; + if (s.events.len == 0) return; + const first_id = s.events[0].id; + for (t.events, 0..) |e, i| { + if (std.mem.eql(u8, e.id, first_id)) { + self.selectEvent(i); + return; + } + } } /// Close trace `index`, freeing its arena and compacting the tab list. @@ -192,6 +246,8 @@ pub fn update(model: *Model, msg: Msg) void { .select_trace => |i| model.selectTrace(i), .close_trace => |i| model.closeTrace(i), .select_event => |i| model.selectEvent(i), + .toggle_payload_node => |id| model.togglePayloadNode(id), + .select_session => |i| model.selectSession(i), .timeline_resized => |f| model.timeline_split = f, } } @@ -356,6 +412,59 @@ test "selecting an event records it on the active trace, bounds-checked" { try testing.expectEqual(@as(?usize, 5), model.activeTrace().?.selected_event); } +test "toggling payload nodes flips collapse bits, bounds-checked" { + var model = Model{ .backing = testing.allocator }; + defer model.deinitAll(); + update(&model, .open_sample); + update(&model, .{ .select_event = 0 }); + + try testing.expect(!model.activeTrace().?.payload_collapsed.isSet(3)); + update(&model, .{ .toggle_payload_node = 3 }); + try testing.expect(model.activeTrace().?.payload_collapsed.isSet(3)); + update(&model, .{ .toggle_payload_node = 3 }); + try testing.expect(!model.activeTrace().?.payload_collapsed.isSet(3)); + + // Ids at/past the tracked range are ignored (no panic, no effect). + update(&model, .{ .toggle_payload_node = max_payload_tree_nodes }); + update(&model, .{ .toggle_payload_node = 999_999 }); +} + +test "reselecting a different event resets payload collapse state" { + var model = Model{ .backing = testing.allocator }; + defer model.deinitAll(); + update(&model, .open_sample); + update(&model, .{ .select_event = 0 }); + update(&model, .{ .toggle_payload_node = 2 }); + try testing.expect(model.activeTrace().?.payload_collapsed.isSet(2)); + + // A new selection opens fully expanded again. + update(&model, .{ .select_event = 6 }); + try testing.expect(!model.activeTrace().?.payload_collapsed.isSet(2)); + + // Re-selecting the same event leaves collapse state intact. + update(&model, .{ .toggle_payload_node = 2 }); + update(&model, .{ .select_event = 6 }); + try testing.expect(model.activeTrace().?.payload_collapsed.isSet(2)); +} + +test "selecting a session jumps to its first event" { + var model = Model{ .backing = testing.allocator }; + defer model.deinitAll(); + update(&model, .open_sample); + const t = model.activeTrace().?; + try testing.expectEqual(@as(usize, 1), t.sessionCount()); + + update(&model, .{ .select_session = 0 }); + // The sample's one session begins at the BootNotification (event 0). + const first = t.sessions[0].events[0]; + const selected = model.activeTrace().?.selected_event.?; + try testing.expectEqualStrings(first.id, model.activeTrace().?.events[selected].id); + + // Out-of-range session index is ignored. + update(&model, .{ .select_session = 42 }); + try testing.expectEqual(@as(?usize, selected), model.activeTrace().?.selected_event); +} + test "the splitter fraction is model-owned and echoed by update" { var model = Model{ .backing = testing.allocator }; defer model.deinitAll();