From d5b60de70d8c19a955bcd29d8ddf51e21c2acee8 Mon Sep 17 00:00:00 2001 From: sepehr-safari Date: Sat, 11 Jul 2026 16:24:27 +0300 Subject: [PATCH] =?UTF-8?q?feat(ui):=20inspector=20shell=20=E2=80=94=20wor?= =?UTF-8?q?kspace=20model=20+=20trace=20loading?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replace the placeholder counter with the foundation of the native inspector (S3): - A Zig `canvas.Ui` builder view instead of `.native` markup (ADR-0006): the event timeline needs the builder-only windowed virtual list, so the main view is a builder view. `src/app.native` is removed. - A bounded multi-trace workspace `Model` / `Msg` / `update` in `src/ui/`, each open trace owning a private engine arena that is freed on close (leak-tested with the testing allocator). - Trace loading from command-line path arguments, read unbounded in `main` via `init.io`, plus a built-in embedded sample. The window opens on an empty state or the loaded trace's overview. - A larger inspector window; the automation smoke test now launches with the sample trace and asserts the loaded overview, exercising the CLI load path live. Interactive open (native dialog + drag-drop) is deferred to a follow-up issue — it needs an ejected runner (see ADR-0006). Closes #27 --- AGENTS.md | 34 ++- CURRENT_STATE.md | 24 +- app.zon | 6 +- docs/adr/0006-inspector-builder-view.md | 59 +++++ scripts/smoke.sh | 26 +- src/app.native | 15 -- src/main.zig | 96 +++++--- src/tests.zig | 156 +++++++----- src/ui/inspector.zig | 119 +++++++++ src/ui/ui.zig | 14 ++ src/ui/workspace.zig | 314 ++++++++++++++++++++++++ 11 files changed, 719 insertions(+), 144 deletions(-) create mode 100644 docs/adr/0006-inspector-builder-view.md delete mode 100644 src/app.native create mode 100644 src/ui/inspector.zig create mode 100644 src/ui/ui.zig create mode 100644 src/ui/workspace.zig diff --git a/AGENTS.md b/AGENTS.md index 3847ebe..15ce81a 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -7,10 +7,13 @@ be productive without rediscovering them. ## Project overview OCPP DebugKit Studio is a native desktop debugger for OCPP charging sessions, -built in Zig on the Native SDK (native-rendered `.native` markup + a Zig -`Model` / `Msg` / `update` loop — no WebView, no web frontend). It is a fully -independent sibling of the TypeScript `@ocpp-debugkit/toolkit`; the two share a -specification contract, not code (see `docs/adr/0001-independent-implementation.md`). +built in Zig on the Native SDK (native-rendered UI + a Zig `Model` / `Msg` / +`update` loop — no WebView, no web frontend). The inspector view is a hand-written +`canvas.Ui` builder view rather than `.native` markup, because the event timeline +needs the builder-only windowed virtual list (see +`docs/adr/0006-inspector-builder-view.md`). It is a fully independent sibling of +the TypeScript `@ocpp-debugkit/toolkit`; the two share a specification contract, +not code (see `docs/adr/0001-independent-implementation.md`). ## Repository layout @@ -18,19 +21,23 @@ specification contract, not code (see `docs/adr/0001-independent-implementation. studio/ ├── app.zon # app manifest: identity, window, view, security policy ├── src/ -│ ├── main.zig # Model, Msg, update, app wiring -│ ├── app.native # the view (declarative markup) +│ ├── main.zig # app wiring + CLI-arg trace loading (re-exports the TEA types) +│ ├── ui/ # the inspector: builder view + workspace state +│ │ ├── workspace.zig # Model, Msg, update — the bounded multi-trace workspace +│ │ ├── inspector.zig # the canvas.Ui builder view (ADR-0006) +│ │ └── ui.zig # aggregate root (test discovery) │ ├── ocpp/ # pure, headless OCPP engine (types, parser, detection, …) │ │ └── conformance/ # vendored shared-contract fixtures + goldens + harness -│ └── tests.zig # headless view/model tests +│ └── tests.zig # headless view/model integration tests ├── scripts/smoke.sh # portable automation smoke test (Xvfb in CI) ├── docs/adr/ # architecture decision records └── .github/workflows/ci.yml # CI: verify (macOS+Linux) + Linux Xvfb smoke ``` -As the engine lands, source grows under `src/ocpp/` (the pure, headless-testable -engine), `src/ui/` (views), `src/capture/` (live proxy), and `src/cli.zig` -(headless mode). Keep the engine free of UI and platform dependencies. +Source grows under `src/ocpp/` (the pure, headless-testable engine), `src/ui/` +(the inspector view + state), and later `src/capture/` (live proxy) and +`src/cli.zig` (headless mode). Keep the engine free of UI and platform +dependencies — `src/ui/` consumes it through the workspace Model. ## Build commands @@ -41,7 +48,7 @@ native dev # build Debug + run, with markup hot reload native test # run the test suite native test -Dplatform=null # headless tests (what CI runs) native build # ReleaseFast binary → zig-out/bin/studio -native check --strict # validate src/*.native + app.zon +native check --strict # validate app.zon (and any .native views) native doctor --strict # toolchain / environment health ``` @@ -51,8 +58,9 @@ Prerequisites: Zig `0.16.0` and `@native-sdk/cli` (`npm install -g @native-sdk/c - **Engine is pure Zig, UI-free.** Everything under `src/ocpp/` must be testable headlessly with `native test`. The UI consumes it through the Model. -- **TEA discipline.** `.native` views are declarative; all side effects (spawn, - fetch, clipboard, sockets) go through the update-side effects channel. +- **TEA discipline.** The view is a pure function of the model (`ui/inspector.zig` + builds the tree; it never mutates state); all side effects (spawn, fetch, + clipboard, sockets) go through the update-side effects channel. - **Version-tagged protocol boundary.** The decoder keys off an OCPP version tag so 2.0.1 can be added later without reworking 1.6J. - **Conformance over duplication.** Studio mirrors the toolkit's *behavior* via a diff --git a/CURRENT_STATE.md b/CURRENT_STATE.md index fba8db9..56b2e49 100644 --- a/CURRENT_STATE.md +++ b/CURRENT_STATE.md @@ -8,8 +8,8 @@ ## Active milestone -**S2 — Detection + conformance (0.1.0): complete.** Next up: **S3 — Inspector UI** -(see [ROADMAP.md](ROADMAP.md)). +**S3 — Inspector UI (0.2.0): in progress.** The engine (S0–S2) is complete; the +native inspector is now being built (see [ROADMAP.md](ROADMAP.md)). ## What's done @@ -72,13 +72,23 @@ toolkit's: ## What's in progress -- Nothing in flight — S2 is closed; S3 is next. +**S3 — Inspector UI (0.2.0).** The inspector shell has landed (#27): the +placeholder counter is replaced by a Zig `canvas.Ui` builder view (ADR-0006), a +bounded multi-trace workspace `Model` / `Msg` / `update` (`src/ui/`), and trace +loading from command-line path arguments (read unbounded in `main` via +`init.io`) plus a built-in embedded sample. The window opens on an empty state or +the loaded trace's overview. + +Still ahead in S3: the virtualized event timeline (#28), the trusted-ingestion +capacity path for 500k-event / 100 MB traces (#29), the 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). ## What's next -**S3 — Inspector UI (0.2.0):** the native inspector — open / drag-drop traces, a -virtualized event timeline, the per-message inspector, the failure panel, and -search / filter, handling traces far larger than a browser tab can hold. +After S3: **S4 — Analysis parity+ (0.3.0)** — reports, anonymize, diff, +wall-clock replay, and a headless CLI mode. ## Known blockers / decisions pending @@ -91,7 +101,7 @@ search / filter, handling traces far larger than a browser tab can hold. | `repo` (tooling, CI) | ✅ done for S0 | | `docs` (docs, ADRs) | ✅ done for S0 | | `ocpp` (engine) | ✅ done for S2 | -| `ui` (native views) | ⬜ placeholder (S3) | +| `ui` (native views) | 🚧 shell landed (S3, #27); timeline + panes next | | `capture` (live proxy) | ⬜ not started (S5) | | `cli` (headless) | ⬜ not started (S4) | | `conformance` | ✅ done for S2 (15/15, `contract-v1`) | diff --git a/app.zon b/app.zon index 801d65f..23428c8 100644 --- a/app.zon +++ b/app.zon @@ -13,8 +13,10 @@ .{ .label = "main", .title = "OCPP DebugKit Studio", - .width = 480, - .height = 320, + .width = 1200, + .height = 800, + .min_width = 900, + .min_height = 560, .restore_state = false, .restore_policy = "center_on_primary", .views = .{ diff --git a/docs/adr/0006-inspector-builder-view.md b/docs/adr/0006-inspector-builder-view.md new file mode 100644 index 0000000..93059bb --- /dev/null +++ b/docs/adr/0006-inspector-builder-view.md @@ -0,0 +1,59 @@ +# ADR-0006 — The inspector is a Zig builder view, not `.native` markup + +- **Status:** Accepted +- **Date:** 2026-07-11 + +## Context + +The Native SDK's default and recommended way to author a view is declarative +`.native` markup (`ADR-0003`), with the Model / Msg / update logic in Zig. The +S0 scaffold followed that: `src/app.native` held the placeholder counter view. +Markup buys real advantages — hot reload in dev, `native check` binding +validation against the model contract, and a smaller, parser-free release binary. + +S3 builds the inspector, whose centerpiece is the **event timeline** (issue #28). +A trace can hold hundreds of thousands of events, and every canvas view has a +hard budget of **1024 retained widget nodes**. The only primitive that renders a +dataset-scale list within that budget is the **windowed virtual list** +(`ui.virtualWindow` + `ui.virtualList`): the runtime owns the scroll and viewport +math, the model owns the data, and the view builds only the visible window. + +Per the native-ui contract, the windowed virtual list is **builder-only**. The +closed markup grammar has no channel for a `for` binding to receive the runtime's +requested index range, so markup can only offer a bounded `` +that still walks every item on each build — fine for hundreds of rows, not for +the 500 000-event capacity target (M8 / issue #29). Markup and a Zig +`canvas.Ui(Msg)` builder view produce the *identical* widget tree (same +structural ids, same typed handler table); the difference is only which +primitives are reachable. + +## Decision + +**Author the inspector's main view as a hand-written `canvas.Ui(Msg)` builder +view (`UiApp` `Options.view`), not a `.native` markup file.** `src/app.native` is +removed; `src/ui/inspector.zig` is the view, `src/ui/workspace.zig` the Model / +Msg / update. + +## Consequences + +- The timeline can use the windowed virtual list from the start, so the view + scales to dataset-size traces within the node budget — the capability the + milestone is defined around — instead of being rebuilt later. +- The full builder surface is available uniformly: `ui.split`, `ui.tree`, + `ui.virtualList`, per-element `ElementOptions`, and dynamic child slices, with + no "not markup-expressible" gaps to work around. +- The view is tested exactly like a markup view would be — build the tree with + `canvas.Ui`, assert on widgets, and drive `msgForPointer` / `msgForKeyboard` — + so headless coverage under `-Dplatform=null` is unchanged (`src/tests.zig`). +- Costs accepted: no markup hot reload for the main view (a data inspector is not + a design-iteration surface), and the `native check` binding-path check does not + apply to Zig view reads — model correctness is carried by the headless view + tests instead. The release binary still needs no markup interpreter. +- Simpler secondary surfaces may still be authored in markup later + (`CompiledMarkupView`) where the virtual list is not needed; this ADR governs + the main inspector view, not a blanket ban on markup. +- Trace loading uses command-line path arguments read in `main` via `init.io` + (unbounded). Interactive open — a native dialog and drag-drop — is deferred + (issue #33): the zero-config `UiApp` exposes no view/effect path to + `showOpenDialog` or file-drop routing, so it would require ejecting the build to + own a custom runner, a standing decision left to its own issue and ADR. diff --git a/scripts/smoke.sh b/scripts/smoke.sh index 31922c1..6d429d7 100755 --- a/scripts/smoke.sh +++ b/scripts/smoke.sh @@ -10,6 +10,9 @@ set -euo pipefail BIN=zig-out/bin/studio AUTO_DIR=.zig-cache/native-sdk-automation +# Launch with the vendored sample as a command-line trace argument, so the smoke +# test exercises the real CLI load path and lands on the loaded overview. +SAMPLE=src/ocpp/testdata/normal-session.json if [ ! -x "$BIN" ]; then echo "smoke: $BIN not found — build first: native build -Dautomation=true" >&2 @@ -28,12 +31,12 @@ export NO_AT_BRIDGE=1 launch() { if command -v xvfb-run >/dev/null 2>&1; then if command -v dbus-run-session >/dev/null 2>&1; then - dbus-run-session -- xvfb-run -a "$BIN" + dbus-run-session -- xvfb-run -a "$BIN" "$SAMPLE" else - xvfb-run -a "$BIN" + xvfb-run -a "$BIN" "$SAMPLE" fi else - "$BIN" + "$BIN" "$SAMPLE" fi } @@ -44,15 +47,16 @@ trap 'kill "$APP_PID" >/dev/null 2>&1 || true' EXIT # Block until the runtime publishes ready=true (or fail loudly on timeout). native automate wait --timeout-ms 60000 -# The window must exist with the expected widget tree. These come from the -# semantics snapshot (GPU-independent), so they hold under software GL too. +# The window must exist with the loaded trace rendered. The app was launched +# with the sample trace as a command-line argument, so it opens straight on the +# overview — proving the CLI load → parse → render path works end to end. These +# assertions read the semantics snapshot (GPU-independent), so they hold under +# software GL too. native automate assert --timeout-ms 30000 \ 'ready=true' \ - 'role=button name="Reset"' \ - 'role=button name="\+"' \ - 'role=button name="-"' \ - 'OCPP DebugKit Studio' \ - 'count: 0' + 'normal-session.json' \ + '22 events' \ + 'OCPP DebugKit Studio' # No runtime/dispatch errors surfaced during startup. native automate assert --absent 'error event=' @@ -61,4 +65,4 @@ native automate assert --absent 'error event=' native automate screenshot main-canvas test -s "$AUTO_DIR/screenshot-main-canvas.png" -echo "smoke: ok — window rendered and automation drove the widget tree" +echo "smoke: ok — window rendered and automation drove the inspector" diff --git a/src/app.native b/src/app.native deleted file mode 100644 index 165e2e2..0000000 --- a/src/app.native +++ /dev/null @@ -1,15 +0,0 @@ - - - - OCPP DebugKit Studio - - - - - {count} - - - count: {count} - diff --git a/src/main.zig b/src/main.zig index 6702cc2..3d13e0f 100644 --- a/src/main.zig +++ b/src/main.zig @@ -1,19 +1,28 @@ -//! A minimal native-rendered Native SDK app: the view lives in -//! `app.native` (embedded into the binary, and watched for hot reload in -//! dev); this file is the logic: `Model`, `Msg`, and `update`. +//! OCPP DebugKit Studio — app wiring. The inspector view is a Zig `canvas.Ui` +//! builder view (ADR-0006: the event timeline needs the builder-only windowed +//! virtual list), so this file hands `UiApp` a `.view` function rather than +//! embedded `.native` markup. State and transitions live in `ui/workspace.zig`; +//! the view in `ui/inspector.zig`. +//! +//! Traces are opened from command-line path arguments — read here in `main` +//! through `init.io` (unbounded, the large-trace path #29 builds on), parsed by +//! the engine, and seeded into the workspace before the loop starts. const std = @import("std"); const runner = @import("runner"); const native_sdk = @import("native_sdk"); +const workspace = @import("ui/workspace.zig"); +const inspector = @import("ui/inspector.zig"); pub const panic = std.debug.FullPanic(native_sdk.debug.capturePanic); -const canvas = native_sdk.canvas; const geometry = native_sdk.geometry; const canvas_label = "main-canvas"; -const window_width: f32 = 480; -const window_height: f32 = 320; +const window_width: f32 = 1200; +const window_height: f32 = 800; +const window_min_width: f32 = 900; +const window_min_height: f32 = 560; const app_permissions = [_][]const u8{ native_sdk.security.permission_command, native_sdk.security.permission_view }; const shell_views = [_]native_sdk.ShellView{ @@ -24,58 +33,45 @@ const shell_windows = [_]native_sdk.ShellWindow{.{ .title = "OCPP DebugKit Studio", .width = window_width, .height = window_height, + .min_width = window_min_width, + .min_height = window_min_height, .restore_state = false, .views = &shell_views, }}; const shell_scene: native_sdk.ShellConfig = .{ .windows = &shell_windows }; -// ------------------------------------------------------------------ model +// ------------------------------------------------------------------ TEA types -pub const Msg = union(enum) { - increment, - decrement, - reset, -}; - -pub const Model = struct { - count: i64 = 0, -}; - -pub fn update(model: *Model, msg: Msg) void { - switch (msg) { - .increment => model.count += 1, - .decrement => model.count -= 1, - .reset => model.count = 0, - } -} - -// ------------------------------------------------------------------- view - -pub const AppUi = canvas.Ui(Msg); -pub const app_markup = @embedFile("app.native"); +pub const Model = workspace.Model; +pub const Msg = workspace.Msg; +pub const update = workspace.update; +pub const view = inspector.view; +pub const AppUi = inspector.Ui; // -------------------------------------------------------------------- app -const CounterApp = native_sdk.UiApp(Model, Msg); +const InspectorApp = native_sdk.UiApp(Model, Msg); -pub fn initialModel() Model { - return .{}; -} +/// Largest trace file `main` will read from disk. Generous so the trusted +/// command-line path isn't the bottleneck; the engine parser still enforces its +/// own ingestion policy on the bytes (raised for trusted files in #29). +const max_trace_file_bytes: usize = 256 * 1024 * 1024; pub fn main(init: std.process.Init) !void { - // The app struct (and any real Model) is multi-MB: `create` - // heap-allocates and constructs everything in place, so neither - // ever rides the stack. Mutate `app_state.model` through the - // pointer before running if boot state is not the default. - const app_state = try CounterApp.create(std.heap.page_allocator, .{ + // The app struct (and the Model) are multi-MB: `create` heap-allocates and + // constructs in place so neither rides the stack. + const app_state = try InspectorApp.create(std.heap.page_allocator, .{ .name = "studio", .scene = shell_scene, .canvas_label = canvas_label, .update = update, - .markup = .{ .source = app_markup, .watch_path = "src/app.native", .io = init.io }, + .view = view, }); defer app_state.destroy(); - app_state.model = initialModel(); + app_state.model = .{ .backing = std.heap.page_allocator }; + defer app_state.model.deinitAll(); + + openTracesFromArgs(&app_state.model, init); try runner.runWithOptions(app_state.app(), .{ .app_name = "studio", @@ -92,7 +88,27 @@ pub fn main(init: std.process.Init) !void { }, init); } +/// Read every trace path passed on the command line into the workspace. A file +/// that cannot be read opens as an error trace rather than aborting startup, so +/// one bad path never sinks the others. +fn openTracesFromArgs(model: *Model, init: std.process.Init) void { + const alloc = std.heap.page_allocator; + const args = init.minimal.args.toSlice(alloc) catch return; + defer alloc.free(args); + if (args.len <= 1) return; + for (args[1..]) |path| { + const name = std.fs.path.basename(path); + const bytes = std.Io.Dir.cwd().readFileAlloc(init.io, path, alloc, .limited(max_trace_file_bytes)) catch |err| { + model.openLoadError(name, @errorName(err)); + continue; + }; + defer alloc.free(bytes); + model.openBytes(name, bytes); + } +} + test { _ = @import("tests.zig"); _ = @import("ocpp/ocpp.zig"); + _ = @import("ui/ui.zig"); } diff --git a/src/tests.zig b/src/tests.zig index 655fddc..4eb5077 100644 --- a/src/tests.zig +++ b/src/tests.zig @@ -1,29 +1,24 @@ +//! Headless integration tests for the inspector view: build the real +//! `canvas.Ui` tree from `inspector.view`, assert on its widgets, and drive the +//! model through the same typed dispatch path the runtime uses — no GUI needed. +//! Workspace-model unit tests live beside the code in `ui/workspace.zig`. + const std = @import("std"); const native_sdk = @import("native_sdk"); const main = @import("main.zig"); +const workspace = @import("ui/workspace.zig"); +const inspector = @import("ui/inspector.zig"); const canvas = native_sdk.canvas; const testing = std.testing; -const AppUi = main.AppUi; +const Ui = inspector.Ui; const Model = main.Model; const Msg = main.Msg; -const AppMarkup = canvas.MarkupView(Model, Msg); - -fn buildTree(arena: std.mem.Allocator, model: *const Model) !AppUi.Tree { - var view = try AppMarkup.init(arena, main.app_markup); - var ui = AppUi.init(arena); - const node = view.build(&ui, model) catch |err| { - // Name the app.native position instead of leaving a bare error - // trace: the usual causes are a binding without a matching - // Model field or an on-* message without a Msg arm. - if (err == error.MarkupBuild) { - std.debug.print("app.native:{d}:{d}: {s}\n", .{ view.diagnostic.line, view.diagnostic.column, view.diagnostic.message }); - } - return err; - }; - return ui.finalize(node); +fn buildTree(arena: std.mem.Allocator, model: *const Model) !Ui.Tree { + var ui = Ui.init(arena); + return ui.finalize(inspector.view(&ui, model)); } fn findByText(widget: canvas.Widget, kind: canvas.WidgetKind, text: []const u8) ?canvas.Widget { @@ -34,69 +29,118 @@ fn findByText(widget: canvas.Widget, kind: canvas.WidgetKind, text: []const u8) return null; } -/// A miss fails the test with the mismatch spelled out instead of a -/// null-unwrap panic: the usual cause is app.native and this test -/// drifting apart after an edit. fn expectByText(widget: canvas.Widget, kind: canvas.WidgetKind, text: []const u8) !canvas.Widget { return findByText(widget, kind, text) orelse { - std.debug.print("no {t} with text \"{s}\" in the view - if you changed app.native, update this test to match\n", .{ kind, text }); + std.debug.print("no {t} with text \"{s}\" in the inspector view\n", .{ kind, text }); return error.WidgetNotFound; }; } -test "clicking the buttons drives the model through typed dispatch" { +fn findKind(widget: canvas.Widget, kind: canvas.WidgetKind) ?canvas.Widget { + if (widget.kind == kind) return widget; + for (widget.children) |child| { + if (findKind(child, kind)) |found| return found; + } + return null; +} + +test "the empty workspace offers the open-sample affordance" { + var arena_state = std.heap.ArenaAllocator.init(testing.allocator); + defer arena_state.deinit(); + + var model = Model{ .backing = testing.allocator }; + defer model.deinitAll(); + + const tree = try buildTree(arena_state.allocator(), &model); + _ = try expectByText(tree.root, .text, "No trace open"); + _ = try expectByText(tree.root, .button, "Open sample"); +} + +test "clicking Open sample loads the sample and the overview reflects it" { var arena_state = std.heap.ArenaAllocator.init(testing.allocator); defer arena_state.deinit(); const arena = arena_state.allocator(); - var model = main.initialModel(); + var model = Model{ .backing = testing.allocator }; + defer model.deinitAll(); + // Press "Open sample" through the real dispatch path. var tree = try buildTree(arena, &model); - _ = try expectByText(tree.root, .text, "0"); - _ = try expectByText(tree.root, .status_bar, "count: 0"); - - // Click "+": the count increments and the view rebuilds with the - // new value, keeping widget ids stable. - const plus = try expectByText(tree.root, .button, "+"); - main.update(&model, tree.msgForPointer(plus.id, .up).?); - try testing.expectEqual(@as(i64, 1), model.count); + const open = try expectByText(tree.root, .button, "Open sample"); + main.update(&model, tree.msgForPointer(open.id, .up).?); + try testing.expect(model.hasTraces()); + // Rebuild: the overview shows the event count and the status bar summarizes. tree = try buildTree(arena, &model); - _ = try expectByText(tree.root, .text, "1"); - _ = try expectByText(tree.root, .status_bar, "count: 1"); - try testing.expectEqual(plus.id, (try expectByText(tree.root, .button, "+")).id); + _ = try expectByText(tree.root, .text, "22"); // events stat tile value + _ = try expectByText(tree.root, .text, "events"); + const status = findKind(tree.root, .status_bar) orelse return error.WidgetNotFound; + try testing.expect(std.mem.indexOf(u8, status.text, "22 events") != null); +} - // Click "-" twice: the count goes negative. - const minus = try expectByText(tree.root, .button, "-"); - main.update(&model, tree.msgForPointer(minus.id, .up).?); - main.update(&model, tree.msgForPointer(minus.id, .up).?); - try testing.expectEqual(@as(i64, -1), model.count); +test "a second trace produces a tab strip that switches the active trace" { + var arena_state = std.heap.ArenaAllocator.init(testing.allocator); + defer arena_state.deinit(); + const arena = arena_state.allocator(); - // Click "Reset": back to zero. - tree = try buildTree(arena, &model); - const reset = try expectByText(tree.root, .button, "Reset"); - main.update(&model, tree.msgForPointer(reset.id, .up).?); - try testing.expectEqual(@as(i64, 0), model.count); + var model = Model{ .backing = testing.allocator }; + defer model.deinitAll(); + workspace.update(&model, .open_sample); + workspace.update(&model, .open_sample); + try testing.expectEqual(@as(usize, 1), model.active); // newest active + + // Two tabs render as buttons carrying the trace name; clicking the first + // switches the active trace. + const tree = try buildTree(arena, &model); + const first_tab = try expectByText(tree.root, .button, workspace.sample_name); + main.update(&model, tree.msgForPointer(first_tab.id, .up).?); + try testing.expectEqual(@as(usize, 0), model.active); +} + +test "an unreadable trace opens in the error state without crashing" { + var arena_state = std.heap.ArenaAllocator.init(testing.allocator); + defer arena_state.deinit(); + + var model = Model{ .backing = testing.allocator }; + defer model.deinitAll(); + model.openLoadError("missing.json", "FileNotFound"); + + const tree = try buildTree(arena_state.allocator(), &model); + _ = try expectByText(tree.root, .text, "Could not read this trace"); + _ = try expectByText(tree.root, .text, "FileNotFound"); +} + +test "the inspector view passes the accessibility sweep (empty and loaded)" { + var arena_state = std.heap.ArenaAllocator.init(testing.allocator); + defer arena_state.deinit(); + const arena = arena_state.allocator(); + + const sweep = canvas.a11y.A11yAuditSweepOptions{ + .min_size = .{ .width = 900, .height = 560 }, + .default_size = .{ .width = 1200, .height = 800 }, + }; + + var model = Model{ .backing = testing.allocator }; + defer model.deinitAll(); + var tree = try buildTree(arena, &model); // empty state + try canvas.expectA11yAuditSweepClean(testing.allocator, tree.root, sweep); + + workspace.update(&model, .open_sample); // loaded state tree = try buildTree(arena, &model); - _ = try expectByText(tree.root, .status_bar, "count: 0"); + try canvas.expectA11yAuditSweepClean(testing.allocator, tree.root, sweep); } -test "the view lays out through the canvas engine" { +test "the inspector view lays out through the canvas engine" { var arena_state = std.heap.ArenaAllocator.init(testing.allocator); defer arena_state.deinit(); - var model = main.initialModel(); - const tree = try buildTree(arena_state.allocator(), &model); + var model = Model{ .backing = testing.allocator }; + defer model.deinitAll(); + workspace.update(&model, .open_sample); - var nodes: [64]canvas.WidgetLayoutNode = undefined; - const layout = try canvas.layoutWidgetTree(tree.root, native_sdk.geometry.RectF.init(0, 0, 480, 320), &nodes); + const tree = try buildTree(arena_state.allocator(), &model); + var nodes: [256]canvas.WidgetLayoutNode = undefined; + const layout = try canvas.layoutWidgetTree(tree.root, native_sdk.geometry.RectF.init(0, 0, 1200, 800), &nodes); try testing.expect(layout.nodes.len > 0); - - const plus = try expectByText(tree.root, .button, "+"); - var saw_button = false; - for (layout.nodes) |node| { - if (node.widget.id == plus.id) saw_button = true; - } - try testing.expect(saw_button); } diff --git a/src/ui/inspector.zig b/src/ui/inspector.zig new file mode 100644 index 0000000..d5d42b7 --- /dev/null +++ b/src/ui/inspector.zig @@ -0,0 +1,119 @@ +//! The inspector view — a hand-written `canvas.Ui` builder view, not `.native` +//! markup (ADR-0006): the event timeline needs the windowed virtual list, which +//! is builder-only. This module owns the visual half of the TEA loop; all state +//! and transitions live in `workspace.zig`. +//! +//! S3 shell scope: the empty state, the workspace tab strip, a per-trace +//! overview (counts), the error state, and the status bar. The virtualized +//! timeline and the detail panes replace `activeBody` in the issues that follow. + +const std = @import("std"); +const native_sdk = @import("native_sdk"); +const canvas = native_sdk.canvas; +const workspace = @import("workspace.zig"); + +const Model = workspace.Model; +const Msg = workspace.Msg; +const LoadedTrace = workspace.LoadedTrace; + +pub const Ui = canvas.Ui(Msg); +const Node = Ui.Node; + +pub fn view(ui: *Ui, model: *const Model) Node { + if (!model.hasTraces()) return emptyState(ui); + return ui.column(.{ .grow = 1 }, .{ + topBar(ui, model), + ui.separator(.{}), + activeBody(ui, model), + statusBar(ui, model), + }); +} + +// --- empty state ----------------------------------------------------------- + +fn emptyState(ui: *Ui) Node { + return ui.column(.{ .grow = 1, .main = .center, .cross = .center, .gap = 10, .padding = 24 }, .{ + ui.text(.{ .size = .heading }, "No trace open"), + ui.text(.{}, "Open the built-in sample, or pass a trace file on the command line:"), + ui.text(.{}, "studio path/to/trace.json"), + ui.button(.{ .on_press = .open_sample, .variant = .primary }, "Open sample"), + }); +} + +// --- top bar: tabs / single-trace name + close ----------------------------- + +fn topBar(ui: *Ui, model: *const Model) Node { + const tabs = model.open(); + const left: Node = if (tabs.len > 1) tabStrip(ui, model) else ui.text(.{ .grow = 1 }, tabs[0].name); + return ui.row(.{ .gap = 8, .cross = .center, .padding = 8 }, .{ + left, + ui.spacer(1), + ui.button(.{ .on_press = .{ .close_trace = model.active }, .variant = .ghost, .size = .sm }, "Close"), + }); +} + +fn tabStrip(ui: *Ui, model: *const Model) Node { + const tabs = model.open(); + const nodes = ui.arena.alloc(Node, tabs.len) catch { + ui.failed = true; + return ui.row(.{}, .{}); + }; + for (tabs, 0..) |*t, i| { + nodes[i] = ui.button(.{ + .on_press = .{ .select_trace = i }, + .selected = (i == model.active), + .variant = if (i == model.active) .secondary else .ghost, + .size = .sm, + }, t.name); + } + return ui.row(.{ .gap = 4, .cross = .center, .grow = 1 }, nodes); +} + +// --- active trace body: overview or error ---------------------------------- + +fn activeBody(ui: *Ui, model: *const Model) Node { + const t = model.activeTrace().?; + if (t.isError()) return errorPanel(ui, t); + return overview(ui, t); +} + +fn errorPanel(ui: *Ui, t: *const LoadedTrace) Node { + return ui.column(.{ .grow = 1, .main = .center, .cross = .center, .gap = 8, .padding = 24 }, .{ + ui.text(.{ .size = .heading }, "Could not read this trace"), + ui.text(.{}, t.load_error orelse "unknown error"), + }); +} + +fn overview(ui: *Ui, t: *const LoadedTrace) Node { + return ui.column(.{ .grow = 1, .gap = 16, .padding = 24 }, .{ + ui.text(.{ .size = .heading }, t.name), + ui.row(.{ .gap = 12, .cross = .start }, .{ + statTile(ui, t.eventCount(), "events"), + statTile(ui, t.sessionCount(), "sessions"), + statTile(ui, t.failureCount(), "failures"), + statTile(ui, t.warningCount(), "warnings"), + }), + ui.text(.{}, "The event timeline and inspector panes arrive in the next step."), + }); +} + +fn statTile(ui: *Ui, value: usize, label: []const u8) Node { + const value_str = std.fmt.allocPrint(ui.arena, "{d}", .{value}) catch "?"; + return ui.column(.{ .gap = 2, .padding = 12, .min_width = 92 }, .{ + ui.text(.{ .size = .heading }, value_str), + ui.text(.{}, label), + }); +} + +// --- status bar ------------------------------------------------------------ + +fn statusBar(ui: *Ui, model: *const Model) Node { + const t = model.activeTrace().?; + const text = if (t.isError()) + std.fmt.allocPrint(ui.arena, "Failed to load {s}: {s}", .{ t.name, t.load_error orelse "unknown error" }) catch "load failed" + else + std.fmt.allocPrint(ui.arena, "{d} events \u{00B7} {d} sessions \u{00B7} {d} failures \u{00B7} {d} warnings", .{ + t.eventCount(), t.sessionCount(), t.failureCount(), t.warningCount(), + }) catch ""; + return ui.statusBar(.{}, text); +} diff --git a/src/ui/ui.zig b/src/ui/ui.zig new file mode 100644 index 0000000..96a8458 --- /dev/null +++ b/src/ui/ui.zig @@ -0,0 +1,14 @@ +//! Aggregate root for the native inspector UI. Mirrors `ocpp/ocpp.zig`: it +//! references each UI module so `native test` discovers their tests, and gives +//! the rest of the app one import for the view layer. +//! +//! The UI is the only part of the app that touches `native_sdk` canvas types; +//! it consumes the pure engine (`ocpp/`) through the workspace Model. + +pub const workspace = @import("workspace.zig"); +pub const inspector = @import("inspector.zig"); + +test { + _ = workspace; + _ = inspector; +} diff --git a/src/ui/workspace.zig b/src/ui/workspace.zig new file mode 100644 index 0000000..af3c42a --- /dev/null +++ b/src/ui/workspace.zig @@ -0,0 +1,314 @@ +//! The inspector's application state: a bounded workspace of open traces plus +//! the `Msg` / `update` half of the TEA loop. Pure and headless — it imports the +//! engine and the canvas message types, never a platform or window module, so it +//! tests under `-Dplatform=null` exactly like the engine does. +//! +//! Each open trace owns a private arena holding everything the engine parsed for +//! it (events, sessions, failures, and the display name). Closing a trace frees +//! that one arena; the model itself allocates nothing per rebuild — the view +//! derives display strings into the per-build UI arena (`inspector.zig`). + +const std = @import("std"); +const ocpp = @import("../ocpp/ocpp.zig"); +const parser = ocpp.parser; +const timeline = ocpp.timeline; +const detection = ocpp.detection; +const types = ocpp.types; + +/// Upper bound on simultaneously open traces (workspace tabs). A hard cap keeps +/// the tab strip and per-trace arenas bounded; opening past it is a no-op. +pub const max_open_traces: usize = 8; + +/// 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 +/// lets the error stay on screen) — `load_error` is non-null and the engine +/// slices are empty. +pub const LoadedTrace = struct { + /// The private arena owning this trace's memory, or null for a slot that + /// never allocated (an out-of-memory load failure). + arena: ?*std.heap.ArenaAllocator = null, + /// Display name (usually the file's basename, or the sample label). + name: []const u8 = "", + events: []types.Event = &.{}, + sessions: []types.Session = &.{}, + failures: []types.Failure = &.{}, + warnings: []types.ParseWarning = &.{}, + /// Non-null when the trace could not be parsed; a human-readable reason. + load_error: ?[]const u8 = null, + /// The timeline row the user selected, if any (wired in the timeline PR). + selected_event: ?usize = null, + + pub fn isError(self: *const LoadedTrace) bool { + return self.load_error != null; + } + pub fn eventCount(self: *const LoadedTrace) usize { + return self.events.len; + } + pub fn sessionCount(self: *const LoadedTrace) usize { + return self.sessions.len; + } + pub fn failureCount(self: *const LoadedTrace) usize { + return self.failures.len; + } + pub fn warningCount(self: *const LoadedTrace) usize { + return self.warnings.len; + } + /// Count of detected failures at the given severity. + pub fn failuresOf(self: *const LoadedTrace, severity: types.FailureSeverity) usize { + var n: usize = 0; + for (self.failures) |f| { + if (f.severity == severity) n += 1; + } + return n; + } + + fn deinit(self: *LoadedTrace, backing: std.mem.Allocator) void { + if (self.arena) |ap| { + ap.deinit(); + backing.destroy(ap); + } + self.* = .{}; + } +}; + +pub const Msg = union(enum) { + /// Load the built-in sample trace into the workspace. + open_sample, + /// Make trace `payload` the active tab. + select_trace: usize, + /// Close trace `payload`, freeing its arena. + close_trace: usize, +}; + +pub const Model = struct { + /// Backing allocator for per-trace arenas. Defaults to the page allocator; + /// `main` may override it and tests inject `std.testing.allocator` so leaks + /// surface. Every open/close balances against this exact allocator. + backing: std.mem.Allocator = std.heap.page_allocator, + traces: [max_open_traces]LoadedTrace = [_]LoadedTrace{.{}} ** max_open_traces, + trace_count: usize = 0, + /// Index of the active trace within `traces[0..trace_count]`. + active: usize = 0, + + // --- derived (never stored) ------------------------------------------- + + pub fn hasTraces(self: *const Model) bool { + return self.trace_count > 0; + } + pub fn open(self: *const Model) []const LoadedTrace { + return self.traces[0..self.trace_count]; + } + /// The active trace, or null when the workspace is empty. + pub fn activeTrace(self: *const Model) ?*const LoadedTrace { + if (self.trace_count == 0) return null; + return &self.traces[self.active]; + } + + // --- mutation --------------------------------------------------------- + + /// Parse `bytes` and append the result as a new trace, made active. Silently + /// a no-op when the workspace is full. `name` is copied into the trace's + /// arena, so the caller's buffer is free on return. A parse failure still + /// appends a trace — one carrying `load_error` — so the reason is visible. + pub fn openBytes(self: *Model, name: []const u8, bytes: []const u8) void { + if (self.trace_count >= max_open_traces) return; + self.traces[self.trace_count] = loadTrace(self.backing, name, bytes); + self.active = self.trace_count; + self.trace_count += 1; + } + + /// Append a trace that failed before parsing (e.g. the file could not be + /// read), so the reason is visible in the workspace like any other error. + pub fn openLoadError(self: *Model, name: []const u8, reason: []const u8) void { + if (self.trace_count >= max_open_traces) return; + self.traces[self.trace_count] = errorTrace(self.backing, name, reason); + self.active = self.trace_count; + self.trace_count += 1; + } + + pub fn selectTrace(self: *Model, index: usize) void { + if (index < self.trace_count) self.active = index; + } + + /// Close trace `index`, freeing its arena and compacting the tab list. + pub fn closeTrace(self: *Model, index: usize) void { + if (index >= self.trace_count) return; + self.traces[index].deinit(self.backing); + var i = index; + while (i + 1 < self.trace_count) : (i += 1) { + self.traces[i] = self.traces[i + 1]; + } + self.trace_count -= 1; + self.traces[self.trace_count] = .{}; + if (self.trace_count == 0) { + self.active = 0; + } else if (self.active >= self.trace_count) { + self.active = self.trace_count - 1; + } else if (self.active > index) { + self.active -= 1; + } + } + + /// Free every open trace's arena. Call before the model goes away so no + /// arena outlives the app; opening more afterward is fine. + pub fn deinitAll(self: *Model) void { + var i: usize = 0; + while (i < self.trace_count) : (i += 1) self.traces[i].deinit(self.backing); + self.trace_count = 0; + self.active = 0; + } +}; + +/// The built-in sample trace: the vendored normal-session fixture, embedded so +/// the empty state has a one-click demo and the smoke test always has content. +/// `@embedFile` resolves within the `src/` module root, so `../ocpp/...` stays +/// in-tree. +pub const sample_name = "normal-session (sample)"; +pub const sample_bytes = @embedFile("../ocpp/testdata/normal-session.json"); + +pub fn update(model: *Model, msg: Msg) void { + switch (msg) { + .open_sample => model.openBytes(sample_name, sample_bytes), + .select_trace => |i| model.selectTrace(i), + .close_trace => |i| model.closeTrace(i), + } +} + +/// Run the whole engine pipeline for one trace into a fresh arena. On any engine +/// error the trace is returned in its error state (arena retained so `name` and +/// the reason survive on screen); only an arena-allocation failure yields the +/// null-arena fallback. +fn loadTrace(backing: std.mem.Allocator, name: []const u8, bytes: []const u8) LoadedTrace { + const arena_ptr = backing.create(std.heap.ArenaAllocator) catch { + return .{ .name = "", .load_error = "out of memory" }; + }; + arena_ptr.* = std.heap.ArenaAllocator.init(backing); + const a = arena_ptr.allocator(); + + const name_copy = a.dupe(u8, name) catch { + arena_ptr.deinit(); + backing.destroy(arena_ptr); + return .{ .name = "", .load_error = "out of memory" }; + }; + + const parsed = parser.parseTrace(a, bytes) catch |err| { + return .{ .arena = arena_ptr, .name = name_copy, .load_error = @errorName(err) }; + }; + const sessions = timeline.buildSessionTimeline(a, parsed.events) catch |err| { + return .{ .arena = arena_ptr, .name = name_copy, .events = parsed.events, .warnings = parsed.warnings, .load_error = @errorName(err) }; + }; + const failures = detection.detectFailures(a, parsed.events, sessions) catch |err| { + return .{ .arena = arena_ptr, .name = name_copy, .events = parsed.events, .sessions = sessions, .warnings = parsed.warnings, .load_error = @errorName(err) }; + }; + + return .{ + .arena = arena_ptr, + .name = name_copy, + .events = parsed.events, + .sessions = sessions, + .failures = failures, + .warnings = parsed.warnings, + }; +} + +/// Build an error-state trace holding just `name` and `reason` in a small arena +/// (for failures that happen before parsing, like a missing file). +fn errorTrace(backing: std.mem.Allocator, name: []const u8, reason: []const u8) LoadedTrace { + const arena_ptr = backing.create(std.heap.ArenaAllocator) catch { + return .{ .name = "", .load_error = "out of memory" }; + }; + arena_ptr.* = std.heap.ArenaAllocator.init(backing); + const a = arena_ptr.allocator(); + const name_copy = a.dupe(u8, name) catch ""; + const reason_copy = a.dupe(u8, reason) catch "load failed"; + return .{ .arena = arena_ptr, .name = name_copy, .load_error = reason_copy }; +} + +// --------------------------------------------------------------------------- +// Tests +// --------------------------------------------------------------------------- + +const testing = std.testing; + +test "empty workspace has no active trace" { + var model = Model{ .backing = testing.allocator }; + defer model.deinitAll(); + try testing.expect(!model.hasTraces()); + try testing.expectEqual(@as(?*const LoadedTrace, null), model.activeTrace()); +} + +test "opening the sample loads events, sessions, and correlates" { + var model = Model{ .backing = testing.allocator }; + defer model.deinitAll(); + + update(&model, .open_sample); + + try testing.expect(model.hasTraces()); + try testing.expectEqual(@as(usize, 1), model.trace_count); + const t = model.activeTrace().?; + try testing.expect(!t.isError()); + try testing.expectEqualStrings(sample_name, t.name); + // The vendored fixture is 22 events correlating into one completed session. + try testing.expectEqual(@as(usize, 22), t.eventCount()); + try testing.expectEqual(@as(usize, 1), t.sessionCount()); + try testing.expectEqual(types.Status.completed, t.sessions[0].status); +} + +test "select and close manage the active index and free arenas" { + var model = Model{ .backing = testing.allocator }; + defer model.deinitAll(); + + update(&model, .open_sample); + update(&model, .open_sample); + update(&model, .open_sample); + try testing.expectEqual(@as(usize, 3), model.trace_count); + try testing.expectEqual(@as(usize, 2), model.active); // newest is active + + update(&model, .{ .select_trace = 0 }); + try testing.expectEqual(@as(usize, 0), model.active); + + // Closing the active (0) keeps a valid active and compacts the list. + update(&model, .{ .close_trace = 0 }); + try testing.expectEqual(@as(usize, 2), model.trace_count); + try testing.expect(model.active < model.trace_count); + + // Closing out to empty resets cleanly (and leaks nothing — testing.allocator). + update(&model, .{ .close_trace = 0 }); + update(&model, .{ .close_trace = 0 }); + try testing.expect(!model.hasTraces()); +} + +test "closing a trace before the active one keeps the same trace active" { + var model = Model{ .backing = testing.allocator }; + defer model.deinitAll(); + update(&model, .open_sample); + update(&model, .open_sample); + update(&model, .open_sample); + update(&model, .{ .select_trace = 2 }); + update(&model, .{ .close_trace = 0 }); + // Was index 2 of 3; one earlier tab closed → now index 1 of 2. + try testing.expectEqual(@as(usize, 2), model.trace_count); + try testing.expectEqual(@as(usize, 1), model.active); +} + +test "a malformed trace opens in its error state without crashing" { + var model = Model{ .backing = testing.allocator }; + defer model.deinitAll(); + + model.openBytes("broken.json", "this is not a trace"); + + try testing.expect(model.hasTraces()); + const t = model.activeTrace().?; + try testing.expect(t.isError()); + try testing.expectEqualStrings("broken.json", t.name); + try testing.expectEqual(@as(usize, 0), t.eventCount()); +} + +test "the workspace is bounded at max_open_traces" { + var model = Model{ .backing = testing.allocator }; + defer model.deinitAll(); + var i: usize = 0; + while (i < max_open_traces + 3) : (i += 1) update(&model, .open_sample); + try testing.expectEqual(max_open_traces, model.trace_count); +}