From 01eee359fd0f038e173c490ea118c39bfc0ff317 Mon Sep 17 00:00:00 2001 From: "fizzy-linux[bot]" <346940526+fizzy-linux[bot]@users.noreply.github.com> Date: Wed, 28 Oct 2026 10:00:09 -0500 Subject: [PATCH 1/2] dashboard: act after the draw; a later provider beats the demo The view recorded nothing: it called act mid-draw and kept reading the card list act may free. It now records the picked (card, action) and calls act once the stage is drawn. The demo cards were registered as the service at load, shadowing a provider loaded later. They are now the view's fallback, never registered. Agent-Team: linux Worker: claude Co-Authored-By: Claude Sonnet 5.5 --- build/app.zig | 1 + docs/PLUGINS.md | 2 +- plugins/dashboard/plugin.zig | 17 ++- plugins/dashboard/src/View.zig | 149 ++++++++++++++++++++++++--- plugins/dashboard/src/demo_cards.zig | 4 +- tests/integration.zig | 2 +- 6 files changed, 148 insertions(+), 27 deletions(-) diff --git a/build/app.zig b/build/app.zig index 87cbd8d30..42c2247a5 100644 --- a/build/app.zig +++ b/build/app.zig @@ -1686,6 +1686,7 @@ fn addTests( // And the workbench's panes: whether a float lies over one is a reading of the window's // subwindows, which a headless frame with a float in it has. integration_module.addImport("workbench", workbench_module_test); + integration_module.addImport("dashboard", dashboard_module_test); integration_module.addAnonymousImport("markdown_sample", .{ .root_source_file = b.path("docs/PLUGINS.md") }); // The synthetic document with the 48KB table of wrapping cells — the case table-row culling // exists for, and the one it could get wrong. Generated by `markdown_sample_tables.py` beside diff --git a/docs/PLUGINS.md b/docs/PLUGINS.md index 51107f50b..e5ebd7696 100644 --- a/docs/PLUGINS.md +++ b/docs/PLUGINS.md @@ -1327,7 +1327,7 @@ The SDK ships definitions for the services in this ecosystem, in | `"wikilink"` | any indexer (e.g. `brain`) | Resolve `[[Note]]` to a file, plus completion candidates and index state | | `"automation"` | the application (fizzy: `app/automation/Service.zig`, over its live driver) | `play` a live tape — input on the app as it is, built with the `tape` module and handed over as ZON or binary bytes — and get a ticket to ask its `outcome` (finished, or interrupted / timed out / stopped before which op); `stop` your own tape; `settled` when nothing is in flight; `snapshot` / `snapshotText` for what is on screen as ZON, a line per widget a person acts on or a tape aims at (role, name, tag, rect, parent — `replay.Snapshot`). One tape at a time, refused while a demo plays; a person's input stops it. For plugin tests, macros, tutorials | | `"demos"` | the application (fizzy: `src/editor/Demos.zig`, over its demo player) | `list` every demo the app can play — its own, and each one a plugin registered with `host.registerDemo` (a name, a title, the tape as ZON or binary bytes, copied by the host and dropped when the plugin goes) — with each one's length, chapters and where it came from; `play` one by name, from a chapter if asked; `current` names the one playing; `generation` moves when the list does. A demo opens on a keyframe and sets the person's session aside untouched while it plays, unsaved work and all; refused only while a live tape drives. For a guides browser, a plugin's own demos, an agent | -| `"dashboard.cards"` | the application, a team layer or a plugin (the `dashboard` plugin registers a demo of three cards only when none is there) | `list` the cards that need the person (id, title, source, the full body, and actions with an emphasis), `act` with the card and action the person picked (the provider does it and drops the card, so it leaves the count), `generation` moves when the list does. Cards in, actions out: the `dashboard` plugin draws them one at a time and knows nothing of where they come from. The provider owns every string | +| `"dashboard.cards"` | the application, a team layer or a plugin (the `dashboard` plugin shows a demo of three cards, without registering it, while none is there) | `list` the cards that need the person (id, title, source, the full body, and actions with an emphasis), `act` with the card and action the person picked (the provider does it and drops the card, so it leaves the count), `generation` moves when the list does. Cards in, actions out: the `dashboard` plugin draws them one at a time and knows nothing of where they come from. The provider owns every string | Everything a plugin might reasonably ask *fizzy* for lives on `Host` instead — `openFile`, `closeDocById`, `docFromPath`, `revealPosition`, `setProjectFolder`, `isPathIgnored`, … — where it diff --git a/plugins/dashboard/plugin.zig b/plugins/dashboard/plugin.zig index 2abbab8d4..e336175ee 100644 --- a/plugins/dashboard/plugin.zig +++ b/plugins/dashboard/plugin.zig @@ -4,15 +4,16 @@ //! them and hears which action the person picked. The plugin owns no cards and no colours of its //! own; it draws from the host's theme, so another app's look applies. //! -//! With no provider registered when it loads, it registers a small demo of three cards -//! (`src/demo_cards.zig`) so it can be tried; an app with a provider of its own never sees them. +//! With no provider registered, it shows a small demo of three cards (`src/demo_cards.zig`) so it +//! can be tried. The demo is never registered as the service, so a provider loaded at any time, +//! before or after this plugin, is the one listed; an app with one of its own never sees them. //! //! Commands: `dashboard.show` reveals the view wherever it lives; `dashboard.status` answers the //! carousel's state as ZON (how many cards, which is shown, how many are drawn), for an agent. const std = @import("std"); const sdk = @import("fizzy_sdk"); const dvui = @import("dvui"); -const View = @import("src/View.zig"); +pub const View = @import("src/View.zig"); const demo_cards = @import("src/demo_cards.zig"); /// Injected at build time from `plugin.zig.zon`. @@ -21,8 +22,6 @@ pub const plugin_id = plugin_options.id; pub const view_id = "dashboard.view"; -const Api = sdk.services.dashboard_cards.Api; - var plugin: sdk.Plugin = .{ .state = undefined, .vtable = &vtable, @@ -38,10 +37,7 @@ pub fn register(host: *sdk.Host) !void { host_ptr = host; plugin.state = @ptrCast(host); try host.registerPlugin(&plugin); - if (host.getServiceTyped(Api) == null) { - demo_cards.reset(); - try host.registerService(Api, &demo_cards.api, &plugin); - } + demo_cards.reset(); try host.registerSurface(.{ .id = view_id, .owner = &plugin, @@ -87,7 +83,7 @@ fn cmdShow(state: *anyopaque) anyerror!void { fn cmdStatus(state: *anyopaque, call: *sdk.Command.Call) anyerror!void { const host: *sdk.Host = @ptrCast(@alignCast(state)); - const count = if (host.getServiceTyped(Api)) |api| api.list().len else 0; + const count = View.provider(host).list().len; var out: std.Io.Writer.Allocating = .init(call.arena); try out.writer.print(".{{ .count = {d}, .index = {d}, .drawn = {d} }}\n", .{ count, View.carousel.index, View.drawn }); call.result = out.written(); @@ -96,4 +92,5 @@ fn cmdStatus(state: *anyopaque, call: *sdk.Command.Call) anyerror!void { test { _ = @import("src/Carousel.zig"); _ = demo_cards; + _ = View; } diff --git a/plugins/dashboard/src/View.zig b/plugins/dashboard/src/View.zig index 3730fa0d9..b65eafb50 100644 --- a/plugins/dashboard/src/View.zig +++ b/plugins/dashboard/src/View.zig @@ -11,6 +11,7 @@ const sdk = @import("fizzy_sdk"); const dvui = @import("dvui"); const core = @import("core"); const Carousel = @import("Carousel.zig"); +const demo_cards = @import("demo_cards.zig"); const Api = sdk.services.dashboard_cards.Api; const Card = Api.Card; @@ -23,23 +24,39 @@ pub var carousel: Carousel = .{}; /// agent reads to know the carousel shows one at a time. pub var drawn: usize = 0; +/// The provider whose cards are shown: whichever the host has, else the demo's. The demo is a +/// fallback and not a registered service, so a provider loaded after the dashboard is the one +/// listed from its first frame. +pub fn provider(host: *sdk.Host) *Api { + return host.getServiceTyped(Api) orelse &demo_cards.api; +} + +/// An action the person picked this frame. The strings are the provider's, valid until `act`. +const Pick = struct { card_id: []const u8, action_id: []const u8 }; + pub fn draw(host: *sdk.Host) void { drawn = 0; var column = dvui.box(@src(), .{ .dir = .vertical }, .{ .expand = .both, .padding = .all(8) }); defer column.deinit(); - const api = host.getServiceTyped(Api) orelse { - _ = header(0, 0, null); - return note("This app has no cards to show."); - }; + const api = provider(host); + // `act` moves `generation` and may free every card, so it comes after the last read of them. + const picked = drawCards(api) orelse return; + api.act(picked.card_id, picked.action_id); +} + +fn drawCards(api: *Api) ?Pick { const cards = api.list(); - const ids = host.arena().alloc(u64, cards.len) catch return; + const ids = dvui.currentWindow().arena().alloc(u64, cards.len) catch return null; for (cards, ids) |c, *id| id.* = Carousel.hashId(c.id); carousel.sync(ids); const go = header(carousel.index, cards.len, ids); if (go) |dir| _ = carousel.go(ids, dir); - if (cards.len == 0) return note("Nothing needs you."); + if (cards.len == 0) { + note("Nothing needs you."); + return null; + } var stage = dvui.overlay(@src(), .{ .expand = .both }); defer stage.deinit(); @@ -50,18 +67,20 @@ pub fn draw(host: *sdk.Host) void { const t = carousel.progress(dvui.frameTimeNS(), core.motion.durationMs(slide_ms)); const slide = carousel.slide; const shown = cards[carousel.index]; + var picked: ?Pick = null; if (t == null or slide == null) { - drawCard(api, shown, 0, .{ .x = 0, .y = 0, .w = stage_rect.w, .h = stage_rect.h }); - return; + drawCard(&picked, shown, 0, .{ .x = 0, .y = 0, .w = stage_rect.w, .h = stage_rect.h }); + return picked; } // Still moving: the next frame is wanted, and `slide` is what this frame ends on (it was // dropped by `progress` at the end, so its last frame is the card in place). dvui.refresh(null, @src(), stage.data().id); const off = Carousel.offsets(slide.?.dir, core.motion.settle(t.?)); - drawCard(api, shown, 0, .{ .x = off.entering * stage_rect.w, .y = 0, .w = stage_rect.w, .h = stage_rect.h }); + drawCard(&picked, shown, 0, .{ .x = off.entering * stage_rect.w, .y = 0, .w = stage_rect.w, .h = stage_rect.h }); if (slide.?.from) |f| { - drawCard(api, cards[f.index], 1, .{ .x = off.leaving * stage_rect.w, .y = 0, .w = stage_rect.w, .h = stage_rect.h }); + drawCard(&picked, cards[f.index], 1, .{ .x = off.leaving * stage_rect.w, .y = 0, .w = stage_rect.w, .h = stage_rect.h }); } + return picked; } /// The title on the left; on the right the count and the two buttons. Returns the way the person @@ -98,7 +117,7 @@ fn button(name: []const u8, glyph: []const u8, enabled: bool, tag: []const u8, i } /// One card in `rect` (within the stage), whole: its source, title and body, then its actions. -fn drawCard(api: *Api, card: Card, id_extra: usize, rect: dvui.Rect) void { +fn drawCard(picked: *?Pick, card: Card, id_extra: usize, rect: dvui.Rect) void { drawn += 1; const theme = dvui.themeGet(); var box = dvui.box(@src(), .{ .dir = .vertical }, .{ @@ -135,8 +154,8 @@ fn drawCard(api: *Api, card: Card, id_extra: usize, rect: dvui.Rect) void { .primary => .highlight, .destructive => .err, }; - if (dvui.button(@src(), a.label, .{}, .{ .id_extra = i, .style = style, .margin = .{ .w = 6 } })) { - api.act(card.id, a.id); + if (dvui.button(@src(), a.label, .{}, .{ .id_extra = i, .tag = a.id, .style = style, .margin = .{ .w = 6 } })) { + picked.* = .{ .card_id = card.id, .action_id = a.id }; } } } @@ -148,3 +167,107 @@ fn note(text: []const u8) void { fn dim() dvui.Color { return dvui.themeGet().color(.control, .text).opacity(0.6); } + +/// A provider whose cards live on the heap and are freed when one is answered, as a real one's do. +const HeapProvider = struct { + gpa: std.mem.Allocator, + cards: []Card = &.{}, + generation: u32 = 0, + answered: ?[]const u8 = null, + + fn add(self: *HeapProvider, id: []const u8, labels: []const []const u8) !void { + const actions = try self.gpa.alloc(Api.Action, labels.len); + for (actions, labels) |*a, l| a.* = .{ .id = try self.gpa.dupe(u8, l), .label = try self.gpa.dupe(u8, l) }; + const cards = try self.gpa.realloc(self.cards, self.cards.len + 1); + cards[cards.len - 1] = .{ .id = try self.gpa.dupe(u8, id), .title = try self.gpa.dupe(u8, id), .body = try self.gpa.dupe(u8, id), .actions = actions }; + self.cards = cards; + } + + fn freeCards(self: *HeapProvider) void { + for (self.cards) |c| { + for (c.actions) |a| { + self.gpa.free(a.id); + self.gpa.free(a.label); + } + self.gpa.free(c.actions); + self.gpa.free(c.id); + self.gpa.free(c.title); + self.gpa.free(c.body); + } + self.gpa.free(self.cards); + self.cards = &.{}; + } + + fn list(ctx: *anyopaque) []const Card { + const self: *HeapProvider = @ptrCast(@alignCast(ctx)); + return self.cards; + } + + fn act(ctx: *anyopaque, card_id: []const u8, action_id: []const u8) void { + const self: *HeapProvider = @ptrCast(@alignCast(ctx)); + self.answered = self.gpa.dupe(u8, action_id) catch null; + _ = card_id; + self.freeCards(); + self.generation +%= 1; + } + + fn currentGeneration(ctx: *anyopaque) u32 { + const self: *HeapProvider = @ptrCast(@alignCast(ctx)); + return self.generation; + } + + const vtable: Api.VTable = .{ .list = list, .act = act, .generation = currentGeneration }; + + fn api(self: *HeapProvider) Api { + return .{ .ctx = self, .vtable = &vtable }; + } +}; + +var test_host: *sdk.Host = undefined; + +fn testFrame() !dvui.App.Result { + draw(test_host); + return .ok; +} + +test "an action picked on a card is acted on after the card is drawn, not while the provider's list is in use" { + var t = try dvui.testing.init(.{ .window_size = .{ .w = 400, .h = 400 } }); + defer t.deinit(); + var host: sdk.Host = .init(std.testing.allocator); + defer host.deinit(); + test_host = &host; + + var heap: HeapProvider = .{ .gpa = std.testing.allocator }; + defer { + heap.freeCards(); + if (heap.answered) |a| heap.gpa.free(a); + } + try heap.add("card", &.{ "first", "second", "third" }); + var api = heap.api(); + try host.registerService(Api, &api, null); + + try dvui.testing.settle(testFrame); + try dvui.testing.moveTo("first"); + try dvui.testing.click(.left); + // `act` frees the card the loop was walking; a later action button read from it would be a + // use after free, which the testing allocator and the filled freed memory turn into a crash. + _ = try dvui.testing.step(testFrame); + try std.testing.expectEqualStrings("first", heap.answered orelse return error.TestUnexpectedResult); + try std.testing.expectEqual(@as(usize, 0), heap.cards.len); + try dvui.testing.settle(testFrame); +} + +test "a provider registered after the dashboard is the one listed, over the demo's cards" { + var host: sdk.Host = .init(std.testing.allocator); + defer host.deinit(); + try std.testing.expectEqual(&demo_cards.api, provider(&host)); + + var heap: HeapProvider = .{ .gpa = std.testing.allocator }; + defer heap.freeCards(); + try heap.add("mine", &.{"ok"}); + var api = heap.api(); + try host.registerService(Api, &api, null); + + try std.testing.expectEqual(&api, provider(&host)); + try std.testing.expectEqualStrings("mine", provider(&host).list()[0].id); +} diff --git a/plugins/dashboard/src/demo_cards.zig b/plugins/dashboard/src/demo_cards.zig index 70535011d..66197f365 100644 --- a/plugins/dashboard/src/demo_cards.zig +++ b/plugins/dashboard/src/demo_cards.zig @@ -1,6 +1,6 @@ //! A tiny `dashboard.cards` provider of three made-up cards, so the dashboard can be tried before -//! an app has a provider of its own. The plugin registers it only when no other provider is there -//! (`plugin.zig`). Answering a card (any action) drops it, as a real provider does. +//! an app has a provider of its own. The view falls back to it when the host has none +//! (`View.provider`); it is never registered as the service. Answering a card (any action) drops it, as a real provider does. const std = @import("std"); const sdk = @import("fizzy_sdk"); diff --git a/tests/integration.zig b/tests/integration.zig index 48214d971..bf28fd139 100644 --- a/tests/integration.zig +++ b/tests/integration.zig @@ -13050,7 +13050,7 @@ test "headless: the whole editor comes up, opens a file, plays the tour and goes try std.testing.expect(host.callCommand("dashboard.show", "", host.arena()) == .ok); for (0..4) |_| _ = try dvui.testing.step(headlessFrame); try std.testing.expectEqualStrings("dashboard.view", host.selectionFor(sdk.keywords.ide.sidebar) orelse ""); - const cards = host.getServiceTyped(sdk.services.dashboard_cards.Api) orelse return error.TestUnexpectedResult; + const cards = @import("dashboard").View.provider(host); const before = try Reader.read(host); try std.testing.expectEqual(@as(usize, 3), before.count); try std.testing.expectEqual(@as(usize, 1), before.drawn); From 93be3fd6d4bdae4d90f9c8ad1d69a997c03a0f54 Mon Sep 17 00:00:00 2001 From: "fizzy-linux[bot]" <346940526+fizzy-linux[bot]@users.noreply.github.com> Date: Wed, 28 Oct 2026 11:53:09 -0500 Subject: [PATCH 2/2] dashboard: act-after-draw (codex) Agent-Team: linux Worker: codex --- plugins/dashboard/src/View.zig | 34 +++++++++++++++++++++++++--- plugins/dashboard/src/demo_cards.zig | 3 ++- 2 files changed, 33 insertions(+), 4 deletions(-) diff --git a/plugins/dashboard/src/View.zig b/plugins/dashboard/src/View.zig index b65eafb50..ac486cc10 100644 --- a/plugins/dashboard/src/View.zig +++ b/plugins/dashboard/src/View.zig @@ -40,7 +40,7 @@ pub fn draw(host: *sdk.Host) void { defer column.deinit(); const api = provider(host); - // `act` moves `generation` and may free every card, so it comes after the last read of them. + // Finish reading cards before `act` can free them. const picked = drawCards(api) orelse return; api.act(picked.card_id, picked.action_id); } @@ -154,7 +154,8 @@ fn drawCard(picked: *?Pick, card: Card, id_extra: usize, rect: dvui.Rect) void { .primary => .highlight, .destructive => .err, }; - if (dvui.button(@src(), a.label, .{}, .{ .id_extra = i, .tag = a.id, .style = style, .margin = .{ .w = 6 } })) { + const tag = dvui.currentWindow().arena().print("dashboard.action.{d}.{d}", .{ id_extra, i }) catch null; + if (dvui.button(@src(), a.label, .{}, .{ .id_extra = i, .tag = tag, .style = style, .margin = .{ .w = 6 } })) { picked.* = .{ .card_id = card.id, .action_id = a.id }; } } @@ -247,7 +248,7 @@ test "an action picked on a card is acted on after the card is drawn, not while try host.registerService(Api, &api, null); try dvui.testing.settle(testFrame); - try dvui.testing.moveTo("first"); + try dvui.testing.moveTo("dashboard.action.0.0"); try dvui.testing.click(.left); // `act` frees the card the loop was walking; a later action button read from it would be a // use after free, which the testing allocator and the filled freed memory turn into a crash. @@ -260,6 +261,7 @@ test "an action picked on a card is acted on after the card is drawn, not while test "a provider registered after the dashboard is the one listed, over the demo's cards" { var host: sdk.Host = .init(std.testing.allocator); defer host.deinit(); + try @import("../plugin.zig").register(&host); try std.testing.expectEqual(&demo_cards.api, provider(&host)); var heap: HeapProvider = .{ .gpa = std.testing.allocator }; @@ -271,3 +273,29 @@ test "a provider registered after the dashboard is the one listed, over the demo try std.testing.expectEqual(&api, provider(&host)); try std.testing.expectEqualStrings("mine", provider(&host).list()[0].id); } + +test "cards with the same action id have distinct tags during a slide" { + var t = try dvui.testing.init(.{ .window_size = .{ .w = 400, .h = 400 } }); + defer t.deinit(); + var host: sdk.Host = .init(std.testing.allocator); + defer host.deinit(); + test_host = &host; + carousel = .{}; + defer carousel = .{}; + + var heap: HeapProvider = .{ .gpa = std.testing.allocator }; + defer heap.freeCards(); + try heap.add("one", &.{"approve"}); + try heap.add("two", &.{"approve"}); + var api = heap.api(); + try host.registerService(Api, &api, null); + + try dvui.testing.settle(testFrame); + const ids = [_]u64{ Carousel.hashId("one"), Carousel.hashId("two") }; + try std.testing.expect(carousel.go(&ids, .next)); + _ = try dvui.testing.step(testFrame); + try std.testing.expectEqual(@as(usize, 2), drawn); + const entering = try dvui.testing.tagGet("dashboard.action.0.0"); + const leaving = try dvui.testing.tagGet("dashboard.action.1.0"); + try std.testing.expect(entering.id != leaving.id); +} diff --git a/plugins/dashboard/src/demo_cards.zig b/plugins/dashboard/src/demo_cards.zig index 66197f365..32bed66e0 100644 --- a/plugins/dashboard/src/demo_cards.zig +++ b/plugins/dashboard/src/demo_cards.zig @@ -1,6 +1,7 @@ //! A tiny `dashboard.cards` provider of three made-up cards, so the dashboard can be tried before //! an app has a provider of its own. The view falls back to it when the host has none -//! (`View.provider`); it is never registered as the service. Answering a card (any action) drops it, as a real provider does. +//! (`View.provider`); it is never registered as the service. Answering a card (any action) drops +//! it, as a real provider does. const std = @import("std"); const sdk = @import("fizzy_sdk");