diff --git a/AGENTS.md b/AGENTS.md index 15ce81a..589162e 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -23,8 +23,8 @@ studio/ ├── src/ │ ├── 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) +│ │ ├── workspace.zig # Model, Msg, update — traces, selection, filter/search state +│ │ ├── inspector.zig # canvas.Ui builder view: timeline, inspector, panels, filter (ADR-0006) │ │ └── ui.zig # aggregate root (test discovery) │ ├── ocpp/ # pure, headless OCPP engine (types, parser, detection, …) │ │ └── conformance/ # vendored shared-contract fixtures + goldens + harness diff --git a/CURRENT_STATE.md b/CURRENT_STATE.md index ad9bfdb..ea77025 100644 --- a/CURRENT_STATE.md +++ b/CURRENT_STATE.md @@ -8,8 +8,10 @@ ## Active milestone -**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)). +**S3 — Inspector UI (0.2.0): complete.** The engine (S0–S2) and the native +inspector (open, virtualized timeline, message inspector, session + failure +panels, search & filter) are done. Next up is **S4 — Analysis parity+ (0.3.0)** +(see [ROADMAP.md](ROADMAP.md)). ## What's done @@ -70,9 +72,9 @@ toolkit's: **Exit criteria met:** 15/15 scenarios match the locked goldens. -## What's in progress +### S3 — Inspector UI (0.2.0) ✅ -**S3 — Inspector UI (0.2.0).** Landed so far: +Every issue landed: - **Inspector shell (#27)** — the placeholder counter is replaced by a Zig `canvas.Ui` builder view (ADR-0006), a bounded multi-trace workspace `Model` / @@ -103,14 +105,20 @@ toolkit's: steps and affected events (accordion) and jumps to its primary event, so a failure and its evidence line up. A clean trace shows a positive "no failures detected" state; the status bar carries the severity breakdown. +- **Search & filter (#32)** — a toolbar over the timeline: a free-text search + field (matching action / unique id / payload, case-insensitive) plus AND-composable + toggle facets (message type, direction, severity). The filtered index set is + derived in the build arena and drives the virtual list, so filtering a huge + trace stays viewport-sized (hidden rows never become widgets); the status bar + shows the match count and an empty result shows a quiet "no matching events". -Still ahead in S3: search / filter (#32), which closes the milestone. -Interactive open (native dialog + drag-drop) is deferred to #33 — it needs an -ejected runner (see ADR-0006). +Deferred out of S3: interactive open — native dialog + drag-drop (#33) — and the +timeline viewport scroll on jump (session/failure), both of which need an ejected +runner (see ADR-0006). Tracked as follow-ups, not blockers. ## What's next -After S3: **S4 — Analysis parity+ (0.3.0)** — reports, anonymize, diff, +**S4 — Analysis parity+ (0.3.0)** — reports (Markdown / HTML), anonymize, diff, wall-clock replay, and a headless CLI mode. ## Known blockers / decisions pending @@ -124,7 +132,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 + inspector + failure panel (S3, #27–#28, #30–#31); search next | +| `ui` (native views) | ✅ S3 inspector — timeline, message inspector, session + failure panels, search & filter (#27–#32) | | `capture` (live proxy) | ⬜ not started (S5) | | `cli` (headless) | ⬜ not started (S4) | | `conformance` | ✅ done for S2 (15/15, `contract-v1`) | diff --git a/ROADMAP.md b/ROADMAP.md index c80289c..94a182f 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -11,8 +11,8 @@ the stable part. | **S0** | 0.0.x | Foundation | ✅ Complete | | **S1** | 0.1.0 | Engine core | ✅ Complete | | **S2** | 0.1.0 | Detection + conformance | ✅ Complete | -| **S3** | 0.2.0 | Inspector UI | Next up | -| **S4** | 0.3.0 | Analysis parity+ | Planned | +| **S3** | 0.2.0 | Inspector UI | ✅ Complete | +| **S4** | 0.3.0 | Analysis parity+ | Next up | | **S5** | 0.4.0 | Live capture ⭐ | Planned | | **S6** | 1.0.0 | 1.0 polish | Planned | @@ -40,11 +40,14 @@ detected failures against locked goldens generated from the toolkit. **Exit criteria met:** 15/15 scenarios match the `contract-v1` goldens under `native test`. -## S3 — Inspector UI (0.2.0) +## S3 — Inspector UI (0.2.0) ✅ -The native inspector: open and drag-drop traces, a virtualized event timeline, -the per-message inspector, the failure panel, search / filter, and a -multi-trace workspace. Handles traces far larger than a browser tab can hold. +The native inspector: a virtualized event timeline, the per-message inspector +(raw / normalized / payload tree), the session and failure panels, search / +filter, and a multi-trace workspace — handling traces far larger than a browser +tab can hold. Traces open from command-line paths and a built-in sample; +interactive open (native dialog + drag-drop) is deferred to #33 (needs an +ejected runner, ADR-0006). ## S4 — Analysis parity+ (0.3.0) diff --git a/src/tests.zig b/src/tests.zig index a9736c9..31d1e74 100644 --- a/src/tests.zig +++ b/src/tests.zig @@ -308,6 +308,112 @@ test "the failure panel lists failures, expands steps, and jumps to the event" { _ = try expectByText(tree.root, .text, "Affected"); } +fn countNodes(widget: canvas.Widget) usize { + var n: usize = 1; + for (widget.children) |child| n += countNodes(child); + return n; +} + +fn statusText(tree: Ui.Tree) []const u8 { + return (findKind(tree.root, .status_bar) orelse return "").text; +} + +test "a message-type facet narrows the timeline and the Clear button restores it" { + 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); + + var tree = try buildTree(arena, &model); + // The filter bar renders its facet buttons and a search field. + _ = try expectByText(tree.root, .button, "Call"); + try testing.expect(findKind(tree.root, .search_field) != null); + try testing.expect(std.mem.indexOf(u8, statusText(tree), "22 events") != null); + + // Filter to Call messages: the status count reflects the match subset. + const call = try expectByText(tree.root, .button, "Call"); + main.update(&model, tree.msgForPointer(call.id, .up).?); + tree = try buildTree(arena, &model); + try testing.expect(std.mem.indexOf(u8, statusText(tree), " of 22 events") != null); + + // Clear restores the full timeline. + const clear = try expectByText(tree.root, .button, "Clear"); + main.update(&model, tree.msgForPointer(clear.id, .up).?); + tree = try buildTree(arena, &model); + try testing.expect(std.mem.indexOf(u8, statusText(tree), " of 22") == null); + try testing.expect(std.mem.indexOf(u8, statusText(tree), "22 events") != null); +} + +test "typing in the search field filters, and clearing it through the input path restores" { + 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); + + var tree = try buildTree(arena, &model); + const field = findKind(tree.root, .search_field) orelse return error.WidgetNotFound; + + // Type an action name: only its event(s) remain, and BootNotification stays. + main.update(&model, tree.msgForTextEdit(field.id, .{ .insert_text = "BootNotification" }).?); + tree = try buildTree(arena, &model); + try testing.expect(std.mem.indexOf(u8, statusText(tree), " of 22 events") != null); + _ = try expectByText(tree.root, .text, "BootNotification"); + + // The search-field clear affordance (x / Escape) arrives as `.clear`. + const field2 = findKind(tree.root, .search_field) orelse return error.WidgetNotFound; + main.update(&model, tree.msgForTextEdit(field2.id, .clear).?); + tree = try buildTree(arena, &model); + try testing.expect(std.mem.indexOf(u8, statusText(tree), " of 22") == null); + try testing.expect(std.mem.indexOf(u8, statusText(tree), "22 events") != null); +} + +test "a non-matching search shows the empty-result state" { + 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); + // Drive the query directly through update (same path the field dispatches). + workspace.update(&model, .{ .search_input = .{ .insert_text = "zzz-no-such-event" } }); + + const tree = try buildTree(arena, &model); + _ = try expectByText(tree.root, .text, "No matching events"); + try testing.expect(std.mem.indexOf(u8, statusText(tree), "0 of 22 events") != null); +} + +test "filtering a large trace keeps the widget tree viewport-sized" { + 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(); + + // 2000 events (Heartbeat call/result pairs) as JSONL — well past a viewport. + var buf: std.ArrayList(u8) = .empty; + defer buf.deinit(testing.allocator); + var i: usize = 0; + while (i < 1000) : (i += 1) { + try buf.appendSlice(testing.allocator, "{\"message\":[2,\"m\",\"Heartbeat\",{}]}\n{\"message\":[3,\"m\",{}]}\n"); + } + model.openBytes("big.jsonl", buf.items); + // Filter to Calls (~1000 matches): the window, not the match count, bounds nodes. + workspace.update(&model, .{ .toggle_type_filter = .call }); + + const tree = try buildTree(arena, &model); + try testing.expect(std.mem.indexOf(u8, statusText(tree), "of 2000 events") != null); + // No materialization of hidden rows: the whole tree stays viewport-sized. + try testing.expect(countNodes(tree.root) < 1024); +} + test "the virtual window stays viewport-sized at dataset scale" { var arena_state = std.heap.ArenaAllocator.init(testing.allocator); defer arena_state.deinit(); @@ -391,6 +497,12 @@ test "the inspector view passes the accessibility sweep (empty and loaded)" { workspace.update(&failing, .{ .select_failure = 0 }); tree = try buildTree(arena, &failing); try canvas.expectA11yAuditSweepClean(testing.allocator, tree.root, sweep); + + // The filter bar (search field + facets) with an active query. + workspace.update(&model, .{ .search_input = .{ .insert_text = "Status" } }); + workspace.update(&model, .{ .toggle_type_filter = .call }); + 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 18d665c..2fdd1cd 100644 --- a/src/ui/inspector.zig +++ b/src/ui/inspector.zig @@ -28,11 +28,27 @@ const row_extent: f32 = 44; pub fn view(ui: *Ui, model: *const Model) Node { if (!model.hasTraces()) return emptyState(ui); + const t = model.activeTrace().?; + if (t.isError()) { + return ui.column(.{ .grow = 1 }, .{ + topBar(ui, model), + ui.separator(.{}), + errorPanel(ui, t), + statusBar(ui, model, null), + }); + } + // Derive the filtered index set ONCE (in the build arena) and thread it to + // the timeline and the status bar. Null = no active filter: the timeline + // indexes `t.events` directly, so an unfiltered huge trace allocates nothing. + const filtered: ?[]const usize = + if (model.filter.isActive()) filteredIndices(ui, t, &model.filter) else null; return ui.column(.{ .grow = 1 }, .{ topBar(ui, model), ui.separator(.{}), - activeBody(ui, model), - statusBar(ui, model), + filterBar(ui, model), + ui.separator(.{}), + activeBody(ui, model, filtered), + statusBar(ui, model, filtered), }); } @@ -78,9 +94,8 @@ fn tabStrip(ui: *Ui, model: *const Model) Node { // --- active trace body: overview or error ---------------------------------- -fn activeBody(ui: *Ui, model: *const Model) Node { +fn activeBody(ui: *Ui, model: *const Model, filtered: ?[]const usize) Node { const t = model.activeTrace().?; - if (t.isError()) return errorPanel(ui, t); // Timeline (left) / detail (right) fill the space above a fixed-height // failure drawer. `split` is horizontal-only, so the vertical stack is a // column: the split grows, the drawer keeps its height. @@ -90,7 +105,7 @@ fn activeBody(ui: *Ui, model: *const Model) Node { .value = model.timeline_split, .on_resize = Ui.valueMsg(.timeline_resized), }, .{ - timelinePane(ui, t), + timelinePane(ui, t, filtered), detailPane(ui, t), }), ui.separator(.{}), @@ -105,12 +120,146 @@ fn errorPanel(ui: *Ui, t: *const LoadedTrace) Node { }); } +// --- filter bar (#32) ------------------------------------------------------ +// +// A full-width toolbar over the timeline: a free-text search field plus toggle +// facets (message type, direction, severity) that AND-compose. The derived +// filtered index set (below) drives the virtual list, so hidden rows are never +// materialized. + +fn filterBar(ui: *Ui, model: *const Model) Node { + const f = &model.filter; + return ui.row(.{ .padding = 8, .gap = 6, .cross = .center }, .{ + ui.el(.search_field, .{ + .grow = 1, + .min_width = 160, + .text = f.searchText(), + .placeholder = "Search action, id, or payload", + .on_input = Ui.inputMsg(.search_input), + .semantics = .{ .label = "search events" }, + }, .{}), + facetButton(ui, "Call", .{ .toggle_type_filter = .call }, f.message_type == .call), + facetButton(ui, "Result", .{ .toggle_type_filter = .call_result }, f.message_type == .call_result), + facetButton(ui, "Error", .{ .toggle_type_filter = .call_error }, f.message_type == .call_error), + ui.separator(.{}), + facetButton(ui, "From CP", .{ .toggle_direction_filter = .cs_to_csms }, f.direction == .cs_to_csms), + facetButton(ui, "From CSMS", .{ .toggle_direction_filter = .csms_to_cs }, f.direction == .csms_to_cs), + ui.separator(.{}), + facetButton(ui, "Crit", .{ .toggle_severity_filter = .critical }, f.severity == .critical), + facetButton(ui, "Warn", .{ .toggle_severity_filter = .warning }, f.severity == .warning), + facetButton(ui, "Info", .{ .toggle_severity_filter = .info }, f.severity == .info), + clearFilterButton(ui, f), + }); +} + +fn facetButton(ui: *Ui, label: []const u8, msg: Msg, active: bool) Node { + return ui.button(.{ + .on_press = msg, + .selected = active, + .variant = if (active) .secondary else .ghost, + .size = .sm, + }, label); +} + +fn clearFilterButton(ui: *Ui, f: *const workspace.Filter) Node { + if (!f.isActive()) return ui.spacer(0); + return ui.button(.{ .on_press = .clear_filters, .variant = .ghost, .size = .sm }, "Clear"); +} + +// --- filter predicate + filtered index derive ------------------------------ + +/// The matching event indices, in timeline order, allocated in the build arena. +/// Called only when the filter is active (see `view`). +fn filteredIndices(ui: *Ui, t: *const LoadedTrace, f: *const workspace.Filter) []const usize { + var list: std.ArrayList(usize) = .empty; + const needle = f.searchText(); + for (t.events, 0..) |e, i| { + if (matchesFilter(t, e, f, needle)) { + list.append(ui.arena, i) catch { + ui.failed = true; + break; + }; + } + } + return list.items; +} + +fn matchesFilter(t: *const LoadedTrace, e: types.Event, f: *const workspace.Filter, needle: []const u8) bool { + if (f.direction) |d| if (e.direction != d) return false; + if (f.message_type) |mt| if (e.message_type != mt) return false; + if (f.severity) |sev| if (!participatesInSeverity(t, e.id, sev)) return false; + if (needle.len > 0) if (!matchesText(e, needle)) return false; + return true; +} + +/// True when `event_id` participates in any detected failure of severity `sev`. +fn participatesInSeverity(t: *const LoadedTrace, event_id: []const u8, sev: types.FailureSeverity) bool { + for (t.failures) |failure| { + if (failure.severity != sev) continue; + for (failure.event_ids) |eid| { + if (std.mem.eql(u8, eid, event_id)) return true; + } + } + return false; +} + +/// Case-insensitive free-text match over the event's action, unique id, error +/// fields, and payload (bounded scan of string keys/values). +fn matchesText(e: types.Event, needle: []const u8) bool { + if (e.action) |a| if (containsCI(a, needle)) return true; + if (containsCI(e.message_id, needle)) return true; + if (e.error_code) |c| if (containsCI(c, needle)) return true; + if (e.error_description) |d| if (containsCI(d, needle)) return true; + return payloadContainsText(e.payload, needle, 0); +} + +fn containsCI(haystack: []const u8, needle: []const u8) bool { + return std.ascii.indexOfIgnoreCase(haystack, needle) != null; +} + +fn payloadContainsText(value: std.json.Value, needle: []const u8, depth: usize) bool { + if (depth > 4) return false; + switch (value) { + .string => |s| return containsCI(s, needle), + .number_string => |s| return containsCI(s, needle), + .array => |a| { + for (a.items, 0..) |item, i| { + if (i >= 32) break; + if (payloadContainsText(item, needle, depth + 1)) return true; + } + return false; + }, + .object => |o| { + const keys = o.keys(); + const vals = o.values(); + for (keys, 0..) |k, i| { + if (i >= 32) break; + if (containsCI(k, needle)) return true; + if (payloadContainsText(vals[i], needle, depth + 1)) return true; + } + return false; + }, + else => return false, + } +} + // --- timeline pane: the windowed virtual list ------------------------------ -fn timelinePane(ui: *Ui, t: *const LoadedTrace) Node { +/// `filtered` null = show all events (display index == event index); non-null = +/// the filtered event indices, so only matching rows exist and hidden rows never +/// become widgets (the window stays viewport-sized either way). +fn timelinePane(ui: *Ui, t: *const LoadedTrace, filtered: ?[]const usize) Node { + const count = if (filtered) |fi| fi.len else t.events.len; + if (filtered != null and count == 0) { + return ui.column(.{ .min_width = 360, .grow = 1 }, .{ + timelineHeader(ui), + ui.separator(.{}), + centeredNote(ui, "No matching events"), + }); + } const opts = Ui.VirtualListOptions{ .id = "event-timeline", - .item_count = t.events.len, + .item_count = count, .item_extent = row_extent, .overscan = 6, .grow = 1, @@ -125,7 +274,8 @@ fn timelinePane(ui: *Ui, t: *const LoadedTrace) Node { return ui.column(.{ .min_width = 360, .grow = 1 }, .{}); }; for (rows, 0..) |*row, offset| { - const index = window.start_index + offset; + const display = window.start_index + offset; + const index = if (filtered) |fi| fi[display] else display; var node = eventRow(ui, t, index); node.key = .{ .int = @intCast(index) }; // identity = the event, not the slot row.* = node; @@ -839,17 +989,24 @@ fn failuresSummary(arena: std.mem.Allocator, failures: []const types.Failure) [] // --- status bar ------------------------------------------------------------ -fn statusBar(ui: *Ui, model: *const Model) Node { +fn statusBar(ui: *Ui, model: *const Model, filtered: ?[]const usize) 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 if (t.detection_skipped) - std.fmt.allocPrint(ui.arena, "{d} events \u{00B7} {d} sessions \u{00B7} detection skipped (large trace) \u{00B7} {d} parse warnings", .{ - t.eventCount(), t.sessionCount(), t.warningCount(), + if (t.isError()) { + const msg = std.fmt.allocPrint(ui.arena, "Failed to load {s}: {s}", .{ t.name, t.load_error orelse "unknown error" }) catch "load failed"; + return ui.statusBar(.{}, msg); + } + // When a filter is active the count reflects matches out of the whole trace. + const events_seg = if (filtered) |fi| + std.fmt.allocPrint(ui.arena, "{d} of {d} events", .{ fi.len, t.eventCount() }) catch "" + else + std.fmt.allocPrint(ui.arena, "{d} events", .{t.eventCount()}) catch ""; + const text = if (t.detection_skipped) + std.fmt.allocPrint(ui.arena, "{s} \u{00B7} {d} sessions \u{00B7} detection skipped (large trace) \u{00B7} {d} parse warnings", .{ + events_seg, t.sessionCount(), t.warningCount(), }) catch "" else - std.fmt.allocPrint(ui.arena, "{d} events \u{00B7} {d} sessions \u{00B7} {s} \u{00B7} {d} parse warnings", .{ - t.eventCount(), t.sessionCount(), failuresSummary(ui.arena, t.failures), t.warningCount(), + std.fmt.allocPrint(ui.arena, "{s} \u{00B7} {d} sessions \u{00B7} {s} \u{00B7} {d} parse warnings", .{ + events_seg, t.sessionCount(), failuresSummary(ui.arena, t.failures), t.warningCount(), }) catch ""; return ui.statusBar(.{}, text); } @@ -905,3 +1062,35 @@ test "failuresSummary breaks down nonzero severities" { const one = [_]types.Failure{testFailure(.heartbeat_interval_violation, .info, "evt-0001")}; try testing.expectEqualStrings("1 failure: 1 info", failuresSummary(a, &one)); } + +test "matchesText searches action, id, and payload case-insensitively" { + var arena_state = std.heap.ArenaAllocator.init(testing.allocator); + defer arena_state.deinit(); + const a = arena_state.allocator(); + + const payload = std.json.parseFromSliceLeaky( + std.json.Value, + a, + "{\"idTag\":\"ABC123\",\"nested\":{\"reason\":\"Local\"}}", + .{}, + ) catch unreachable; + const e = types.Event{ + .id = "evt-0001", + .message_id = "msg-77", + .timestamp = null, + .direction = .cs_to_csms, + .message_type = .call, + .action = "Authorize", + .payload = payload, + .error_code = null, + .error_description = null, + .raw_message = .null, + }; + + try testing.expect(matchesText(e, "auth")); // action, case-insensitive + try testing.expect(matchesText(e, "MSG-77")); // unique id, case-insensitive + try testing.expect(matchesText(e, "abc123")); // payload string value + try testing.expect(matchesText(e, "idTag")); // payload key + try testing.expect(matchesText(e, "local")); // nested value, case-insensitive + try testing.expect(!matchesText(e, "zzz")); +} diff --git a/src/ui/workspace.zig b/src/ui/workspace.zig index 6dd985f..8cb8236 100644 --- a/src/ui/workspace.zig +++ b/src/ui/workspace.zig @@ -9,6 +9,8 @@ //! derives display strings into the per-build UI arena (`inspector.zig`). const std = @import("std"); +const native_sdk = @import("native_sdk"); +const canvas = native_sdk.canvas; const ocpp = @import("../ocpp/ocpp.zig"); const parser = ocpp.parser; const timeline = ocpp.timeline; @@ -33,6 +35,38 @@ pub const max_payload_tree_nodes: usize = 100; /// resets to empty when the trace is freed. pub const PayloadCollapse = std.StaticBitSet(max_payload_tree_nodes); +/// Max length of the timeline free-text search query. +pub const max_search_len: usize = 128; + +/// Timeline search + filter state. Facets AND-compose; an inactive filter shows +/// the whole timeline. Lives on the `Model` (never memcpy'd) and stores the +/// search query as a fixed buffer + length + caret — no stored slice pointer — +/// so the struct stays trivially copyable and self-reference-free. +pub const Filter = struct { + search_buf: [max_search_len]u8 = undefined, + search_len: usize = 0, + search_sel: canvas.TextSelection = .{}, + /// Facets (null = any). + direction: ?types.Direction = null, + message_type: ?types.MessageType = null, + /// Match events that participate in a failure of this severity. + severity: ?types.FailureSeverity = null, + + pub fn searchText(self: *const Filter) []const u8 { + return self.search_buf[0..self.search_len]; + } + + pub fn isActive(self: *const Filter) bool { + return self.search_len > 0 or self.direction != null or + self.message_type != null or self.severity != null; + } + + /// A `TextEditState` view over the current query (slice computed fresh). + pub fn editState(self: *const Filter) canvas.TextEditState { + return .{ .text = self.searchText(), .selection = self.search_sel }; + } +}; + /// 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 @@ -117,6 +151,16 @@ pub const Msg = union(enum) { select_failure: usize, /// The timeline / detail splitter moved to fraction `payload`. timeline_resized: f32, + /// A text edit (`insert`, `delete`, `clear`, …) from the search field. + search_input: canvas.TextInputEvent, + /// Toggle the direction facet to `payload` (or off, if already set to it). + toggle_direction_filter: types.Direction, + /// Toggle the message-type facet to `payload` (or off). + toggle_type_filter: types.MessageType, + /// Toggle the severity facet to `payload` (or off). + toggle_severity_filter: types.FailureSeverity, + /// Reset every facet and the search query. + clear_filters, }; pub const Model = struct { @@ -131,6 +175,9 @@ pub const Model = struct { /// The timeline / detail splitter fraction (first pane), model-owned so the /// `split` reconcile echoes it back through `value` after each drag. timeline_split: f32 = 0.62, + /// Search + filter applied to the active trace's timeline. Model-owned (not + /// per-trace) so the query buffer keeps a stable address. + filter: Filter = .{}, // --- derived (never stored) ------------------------------------------- @@ -232,6 +279,26 @@ pub const Model = struct { } } + /// Apply a search-field text edit to the query buffer. The edit is applied + /// into a scratch buffer (no aliasing with the persistent one), then copied + /// back and re-pointed. An edit that would overflow the query is dropped. + pub fn applySearchInput(self: *Model, ev: canvas.TextInputEvent) void { + var scratch: [max_search_len]u8 = undefined; + const next = canvas.applyTextInputEvent(self.filter.editState(), ev, &scratch) catch return; + const n = @min(next.text.len, self.filter.search_buf.len); + @memcpy(self.filter.search_buf[0..n], next.text[0..n]); + self.filter.search_len = n; + self.filter.search_sel = .{ + .anchor = @min(next.selection.anchor, n), + .focus = @min(next.selection.focus, n), + }; + } + + /// Reset every facet and the search query to the inactive state. + pub fn clearFilters(self: *Model) void { + self.filter = .{}; + } + /// 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; @@ -278,6 +345,11 @@ pub fn update(model: *Model, msg: Msg) void { .select_session => |i| model.selectSession(i), .select_failure => |i| model.selectFailure(i), .timeline_resized => |f| model.timeline_split = f, + .search_input => |ev| model.applySearchInput(ev), + .toggle_direction_filter => |d| model.filter.direction = if (model.filter.direction == d) null else d, + .toggle_type_filter => |t| model.filter.message_type = if (model.filter.message_type == t) null else t, + .toggle_severity_filter => |s| model.filter.severity = if (model.filter.severity == s) null else s, + .clear_filters => model.clearFilters(), } } @@ -523,6 +595,45 @@ test "selecting a failure expands it and jumps to its primary event" { try testing.expectEqual(@as(?usize, sel), model.activeTrace().?.selected_event); } +test "search input builds and clears the query through edit events" { + var model = Model{ .backing = testing.allocator }; + defer model.deinitAll(); + update(&model, .open_sample); + try testing.expect(!model.filter.isActive()); + + update(&model, .{ .search_input = .{ .insert_text = "Boot" } }); + try testing.expectEqualStrings("Boot", model.filter.searchText()); + try testing.expect(model.filter.isActive()); + + update(&model, .{ .search_input = .delete_backward }); + try testing.expectEqualStrings("Boo", model.filter.searchText()); + + // The search field's clear affordance (x / Escape) arrives as `.clear`. + update(&model, .{ .search_input = .clear }); + try testing.expectEqualStrings("", model.filter.searchText()); + try testing.expect(!model.filter.isActive()); +} + +test "facet filters toggle on and off and clear together" { + var model = Model{ .backing = testing.allocator }; + defer model.deinitAll(); + update(&model, .open_sample); + + update(&model, .{ .toggle_type_filter = .call }); + try testing.expectEqual(@as(?types.MessageType, .call), model.filter.message_type); + update(&model, .{ .toggle_type_filter = .call }); // same value toggles it off + try testing.expectEqual(@as(?types.MessageType, null), model.filter.message_type); + + update(&model, .{ .toggle_direction_filter = .cs_to_csms }); + update(&model, .{ .toggle_severity_filter = .critical }); + try testing.expect(model.filter.isActive()); + + update(&model, .clear_filters); + try testing.expect(!model.filter.isActive()); + try testing.expectEqual(@as(?types.Direction, null), model.filter.direction); + try testing.expectEqual(@as(?types.FailureSeverity, null), model.filter.severity); +} + test "the splitter fraction is model-owned and echoed by update" { var model = Model{ .backing = testing.allocator }; defer model.deinitAll();